55 Commits
Author SHA1 Message Date
swaits 688938d8e8 chore: release 1.5.2 2026-05-11 15:03:13 -06:00
swaits e9360c28ba chore: release 1.5.1 2026-05-11 11:38:38 -06:00
swaits d7a1ee86d0 fix(radio): cache mod params on SET_CONFIG so TX works without prior RX
The TX state machine's `prep_for_cad_act` (TxCadPrep entry) and
`prep_for_tx_act` (TxPrep entry) both read `ctx.mdltn` and emit
`TransmitFailed` → `ERR(ERadio)` if it's None. But `ctx.mdltn` was
only ever assigned on the RX path — `try_start_rx_hw` (RxArming entry)
and `cache_rx_pkt` (RxSession entry). `reconfigure_radio` already
called `to_lora_mod_params(lora, cfg)` during SET_CONFIG but discarded
the result via `let _ = …?;` (comment claimed image calibration; the
function is pure compute on SX126x).

Result: a freshly-booted dongle that issues TX before ever calling
RX_START gets `ERR(ERadio)` on the first CmdTx because the chart's
TX-entry actions find `ctx.mdltn = None`. The bug was hidden in
practice by any prior RX_START (ai-bot, ping_pong --role rx, etc.)
leaving `ctx.mdltn` populated across sessions until the next reflash.

Fix: thread the `ModulationParams` out of `reconfigure_radio` as its
return value, and have `apply_set_config` cache it in `self.mdltn`
alongside `self.config`. The failure path clears `self.mdltn` along
with `self.config` so a SET_CONFIG that fails leaves a clean
Unconfigured state.

Surfaced while testing the matching sync-word fix on freshly-flashed
dongles via `examples/sync_word_test.py` — the first TX failed with
`ERR(ERadio)` until this path was wired up.
2026-05-11 11:15:30 -06:00
swaits 0b9f9ee882 fix(radio): apply LoRaConfig.sync_word to the chip on SET_CONFIG
`reconfigure_radio` previously consumed `cfg.sync_word` from the host
in name only — it called `enter_standby` and constructed a fresh set
of LoRa modulation params (which carry sf/bw/cr/freq but not the sync
word), then returned. lora-phy v3 has no public sync-word setter for
SX126x, and there's no codepath in this crate that touches the chip's
LoRaSyncword registers (0x0740/0x0741) after boot. The chip therefore
sat on whatever `LoRa::new(_, false, _)` set at cold init —
`LORAWAN_PRIVATE_SYNCWORD` (byte 0x12, register pair 0x1424) — for the
entire session, regardless of what the host requested.

That happened to work for every existing user because everyone wants
0x1424 (MeshCore's sync byte). It manifested as soon as a host actually
needed a different value: `anon_ping.py` survived because MeshCore
matches, but any custom mesh / non-MeshCore network would get silent
wrong-sync-word TXes that no peer could hear and no host-side diagnostic
could pin down.

This commit applies the host-supplied sync word inside
`reconfigure_radio`, between the existing `enter_standby` and the
mod-params construction:

  - `cfg.sync_word` (the protocol's u16, RadioLib-style; e.g.
    0x1424 == byte 0x12, 0x3444 == byte 0x34) is decoded back to its
    underlying single byte: `byte = (hi & 0xF0) | (lo >> 4)` after
    `to_be_bytes()`, the inverse of lora-phy's `convert_sync_word`
    nibble-expansion.

  - That byte is handed to lora-phy's new
    `LoRa<RK,DLY>::set_lora_sync_word` (added in the matching
    lora-phy commit), which honours SX1261/2 datasheet §8.3.1 by
    driving `ensure_ready` + conditional `set_standby` before
    writing the register pair, and caches the new value in the
    `LoRa` struct so a later cold-reset NRESET (Recovering state)
    resumes with the host's sync word rather than reverting to the
    construction-time default.

  - Failure of the sync-word write lands the same way `enter_standby`
    failure already did: `apply_set_config` wipes `self.config`,
    sends `ERR(ERadio)`, and routes the chart to `Recovering` to
    NRESET + cold-init. No new error-handling plumbing required.

Cargo.lock bumps lora-phy from `fixed#e8a52ebd` to `fixed#8d66d1c4`,
which is the merge of three pre-existing `fix/*` branches plus a
new `feat/set-sync-word` branch carrying the lora-phy API addition.

Verified on hardware with a two-dongle bench test
(`examples/sync_word_test.py`): one dongle pinned to sync 0x5464,
the other rotating through six sync words. RX hears exactly the
matching cycles with realistic RSSI / SNR; non-matching cycles are
correctly filtered by the SX126x packet engine before reaching the
host.
2026-05-11 11:10:47 -06:00
swaits 04dc58c943 chore(deps): bump hsmc 0.5 -> 0.6
0.6.0 is a perf pass + bench harness migration; chart syntax and
public API are unchanged. All 10 board features check clean.
2026-05-11 10:49:32 -06:00
swaits 948589ae5d chore: release 1.5.0 2026-04-30 07:11:16 -06:00
swaits aca4c3cffd feat(t114): add Heltec Mesh Node T114 board support
Adds nRF52840 + SX1262 + 1.14" 240×135 ST7789 color TFT support. First
color display board in the project; introduces a BoardDisplay trait so
each board renders to its native pixel format and resolution without
aliasing or color-quantization round-trips.

Architecture:
- New BoardDisplay trait (src/display.rs) bundles render_*, present(),
  set_bright/set_dim. Replaces the old DrawTarget<Color=BinaryColor>
  + DisplayBrightness combo as the LoRaBoard::DisplayDriver bound.
- Mono OLED drivers (Sh1106, Ssd1306Async) get adapter impls that
  delegate to the existing display::render module — no behavior change
  for any existing board.
- T114 ST7789 driver (src/driver/st7789.rs) wraps lcd-async (async fork
  of mipidsi) with a 64,800-byte raw byte framebuffer in .bss. Renders
  via lcd_async::raw_framebuf::RawFrameBuf<Rgb565, _>, presents via a
  single async DMA blit (panel.show_raw_data(...).await) — the await
  yields the executor for the ~65 ms SPI transfer so the radio task
  isn't starved during repaints.
- PWM backlight on PWM0 / P0.15 (active-LOW, inverted polarity) for
  smooth dim/bright transitions instead of GPIO toggle.
- Native 240×135 Rgb565 renderer (src/display/render_color.rs) with
  splash + dashboard + sparkline.
- Shared QR-code blit (src/display/qr.rs), generic over PixelColor,
  re-centers the 64×64 bitmap which has cells in cols 0..50 and ~14 px
  of right-side padding — both mono and color splash now show the QR
  with 7 px quiet zones on all four sides (was visibly left-shifted).
- Shared content types (BoardInfo, DashboardCtx) decouple what to draw
  from how each board renders it.
- create_display() now takes the board's full DisplayParts bundle
  (was just the I2C bus) so non-I2C displays can carry SPI + control
  pins through.

Hardware:
- VEXT_ENABLE on P0.21 driven HIGH at boot — powers the SX1262 + TFT
  rail. Without this, lora-phy init succeeds at the SPI level but
  TX_DONE never fires.
- VTFT_CTRL on P0.03, active LOW; backlight on P0.15, active LOW.
- TFT SPI2 (SCK P1.08 / MOSI P1.09) at 8 MHz over EasyDMA. Async
  ExclusiveDevice from embedded-hal-bus (with the 'async' feature).
- lcd-async builder at display_size(135, 240), display_offset(52, 40),
  Deg90 rotation, ColorInversion::Inverted — standard 1.14" ST7789
  die mapping.
- Linker: shared ld/nrf52840-memory.x (S140 SoftDevice preserved at
  0x01000-0x26000 per project policy).
2026-04-30 03:11:36 -06:00
swaits 188e445e80 docs: consolidate PROTOCOL.md to canonical home, move BLE_MUX_DESIGN_DOC into docs/ 2026-04-29 23:59:17 -06:00
swaits 333dc1bb3b chore(esp): drop esp-hal patches, bump to crates.io 1.1, add esp_app_desc!() and espflash v4 2026-04-29 23:52:11 -06:00
swaits 6c6e74f26f chore: release 1.4.1 2026-04-29 22:38:54 -06:00
swaits 732bd18ac3 fix(deps): pin lora-phy to swaits/lora-rs#fixed for sx127x/sx126x reliability
Switch lora-phy from crates.io to the swaits/lora-rs "fixed" branch
which carries three fixes awaiting upstream PR acceptance:

  - phy: sx127x: clear stale IRQ flags before reconfiguring DIO mapping
    in set_irq_params (fix/sx127x-irq-clear-position)
  - phy: sx127x: clear stale IRQ flags in do_rx before mode change
    (fix/sx127x-rx-irq-clear)
  - phy: sx126x: pre-command BUSY check + standby/IRQ-clear hygiene
    around CAD (fix/sx126x-busy-race)

Validates TBeam classic (sx127x) in particular and tightens reliability
across every board (the sx126x BUSY-race fix lands on every nRF/RP2040
board too). Pinned to commit e8a52ebd via Cargo.lock; revert to
crates.io once the three PRs land upstream.
2026-04-29 22:37:04 -06:00
swaits f7bac5903c fix(display): pick smaller font when board name overflows splash
Cap the splash board-name on a 128 px-wide OLED: every name must fit
the FONT_5X8 fallback (25 ASCII chars) or the const_assert at the
BOARD_NAME definition fails the build. At runtime, names that fit
FONT_6X10 (21 chars) keep the primary font; longer names switch to
FONT_5X8 instead of overflowing. Also drop the URL-line font on the
"learn more" screen from FONT_5X8 to FONT_4X6 so "donglora.com" sits
inside the 64 px column with breathing room instead of touching both
edges.
2026-04-29 22:26:29 -06:00
swaits b56c044d57 refactor(host/usb): bump hsmc to 0.5 and collapse WaitingForFirstFrame into Connected
hsmc 0.5 makes default(...) optional on every state. Drop the
WaitingForFirstFrame substate that existed only because 0.4 required
Connected to declare a default child — Connected itself is now the
resting "DTR up, no traffic yet" state, and on(FrameReceived) => Active
descends into the inactivity-timer state on the first byte. Behavior is
identical (entry/exit chain math unchanged); the chart has one fewer
state and one fewer default().

Display and Radio charts left as-is — their defaults are all classic
descents serving real grouping/lifecycle purposes (Splash grouping,
Bright sub-state isolation, RX/TX session lifecycle), not 0.4 artifacts.
No journal-replay tests in this repo, so the 0.5 journal-format change
does not apply.
2026-04-29 22:26:16 -06:00
swaits f68e5d9be5 fix(host/uart): deliver RX to host and survive sustained bidirectional load
Two regressions on UART boards (heltec_v3_uart, etc.):

1. RADIO_THROTTLED was never cleared on UART. The throttle gate at
   src/radio.rs:2017 (added in 276f941, "chart-first restructure ...
   soak hardening") silently drops async RX while the flag is true.
   USB host_task clears it on first successful write_packet
   (src/host/usb.rs:354/373), but the UART path was never updated --
   every RX event was dropped from boot. The OLED bar lit up
   regardless because record_packet announces PacketRx before the
   gate. Mirror the USB pattern: clear on successful tx.write_all,
   re-arm on failure.

2. Once RX flowed, sustained bidirectional spam tripped the §3.4
   inactivity watchdog spuriously. The select3-based loop serialised
   read and write; tx.write_all (~25 ms per max frame at 115_200
   baud) blocked the read poll long enough for last_frame_at to age
   past 1000 ms under a saturated outbound queue. The Reset cascaded
   to radio (Unconfigured) and display (splash), leaving subsequent
   host TX commands timing out waiting for TX_DONE. Restructure
   host_task into two sub-loops joined in the same task: read_loop
   owns rx + watchdog, write_loop owns tx + throttle. The read future
   re-polls on every executor wake regardless of write progress.
   Same executor, disjoint borrows on the split rx/tx halves, no
   Mutex needed.

Also reset the FrameDecoder when the watchdog fires so a host that
resumes mid-frame resyncs cleanly on the next 0x00 sentinel (USB
gets this for free via DTR-drop reset; UART has no DTR).

Build: bump esp-hal/esp-rtos/esp-backtrace/esp-println version
requirements (1.0->1.1, 0.2->0.3, 0.18->0.19, 0.16->0.17) to match
what the upstream esp-rs/esp-hal main branch now publishes. cargo
update had invalidated the [patch.crates-io] entries since the
patched versions were no longer semver-compatible with the old
top-level requirements, which silently dropped the patches and fell
back to crates.io releases that pin embassy-executor 0.9 (mismatched
with our 0.10).
2026-04-29 22:26:16 -06:00
swaits eb8202058f build: declare ESP toolchain in mise.toml; drop ~/export-esp.sh dependence
mise.toml now owns the ESP cross-toolchain end to end:

- [env] pins PATH (xtensa-esp-elf-gcc bin) and LIBCLANG_PATH so any
  `mise exec` (which the justfile already uses for `set shell`)
  resolves the linker without sourcing espup's stray ~/export-esp.sh.
- [tasks.esp-install] installs the matching crosstool version via
  espup (espup hardcodes ~/.rustup/toolchains/esp/ -- there is no
  --prefix flag -- so the binaries necessarily live there, but the
  project now declaratively owns the version + path knowledge).
- cargo:espup pinned to 0.17.1 to match the [env] paths.

Triggered by a real failure: ~/export-esp.sh was moved to Trash and
the ESP build broke with `linker xtensa-esp32s3-elf-gcc not found`.
The previous setup silently no-op'd `[ -f $HOME/export-esp.sh ] && .
$HOME/export-esp.sh` in justfile, leaving the build with no PATH for
the cross-toolchain. Encoding the paths in mise.toml removes the
stray-file dependency.

justfile changes:
- _cargo: drop the `[ -f $HOME/export-esp.sh ] && . ...` source.
- setup / _require_esp_toolchain: install via `mise run esp-install`
  instead of bare `espup install`, so the version pin and the [env]
  paths stay in sync from a single source.

Bumping ESP versions remains a two-touch change: edit the version
dirs in BOTH [tasks.esp-install] and [env], then re-run the task.
2026-04-29 22:26:16 -06:00
swaits 1c98786ce6 feat(build): per-board memory.x override with MCU-family fallback
build.rs resolves the linker layout in two steps: per-board override
`ld/{board}-memory.x` first, MCU-family default second. Lets a board
deviate from the family default without a per-board build matrix.

Adds `ld/wio_tracker_l1-memory.x` with `ORIGIN = 0x1000` (no-SD
layout). The Wio Tracker L1 ships from Seeed with s140 SoftDevice, but
our earlier `ORIGIN = 0x1000, LENGTH = 0xE9000` linker silently
overwrote it across many UF2 flashes; the bootloader now reports
`SoftDevice: not found`. Until s140 is restored via SWD, the WIO
needs the no-SD layout to match the bootloader's actual boot chain.

Delete the per-board file once SD is restored -- the build will then
fall back to the SD-aware `ld/nrf52840-memory.x` and the WIO matches
every other nRF52840 board.
2026-04-28 11:03:46 -06:00
swaits 16374ca241 refactor(radio): make every lora-phy operation cancel-safe via chart structure
Rewrite the Operational subtree of the radio chart so that every
cancel-unsafe lora-phy future (lora.rx() / lora.cad() / lora.tx() /
lora.init()) runs to natural completion, honoring lora-phy's documented
'must run to completion' contract.

Architectural pivot: every cancel-unsafe future is the body of a leaf-
state during whose host state declares NO outbound event handlers other
than events the during itself emits on natural completion. Per hsmc spec
§6, the chart cannot drop the during because no transition can fire.

RX subtree (RxSession super-state):
  - irq_pump (cancel-safe: wait_for_irq + get_irq_state + clear_irq_status)
    runs at RxSession scope, emits Dio1Asserted(IrqState) to chart
  - try_start_rx_hw chains prepare_for_rx -> start_rx so the chip is
    actually receiving when RxActive is entered (both single SPI ops)
  - RxActive's collect_rx_packet action does post-IRQ get_rx_result work
  - RxDraining's enter_standby cleanly stops RX before transitions out

TX subtree (TxSession super-state):
  - TxCadPrep / TxCadWait / TxBackoff / TxPrep / TxOnAir / TxFanout
    leaf states each owning one cancel-safe SPI op or one sandboxed
    cancel-unsafe await
  - lora.cad() / lora.tx() called AS-IS in states with zero non-self
    event handlers
  - chart timers (on(after Duration)) set context flags for wedge
    detection; the during checks the flag at its next cancel-safe
    wakeup and returns cleanly
  - mark_pending_reset action defers Reset events until natural
    completion (per spec §5 emit() semantics)

Removes lora_setup_with wrappers around lora.cad(), lora.tx(),
lora.init() -- those wrappers used embassy_time::with_timeout which
DROPS the wrapped future on timeout, the proximate cause of every
'lora_setup timeout: do_tx.cad' warn we've seen.

Keeps lora_setup around enter_standby / prepare_for_rx /
prepare_for_cad / prepare_for_tx -- those are short SPI sequences where
drop is documented-safe.

Documented latency budget: chart-cycle overhead <100us; regulatory
LBT (CAD-to-TX <50ms) well within budget.
2026-04-28 11:03:24 -06:00
swaits e5aeb3896a fix(radio): guarantee TX_DONE/TX_FAILED reply on every CmdTx via Transmitting exit action
Add exit: respond_tx_failed on the Transmitting super-state. Per hsmc spec
§6, exit actions fire on every departure from a state, including the
Operational-level on(WedgeDetected) => Recovering transition that bypasses
Transmitting's own on(TransmitFailed) => respond_tx_failed handler.

respond_tx_failed is a clean no-op when last_tx_tag is None (the
normal-completion path consumed it via .take()), so this is a safe
unconditional exit action. Net effect: every CmdTx gets exactly one
tag-correlated TX_DONE/TX_FAILED/CHANNEL_BUSY reply no matter how
Transmitting is exited. The host (mux/ai-bot) stops eating 2s firmware-
timeouts on every CAD wedge mid-TX.
2026-04-28 10:59:28 -06:00
swaits 276f941fbb refactor(radio): chart-first restructure on hsmc 0.3 + soak hardening
Migrate from hsmc 0.2 (local-patched) to hsmc 0.3.0 (crates.io, with
embassy + trace-defmt features). Restructure radio chart and bundle
several adjacent stability changes that need to land together for
soak runs.

Radio chart:
- Booting (default, no handlers) -> Operational (default Unconfigured;
  super-state owning CmdPing/CmdGetInfo/Reset/WedgeDetected handlers
  + Recovering + CheckRecoveryBudget) -> Dead (terminal).
- Cold init's during is structurally un-cancellable.
- radio_task is a 30-line shim that panics if run() returns (chart is
  non-terminating).
- Internal queue capacity bumped 8 -> 32 to cover action-emit bursts.

Host chart:
- Connected splits into WaitingForFirstFrame (default, no timer) +
  Active (chart-owned 1s inactivity timer via on(after) + self-
  transition on FrameReceived). Manual Timer::after + last_frame_at
  removed.

Soak hardening:
- ld/nrf52840-memory.x: app start 0x1000 -> 0x26000 to leave the
  factory s140 SoftDevice dormant in flash.
- hal/nrf52840.rs: dump POWER.RESETREAS on boot -- WARN on DOG/LOCKUP,
  INFO on RESETPIN/SREQ/wake-from-OFF.
- host/usb.rs: read_packet errors discriminated -- Disabled is debug
  (normal during USB enum), BufferOverflow stays warn.
- COBS encode warns now carry type/tag/payload-length for diagnosis.
- RADIO_THROTTLED static initializes to true (no proof of host drain
  at boot).
- driver/sh1106.rs: init retries 3x with 100ms backoff (intermittent
  first-write race on power-up).
- Cargo.toml: unexpected_cfgs allowlist for hsmc's journal/trace-*
  features.

docs/semtech_lora_cad_lbt_guidebook.md: vendor the Semtech CAD/LBT
reference for offline lookup.
2026-04-28 10:59:28 -06:00
swaits 481712930c chore: release 1.4.0 2026-04-25 20:23:39 -06:00
swaits d262abe853 feat(radio): adaptive LBT — scale CAD retries to current LoRa config
The CAD pre-TX loop previously ran up to 10 retries with a fixed 10 ms
sleep — roughly one max-length packet only at SF7/BW500. At SF10/BW125
the 100 ms total was ~4% of one packet; at SF12/BW125 LBT was a no-op.

Now the per-retry sleep is sized to one time-on-air of an assumed-peer
80-byte packet under the active config, cached on RadioContext and
recomputed on every SET_CONFIG. Retries scale as
clamp(floor(BUDGET/T)+1, 1, 10) with BUDGET=2.5 s, so fast configs
still get up to 10 retries while SF11/12 degrade to single-CAD-and-bail
(logged at config-apply). Sleeps are jittered ±20% per iteration to
decorrelate simultaneously-backing-off contenders.

The ToA math, CR→{1..4} mapping, retry-count derivation, and jitter
helper live in a pure src/lbt.rs module so they're host-testable via
DONGLORA_HOST_TEST=1 cargo test (13 tests).

Validated on-device: 20 iterations of the AI Bot test flow that
previously saw ~2 ChannelBusy errors now see zero at the same SF/BW.
2026-04-25 20:15:44 -06:00
swaits 23ddc15557 fix(radio): consume upstream lora-phy pre-command BUSY check
Pick up the proper fix from our fork at swaits/lora-rs@fix/sx126x-busy-race:
move the ready check from *after* to *before* each SPI op per the
Semtech manuals (SX1261/2 §8.3.1, LR1121 UM §3 / §3.5, LR1110 DS
§1.2.5) and rename InterfaceVariant::wait_on_busy to wait_until_ready.

Firmware side is surgical: since the upstream fix keeps the
GenericSx126xInterfaceVariant::new signature at 5 args (no DelayNs
threading), there is nothing to change in the four nRF / ESP / RP2040
board files beyond a one-line comment rename (wait_on_busy ->
wait_until_ready) in the BUSY-pullup comment on the two nRF boards.

Wire in the [patch.crates-io] override pointing at ../../lora-rs/lora-phy,
bump the dep spec to the pre-release 3.0.2-alpha that matches the fork,
and enable the defmt-03 feature that upstream moved behind a gate
between 3.0.1 and the fix branch.

TODO.md rewritten to track the new approach: PR body template with
verbatim citations from all three Semtech chip manuals (SX1261/2,
LR1121 UM, LR1110 DS) plus the SX1276 pin table confirming the family
has no BUSY line.

No change to firmware behavior beyond picking up the upstream fix,
which takes first-TX-after-boot success from ~50% to 20/20 on the
Seeed Wio Tracker L1 repro board.
2026-04-24 12:44:01 -06:00
swaits 4d723a85ce fix(boards): BUSY pin pull-down → none on nRF SX1262 boards
SX1262's BUSY pin has an internal 20 kΩ pull-up (datasheet page 53).
Our Input::new(..., Pull::Down) on BUSY creates contention with that
internal pull-up, leaving BUSY at an indeterminate voltage. This can
cause wait_on_busy to mis-read the chip's ready state — sometimes
commands land while the chip is still busy (ignored or corrupted),
which manifests as TX_DONE never firing on DIO1 because the SX1262
never actually completed the SetTx it received during BUSY=high.

Fix per lora-rs/lora-rs#260: Pull::Down → Pull::None on BUSY.
Matches the official lora-rs nRF52840 examples.

Applied to both Wio Tracker L1 (reported broken by @swaits today) and
RAK4631 (currently works for us, but was affected by the same class
of bug per the upstream issue — this makes it deterministically
correct rather than 'happens to work').
2026-04-24 01:34:32 -06:00
swaits 6380bcbf6a just(flash): add optional extra_features arg for build/flash/flash-probe
Appends comma-separated extra features to the board feature when
building. Enables iteration workflows like:

    just flash wio_tracker_l1 debug-checkpoint

without having to drop to raw cargo + rust-objcopy + uf2 cp.
2026-04-24 01:34:32 -06:00
swaits 7a045edc1a docs: add TODO.md with esp-hal upgrade task 2026-04-24 00:31:14 -06:00
swaits b53570ce66 chore: update tool specs in mise.toml 2026-04-24 00:25:50 -06:00
swaits 647929ff8d feat(boards): RAK4631 WisBlock bring-up + build-system cleanup
RAK4631 board:
- SX126X_POWER_EN on P1.05 driven HIGH at init (radio is power-gated;
  without this the chip has no power and SPI hangs)
- HFXO + LFXO external crystals in embassy_nrf::Config (USB CDC-ACM
  needs +/- 500 ppm which the internal RC oscillator can't provide)
- SoftwareVbusDetect(true, true) so USB enumerates without needing a
  CLOCK_POWER interrupt handler
- Green LED on P1.03 via SimpleLed (RAK19007 Base LED1, active HIGH)
- driver/mod.rs: open simple_led feature gate for rak_wisblock_4631
- Spim::new pin-order comment on SPI setup (arg order is sck/miso/mosi,
  not sck/mosi/miso — easy to get wrong)

debug-checkpoint instrumentation (feature-gated, zero cost in release):
- radio.rs: debug_blink::set(N) at each TX-phase boundary, filling in
  the long-aspirational blink-map doc
- debug_blink.rs: checkpoint 19 between prepare_for_cad and cad(), so
  future bring-ups can distinguish modem-setup hang from DIO1-wait hang

justfile + mise.toml:
- set shell := ["mise", "exec", "--", "sh", "-c"] so recipes
  always resolve tools through mise, not whatever happens to be on PATH
- replace per-board := vars + long if/elif _info chain with a single
  case statement — one place per board, two lists (names + metadata)
- new flash-dfu recipe using adafruit-nrfutil via uvx, with VID:PID
  (239a:0029) port autodetect for the Adafruit nRF52 bootloader
- mise.toml: rust pins now carry ARM targets + clippy, so 'mise install'
  is sufficient bootstrapping (no extra rustup target add steps)

Verified TX on real hardware (RAK19007 Base + RAK4631 Core + RAK1921
OLED, US915 band): ADVERT round-trip with an external receiver.
2026-04-24 00:15:29 -06:00
swaits 0fbd8412c2 chore: release 1.3.1 2026-04-23 18:25:50 -06:00
swaits ebd576b4e2 fix(boards): invert LilyGo T-Beam (classic) LED polarity via InvertedPin wrapper
The classic T-Beam's red LED on GPIO4 is wired active-low (anode to V+,
cathode to GPIO). Release v1.3.0 drove it as active-high, so the LED sat
lit at idle and dimmed briefly on TX/RX instead of flashing on activity.

Express the polarity where it physically lives — at the pin — via a new
generic InvertedPin<P> wrapper that swaps set_high/set_low. SimpleLed
stays polarity-agnostic and every other board's code path is
byte-for-byte unchanged from v1.3.0.

Empirically verified against hardware on both T-Beam boards: classic red
LED is now dark at idle and flashes on TX/RX; Supreme blue LED behaves
unchanged.
2026-04-23 18:04:20 -06:00
swaits a2c611841b chore: release 1.3.0 2026-04-23 12:50:03 -06:00
swaits a1b92356b3 feat(boards): LilyGo T-Beam (classic) support
First classic-ESP32 (non-S3) board in the firmware, and first SX127x
radio. ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC, CP2102 USB-UART
bridge. Pin map verified from MeshCore variants/lilygo_tbeam_SX1276.

The AXP192 gates power to the LoRa chip and OLED. Its I²C bus is
shared with the display, so the firmware builds I²C0 in blocking mode
first for the PMIC init, then converts the same handle to async and
hands it to the display task — avoids double-consuming the peripheral.

Lora-phy's Sx127x support drives DIO0 as the sole IRQ (SX127x has no
BUSY line); the chip runs from a crystal, tx_boost routes +20 dBm
through PA_BOOST.

The `esp-hal` / `esp-rtos` / `esp-backtrace` / `esp-println` deps have
been restructured so the chip flavour (esp32 vs esp32s3) is selected
at the per-board feature level instead of baked into the dep lines.
This keeps the existing S3 boards building on esp32s3 while the new
T-Beam feature selects classic esp32 via the same dependency set.

LoRaBoard gains supported_sf_bitmap() / supported_bw_bitmap() methods
(with SX126x defaults) so the T-Beam can report SF6–SF12 via GET_INFO
— the SX1276 has no SF5. radio_chip_id() reports 0x0011 per
donglora-protocol RadioChipId::Sx1276. freq_range_hz widens to the
SX1276 datasheet span of 137 MHz–1020 MHz.

New modules:
- src/hal/esp32.rs           — classic-ESP32 MCU primitives
- src/board/esp32.rs         — Sx127x radio + SSD1306 display pipe
- src/board/lilygo_tbeam.rs  — the board file
- src/driver/axp.rs          — AXP192 init added alongside AXP2101
2026-04-23 10:42:42 -06:00
swaits 7fca5fcb50 feat(boards): LilyGo T-Beam S3 Supreme support
ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC, native USB-C.

The AXP2101 PMIC gates power to the LoRa chip and the OLED. Without
its init sequence (ALDO3 = 3.3 V for LoRa, ALDO1 = 3.3 V for OLED)
the SX1262 never responds on SPI and radio init fails.

Pin map verified against MeshCore's variants/lilygo_tbeam_supreme_SX1262.
PMIC lives on a dedicated I²C1 bus (SDA=42, SCL=41) separate from the
SH1106 on I²C0 (SDA=17, SCL=18); I²C1 is used blocking for a one-shot
init and then dropped. TCXO at 1.6 V via DIO3.

New src/driver/axp.rs holds the AXP2101 init sequence — kept minimal
(only the LoRa + OLED rails), since DongLoRa is a LoRa-over-USB bridge
and every other rail (GPS, BME280, IMU, SD, batt ADC) is out of scope.
2026-04-23 10:29:48 -06:00
swaits 6867439044 chore: release 1.2.1 2026-04-22 23:59:44 -06:00
swaits bcfe736b30 fix(radio): preserve RX session across transient failures
Extends the v1.1.0 "SET_CONFIG during RX auto-re-arms" invariant to
every failure path that can fire while a client has RX active:

* `Receiving.on(RxFailed)` now transitions to `ResumingRx` instead of
  `Idle`. A transient `next_packet` error re-arms the chip rather than
  ending the session.
* `TransmittingFromRx.on(TransmitFailed)` now transitions to
  `ResumingRx` instead of `Idle`. TX failure during RX still responds
  `TX_FAILED` to the caller, then returns to RX — matching the shape
  of the adjacent `ChannelBusy` handler, which already did this.
* `ResumingRx.on(StartRxFailed)` is now a bounded retry (5 attempts,
  25 ms backoff) rather than an immediate drop to `Idle`. Only after
  the retry budget is exhausted does the chip actually land in `Idle`,
  at which point it's genuinely wedged and needs reconfiguring.

Observable effect: the OLED dashboard no longer bounces to Splash
when a client (e.g. ai-bot) TXes while bridges are receiving.
Previously any single hiccup in the RX/TX/ResumeRX cycle would
announce `RadioEvent::Idle`, bouncing the OLED, and the radio would
sit in Idle with no client aware enough to re-issue RX_START —
stranding the session. The invariant should have held for the whole
RX window between `CMD_RX_START` and `CMD_RX_STOP`; now it does.

Implementation notes:

* Two new internal `RadioInput` variants: `ResumeRxRetry` drives the
  `ResumingRx` self-transition; `ResumeRxExhausted` breaks out to
  Idle after too many failed tries.
* New `resume_retries: u8` field on `RadioContext`, reset to 0 on
  every successful `StartRxOk`, bumped by `bump_resume_retries`.
* Constants `MAX_RESUME_RETRIES = 5` and `RESUME_RX_RETRY_BACKOFF_MS
  = 25` at the top of `radio.rs`.
* Removed the dead `announce_rx_failed` helper — its only caller was
  the now-deleted `Receiving.on(RxFailed) => Idle` edge.

No behavior change for the three paths where `Idle` remains the
correct landing state: initial `CmdRxStart` failure in `StartingRx`,
`CmdRxStop` in `Receiving`, and all three `TransmittingFromIdle`
outcomes (where RX wasn't running to begin with).
2026-04-22 23:59:12 -06:00
swaits 0033bbb941 chore: release 1.2.0 2026-04-22 20:50:11 -06:00
swaits 42a6b1142d feat(display): OLED dims a few seconds after last activity
Splash holds bright for 10 s on (re-)entry, then drops to dim while
LearnMore/Info keep alternating. The dashboard stays bright for 3 s
after the last packet or mode change, then dims in place without
disturbing the Listening/Transmitting state or the 1 s sparkline
timer. Cross-leaf TX↔RX hops stay bright throughout — `set_dim` is
fired by the 3 s timer handler as an action rather than as a sibling
`exit:` action, so mode-change leaf-crossings no longer flash
dim→bright on every swap.

Two driver families (`ssd1306::Ssd1306Async` and the in-tree
`Sh1106`) wrapped behind a single `DisplayBrightness` trait in
`src/driver/mod.rs`; the board trait's `DisplayDriver` associated
type now requires it. Dim/bright contrast values centralized as
`CONTRAST_BRIGHT` / `CONTRAST_DIM` constants — tune in one place.

`On.on(CmdReset)` now targets `LearnMore` directly rather than the
ancestor `Splash`. Under hsmc 0.2 up-transition semantics, targeting
an already-active ancestor unwinds only below it without re-descent;
the USB-disconnect reset path was previously relying on the old
re-enter-and-descend behavior and would have parked at `Splash`
with a dead alternation timer.

Bumps `hsmc` 0.1.0 → 0.2 for the up-transition semantic fix.
2026-04-22 19:38:42 -06:00
swaits fa1eeceb7c fix(boards): Elecrow ThinkNode-M2 uses CH340K, not CP2102
The shipping revision of this board has a WCH CH340K USB-UART bridge
(1a86:7522), not a Silicon Labs CP2102 as the original board-module
comment claimed. Update the doc header, and add the CH340K +
CH340-standard VID:PIDs to the `just flash` port list so we stop
falling through to espflash auto-detect on these boards.
2026-04-22 19:32:34 -06:00
swaits 027ac5eb04 chore: release 1.1.0 2026-04-22 11:47:42 -06:00
swaits b7d9b37bb0 feat: auto-resume RX across SET_CONFIG (protocol v1.1 §3.5)
Inside state Receiving, override the parent Configured state chart handler on(ConfigApplied) => Idle with on(ConfigApplied) => ResumingRx. ResumingRx entry (try_start_rx_hw) re-enables RX on the chip with the new modulation once apply_set_config has finished the SPI write + calibration, and transitions back to Receiving on StartRxOk.


Net effect: SET_CONFIG issued while the radio is in continuous RX no longer kicks the radio (and therefore the OLED) out to idle. The RX ring is still cleared by apply_set_config; callers never need to re-issue RX_START after SET_CONFIG.


This matches donglora-protocol 1.1.0 §3.5 and §6.3 normative behavior.
2026-04-22 11:47:14 -06:00
swaits b0ce0a7aca chore: release 1.0.0 2026-04-22 09:32:06 -06:00
swaits 2a3c2be060 feat!: migrate firmware to DongLoRa Protocol v2 wire format
Wire-incompatible break with the 0.x protocol. Every host-side path (UART, USB CDC, framing, radio dispatcher, display update channel) speaks the v2 wire format defined in PROTOCOL.md.

* Replace the host framing path with streaming FrameDecoder + encode_frame from donglora-protocol, matching the new (type_id, tag, payload) shape.

* Update src/channel.rs and src/host/{uart,usb,framing}.rs for the new frame semantics: per-request tag echo, CRC checked in the decoder, async ERR fan-out on tag 0.

* Rework src/radio.rs to emit RX / TX_DONE / OK / ERR messages in v2 shape, including RxOrigin, TxDonePayload, and SetConfigResult codes.

* Update src/protocol.rs glue to re-export v2 types from donglora-protocol and drop the legacy 0.x shim surface.

* Refresh PROTOCOL.md to the v2 normative spec (kept in sync with the donglora-protocol crate).

* Minor display and main-loop cleanups needed for the new radio event shape.
2026-04-22 09:32:06 -06:00
swaits a0b15ddcaa chore: release v0.5.0 2026-04-16 13:30:52 -06:00
swaits 388d07e90f refactor: complete hsmc conversion and add debug-checkpoint feature
Finishes the hsmc migration that was staged across the prior two
commits:

- Radio task is now a single `machine.run().await`; all I/O lives in
  state-scoped `during:` activities (`next_command`, `next_packet`,
  `perform_tx`). The old `push/drain` helpers, the imperative
  top-level `select`, `handle_cmd`, `handle_rx_result`, and
  `execute_transmit` are gone. `StartingRx` / `ResumingRx` intermediate
  states model the host-visible difference between a responding and a
  silent RX re-arm.

- Host/USB task is now an hsmc statechart (Disconnected/Connected)
  with entry/exit actions handling display wake, radio StopRx, and
  display reset — replacing the open-coded `was_connected` edge
  detector. The `protocol_loop` keeps its imperative read path because
  `read_packet` blocks.

- New `debug-checkpoint` cargo feature ships a dedicated
  `debug_blink.rs` task and a shared atomic counter that code along
  the boot/dispatch/TX path bumps at notable points. A ladder-latching
  write policy preserves the deepest TX phase reached on the first
  attempt until reboot — diagnostic output for UART-only boards where
  defmt would corrupt the host protocol. Zero footprint when the
  feature is off.

- Silence a stray `clippy::unit_arg` on `led_off` / `led_blip` in the
  display task for boards with `LedDriver = ()`.
2026-04-16 13:28:44 -06:00
swaits c6ef9559ec refactor: drive radio task via hsmc statechart
Replaces the imperative Mode enum + if-branches in the radio loop
with a declarative hsmc statechart mirroring the display:

  Radio
  ├── Unconfigured
  └── Configured
      ├── Idle
      ├── Receiving
      └── Transmitting
          ├── FromIdle
          └── FromRx

The `TransmittingFrom{Idle,Rx}` substates encode "where to return after
TX" as state identity, killing the `was_receiving` bookkeeping. Entry
actions announce `RadioEvent`s on the display channel; RF I/O stays in
the task loop because `rx_once` blocks indefinitely and cannot live
inside an action. Radio-init failure runs a Ping-only degraded loop
without ever instantiating the machine.
2026-04-16 13:26:23 -06:00
swaits 596bc3ed96 refactor: drive display via hsmc statechart
Replaces the ad-hoc display loop with a declarative hsmc statechart
(Off/On.{Splash.{LearnMore,Info}, Dashboard.{Listening,Transmitting}})
and swaps the Radio→Display channel from a RadioStatus watch to a
typed RadioEvent queue. The radio task now emits discrete mode and
packet events instead of publishing full status snapshots; the
display consumes them through the statechart, which also handles the
5 s splash alternation and 1 s sparkline tick via `on(after …)` /
`on(every …)` transitions.

Also:
- Add hsmc dep and embassy/tokio feature shims (hsmc proc macro
  evaluates `cfg(feature = "embassy")` in this crate).
- Fix USB disconnect path in `host/usb.rs` so the DTR branch always
  runs on read error (was early-continuing, starving display/radio
  reset).
2026-04-16 13:26:04 -06:00
swaits 272abceedb refactor: consume donglora-protocol as a path dep
Pulls the wire protocol types and COBS framing out of firmware and into
the sibling ../protocol crate so client-rs, mux-rs, and bridge can share
them. Firmware's src/protocol.rs is now a thin re-export shim preserving
every crate::protocol::\* call site; src/host/framing.rs drops the
in-tree CobsDecoder / cobs_encode_response and re-exports the codec,
keeping only route_command (which needs embassy channels).

All seven board features still build clean, cargo fmt clean, cargo test
green.
2026-04-16 13:25:20 -06:00
swaits f9582c0b94 style: cargo fmt 2026-04-16 13:25:19 -06:00
swaits 649c29218c docs: add BLE soft-mesh multiplexer design doc 2026-04-16 13:24:37 -06:00
swaits fd4e180879 chore: release v0.4.0 2026-04-11 22:35:33 -06:00
swaits 8b549d49a9 feat: add ELECROW ThinkNode-M2 board support
ESP32-S3 + SX1262 + 1.3" SH1106 OLED + CP2102 UART bridge +
1000 mAh Li-Po. Pin map verified against Meshtastic's
variants/esp32s3/ELECROW-ThinkNode-M2. DIO3 TCXO at 3.3 V (vs 1.8 V
on Heltec), VEXT_ENABLE active HIGH (opposite polarity), dedicated
LORA_POWER_EN on GPIO48 sequenced before SPI init. Reuses the
existing async SH1106 driver at I2C address 0x3C.

Refactors hal::esp32s3::{init_spi,init_i2c} and board::esp32s3::init_radio
to accept generic GPIO bounds so ESP32-S3 boards with different pin
assignments can share the helpers. init_radio now takes a
TcxoCtrlVoltage parameter instead of hardcoding Ctrl1V8.

Incidental cleanups needed to make `just check-all` green:

- Replace the core::mem::forget(output) pattern with let _foo = ...;
  current esp-hal makes Output non-Drop, so forget is a no-op that
  trips clippy's forget_non_drop lint.

- Switch justfile xtensa builds from `cargo +nightly` with a RUSTC
  override to `cargo +esp` so the compiler and rust-src versions stay
  aligned. Public nightly's rust-src had drifted two months ahead of
  the esp fork and broke -Zbuild-std on core.
2026-04-11 22:29:04 -06:00
swaits 7184610eea feat: migrate to embassy-executor 0.10
Bump embassy-executor 0.9 -> 0.10 and embassy-rp 0.9 -> 0.10 from crates.io.
Patch esp-hal, esp-rtos, esp-backtrace, esp-println from esp-rs/esp-hal main
branch (esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9).

API migrations:
- embassy-executor: arch-cortex-m -> platform-cortex-m feature rename
- embassy-executor: SpawnToken now returned as Result from task functions
- esp-hal main: efuse::Efuse::mac_address() -> efuse::base_mac_address()
- esp-hal main: esp_rtos::start() now requires SoftwareInterrupt<0>
- embassy-rp 0.10: SPI constructor requires DMA interrupt bindings

Also fixes UART display stuck on splash (sent Reset instead of On at boot)
and switches just check to clippy -D warnings.
2026-04-11 02:34:04 -06:00
swaits 46822c182c feat: add Waveshare RP2040-LoRa board support
First RP2040 board and third MCU family (after ESP32-S3 and nRF52840).

Hardware: RP2040 + SX1262 radio, 850-930 MHz HF, PE4259 antenna switch,
LED on GPIO25, no OLED, USB-C via FPC ribbon cable to adapter board.

New files:
- src/hal/rp2040.rs: MCU primitives (SPI bus, USB driver, flash unique ID MAC)
- src/board/waveshare_rp2040_lora.rs: board implementation
- ld/rp2040-memory.x: linker memory layout

Also fixes cortex-m-rt/set-vtor (moved from global dep to per-board feature,
since Cortex-M0+ has no VTOR register) and makes build.rs memory.x selection
target-aware.
2026-04-10 16:05:08 -06:00
swaitsandClaude Opus 4.6 2b7aacdb99 docs: fix stale path reference in changelog
clients/python/ → donglora on PyPI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:46:12 -06:00
swaitsandClaude Opus 4.6 cabc8c1cc0 feat: add espflash save-image for Xtensa boards
Generate .bin firmware images for ESP32 boards alongside .elf, matching
the existing .hex/.uf2 flow for nRF52840 boards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:34:25 -06:00
swaits 0cbf59765c docs: add missing LICENSE file 2026-04-07 19:52:52 -06:00
55 changed files with 12835 additions and 2205 deletions

No files matched your search

+7
View File
@@ -11,3 +11,10 @@ rustflags = [
"-C", "link-arg=-Tdefmt.x",
"-C", "opt-level=z",
]
[target.thumbv6m-none-eabi]
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
"-C", "opt-level=z",
]
+660 -1
View File
@@ -5,6 +5,665 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.5.2] - 2026-05-11
### Changed
- Refreshed `Cargo.lock` to pull in the latest compatible patch/minor
releases of dependencies. Notable bumps:
- `donglora-protocol` 1.1.0 → 1.2.0
- `esp-hal` 1.1.0 → 1.1.1
- `heapless` 0.9.2 → 0.9.3 (propagates through embassy + esp crates)
- `embassy-usb-driver` 0.2.0 → 0.2.1
- `embassy-usb-synopsys-otg` 0.3.2 → 0.3.3
- `enumset` 1.1.10 → 1.1.12
- `digest` 0.11.2 → 0.11.3, `hashbrown` 0.17.0 → 0.17.1,
`hybrid-array` 0.4.11 → 0.4.12, `cc` 1.2.61 → 1.2.62
No source changes; all 10 board targets compile clean and host-side
protocol tests pass.
## [1.5.1] - 2026-05-11
### Fixed
- **`LoRaConfig.sync_word` is now actually applied to the SX126x.**
`reconfigure_radio` accepted the host's `sync_word` field in name
only — the value was stored in `self.config` but never written to
the chip's `LoRaSyncwordMSB`/`LoRaSyncwordLSB` register pair
(0x0740/0x0741, SX1261/2 datasheet Rev 2.2 §13.4.4). The chip sat
on whatever `LoRa::new(_, false, _)` set at cold init —
`LORAWAN_PRIVATE_SYNCWORD` (byte `0x12`, register pair `0x1424`).
Everyone got byte `0x12` regardless of the requested value. Worked
by accident for MeshCore (which uses byte `0x12`); silently broke
any host trying to talk to a non-MeshCore network. Fix wires
`cfg.sync_word` through to `lora.set_lora_sync_word(byte)` after
`enter_standby`, using the new lora-phy API from
[lora-rs/lora-rs#433](https://github.com/lora-rs/lora-rs/pull/433).
The `u16` protocol field is decoded back to its underlying byte
(`byte = (hi & 0xF0) | (lo >> 4)` after `to_be_bytes()`), the
inverse of lora-phy's `convert_sync_word` nibble-expansion. Honors
SX1261/2 §8.3.1 (BUSY-low precondition) via the existing
`ensure_ready + conditional set_standby` preamble in
`LoRa::set_lora_sync_word`.
- **First TX after `SET_CONFIG` no longer fails with `ERR(ERadio)` on
a freshly-booted chip.** The chart's `prep_for_cad_act`
(`TxCadPrep` entry) and `prep_for_tx_act` (`TxPrep` entry) both
read `ctx.mdltn` and emit `TransmitFailed` → `ERR(ERadio)` if it's
`None`. But `ctx.mdltn` was only ever assigned on the RX path —
`try_start_rx_hw` (`RxArming` entry) and `cache_rx_pkt`
(`RxSession` entry). `reconfigure_radio` already called
`to_lora_mod_params(lora, cfg)` during `SET_CONFIG` but discarded
the result via `let _ = …?;` (the dropped comment claimed image
calibration; the function is pure compute on SX126x). Hidden in
practice by any prior `RX_START` (ai-bot, `ping_pong --role rx`,
etc.) leaving `ctx.mdltn` populated until the next reflash. Fix
threads the `ModulationParams` out of `reconfigure_radio` as its
return value, and `apply_set_config` caches it in `self.mdltn`
alongside `self.config`. First TX on a fresh boot now works
without a preceding `RX_START`.
### Changed
- **`lora-phy` `swaits/lora-rs#fixed` bumped from `e8a52ebd` →
`043d7659`.** The fork's `fixed` merge now includes a new
`feat/set-sync-word` branch alongside the three pre-existing
`fix/*` branches.
[lora-rs/lora-rs#433](https://github.com/lora-rs/lora-rs/pull/433)
adds the runtime `LoRa::set_lora_sync_word(u8)` API the radio fix
above depends on (was unavailable in stock lora-phy; the only
public sync-word entry points were `with_syncword` /
`new(_, public_network: bool, _)` at construction time). Strictly
additive in lora-phy: pure addition, no behavioral change to
`init_lora` / `with_syncword` / any existing path.
- **`hsmc` 0.5 → 0.6.** Patch bump tracked via `cargo update -p
hsmc`; no behavior change in this crate's usage. Pulled in
separately from the radio fixes so the dep churn doesn't blur the
fix commits.
## [1.5.0] - 2026-04-30
### Added
- **Heltec Mesh Node T114 board support** (nRF52840 + SX1262 + 1.14"
240×135 ST7789 color TFT). The first color display board in the
project, and the third nRF52840 + SX1262 board alongside the
RAK4631 and Wio Tracker L1.
- `VEXT_ENABLE` (P0.21) driven HIGH at boot to power the SX1262 +
TFT rail. Without this, lora-phy init succeeds at the SPI level
but TX_DONE never fires.
- `VTFT_CTRL` on P0.03 (active-LOW); PWM backlight on P0.15
(active-LOW, inverted) for smooth dim/bright transitions.
- TFT SPI2 (SCK P1.08 / MOSI P1.09) at 8 MHz over EasyDMA, async
`ExclusiveDevice` from `embedded-hal-bus`.
- Linker uses the shared `ld/nrf52840-memory.x` so the S140
SoftDevice region (0x01000–0x26000) is preserved per project
policy.
- **`BoardDisplay` trait** (`src/display.rs`) bundles `render_*`,
`present()`, `set_bright()`, and `set_dim()`. Replaces the previous
`DrawTarget<Color = BinaryColor>` + `DisplayBrightness` combo as
the `LoRaBoard::DisplayDriver` bound, so each board renders in its
native pixel format and resolution without aliasing or
color-quantization round-trips. Existing mono OLED drivers
(`Sh1106`, `Ssd1306Async`) get adapter impls that delegate to the
existing `display::render` module — no behavior change for any
previously supported board.
- **Async ST7789 driver** (`src/driver/st7789.rs`) wrapping
`lcd-async` (async fork of `mipidsi`) with a 64,800-byte raw-byte
framebuffer in `.bss`. Renders via
`lcd_async::raw_framebuf::RawFrameBuf<Rgb565, _>` and presents via
a single async DMA blit (`panel.show_raw_data(...).await`); the
await yields the executor for the ~65 ms SPI transfer so the radio
task isn't starved during repaints.
- **Native 240×135 RGB565 renderer** (`src/display/render_color.rs`)
with splash, dashboard, and sparkline parity with the mono path,
plus a shared QR-code blit (`src/display/qr.rs`) generic over
`PixelColor`. The blit re-centers the 64×64 bitmap (cells in cols
0..50 with ~14 px right-side padding), so both mono and color
splash now show the QR with 7 px quiet zones on all four sides
(was visibly left-shifted on mono).
- **Shared content types** (`BoardInfo`, `DashboardCtx`) decouple
*what* to draw from *how* each board renders it. `create_display()`
now takes the board's full `DisplayParts` bundle (was just the I2C
bus), so non-I2C displays can carry SPI + control pins through.
- **IDF app descriptor on every ESP image.** New
`esp-bootloader-esp-idf` 0.5 dependency (chip-feature-gated like
the rest of the `esp-*` stack) plus `esp_app_desc!()` in
`src/main.rs`, which emits the `.flash.appdesc` section that
`espflash` v4 requires in every ESP binary.
### Changed
- **ESP build modernized to upstream crates.io.** Removed the
`[patch.crates-io]` block that pinned `esp-hal`, `esp-rtos`,
`esp-backtrace`, and `esp-println` to `esp-rs/esp-hal#main`. The
versions tracked there (`esp-hal 1.1`, `esp-rtos 0.3`,
`esp-backtrace 0.19`, `esp-println 0.17`) have all reached
crates.io with the `embassy-executor` 0.10 compatibility we needed,
so the local fork pins are no longer required. `cargo:espflash`
bumped to v4 in `mise.toml` to match.
- **`hsmc` 0.5.0 → 0.5.1.** Patch bump pulled in transparently with
the lockfile refresh; no behavior change.
- **Documentation consolidated.** `PROTOCOL.md` removed from the
firmware repo (it now lives canonically in the spec repo, linked
from `README.md`). `BLE_MUX_DESIGN_DOC.md` moved to
`docs/ble_mux_design.md` so all firmware design docs sit under
`docs/`.
## [1.4.1] - 2026-04-29
### Fixed
- **Major LoRa reliability boost across every radio family.** Switched
`lora-phy` from crates.io to the
[`swaits/lora-rs#fixed`](https://github.com/swaits/lora-rs/tree/fixed)
fork branch — a merge of three fixes awaiting upstream PR acceptance,
pinned at `e8a52ebd` via `Cargo.lock`:
- [#428](https://github.com/lora-rs/lora-rs/pull/428) — sx126x:
pre-command BUSY check + standby/IRQ-clear hygiene around CAD.
- [#431](https://github.com/lora-rs/lora-rs/pull/431) — sx127x:
clear stale IRQ flags in `do_rx` before mode change.
- [#432](https://github.com/lora-rs/lora-rs/pull/432) — sx127x:
clear stale IRQ flags before reconfiguring DIO mapping in
`set_irq_params`.
Stability gain across the fleet: the sx126x BUSY-race fix lands on
every nRF / RP2040 board, and the sx127x IRQ-clear fixes unblock the
LilyGo T-Beam (classic) in particular.
- **UART host RX delivery on UART-only boards.** `RADIO_THROTTLED` was
never cleared on UART (`heltec_v3_uart`, `elecrow_thinknode_m2`,
`lilygo_tbeam`) — the throttle gate added in v1.3.0's chart-first
restructure silently dropped every async RX from boot. Mirrored the
USB pattern: clear the throttle on successful `tx.write_all`, re-arm
on failure.
- **UART host watchdog spurious-trip under saturated bidirectional
load.** The `select3`-based loop serialised read and write;
`tx.write_all` (~25 ms per max frame at 115200 baud) blocked the read
poll long enough for `last_frame_at` to age past 1000 ms, cascading a
Reset to radio (`Unconfigured`) and display (splash) and leaving
subsequent host TX commands timing out. Restructured `host_task` into
two sub-loops joined in the same task: `read_loop` owns rx +
watchdog, `write_loop` owns tx + throttle. Same executor, disjoint
borrows on the split rx/tx halves, no Mutex needed. Also reset the
`FrameDecoder` on watchdog fire so a host that resumes mid-frame
resyncs cleanly on the next `0x00` sentinel (USB gets this for free
via DTR-drop reset; UART has no DTR).
- **Splash board-name overflow on long names.** Names that don't fit
`FONT_6X10` (21 ASCII chars on a 128 px-wide OLED) now fall back to
`FONT_5X8` (25 chars). A `const_assert` at the `BOARD_NAME`
definition rejects names that overflow even the fallback, so the
board porter shortens `NAME` at compile time rather than getting
silent overflow at runtime. Also dropped the URL-line font on the
"learn more" screen from `FONT_5X8` to `FONT_4X6` so `donglora.com`
sits inside the 64 px column with breathing room instead of touching
both edges.
### Changed
- **`hsmc` 0.4 → 0.5.** `default(...)` is optional on every state in
0.5; collapsed the `WaitingForFirstFrame` substate that existed only
because 0.4 required `Connected` to declare a default child.
`Connected` itself is now the resting "DTR up, no traffic yet" state,
with `on(FrameReceived) => Active` descending into the
inactivity-timer state on the first byte. Behavior is identical
(entry/exit chain math unchanged); the host/usb chart has one fewer
state and one fewer `default()`. Display and Radio charts left as-is
— their defaults serve real grouping/lifecycle purposes, not 0.4
artifacts.
- **ESP cross-toolchain pinned in `mise.toml`.** `[env]` now owns the
PATH (`xtensa-esp-elf-gcc` bin) and `LIBCLANG_PATH`, so any `mise
exec` resolves the linker without sourcing espup's stray
`~/export-esp.sh`. New `[tasks.esp-install]` invokes espup with the
matching crosstool version pin. `cargo:espup` pinned to `0.17.1` to
match the `[env]` paths. Triggered by a real failure where
`~/export-esp.sh` was moved to Trash and the ESP build broke with
`linker xtensa-esp32s3-elf-gcc not found`. Bumping ESP versions
remains a two-touch change: edit version dirs in BOTH
`[tasks.esp-install]` and `[env]`.
- **ESP dependency requirements bumped** to track the upstream
`esp-rs/esp-hal` main branch: `esp-hal 1.0 → 1.1`, `esp-rtos 0.2 →
0.3`, `esp-backtrace 0.18 → 0.19`, `esp-println 0.16 → 0.17`. `cargo
update` had silently invalidated the `[patch.crates-io]` entries (the
patched versions were no longer semver-compatible with the old
top-level requirements), dropping the patches and falling back to
crates.io releases that pin `embassy-executor 0.9` — mismatched with
our `0.10`. Catching this keeps the patch suite consistent until the
planned `[patch.crates-io]`-removal pass.
## [1.4.0] - 2026-04-25
### Added
- **RAK4631 WisBlock board support** (nRF52840 + SX1262, RAK19007 base
+ RAK4631 Core + RAK1921 OLED). The second nRF52840 + SX1262 board
alongside the Wio Tracker L1.
- `SX126X_POWER_EN` (P1.05) driven HIGH at init — the radio is
power-gated on this module; without this the chip has no power
and SPI hangs.
- HFXO + LFXO external crystals selected in `embassy_nrf::Config`.
USB CDC-ACM needs ±500 ppm which the internal RC oscillator
can't provide.
- `SoftwareVbusDetect(true, true)` so USB enumerates without a
`CLOCK_POWER` interrupt handler.
- Green LED on P1.03 (RAK19007 Base LED1, active-HIGH) via
`SimpleLed`.
- Hardware-validated end-to-end: ADVERT round-trip with an external
receiver on US915.
- Added `debug-checkpoint` blink-map coverage at every TX-phase
boundary in `radio.rs`, including a new checkpoint between
`prepare_for_cad` and `cad()` so future bring-ups can distinguish
a modem-setup hang from a DIO1-wait hang.
- **Adaptive listen-before-talk.** The CAD pre-TX retry loop scales to
the active LoRa config instead of running a fixed 10 retries × 10 ms
(which was roughly one max-length packet at SF7/BW500 and a no-op at
SF12/BW125).
- Per-retry sleep is sized to the time-on-air of an assumed-peer
80-byte packet under the current SF/BW/CR — cached on
`RadioContext`, recomputed on every `SET_CONFIG`.
- Retry count = `clamp(floor(BUDGET/T)+1, 1, 10)` with `BUDGET = 2.5
s`. Fast configs still get up to 10 retries; SF11/12 degrade to
single-CAD-and-bail (logged at config-apply).
- Sleeps jittered ±20% per iteration to decorrelate
simultaneously-backing-off contenders.
- ToA math, CR→{1..4} mapping, retry-count derivation, and jitter
helper live in a pure `src/lbt.rs` module — host-testable via
`DONGLORA_HOST_TEST=1 cargo test` (13 tests).
- Validated on-device: 20 iterations of the AI Bot test flow that
previously saw ~2 `ChannelBusy` errors now sees zero at the same
SF/BW.
### Fixed
- **First-TX-after-boot reliability on SX1262 boards.** Picked up the
upstream `lora-phy` fix that moves the BUSY-handshake check from
*after* to *before* each SPI op, per Semtech SX1261/2 §8.3.1, LR1121
UM §3, and LR1110 DS §1.2.5. The existing post-op wait is preserved
as the belt that closes the BUSY-assertion latency window
(T_SW ≤ 600 ns) that a single level-poll can miss. Consumed via
`[patch.crates-io]` against `swaits/lora-rs#fix/sx126x-busy-race`
until the upstream PR merges.
- Hardware-verified on Seeed Wio Tracker L1: 20/20 clean-boot TX
(vs ~50% before).
- Renames `InterfaceVariant::wait_on_busy` → `wait_until_ready` to
match the new semantics.
- **BUSY pin pull-down → none on nRF SX1262 boards.** SX1262's BUSY
pin has an internal 20 kΩ pull-up (datasheet p. 53); our
`Input::new(..., Pull::Down)` was contending with it, leaving BUSY
at an indeterminate voltage and intermittently mis-reading the
chip's ready state. Per `lora-rs/lora-rs#260`, switched to
`Pull::None` on both Wio Tracker L1 and RAK4631. The RAK4631
appeared to work before, but was affected by the same class of bug
per the upstream issue — now deterministically correct rather than
"happens to work".
### Changed
- **Build-system DX.** `just flash`, `just flash-probe`, and
`just build` accept an optional comma-separated `extra_features`
argument that is appended to the board feature, e.g. `just flash
wio_tracker_l1 debug-checkpoint`. No more dropping to raw
`cargo + rust-objcopy + uf2 cp` for iteration loops.
- **`just` recipes now resolve all tools through `mise`** via
`set shell := ["mise", "exec", "--", "sh", "-c"]`. `cargo`,
`espflash`, `rust-objcopy`, `cargo-hex-to-uf2` etc. always pick up
the versions pinned in `mise.toml` regardless of `PATH` ordering.
- **`mise.toml` rust pins now carry ARM targets + clippy**, so a fresh
`mise install` is sufficient bootstrapping — no manual
`rustup target add` for `thumbv6m-none-eabi` /
`thumbv7em-none-eabihf` and no separate clippy install.
- **New `just flash-dfu` recipe** for the Adafruit nRF52 bootloader
(VID:PID `239a:0029`) via `adafruit-nrfutil` under `uvx`, with port
autodetect.
- **Per-board `_info` chain in the justfile** collapsed from a long
per-board `if/elif` ladder + per-board `:=` variables into a single
`case` statement — one place per board, two lists (names +
metadata).
## [1.3.1] - 2026-04-23
### Fixed
- **LilyGo T-Beam (classic) red LED now flashes on TX/RX** instead of
dimming off. The red LED under the OLED is wired active-LOW on this
board (anode to V+, cathode to GPIO4); v1.3.0 drove it as active-HIGH,
so the LED sat lit at idle and briefly dimmed during activity.
- Empirically verified on hardware against both T-Beam variants.
- Introduces `src/driver/inverted_pin.rs::InvertedPin<P>`, a generic
`OutputPin` wrapper that swaps `set_high`/`set_low`. Polarity is
expressed at the pin (where the physical wiring lives), so
`SimpleLed` stays polarity-agnostic and every other board's code
path is byte-for-byte unchanged.
- Classic T-Beam now constructs its LED as
`SimpleLed(InvertedPin(led_pin))` with pin init at `Level::High`
(dark at boot).
## [1.3.0] - 2026-04-23
### Added
- LilyGo T-Beam S3 Supreme board support (ESP32-S3 + SX1262 + SH1106
OLED + AXP2101 PMIC, native USB-C).
- Pin map verified against MeshCore's
`variants/lilygo_tbeam_supreme_SX1262`.
- New `src/driver/axp.rs` module with an AXP2101 init sequence
(ALDO3 = 3.3 V LoRa, ALDO1 = 3.3 V OLED). The PMIC gates LoRa and
display power — without it the SX1262 never responds on SPI.
- PMIC lives on a dedicated I²C1 bus (SDA=42, SCL=41), separate
from the SH1106 on I²C0 (SDA=17, SCL=18). I²C1 is used blocking
for a one-shot init and then dropped.
- TCXO driven at 1.6 V via DIO3 (MeshCore uses the same).
- LilyGo T-Beam (classic) board support (ESP32 + SX1276 + SSD1306
OLED + AXP192 PMIC, CP2102 USB-UART bridge). This is the first
board in the firmware on classic ESP32 (non-S3) and the first on
the SX127x radio family.
- Pin map verified against MeshCore's `variants/lilygo_tbeam_SX1276`.
- New `src/hal/esp32.rs` (classic-ESP32 MCU primitives, peripheral
DMA via `DMA_SPI2`) and `src/board/esp32.rs` (SX1276 radio helper
+ SSD1306 display pipe).
- AXP192 init added to `src/driver/axp.rs`: DCDC1 = 3.3 V for the
OLED, LDO2 = 3.3 V for the SX1276 PA_BOOST rail. Shares the
OLED I²C bus (SDA=21, SCL=22); the I²C instance is built in
blocking mode for PMIC init and converted to async for the
display task.
- SX127x support via lora-phy 3's `Sx127x<Sx1276>` +
`GenericSx127xInterfaceVariant` (DIO0 as the sole IRQ; no
BUSY line on SX127x). XTAL clocked (no TCXO), `tx_boost = true`
for the PA_BOOST path.
- `LoRaBoard` trait gains `supported_sf_bitmap()` and
`supported_bw_bitmap()` methods with SX126x defaults
(`0x1FE0`, `0x03FF`). T-Beam classic overrides SF to `0x1FC0`
(SF6–SF12) — the SX1276 has no SF5.
- `radio_chip_id()` reports `0x0011` (`RadioChipId::Sx1276`) and
`freq_range_hz()` widens to 137 MHz–1020 MHz per the SX1276
datasheet.
### Changed
- `Cargo.toml`: the `esp32s3` chip feature has been moved out of
the `esp-hal` / `esp-rtos` / `esp-backtrace` / `esp-println`
dependency lines and into each ESP board's feature list (as
`esp-hal/esp32s3`, etc.). This lets `lilygo_tbeam` select the
classic `esp32` chip flavour from the same dependency set.
## [1.2.1] - 2026-04-23
### Fixed
- **Radio no longer drops to Idle mid-RX-session on transient failures.**
Extends the v1.1.0 invariant ("`SET_CONFIG` during RX auto-re-arms")
to all failure paths that can occur while a client has RX active:
- `Receiving.on(RxFailed) => Idle` now transitions to `ResumingRx`
instead of `Idle`. A transient `next_packet` error re-arms the chip
rather than ending the RX session.
- `TransmittingFromRx.on(TransmitFailed) => Idle` now transitions to
`ResumingRx`. TX failure during an active RX session returns
`TX_FAILED` to the caller and resumes RX, matching the shape of the
adjacent `ChannelBusy` handler.
- `ResumingRx.on(StartRxFailed) => Idle` is now a bounded retry
(5 × 25 ms backoff via a new `bump_resume_retries` action and a
`ResumeRxRetry` / `ResumeRxExhausted` pair of internal events).
The chip only lands in `Idle` after exhausting the retry budget —
which means it's genuinely wedged and needs reconfiguring.
Visible effect: the OLED dashboard no longer bounces to Splash when
`ai-bot` (or any other client that TXes while RX is running) sends a
packet. Previously any of the three transitions above would fire
`RadioEvent::Idle`, which the display uses as its `Dashboard → Splash`
trigger; the radio would land in `Idle` with no client aware enough
to re-send `RX_START`, stranding the dashboard until the session was
manually torn down and restarted.
- Removed `announce_rx_failed` helper action: its only purpose was
emitting `RadioEvent::Idle` on the now-deleted `Receiving.on(RxFailed)
=> Idle` edge. No replacement needed — `ResumingRx` handles recovery
silently.
## [1.2.0] - 2026-04-22
### Added
- **OLED auto-dim after inactivity.** The display is bright on any
activity — splash entry, mode change into a dashboard leaf, packet
RX/TX — and dims after a short idle window. Splash holds bright for
10 s, then drops to dim while the LearnMore / Info carousel keeps
alternating. The dashboard stays bright for 3 s after the last
packet or mode change, then dims in place without changing
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
bright→bright with no intermediate dim — `set_dim` is fired as an
action by the 3 s timer handler rather than as a sibling `exit:`
action, so mode-change leaf-crossings never see a flash.
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
and the in-tree `Sh1106` driver. Contrast values centralized as
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
the two levels can be tuned in one place.
### Changed
- `src/display.rs` statechart restructured:
- `Splash` gains `entry: set_bright` plus an action-only
`on(after 10s) => set_dim` handler so the splash bright window
is time-scoped without disturbing the LM↔Info alternation.
- `Dashboard` `Listening` and `Transmitting` each gain a nested
`*Bright` substate owning the `entry: set_bright` action. Packet
handlers moved into the leaves and target the leaf's `*Bright` —
sibling-to-sibling within the leaf restarts the 3 s timer without
re-entering the leaf itself (paint / 1 s sparkline timer
preserved). The 3 s timeout handler fires `set_dim` as an action
and up-transitions to the leaf, parking the machine at the leaf
with no active child.
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
would only unwind below `Splash` and park with no active child,
leaving the LM↔Info alternation timer dead and the QR frozen on
screen.
- `LoRaBoard::DisplayDriver` trait bound extended with
`+ DisplayBrightness` so brightness is part of the board contract
alongside `DrawTarget<Color = BinaryColor>`.
### Fixed
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
the CH340K / CH340 VID:PIDs directly instead of falling through
to espflash auto-detect.
### Dependencies
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
to an already-active ancestor unwinds only the subtree strictly
below the target; no exit/entry on the target; no default descent).
## [1.1.0] - 2026-04-22
Small but high-value behavior change: `SET_CONFIG` issued while the
radio is in continuous RX no longer drops the radio (and the OLED)
back to idle. The radio now auto-re-arms RX with the new modulation
once reconfiguration completes, matching DongLoRa Protocol 1.1's
§3.5 / §6.3 amendments.
### Changed
- `src/radio.rs`: inside `state Receiving`, add `on(ConfigApplied)
=> ResumingRx` to shadow the parent `Configured` state's
`on(ConfigApplied) => Idle` handler. The reconfigure path now
runs `apply_set_config` → `ConfigApplied` → `ResumingRx.entry:
try_start_rx_hw` → `StartRxOk` → `Receiving` without ever
entering `Idle`.
- The OLED's `Dashboard → Splash` transition was previously driven
by `RadioEvent::Idle` firing on `Configured.Idle.entry`. That
event no longer fires during a SET_CONFIG-from-Receiving
sequence, so the display stays on Dashboard.
### Dependencies
- `donglora-protocol` bumped to `1.1.0` (spec-only change; matches
the new normative text the firmware now implements).
### Use-case unlock
- Scanner / time-division / dynamic-modulation workflows that
rotate LoRa parameters mid-RX no longer need to re-issue
`RX_START` after every `SET_CONFIG`. One call, radio continues
to receive.
## [1.0.0] - 2026-04-22
The 1.0 release. Firmware now speaks DongLoRa Protocol v2 end to end,
depends on the freshly-published `donglora-protocol` 1.0.0 and `hsmc`
0.1.0 from crates.io, and ships GitHub Release binaries for every
supported board.
### Breaking
- **DongLoRa Protocol v2 wire format** (wire-incompatible with 0.x
clients and firmware). Every `type_id`, tag-correlation rule, and
field layout follows `PROTOCOL.md` v1.0.
- **Host framing replaced.** `src/host/framing.rs` now drives a
streaming `FrameDecoder` from `donglora-protocol` and emits
`(type_id, tag, payload)` frames; the 0.x COBS-per-response shim is
gone.
- **Radio task reshape.** `src/radio.rs` emits v2 `TX_DONE` /
`SetConfigResult` / `RxPayload` / `RxOrigin` shapes directly; no 0.x
translation layer remains.
### Changed
- `hsmc` switched from path-dep on the local workspace to
`version = "0.1.0"` from crates.io.
- `donglora-protocol` switched from path-dep on `../protocol` to
`version = "1.0.0"` from crates.io; firmware builds are now
reproducible from a clean checkout with no sibling repo required.
- `PROTOCOL.md` updated to the DongLoRa Protocol v2 normative spec,
kept byte-for-byte in sync with the `donglora-protocol` crate's
copy.
### Infrastructure
- Cargo.toml `repository` + `homepage` now point at the canonical
`https://github.com/donglora/firmware` (the `swaits/donglora`
repo is archived).
## [0.5.0] - 2026-04-16
### Added
- BLE soft-mesh multiplexer design document (`BLE_MUX_DESIGN_DOC.md`) —
specification for turning a dongle from a 1:1 USB device into a shared
near-field radio gateway for multiple BLE-attached phones. Three-phase
rollout (v1 MVP, v2 hardening, v3 multi-dongle mesh) with regulatory
duty-cycle and TX-access-control enforcement from v1. Targets nRF52840
first; ESP32-S3 reserved for v2.
- `debug-checkpoint` cargo feature with a dedicated `debug_blink` task and
a latching atomic checkpoint — LED-pattern diagnostic for UART-only
boards where defmt output would corrupt the host protocol. Zero
footprint when the feature is off.
### Changed
- Radio task rewritten as an `hsmc` statechart (`Unconfigured` /
`Configured.{Idle, StartingRx, Receiving, ResumingRx,
Transmitting.{FromIdle, FromRx}}`). The main loop is now a single
`machine.run().await`; command reading, packet RX, and TX are
state-scoped `during:` activities. Post-TX RX re-arm is modeled as
state identity (`TransmittingFromRx` → `ResumingRx`) instead of a
`was_receiving` flag.
- Display task rewritten as an `hsmc` statechart (`Off` /
`On.{Splash.{LearnMore, Info}, Dashboard.{Listening, Transmitting}}`).
The 5 s splash alternation and 1 s sparkline tick use `on(after …)` /
`on(every …)` transitions — no external timer task.
- USB host task now carries a small `hsmc` statechart (`Disconnected` /
`Connected`) whose entry/exit actions handle display wake, radio
`StopRx`, and display reset — replacing the open-coded `was_connected`
edge detector.
- Radio → Display channel switched from a `RadioStatus` watch to a typed
`RadioEvent` queue. The radio emits discrete `EnteredRx` / `EnteredTx`
/ `Idle` / `PacketRx` / `PacketTx` / `ConfigChanged` events; downstream
consumers observe transitions directly rather than diffing snapshots.
- Wire protocol types and COBS framing moved out of firmware into the
shared `donglora-protocol` crate (path dep on `../protocol`). Firmware
`src/protocol.rs` is now a re-export shim preserving every call site;
`src/host/framing.rs` keeps only the embassy-specific `route_command`.
### Fixed
- USB disconnect recovery: on a read error the DTR-edge-detect branch
now runs unconditionally (was early-continuing in some paths, starving
the display-reset / radio-stop handoff).
## [0.4.0] - 2026-04-11
### Added
- ELECROW ThinkNode-M2 board support (ESP32-S3 + SX1262 + 1.3" SH1106 OLED
via CP2102 UART bridge, 1000 mAh Li-Po handheld)
- Pin map verified against Meshtastic's
`variants/esp32s3/ELECROW-ThinkNode-M2`
- DIO3 TCXO driven at 3.3 V (not 1.8 V like Heltec)
- Dedicated `LORA_POWER_EN` (GPIO48) sequenced HIGH before SPI init
- `VEXT_ENABLE` (GPIO46) is active HIGH — opposite polarity from Heltec
- Reuses the existing async SH1106 driver at I2C address 0x3C
### Changed
- `hal::esp32s3::{init_spi, init_i2c}` and `board::esp32s3::init_radio` are
now generic over GPIO type, so ESP32-S3 boards with different pin
assignments can share the helpers.
- `board::esp32s3::init_radio` takes a `TcxoCtrlVoltage` parameter instead
of hardcoding `Ctrl1V8` — needed for ThinkNode-M2's 3.3 V TCXO.
- SSD1306 display items in `board::esp32s3` are now cfg-gated to Heltec
boards (ThinkNode-M2 supplies its own SH1106 `create_display`).
- `justfile` uses `cargo +esp` for Xtensa targets instead of `cargo +nightly`
with a `RUSTC=esp` override — the self-contained esp toolchain keeps its
cargo, rustc, and rust-src versions aligned. Public nightly's rust-src
had drifted ahead of the esp fork, breaking `-Zbuild-std`.
- Migrated to embassy-executor 0.10 and embassy-rp 0.10
- ESP crates (esp-hal, esp-rtos, esp-backtrace, esp-println) patched from
esp-rs/esp-hal main branch until esp-rtos publishes with 0.10 support
- Adapted to embassy-executor 0.10 SpawnToken API (task functions now return
`Result<SpawnToken, SpawnError>`, `Spawner::spawn` no longer returns Result)
- Adapted to esp-hal main branch API changes: `efuse::base_mac_address()`,
`esp_rtos::start` now requires `SoftwareInterrupt`, embassy-rp SPI
constructor now requires DMA interrupt bindings
- `just check` now runs clippy with `-D warnings` instead of plain `cargo check`
- Rust toolchain bumped to 1.94 in mise.toml
### Fixed
- `core::mem::forget(output)` pattern in ESP32-S3 board files is now
`let _vext = Output::new(...)` — current esp-hal makes `Output` non-Drop,
which made `forget` a no-op and tripped clippy's `forget_non_drop` lint.
- UART boards (heltec_v3_uart) display stuck on splash screen after boot.
Host task sent `DisplayCommand::Reset` (which sets disconnected=true) instead
of `DisplayCommand::On`. UART has no DTR, so nothing ever cleared it.
## [0.3.0] - 2026-04-10
### Added
- Waveshare RP2040-LoRa board support (RP2040 + SX1262, no OLED, LED on GPIO25)
- First RP2040 board — third MCU family after ESP32-S3 and nRF52840
- New `hal/rp2040.rs` module with MCU primitives (SPI, USB, flash unique ID)
- USB-C via FPC ribbon cable to adapter board
- PE4259 antenna switch on GPIO17, 850-930 MHz (HF)
### Changed
- `cortex-m-rt/set-vtor` moved from global dependency to per-board feature
(Cortex-M0+ has no VTOR register)
- `build.rs` memory.x selection is now target-aware (thumbv6m vs thumbv7em)
## [0.2.1] - 2026-04-07
### Fixed
@@ -72,7 +731,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Config validation against per-board hardware limits
- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power
- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631
- Python host library (`clients/python/`) with `pip install` and `donglora-mux` CLI
- Python host library (`donglora` on PyPI) with `pip install` and `donglora-mux` CLI
- USB multiplexer for sharing one dongle with multiple applications
- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`)
Generated
+1174 -126
View File
File diff suppressed because it is too large. Load diff
+217 -25
View File
@@ -3,118 +3,306 @@ members = ["."]
[package]
name = "donglora"
version = "0.2.1"
version = "1.5.2"
edition = "2021"
build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
repository = "https://github.com/swaits/donglora"
repository = "https://github.com/donglora/firmware"
homepage = "https://github.com/donglora/firmware"
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
categories = ["embedded", "hardware-support", "no-std"]
# UPGRADE BLOCKED: embassy-executor 0.10 and ucobs 0.3 need Rust >= 1.93
# stable. The ESP Xtensa toolchain (espup) is a nightly fork that may not
# support the required language features or pass MSRV gating. Revisit when
# espup catches up. Check: rustup run esp rustc --version
[dependencies]
cfg-if = "1.0"
critical-section = "1.2"
defmt = "1.0"
defmt = "0.3"
embedded-hal = "1.0"
embedded-hal-async = "1.0"
embassy-embedded-hal = "0.6"
embassy-executor = "0.9"
embassy-executor = "0.10"
embassy-sync = "0.8"
embassy-time = "0.5"
embassy-usb = { version = "0.6", features = ["defmt"] }
embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
lora-phy = "3.0"
donglora-protocol = "1"
hsmc = { version = "0.6", default-features = false, features = ["embassy", "trace-defmt"] }
# Fork branch carrying CAD IRQ-clear / standby-after-CAD fixes awaiting upstream PRs.
# Switch back to crates.io once changes are upstreamed.
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
portable-atomic = { version = "1", default-features = false, features = ["critical-section"] }
static_cell = "2.1"
# Display rendering
display-interface = "0.5"
embedded-graphics = "0.8"
ssd1306 = { version = "0.10", features = ["async"] }
# T114 ST7789 TFT (color, async SPI). Optional — only pulled in by the
# heltec_mesh_node_t114 feature. Mono OLED boards keep their existing
# display-interface + ssd1306/sh1106 stack and don't pay for these.
#
# `lcd-async` is an async fork of mipidsi: same model/builder shape but
# `init()` and pixel writes are `async fn`s. Combined with embassy-nrf's
# DMA-backed async Spim, the dashboard repaint yields the executor for
# the duration of the SPI transfer instead of busy-looping for ~65 ms,
# so the radio task isn't starved.
#
# embedded-hal-bus needs the `async` feature for `ExclusiveDevice` to
# implement `embedded_hal_async::spi::SpiDevice` (in addition to the
# blocking impl).
lcd-async = { version = "0.1", optional = true }
embedded-hal-bus = { version = "0.3", optional = true, features = ["async"] }
# Async IO traits and combinators
embedded-io-async = "0.7"
embassy-futures = "0.1"
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
esp-backtrace = { version = "0.18", optional = true, features = [
"esp32s3",
# Xtensa ESP crates — requires esp toolchain (espup). The chip feature
# (esp32 / esp32s3) is selected per board via the board-level feature flag
# below, so the same dep lines support both classic ESP32 and ESP32-S3.
esp-backtrace = { version = "0.19", optional = true, features = [
"panic-handler",
"defmt",
] }
esp-hal = { version = "1.0", optional = true, features = [
"esp32s3",
esp-hal = { version = "1.1", optional = true, features = [
"unstable",
] }
esp-println = { version = "0.16", optional = true, features = [
"esp32s3",
esp-println = { version = "0.17", optional = true, features = [
"defmt-espflash",
] }
esp-rtos = { version = "0.2", optional = true, features = [
"esp32s3",
esp-rtos = { version = "0.3", optional = true, features = [
"embassy",
] }
# Emits the IDF app descriptor (`.flash.appdesc`) that espflash v4
# requires in every ESP binary. The chip subfeature is selected per
# board below alongside the other esp-* chip features.
esp-bootloader-esp-idf = { version = "0.5", optional = true, features = [
"defmt",
] }
# nRF52840 (RAK 4631)
cortex-m = { version = "0.7", optional = true, features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7", optional = true, features = ["set-vtor"] }
defmt-rtt = { version = "1.0", optional = true }
cortex-m = { version = "0.7", optional = true, features = [
"critical-section-single-core",
] }
cortex-m-rt = { version = "0.7", optional = true }
defmt-rtt = { version = "0.4", optional = true }
embassy-nrf = { version = "0.10", optional = true, features = [
"nrf52840",
"gpiote",
"time-driver-rtc1",
"defmt",
# UICR pin-config flags — must match what the Adafruit nRF52 bootloader
# actually programmed into UICR on this board. Verified empirically on
# RAK4631 via `probe-rs read b32 0x10001200 4`:
# PSELRESET[0/1] = 0x12 (P0.18 connected as RESET — NOT gpio)
# NFCPINS = 0xFFFFFFFE (bit 0 = 0 → NFC pins as GPIO)
# So `nfc-pins-as-gpio` matches; `reset-pin-as-gpio` would NOT match
# (would request GPIO when UICR has RESET enabled). The bootloader
# uses NRESET for its physical button-reset path, so leaving the pin
# as RESET is the right state.
"nfc-pins-as-gpio",
] }
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
# RP2040 (Waveshare RP2040-LoRa)
embassy-rp = { version = "0.10", optional = true, features = [
"rp2040",
"time-driver",
"defmt",
] }
[build-dependencies]
minijinja = "2.18"
owo-colors = "4.3"
[features]
# Bridge features for hsmc's macro-expanded `#[cfg(feature = "...")]`
# attributes. Macros expand into the *call-site* crate, so any cfg the
# macro emits is evaluated against THIS crate's feature set, not hsmc's.
# That means enabling `hsmc/embassy` or `hsmc/trace-defmt` via the dep
# only activates them inside hsmc's own compilation — for the cfg gates
# inside expanded macro bodies to fire here, we need same-named features
# in this Cargo.toml. `tokio` is declared just to silence the macro's
# `#[cfg(feature = "tokio")]` checks.
#
# `trace-defmt` must be in every board feature so chart trace lines
# (entries, exits, transitions, etc.) actually compile in. Without it,
# hsmc 0.4's `__chart_observe!` macro expands to nothing for the defmt
# arm and you get silent statecharts.
embassy = []
tokio = []
trace-defmt = []
# When enabled, replaces the display task with a LED-based diagnostic
# blinker driven by `crate::debug_blink::CHECKPOINT`. Used for diagnosing
# the firmware on UART-only boards where defmt output would corrupt the
# host protocol on the same UART. The LED flashes N times then pauses —
# a human watches the pattern and reports N to determine where execution
# reached / stopped.
debug-checkpoint = []
# NOTE: chart auto-tracing in hsmc 0.4 emits `__chart_observe!` calls
# whose defmt arm is gated on `#[cfg(feature = "trace-defmt")]`. That
# cfg is evaluated against THIS crate (proc-macro cfg quirk above), so
# every board feature includes `trace-defmt` to keep the arm live. The
# `trace;` keyword in `statechart!` blocks is deprecated in 0.4 and
# no longer gates emission. Whether trace lines actually emit at
# runtime is still controlled by defmt's standard compile-time level
# filter (`DEFMT_LOG` env var) -- release builds with `DEFMT_LOG`
# unset compile the calls out to nothing.
# When enabled, the SH1106 OLED driver emits a defmt::info! line for
# every I2C transaction it issues — bytes, lengths, addresses. Used to
# debug the embassy_nrf::twim assertion panic at twim.rs:541 (cannot
# happen via the documented call path; instrumenting the SH1106 path
# captures the actual transaction sequence preceding the panic so we
# can root-cause). Off by default; opt in via
# `just flash-attach rak_wisblock_4631 debug-twim`.
debug-twim = []
heltec_v3 = [
"embassy",
"trace-defmt",
"dep:esp-backtrace",
"dep:esp-bootloader-esp-idf",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32s3",
"esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3",
"esp-println/esp32s3",
"esp-rtos/esp32s3",
]
heltec_v3_uart = [
"embassy",
"trace-defmt",
"dep:esp-backtrace",
"dep:esp-bootloader-esp-idf",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32s3",
"esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3",
"esp-println/esp32s3",
"esp-rtos/esp32s3",
]
heltec_v4 = [
"embassy",
"trace-defmt",
"dep:esp-backtrace",
"dep:esp-bootloader-esp-idf",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32s3",
"esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3",
"esp-println/esp32s3",
"esp-rtos/esp32s3",
]
elecrow_thinknode_m2 = [
"embassy",
"trace-defmt",
"dep:esp-backtrace",
"dep:esp-bootloader-esp-idf",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32s3",
"esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3",
"esp-println/esp32s3",
"esp-rtos/esp32s3",
]
lilygo_tbeam_supreme = [
"embassy",
"trace-defmt",
"dep:esp-backtrace",
"dep:esp-bootloader-esp-idf",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32s3",
"esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3",
"esp-println/esp32s3",
"esp-rtos/esp32s3",
]
lilygo_tbeam = [
"embassy",
"trace-defmt",
"dep:esp-backtrace",
"dep:esp-bootloader-esp-idf",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32",
"esp-bootloader-esp-idf/esp32",
"esp-hal/esp32",
"esp-println/esp32",
"esp-rtos/esp32",
]
rak_wisblock_4631 = [
"embassy",
"trace-defmt",
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
wio_tracker_l1 = [
"embassy",
"trace-defmt",
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
heltec_mesh_node_t114 = [
"embassy",
"trace-defmt",
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"dep:lcd-async",
"dep:embedded-hal-bus",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
waveshare_rp2040_lora = [
"embassy",
"trace-defmt",
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-rp",
"dep:panic-probe",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
[lints.rust]
# hsmc 0.3's `statechart!` macro emits `#[cfg(feature = "journal")]`
# attributes (and others). check-cfg evaluates those in the CALLER's
# crate context, so unless donglora declares the same feature names
# (we don't — they're hsmc's, not ours), rustc lints them. Per hsmc's
# README, the documented fix is to allow the lint with an explicit
# check-cfg list so other unexpected cfgs still surface as warnings.
unexpected_cfgs = { level = "allow", check-cfg = [
'cfg(feature, values("tokio", "embassy", "journal", "trace-defmt", "trace-log", "trace-tracing"))',
] }
[lints.clippy]
undocumented_unsafe_blocks = "deny"
@@ -122,6 +310,10 @@ undocumented_unsafe_blocks = "deny"
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
panic = "abort"
strip = true
# `strip = true` was killing defmt's `_defmt_version_` / `_defmt_encoding_`
# symbols, breaking RTT log decoding via probe-rs. Keep symbols + DWARF
# so probe-rs can show source locations for log lines.
strip = false
debug = 2
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-291
View File
@@ -1,291 +0,0 @@
# DongLoRa USB Protocol
DongLoRa communicates over USB CDC-ACM (virtual serial port).
No baud rate configuration needed.
**USB identifiers:** VID `1209`, PID `5741`
## Framing
Each message is [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing)-encoded
and terminated with a `0x00` sentinel byte:
```
[COBS-encoded bytes] [0x00]
```
COBS guarantees `0x00` never appears in the encoded data, so the
sentinel unambiguously marks frame boundaries. USB packets are
chunked at 64 bytes.
## Encoding
All integers are **fixed-size little-endian**. No variable-length
encoding. Every field has a known size at a known offset.
| Type | Size | Encoding |
|------|------|----------|
| `u8` | 1 | Raw byte |
| `i8` | 1 | Raw byte (two's complement) |
| `u16` | 2 | Little-endian |
| `i16` | 2 | Little-endian (two's complement) |
| `u32` | 4 | Little-endian |
## RadioConfig (13 bytes)
| Offset | Size | Field | Type | Valid range |
|--------|------|-------|------|-------------|
| 0 | 4 | `freq_hz` | u32 LE | 150,000,000 – 960,000,000 |
| 4 | 1 | `bw` | u8 | 0–9 (see bandwidth table) |
| 5 | 1 | `sf` | u8 | 5–12 |
| 6 | 1 | `cr` | u8 | 5–8 (coding rate denominator) |
| 7 | 2 | `sync_word` | u16 LE | any |
| 9 | 1 | `tx_power_dbm` | i8 | board-dependent, or `-128` for max |
| 10 | 2 | `preamble_len` | u16 LE | 6–65535, or `0` for default (16) |
| 12 | 1 | `cad` | u8 | 0 = disabled, non-zero = enabled |
Set `tx_power_dbm` to `-128` (0x80) to use the board's maximum
TX power. The firmware resolves this to the actual hardware max.
Set `preamble_len` to `0` to use the firmware default of 16 symbols.
Longer preambles improve reliability in mesh/noisy environments at
the cost of slightly longer air time.
Set `cad` to `1` (or any non-zero value) to enable Channel Activity
Detection (listen-before-talk) before each transmission. The radio
checks for channel activity and waits for a clear channel before
transmitting. Set to `0` to disable. Default: enabled.
## Commands (Host → Firmware)
Each command is a tag byte followed by fixed-size fields.
| Tag | Command | Payload |
|-----|---------|---------|
| 0 | Ping | — |
| 1 | GetConfig | — |
| 2 | SetConfig | RadioConfig (10 bytes) |
| 3 | StartRx | — |
| 4 | StopRx | — |
| 5 | Transmit | has_config (1) + [RadioConfig if 1] + len (u16 LE) + payload |
| 6 | DisplayOn | — |
| 7 | DisplayOff | — |
| 8 | GetMac | — |
## Responses (Firmware → Host)
| Tag | Response | Payload |
|-----|----------|---------|
| 0 | Pong | — |
| 1 | Config | RadioConfig (10 bytes) |
| 2 | RxPacket | rssi (i16 LE) + snr (i16 LE) + len (u16 LE) + payload |
| 3 | TxDone | — |
| 4 | Ok | — |
| 5 | Error | code (u8) |
| 6 | MacAddress | 6 raw bytes (board's MAC/device address) |
## Bandwidth
| Value | Bandwidth |
|-------|-----------|
| 0 | 7.8 kHz |
| 1 | 10.4 kHz |
| 2 | 15.6 kHz |
| 3 | 20.8 kHz |
| 4 | 31.25 kHz |
| 5 | 41.7 kHz |
| 6 | 62.5 kHz |
| 7 | 125 kHz |
| 8 | 250 kHz |
| 9 | 500 kHz |
## Error Codes
| Value | Error | Meaning |
|-------|-------|---------|
| 0 | InvalidConfig | Radio config validation failed |
| 1 | RadioBusy | Radio busy (e.g. RX restart failed) |
| 2 | TxTimeout | Transmission timed out |
| 3 | CrcError | CRC error on received packet |
| 4 | NotConfigured | SetConfig required first |
| 5 | NoDisplay | No display attached |
## Worked Example
Configure for 915 MHz, 125 kHz BW, SF7, CR 4/5, max TX power, default preamble, CAD on:
### 1. Build the bytes
```python
import struct
config = struct.pack("<IBBBHBHB",
915_000_000, # freq_hz
7, # bw (125 kHz)
7, # sf
5, # cr (4/5)
0x1424, # sync_word
0x80, # tx_power_dbm (-128 = max)
0, # preamble_len (0 = default 16)
1, # cad (1 = enabled)
)
command = b"\x02" + config # tag 2 = SetConfig
```
Raw bytes: `02 C0 CA 89 36 07 07 05 24 14 80 00 00 01`
### 2. COBS-encode and send
COBS-encode the bytes, append `0x00`, write to USB serial port.
Read back the response, COBS-decode it. First byte `0x04` = Ok.
## Command/Response Discipline
### Solicited vs. unsolicited responses
Every response is either **solicited** (exactly one per command) or
**unsolicited** (arrives at any time):
| Response | Category |
|----------|----------|
| `RxPacket` | **Unsolicited** — arrives whenever a LoRa packet is received during an active RX session |
| All others | **Solicited** — exactly one per command, in the order commands were sent |
### Command → response mapping
Each command produces exactly one solicited response:
| Command | Success | Possible errors |
|---------|---------|-----------------|
| Ping | Pong | — |
| GetConfig | Config | Error(NotConfigured) |
| SetConfig | Ok | Error(InvalidConfig) |
| StartRx | Ok | Error(NotConfigured), Error(InvalidConfig) |
| StopRx | Ok | — |
| Transmit | TxDone | Error(NotConfigured), Error(InvalidConfig), Error(TxTimeout) |
| DisplayOn | Ok | Error(NoDisplay) |
| DisplayOff | Ok | Error(NoDisplay) |
| GetMac | MacAddress | — |
### One outstanding command
The host **must** wait for the solicited response to the current
command before sending the next command. Pipelining multiple commands
without waiting for responses produces **undefined response ordering**.
### Processing order
Commands are processed in FIFO order. The firmware never reorders
commands or drops them silently.
### Interleaving with RxPacket
Between sending a command and receiving its solicited response, the
host may receive zero or more unsolicited `RxPacket` frames.
Hosts **must skip** `RxPacket` frames (tag 2) when waiting for a
solicited response. Skipped `RxPacket` frames should be buffered,
not discarded, if the application needs received LoRa data.
### Locally-handled commands
`DisplayOn`, `DisplayOff`, and `GetMac` are handled immediately by
the USB task and respond without involving the radio. All other
commands are routed to the radio task and respond after radio
processing. The one-outstanding-command rule ensures these two
response paths never conflict.
## Received Packet Quality
The SX126x radio will demodulate and deliver packets that are
technically intact (LoRa CRC passed) but were received so close to the
noise floor that the payload may be corrupt. This is inherent to LoRa:
the radio's internal CRC is only 16 bits, and at very low SNR the false-
positive rate is non-trivial. The firmware delivers every packet the
radio accepts — it is the host's responsibility to assess quality.
### SNR demodulation floor
Each spreading factor has a theoretical minimum SNR for reliable
demodulation. Packets received below this floor are statistically
likely to contain errors even though the radio reported a valid CRC:
| SF | Min SNR (dB) |
|----|--------------|
| 5 | -2.5 |
| 6 | -5.0 |
| 7 | -7.5 |
| 8 | -10.0 |
| 9 | -12.5 |
| 10 | -15.0 |
| 11 | -17.5 |
| 12 | -20.0 |
The pattern is: **min_snr = -2.5 × (SF - 4)**.
### SNR validity range
The SX126x reports SNR as a signed value in the range **-32 to +32 dB**.
Values outside this range indicate a firmware or decoding error — treat
the packet as unreliable.
### Recommended quality grading
Hosts should classify each `RxPacket` before trusting the payload:
```
function grade_packet(snr, sf):
# Step 1: reject impossible SNR values
if snr < -32 or snr > 32:
return INVALID # bad metadata, discard or flag
# Step 2: check against demodulation floor
min_snr = -2.5 * (sf - 4)
margin = snr - min_snr # dB above the floor
if margin < 0:
return UNRELIABLE # below floor, likely corrupt
if margin < 3:
return MARGINAL # near floor, may contain errors
return GOOD # comfortable margin
```
**What to do with each grade:**
- **GOOD** — process normally.
- **MARGINAL** — process, but prefer application-layer integrity
checks (checksums, sequence numbers, known-format validation)
before acting on the payload. Log for diagnostics.
- **UNRELIABLE** — log for diagnostics but do not trust the payload
for routing decisions, command execution, or retransmission.
Applications may still display the data with a warning.
- **INVALID** — discard. The SNR metadata itself is corrupt.
### Application-layer defenses
For safety-critical or command-and-control applications, do not rely
solely on the radio CRC. Add your own integrity checks:
- An application-layer CRC or hash over the payload
- Sequence numbers to detect duplicates and missing packets
- Known-format validation (magic bytes, expected lengths)
- Require acknowledgement before acting on commands
### Radio config impact
Wider bandwidth and lower spreading factor increase throughput but
reduce sensitivity, making low-SNR packets more common in marginal
links. If your application sees frequent MARGINAL/UNRELIABLE packets,
consider increasing SF or decreasing BW to improve link margin at the
cost of air time.
## Host Implementation Checklist
1. Open USB serial port (find by VID:PID `1209:5741`)
2. Implement COBS encode/decode (libraries exist for every language)
3. Pack commands with `struct.pack` or equivalent — all fields are fixed-size LE
4. Optionally send `GetMac`, read back `MacAddress` (useful with multiple dongles)
5. Send `SetConfig`, read back `Ok`
6. Send `StartRx`, read back `Ok`
7. Loop: read frames, decode `RxPacket` (rssi, snr, payload at known offsets)
8. To transmit: send `Transmit`, read back `TxDone`
See `examples/` for working Python implementations.
+10 -1
View File
@@ -20,10 +20,15 @@ All commands are run from the `firmware/` directory.
| Board | Feature | MCU | Radio | USB | Target |
|-------|---------|-----|-------|-----|--------|
| Heltec Mesh Node T114 | `heltec_mesh_node_t114` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
| Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
| LilyGo T-Beam | `lilygo_tbeam` | ESP32 | SX1276 | CP2102 bridge | xtensa-esp32-none-elf |
| LilyGo T-Beam S3 Supreme | `lilygo_tbeam_supreme` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
| Waveshare RP2040-LoRa | `waveshare_rp2040_lora` | RP2040 | SX1262 | Native CDC-ACM | thumbv6m-none-eabi |
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
### Heltec V3 USB Note
@@ -58,4 +63,8 @@ See [src/board/PORTING.md](src/board/PORTING.md).
## Protocol
See [PROTOCOL.md](PROTOCOL.md) for the complete wire format specification.
See [PROTOCOL.md](https://github.com/donglora/protocol-rs/blob/main/PROTOCOL.md)
in the [donglora-protocol](https://github.com/donglora/protocol-rs) repo
(crates.io: [`donglora-protocol`](https://crates.io/crates/donglora-protocol))
for the complete wire format specification — that is the canonical home;
this firmware just consumes it.
+37 -4
View File
@@ -1,4 +1,37 @@
- LED blinking on TX/RX? colored?
- publish new client, mux, bridge (updates in client for UART crap)
- split out mux and client repos
- move to GH organization
# Firmware TODO List
## `lora-phy` fix: drop fork pin once PRs #428 / #431 / #432 merge + release
Upstream PRs (all open):
- [#428 — *phy: add pre-command BUSY checks per Semtech
datasheets*](https://github.com/lora-rs/lora-rs/pull/428) (sx126x):
pre-command BUSY check + standby/IRQ-clear hygiene around CAD.
- [#431 — *phy: sx127x: clear stale IRQ flags in do_rx before mode
change*](https://github.com/lora-rs/lora-rs/pull/431).
- [#432 — *phy: sx127x: clear stale IRQ flags before reconfiguring DIO
mapping in set_irq_params*](https://github.com/lora-rs/lora-rs/pull/432).
Fork branch we ship from:
[`swaits/lora-rs @ fixed`](https://github.com/swaits/lora-rs/tree/fixed)
— a merge of all three fix branches kept up-to-date with upstream
`main`.
Firmware consumes the fixes via a direct `git` dependency in
`Cargo.toml`:
```toml
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
```
When all three PRs merge and a `lora-phy` release ships on crates.io
that contains them, switch this line back to a normal
`lora-phy = "X.Y"` crates.io version and drop the explanatory comment
block above it.
Hardware impact: large stability gain on every board — the sx126x
BUSY-race fix lands on every nRF/RP2040 board, and the sx127x IRQ-clear
fixes unblock TBeam classic in particular. Hardware-verified on Wio
Tracker L1 (sx126x): 20/20 clean-boot TX after the fix (vs ~50%
before). LilyGo T-Beam classic (sx127x): TX confirmed working
2026-04-30 on the post-esp-hal-1.1 build.
+22 -5
View File
@@ -108,14 +108,31 @@ fn main() {
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
println!("cargo:rerun-if-changed={}", board_dir);
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy our nRF layout into
// OUT_DIR so it's only visible to ARM builds.
// cortex-m-rt's link.x does `INCLUDE memory.x`. Resolve the layout
// in two steps:
// 1. Per-board override: `ld/{board}-memory.x` if it exists.
// 2. MCU-family default by target triple.
// The override path is for boards that deviate from the family
// default (e.g. WIO Tracker L1 currently has no SoftDevice in flash
// and needs ORIGIN=0x1000 instead of the SD-aware 0x26000). Drop
// the per-board file once the layout matches the family default.
let target = std::env::var("TARGET").unwrap_or_default();
if !target.starts_with("xtensa") {
let board_specific = format!("ld/{}-memory.x", enabled[0]);
let memory_x: Option<String> = if std::path::Path::new(&board_specific).exists() {
Some(board_specific.clone())
} else {
match target.as_str() {
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x".to_string()),
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x".to_string()),
_ => None,
}
};
if let Some(src) = memory_x {
let out_dir = std::env::var("OUT_DIR").unwrap();
fs::copy("ld/nrf52840-memory.x", format!("{out_dir}/memory.x"))
.expect("failed to copy ld/nrf52840-memory.x");
fs::copy(&src, format!("{out_dir}/memory.x")).expect("failed to copy memory.x");
println!("cargo:rustc-link-search={out_dir}");
}
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
println!("cargo:rerun-if-changed=ld/rp2040-memory.x");
println!("cargo:rerun-if-changed={}", board_specific);
}
+6
View File
@@ -0,0 +1,6 @@
workers = ["app.routes"]
[settings]
cache = ".script"
address = "127.0.0.1"
port = 3500
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+116 -67
View File
@@ -1,35 +1,30 @@
set shell := ["bash", "-c"]
# Board definitions: feature target chip
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
heltec_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
# Run every recipe with mise's tool versions active — so `just` invocations
# of cargo, espflash, rust-objcopy, etc. always resolve to the pinned
# versions in mise.toml, even if the user has a different version elsewhere
# on PATH (e.g. a stray `cargo install espflash`).
set shell := ["mise", "exec", "--", "sh", "-c"]
builds_dir := "builds"
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
# All known boards
boards := "heltec_v3 heltec_v3_uart heltec_v4 rak_wisblock_4631 wio_tracker_l1"
# All known boards. ADD A NEW BOARD HERE and in the `_info` case below.
#
# Board metadata ("feature target chip") lives in the `_info` recipe —
# Xtensa targets are built with `+esp` + `-Zbuild-std=core,alloc` (the
# self-contained esp toolchain ships its own rust-src; the public
# nightly drifts ahead and breaks core-stdlib compilation).
boards := "heltec_mesh_node_t114 heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
# Install all required tools and toolchains
setup:
mise trust --yes
mise install
@# ESP Xtensa toolchain (not managed by mise)
@# ESP Xtensa toolchain — installed via the `esp-install` mise task
@# (which pins versions to match mise.toml's [env] paths).
@if ! rustup toolchain list | grep -q "^esp"; then \
echo "ESP toolchain not found, installing via espup (this may take a while)..."; \
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
echo "ESP toolchain not found, installing via mise task (this may take a while)..."; \
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
fi
@# nightly rust-src for -Zbuild-std
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
rustup component add rust-src --toolchain nightly; \
fi
@# ARM target
@rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf
# Build release firmware for all boards, installing toolchains as needed
build-all:
@@ -55,27 +50,27 @@ check-all:
fi; \
done
# Check a single board compiles
# Check a single board compiles (clippy with warnings as errors)
check board:
@just _cargo {{board}} "check --release"
# Run clippy on a single board
clippy board:
@just _cargo {{board}} "clippy --release" "-- -D warnings"
# Build release firmware and copy to builds/ with a readable name
build board profile="release":
@just _cargo {{board}} "build --{{profile}}"
# Build release firmware and copy to builds/ with a readable name.
# `extra_features` (comma-separated) is appended to the board feature,
# e.g. `just build wio_tracker_l1 release debug-checkpoint`.
build board profile="release" extra_features="":
@just _cargo {{board}} "build --{{profile}}" "" "{{extra_features}}"
@just _copy_firmware {{board}} {{profile}}
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM)
flash board:
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM).
# `extra_features` is passed through to the build, e.g.
# `just flash wio_tracker_l1 debug-checkpoint`.
flash board extra_features="":
@just _ensure_tools
@just build {{board}} release
@read -r feat target chip <<< "$(just _info {{board}})"; \
@just build {{board}} release "{{extra_features}}"
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
case "$target" in \
xtensa-*) \
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
if [ -n "$port" ]; then \
echo "Using $port"; \
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
@@ -86,13 +81,50 @@ flash board:
*) just _flash_uf2 {{board}} ;; \
esac
# Flash ARM board via debug probe (requires J-Link or similar)
flash-probe board:
# Flash ARM board via debug probe (requires J-Link or similar).
# `extra_features` is passed through to the build.
# Sets DEFMT_LOG at build time so defmt::info!/warn!/error! are actually
# compiled in (default filter is ERROR-only — strips info logs).
# `embassy_usb=error` silences the benign Linux modem-manager
# `SET_INTERFACE: alt setting out of range` WARN that fires once per
# enumeration; it's a normal STALL response (correct USB behavior),
# the WARN is just embassy_usb noise. Other crates inherit the `info`
# default so chart traces, our own info!/warn!/error! all still fire.
flash-probe board extra_features="":
@just _ensure_tools
@just build {{board}} release
@read -r feat target chip <<< "$(just _info {{board}})"; \
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
# UF2-flash via the bootloader, then probe-rs attach for RTT log streaming.
# Required on boards (e.g. RAK4631) where the bootloader gates the app
# behind a "valid app flag" in its settings page that only the bootloader
# itself can set — `probe-rs run`'s direct SWD writes bypass that flag and
# leave the bootloader stuck in DFU mode. UF2 path goes through the
# bootloader, sets the flag, then we attach for RTT (no reset, no reflash).
flash-attach board extra_features="":
@just _ensure_tools
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
@just _flash_uf2 {{board}}
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
sleep 1; \
probe-rs attach --chip $chip --no-catch-reset \
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
# Attach probe-rs for live RTT/defmt streaming WITHOUT flashing or
# resetting the chip. Logs are tee'd to a UTC-timestamped file in /tmp
# so soak captures don't clobber each other across runs. Use this with
# the dongle already running the desired firmware (flash via `just flash`
# or `just flash-attach` first).
attach board:
@just _ensure_tools
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
log="/tmp/donglora-soak-$(date -u +%Y%m%dT%H%M%SZ).log"; \
echo "Logging to $log"; \
probe-rs attach --chip $chip --no-catch-reset \
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf" 2>&1 \
| tee "$log"
# Show binary size for a release build
size board:
@just _cargo {{board}} "size --release"
@@ -103,10 +135,7 @@ test:
# Check for outdated dependencies
outdated:
@# embassy-executor 0.10 renamed arch-* → platform-* features.
@# esp-rtos 0.2 still pins embassy-executor ^0.9, blocking the upgrade.
@# cargo-outdated crashes trying to resolve 0.10 with the old feature name.
cargo outdated --exclude embassy-executor
cargo outdated
# ── Private helpers ───────────────────────────────────────────────────
@@ -115,40 +144,41 @@ outdated:
_ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Run a cargo command for a board.
# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core
# Run a cargo command for a board, optionally with extra Cargo features
# (comma-separated, e.g. "debug-checkpoint").
# Xtensa: esp toolchain (cargo + rustc + rust-src together) + -Zbuild-std=core
[private]
_cargo board cmd extra_args="":
_cargo board cmd extra_args="" extra_features="":
@just _ensure_tools
@read -r feat target chip <<< "$(just _info {{board}})"; \
env=""; extra=""; \
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
[ -n "{{extra_features}}" ] && feat="$feat,{{extra_features}}"; \
extra=""; buildstd=""; \
case "$target" in xtensa-*) \
just _require_esp_toolchain; \
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
esac; \
eval $env cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
# Ensure a board's toolchain is available, auto-installing if needed
[private]
_can_build board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
case "$target" in \
xtensa-*) just _require_esp_toolchain ;; \
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
esac
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise)
# Ensure the ESP Xtensa toolchain is installed. The actual install is
# delegated to the `esp-install` mise task so the version pin lives in
# mise.toml alongside the matching [env] PATH/LIBCLANG_PATH entries.
# The esp toolchain ships its own cargo, rustc, and rust-src — no extra
# components are needed on top.
[private]
_require_esp_toolchain:
@just _ensure_tools
@if ! rustup toolchain list | grep -q "^esp"; then \
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
fi; \
if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
echo "Installing nightly rust-src (needed for -Zbuild-std)..." >&2; \
rustup component add rust-src --toolchain nightly || { echo "error: failed to install rust-src" >&2; exit 1; }; \
echo "ESP toolchain not found, installing via mise task (this may take a while)..." >&2; \
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
fi
# Find a serial port matching any of the given VID:PID pairs (checked in order)
@@ -170,15 +200,25 @@ _find_port +vid_pids:
[private]
_copy_firmware board profile:
@mkdir -p {{builds_dir}}
@read -r feat target chip <<< "$(just _info {{board}})"; \
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
src="target/$target/{{profile}}/donglora"; \
dst="{{builds_dir}}/donglora-{{board}}-v{{version}}"; \
if [ -f "$src" ]; then \
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
case "$target" in \
xtensa-*) \
espflash save-image --chip "$chip" --merge --skip-padding "$src" "$dst.bin"; \
echo "→ $dst.bin"; \
;; \
thumbv6m-*) \
rust-objcopy -O ihex "$src" "$dst.hex"; \
cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family rp2040; \
rm "$dst.hex"; \
echo "→ $dst.uf2"; \
;; \
thumbv7em-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
rust-objcopy -O ihex "$src" "$dst.hex"; \
cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
rm "$dst.hex"; \
echo "→ $dst.uf2"; \
;; \
@@ -201,11 +241,20 @@ _flash_uf2 board:
sync; \
echo "Done — board will reboot automatically"
# Single source of truth for per-board metadata: "feature target chip".
# ADD A NEW BOARD HERE and in the `boards` list near the top of this file.
[private]
_info name:
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
else echo "Unknown board: {{name}}" >&2; exit 1; fi
@case "{{name}}" in \
heltec_mesh_node_t114) echo "heltec_mesh_node_t114 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
heltec_v3) echo "heltec_v3 xtensa-esp32s3-none-elf esp32s3" ;; \
heltec_v3_uart) echo "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3" ;; \
heltec_v4) echo "heltec_v4 xtensa-esp32s3-none-elf esp32s3" ;; \
elecrow_thinknode_m2) echo "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3" ;; \
lilygo_tbeam) echo "lilygo_tbeam xtensa-esp32-none-elf esp32" ;; \
lilygo_tbeam_supreme) echo "lilygo_tbeam_supreme xtensa-esp32s3-none-elf esp32s3" ;; \
rak_wisblock_4631) echo "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
waveshare_rp2040_lora) echo "waveshare_rp2040_lora thumbv6m-none-eabi rp2040" ;; \
wio_tracker_l1) echo "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
*) echo "Unknown board: {{name}}" >&2; exit 1 ;; \
esac
+9 -4
View File
@@ -1,16 +1,21 @@
/* nRF52840 memory layout — Adafruit UF2 bootloader
/* nRF52840 memory layout — Adafruit UF2 bootloader + s140 SoftDevice
*
* 0x00000 – 0x01000 MBR (Master Boot Record)
* 0x01000 – 0xEA000 Application ← we live here
* 0x01000 – 0x26000 s140 SoftDevice (dormant — we don't use BLE)
* 0x26000 – 0xEA000 Application ← we live here
* 0xEA000 – 0xF4000 Reserved (app data)
* 0xF4000 – 0xFC000 UF2 Bootloader
* 0xFF000 – 0x100000 Bootloader settings
*
* RAM: first 8 bytes reserved for MBR interrupt forwarding.
* RAM: first 8 bytes reserved for MBR interrupt forwarding. SoftDevice
* RAM (typically 0x20000008–0x20002000) is NOT reserved here because we
* never call sd_softdevice_enable — SD sits dormant in flash and never
* touches RAM. cortex-m-rt's set-vtor feature handles relocating VTOR
* to the app's vector table at 0x26000 at runtime.
*/
MEMORY
{
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
FLASH : ORIGIN = 0x00026000, LENGTH = 0xCE000
RAM : ORIGIN = 0x20000008, LENGTH = 255K
}
+24
View File
@@ -0,0 +1,24 @@
/* RP2040 memory layout — UF2 bootloader (ROM boot2)
*
* 0x10000000 – 0x10000100 boot2 (second-stage bootloader, 256 bytes)
* 0x10000100 – 0x10200000 Application <- we live here
*
* Flash: 2MB (W25Q16JV on Waveshare RP2040-LoRa)
* RAM: 264KB SRAM (two 256KB banks + 2x 4KB scratch)
*/
MEMORY
{
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
EXTERN(BOOT2_FIRMWARE)
SECTIONS {
.boot2 ORIGIN(BOOT2) :
{
KEEP(*(.boot2));
} > BOOT2
} INSERT BEFORE .text;
+25
View File
@@ -0,0 +1,25 @@
/* Wio Tracker L1 memory layout — UF2 bootloader, NO SoftDevice
*
* 0x00000 – 0x01000 MBR (Master Boot Record)
* 0x01000 – 0xEA000 Application ← we live here (no SD present)
* 0xEA000 – 0xF4000 Reserved (app data)
* 0xF4000 – 0xFC000 UF2 Bootloader
* 0xFF000 – 0x100000 Bootloader settings
*
* Background: Seeed factory firmware ships s140 SoftDevice at
* 0x01000–0x26000, but our earlier UF2 flashes (with the original
* `ORIGIN = 0x1000` layout) silently overwrote that region. The WIO
* bootloader now reports `SoftDevice: not found`; until we restore
* s140 via SWD, the app must live at 0x1000 to match the bootloader's
* no-SD boot chain.
*
* Delete this file once SD is reinstalled — `build.rs` will then fall
* back to `ld/nrf52840-memory.x` (the SD-aware default at 0x26000)
* and the WIO matches every other nRF52840 board.
*/
MEMORY
{
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
RAM : ORIGIN = 0x20000008, LENGTH = 255K
}
+43 -6
View File
@@ -1,9 +1,46 @@
[tools]
rust = "1.93"
just = "latest"
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 via Intel HEX for nRF52840 DFU
"cargo:espup" = "0.16"
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
"cargo:probe-rs-tools" = "0.31"
# Cargo-installed tooling via mise's `cargo:` backend.
"cargo:cargo-binutils" = "latest" # rust-objcopy, rust-size
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 for nRF52 / RP2040 DFU drives
"cargo:espup" = "0.17.1" # ESP Xtensa toolchain installer (pinned: [env] paths below match this version's defaults)
"cargo:espflash" = "4" # flash + save-image for ESP targets
"cargo:probe-rs-tools" = "latest" # debug-probe flash/run for ARM targets
# Stable Rust + cross-compile targets. Targets land under $RUSTUP_HOME for
# the pinned toolchain, so they're always available under `mise exec` — no
# extra `rustup target add` step at setup time.
#
# Xtensa ESP targets are NOT listed here — they use the self-contained `esp`
# toolchain populated by `mise run esp-install` (see [tasks] below). That
# toolchain ships its own rust-src for `-Zbuild-std`.
#
# profile = "minimal" (just rustc, rust-std, cargo), "default" (adds rust-docs, rustfmt, clippy)
#
[tools.rust]
version = "1.94"
profile = "default"
targets = "thumbv6m-none-eabi,thumbv7em-none-eabihf"
# ESP Xtensa toolchain. `cargo:espup` above only installs the *installer*;
# the actual rustc-esp + xtensa-esp-elf-gcc + esp-clang toolchains are
# populated by `mise run esp-install` (or `just setup`).
#
# espup hardcodes its install root to ~/.rustup/toolchains/esp/ — there's
# no --prefix or --install-dir flag — so the binaries necessarily live
# there. The [env] block below pins the exact PATH and LIBCLANG_PATH so
# every `mise exec` (which the justfile uses for `set shell`) resolves
# the cross-toolchain without depending on espup's stray ~/export-esp.sh.
#
# Bumping versions: edit the version dirs in BOTH [tasks.esp-install] (so
# espup installs the matching crosstool) AND [env] (so the path resolves).
[env]
_.path = [
"{{env.HOME}}/.rustup/toolchains/esp/xtensa-esp-elf/esp-15.2.0_20250920/xtensa-esp-elf/bin",
]
LIBCLANG_PATH = "{{env.HOME}}/.rustup/toolchains/esp/xtensa-esp32-elf-clang/esp-20.1.1_20250829/esp-clang/lib"
[tasks.esp-install]
description = "Install the ESP Xtensa toolchain (rustc-esp + GCC + clang) at versions matching [env]"
run = "espup install --crosstool-toolchain-version esp-15.2.0_20250920"
+5 -2
View File
@@ -7,6 +7,7 @@
Create `src/board/your_board.rs`. Use an existing board as a template:
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `board/esp32s3.rs` and `hal/esp32s3.rs`)
- nRF52840 boards: copy `rak_wisblock_4631.rs` (uses shared helpers from `hal/nrf52840.rs`)
- RP2040 boards: copy `waveshare_rp2040_lora.rs` (uses shared helpers from `hal/rp2040.rs`)
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
@@ -69,8 +70,10 @@ impl LoRaBoard for Board {
### 5. Add display init
```rust
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
// Construct and initialize your display driver (SSD1306, SH1106, etc.)
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
// Construct and initialize your display driver (SSD1306, SH1106,
// ST7789, etc.) using whatever pins/buses you bundled into
// DisplayParts in `into_parts()`.
}
```
+141
View File
@@ -0,0 +1,141 @@
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
//!
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
//!
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
//! and PR meshtastic/firmware#6354):
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
//! LoRa ctl: RESET=21, BUSY=14, DIO1=3
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
//! LED: GPIO6 (active HIGH indicator)
//!
//! Notable differences from the Heltec ESP32-S3 boards:
//! * Display is SH1106 (not SSD1306) — uses the existing sh1106 driver.
//! * VEXT_ENABLE polarity is reversed (active HIGH here, active LOW on Heltec).
//! * Dedicated LoRa power enable on GPIO48 — must be sequenced before SPI init.
//! * TCXO is driven at 3.3 V via DIO3 (Heltec uses 1.8 V).
use esp_hal::gpio::{Level, Output, OutputConfig};
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::sh1106::Sh1106;
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
// ── Concrete peripheral types ───────────────────────────────────────
pub type DisplayDriver = Sh1106<DisplayI2c>;
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
pub type LedDriver = SimpleLed<Output<'static>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct UartParts {
pub driver: UartDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
let mut display = Sh1106::new(parts.i2c, 0x3C);
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SH1106 display init failed");
return None;
}
let _ = display.set_brightness(0xFF).await;
Some(display)
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "ELECROW ThinkNode-M2";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UartParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// VEXT_ENABLE: GPIO46, active HIGH — powers OLED rail (opposite of Heltec).
// Output has no Drop impl, so the pin stays HIGH for program lifetime.
let _vext = Output::new(p.GPIO46, Level::High, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
// LORA_POWER_EN: GPIO48, active HIGH — must be HIGH before any SPI traffic.
let _lora_pwr = Output::new(p.GPIO48, Level::High, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
// SX1262: CS=10, RESET=21, DIO1=3, BUSY=14, TCXO driven at 3.3 V via DIO3.
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO10,
p.GPIO21,
p.GPIO3,
p.GPIO14,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl3V3,
);
// UART0 via CP2102 bridge: TX=43, RX=44
use esp_hal::uart::{Config as UartConfig, Uart};
let host = UartParts {
driver: Uart::new(p.UART0, UartConfig::default())
.expect("UART init")
.with_tx(p.GPIO43)
.with_rx(p.GPIO44)
.into_async(),
};
// I2C0 for SH1106 OLED: SDA=16, SCL=15
let i2c = mcu::init_i2c(p.I2C0, p.GPIO16, p.GPIO15);
let display = Some(DisplayParts { i2c });
// LED on GPIO6 (active HIGH)
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Shared types and peripheral wiring for classic-ESP32 SX1276 boards.
//!
//! Counterpart to `board::esp32s3` for the SX126x line. Builds an
//! `Sx127x<Sx1276>` driver from an already-initialized SPI bus plus the
//! board's reset and DIO0 pins. SX127x has no `BUSY` line — DIO0 carries
//! every IRQ (TX done, RX done, header valid, …) and lora-phy's
//! `GenericSx127xInterfaceVariant` waits on it directly.
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
use lora_phy::iv::GenericSx127xInterfaceVariant;
use lora_phy::sx127x::{self, Sx127x};
use crate::hal::esp32 as mcu;
// ── Re-export MCU types ─────────────────────────────────────────────
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx127xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx127x<RadioSpiDevice, Iv, sx127x::Sx1276>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display driver ──────────────────────────────────────────────────
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
/// Construct and initialize an SSD1306 display from raw I²C.
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(parts.i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 display init failed");
return None;
}
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
Some(display)
}
// ── Shared peripheral init helpers ──────────────────────────────────
/// Construct an SX1276 radio from an initialized SPI bus.
///
/// `dio0` is the primary IRQ (TX/RX done). The SX1276 module on the T-Beam
/// runs from a crystal oscillator, not a TCXO, and power comes from the
/// AXP192's PA_BOOST rail — `tx_boost = true` routes TX through the PA
/// output for +20 dBm.
pub fn init_radio(
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
nss_pin: impl OutputPin + 'static,
reset_pin: impl OutputPin + 'static,
dio0_pin: impl InputPin + 'static,
) -> RadioParts {
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(reset_pin, Level::High, OutputConfig::default());
let dio0 = Input::new(dio0_pin, InputConfig::default().with_pull(Pull::Down));
let iv =
GenericSx127xInterfaceVariant::new(reset, dio0, None, None).expect("SX1276 interface init");
let sx_config = sx127x::Config {
chip: sx127x::Sx1276,
tcxo_used: false,
tx_boost: true,
rx_boost: false,
};
RadioParts {
driver: Sx127x::new(spi_device, iv, sx_config),
delay: Delay,
}
}
+31 -8
View File
@@ -7,7 +7,7 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
@@ -32,12 +32,25 @@ pub struct RadioParts {
pub delay: Delay,
}
// SSD1306 display support is Heltec-specific. ESP32-S3 boards that use a
// different controller (e.g. ThinkNode-M2 uses SH1106) provide their own
// DisplayParts / DisplayDriver / create_display directly.
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4"
))]
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display driver ──────────────────────────────────────────────────
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4"
))]
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
@@ -45,13 +58,18 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
>;
/// Construct and initialize an SSD1306 display from raw I2C.
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4"
))]
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(i2c);
let interface = I2CDisplayInterface::new(parts.i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
@@ -67,12 +85,17 @@ pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
// ── Shared peripheral init helpers ──────────────────────────────────
/// Construct SX1262 radio from an initialized SPI bus.
///
/// Generic over GPIO type so boards with different radio pin assignments can
/// share this helper. `tcxo_voltage` selects the DIO3-driven TCXO supply:
/// Heltec boards use `Ctrl1V8`, the ELECROW ThinkNode-M2 uses `Ctrl3V3`, etc.
pub fn init_radio(
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
nss_pin: esp_hal::peripherals::GPIO8<'static>,
reset_pin: esp_hal::peripherals::GPIO12<'static>,
dio1_pin: esp_hal::peripherals::GPIO14<'static>,
busy_pin: esp_hal::peripherals::GPIO13<'static>,
nss_pin: impl OutputPin + 'static,
reset_pin: impl OutputPin + 'static,
dio1_pin: impl InputPin + 'static,
busy_pin: impl InputPin + 'static,
tcxo_voltage: sx126x::TcxoCtrlVoltage,
) -> RadioParts {
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
let spi_device = SpiDevice::new(spi_bus, nss);
@@ -86,7 +109,7 @@ pub fn init_radio(
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
tcxo_ctrl: Some(tcxo_voltage),
use_dcdc: true,
rx_boost: false,
};
+285
View File
@@ -0,0 +1,285 @@
//! Heltec Mesh Node T114 (nRF52840 + SX1262 + 1.14" ST7789 TFT).
//!
//! Hardware reference: cross-verified across MeshCore, Meshtastic, and
//! RNode firmwares. Pin maps quoted from
//! `meshcore-spec/references/MeshCore/variants/heltec_t114/variant.h` and
//! `meshtastic/firmware/variants/nrf52840/heltec_mesh_node_t114/variant.h`.
//!
//! Stage 2: panel comes up with static colors via the `St7789Color`
//! driver. Stage 3 swaps in the proper color renderer module.
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
use embassy_nrf::pwm::{Prescaler, SimpleConfig, SimplePwm};
use embassy_nrf::spim::{self, Spim};
use embassy_nrf::usb::Driver;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::{Delay, Timer};
use embedded_hal_bus::spi::ExclusiveDevice;
use lcd_async::interface::SpiInterface;
use lcd_async::models::ST7789;
use lcd_async::options::{ColorInversion, Orientation, Rotation};
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use static_cell::ConstStaticCell;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::inverted_pin::InvertedPin;
use crate::driver::simple_led::SimpleLed;
use crate::driver::st7789::{St7789Color, FB_BYTES};
use crate::hal::nrf52840 as mcu;
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbNrfDriver;
// Green LED on P1.03, active LOW.
pub type LedDriver = SimpleLed<InvertedPin<Output<'static>>>;
// ── TFT bus and driver type aliases ─────────────────────────────────
type TftSpim = Spim<'static>;
type TftSpiDevice = ExclusiveDevice<TftSpim, Output<'static>, Delay>;
type TftInterface = SpiInterface<TftSpiDevice, Output<'static>>;
pub type DisplayDriver = St7789Color<TftInterface, Output<'static>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
/// Assembled-but-not-yet-initialized TFT plumbing. Real initialization
/// (power gate, RST cycle, mipidsi init) happens in `create_display`.
pub struct DisplayParts {
interface: TftInterface,
rst_pin: Output<'static>,
vdd_en: Output<'static>,
backlight: SimplePwm<'static>,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
let DisplayParts {
interface,
rst_pin,
mut vdd_en,
mut backlight,
} = parts;
// 1. Drive TFT VDD enable LOW (active LOW per Meshtastic) and let
// the rail settle. Heltec's schematic has the panel power gated
// by P0.03 — touching SPI before VDD is stable produces snow.
vdd_en.set_low();
Timer::after_millis(50).await;
// 2. Build the lcd-async display.
// The T114 panel is wired natively portrait (135w × 240h) at
// chip framebuffer offset (52, 40) — the standard 1.14" 135×240
// ST7789 die mapping. Deg90 rotation rotates the user-facing
// surface to landscape (240w × 135h). Meshtastic's variant.h
// sets TFT_OFFSET_X=0/Y=0 because their fork of TFT_eSPI applies
// these offsets internally; lcd-async expects them in display_offset.
// If the image comes up mirrored or upside-down, swap
// Deg90 ↔ Deg270 or set `mirrored = true`.
let mut delay = Delay;
let panel = match lcd_async::Builder::new(ST7789, interface)
.reset_pin(rst_pin)
.display_size(135, 240)
.display_offset(52, 40)
.orientation(Orientation::new().rotate(Rotation::Deg90))
.invert_colors(ColorInversion::Inverted)
.init(&mut delay)
.await
{
Ok(p) => p,
Err(_) => {
defmt::error!("ST7789 init failed");
return None;
}
};
defmt::info!("ST7789 init OK");
// 3. Bring the backlight up at full brightness. Stage 2 uses the
// full-on duty; the splash dimming logic handled by the display
// statechart will hand off to set_dim/set_bright shortly.
backlight.set_duty(0, embassy_nrf::pwm::DutyCycle::inverted(0));
// Forget vdd_en so it stays driven for the lifetime of the program.
// We took it by move so we could call set_low() during init.
core::mem::forget(vdd_en);
// Take the static framebuffer (240×135 × 2 bytes Rgb565 BE
// = 64,800 bytes zero-initialized in .bss). Stored as raw bytes
// so present() can DMA-blit the slice straight to the panel
// without an iterator-conversion pass — the lcd-async
// `RawFrameBuf<Rgb565, _>` wrapper handles BE byte packing on
// each pixel write.
static FRAMEBUFFER: ConstStaticCell<[u8; FB_BYTES]> =
ConstStaticCell::new([0u8; FB_BYTES]);
let fb = FRAMEBUFFER.take();
Some(St7789Color::new(panel, backlight, fb))
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: embassy_nrf::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Heltec Mesh Node T114";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
// USB requires the 32 MHz HFXO (±50 ppm); the RC oscillator is out of spec.
let mut config = embassy_nrf::config::Config::default();
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
let p = embassy_nrf::init(config);
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
// ── VEXT_ENABLE (P0.21, active HIGH) ────────────────────
// Powers the SX1262 *and* the TFT/peripheral 3V3 rail. Must be
// driven HIGH before any SPI traffic to the radio or panel —
// without this, lora-phy init can succeed (SPI bus works
// electrically) but TX_DONE never fires because the PA is
// unpowered. Held forever via core::mem::forget. Mirrors
// Meshtastic's `VEXT_ENABLE = P0.21, VEXT_ON_VALUE = HIGH`.
let vext_en = Output::new(p.P0_21, Level::High, OutputDrive::Standard);
core::mem::forget(vext_en);
// ── SPI3 bus for SX1262 ─────────────────────────────────
// Per Heltec T114 schematic + Meshtastic variant.h:
// SCK=P0.19, MOSI=P0.22, MISO=P0.23
// NSS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
// No external RXEN/TXEN — antenna switch is driven internally
// by SX1262 DIO2. TCXO is on DIO3 at 1.8 V.
let mut spi_cfg = spim::Config::default();
spi_cfg.frequency = spim::Frequency::M1;
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P0_19, p.P0_23, p.P0_22, spi_cfg);
let spi_bus = mcu::share_spi_bus(spi);
let nss = Output::new(p.P0_24, Level::High, OutputDrive::Standard);
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(p.P0_25, Level::High, OutputDrive::Standard);
let dio1 = Input::new(p.P0_20, Pull::Down);
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
// p. 53); an MCU-side Pull fights it. See lora-rs/lora-rs#260.
let busy = Input::new(p.P0_17, Pull::None);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ─────────────────────────────────────────────────
let vbus = mcu::alloc_vbus_detect(true, true);
let host = UsbParts {
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
};
// Battery divider control (P0.06) LOW disables the divider —
// saves a few tens of µA. Battery sense (P0.04 / AIN2) deferred
// until the protocol gets a battery field.
let bat_ctl = Output::new(p.P0_06, Level::Low, OutputDrive::Standard);
core::mem::forget(bat_ctl);
// ── SPI2 bus for ST7789 TFT ─────────────────────────────
// Per Meshtastic variant.h:
// SCK=P1.08, MOSI=P1.09, CS=P0.11, DC=P0.12, RST=P0.02
// VDD enable=P0.03 (active HIGH), Backlight=P0.15 (active LOW PWM)
// Write-only bus — embassy_nrf::Spim still requires a MISO slot,
// so we wire a free unused pin (P1.06) as a no-op input.
let mut tft_spi_cfg = spim::Config::default();
// 8 MHz lets us repaint the 240×135 panel comfortably. Higher
// rates work with mipidsi but are platform-dependent (board
// routing, pull strengths). 8 MHz is the safe default.
tft_spi_cfg.frequency = spim::Frequency::M8;
let tft_spi = Spim::new(p.SPI2, mcu::Irqs, p.P1_08, p.P1_06, p.P1_09, tft_spi_cfg);
let tft_cs = Output::new(p.P0_11, Level::High, OutputDrive::Standard);
let tft_dc = Output::new(p.P0_12, Level::Low, OutputDrive::Standard);
let tft_rst = Output::new(p.P0_02, Level::High, OutputDrive::Standard);
// VTFT_CTRL is active LOW per Meshtastic
// (`#define TFT_BACKLIGHT_ON LOW`, `digitalWrite(VTFT_CTRL, LOW)`
// at boot). Start HIGH (panel rail off); create_display drives
// LOW to enable the panel.
let tft_vdd = Output::new(p.P0_03, Level::High, OutputDrive::Standard);
let tft_spi_device = ExclusiveDevice::new(tft_spi, tft_cs, Delay)
.expect("TFT ExclusiveDevice init");
// lcd-async's SpiInterface streams pixel data directly from
// the caller's slice — no scratch buffer required (mipidsi's
// sync version needed one for batching).
let tft_interface = SpiInterface::new(tft_spi_device, tft_dc);
// ── PWM backlight (active-LOW on P0.15) ─────────────────
// Default SimpleConfig is fine — countertop=1000 → ~1 kHz
// (well above flicker threshold), prescaler default. Channel 0
// idle level matches the active-LOW wiring.
let mut bl_cfg = SimpleConfig::default();
bl_cfg.max_duty = crate::driver::st7789::BL_MAX;
bl_cfg.prescaler = Prescaler::Div128;
bl_cfg.ch0_idle_level = Level::High;
let backlight = SimplePwm::new_1ch(p.PWM0, p.P0_15, &bl_cfg);
let display_parts = DisplayParts {
interface: tft_interface,
rst_pin: tft_rst,
vdd_en: tft_vdd,
backlight,
};
// ── Green LED (P1.03, active LOW) ───────────────────────
// Idle level HIGH = LED off. InvertedPin flips polarity so
// SimpleLed can use normal "set_high = on" semantics.
let led_pin = Output::new(p.P1_03, Level::High, OutputDrive::Standard);
let led = SimpleLed(InvertedPin(led_pin));
BoardParts {
radio,
host,
display: Some(display_parts),
led,
mac: Self::mac_address(),
}
}
}
+15 -10
View File
@@ -14,9 +14,7 @@ use super::traits::{BoardParts, LoRaBoard};
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
};
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
@@ -51,14 +49,21 @@ impl LoRaBoard for Board {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
// Vext power: GPIO36, active LOW to enable peripherals.
// Output has no Drop impl, so the pin stays configured for program lifetime.
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO8,
p.GPIO12,
p.GPIO14,
p.GPIO13,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
);
// Note: switches internal USB PHY from Serial-JTAG to OTG.
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
@@ -72,12 +77,12 @@ impl LoRaBoard for Board {
),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
// SSD1306 display reset: pulse GPIO21 low->high before I2C init.
// Output has no Drop impl; pin stays HIGH after this block.
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
+15 -10
View File
@@ -13,9 +13,7 @@ use super::traits::{BoardParts, LoRaBoard};
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
};
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
pub type LedDriver = SimpleLed<Output<'static>>;
@@ -50,14 +48,21 @@ impl LoRaBoard for Board {
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
// Vext power: GPIO36, active LOW to enable peripherals.
// Output has no Drop impl, so the pin stays configured for program lifetime.
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO8,
p.GPIO12,
p.GPIO14,
p.GPIO13,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
);
use esp_hal::uart::{Config as UartConfig, Uart};
let host = UartParts {
@@ -68,12 +73,12 @@ impl LoRaBoard for Board {
.into_async(),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
// SSD1306 display reset: pulse GPIO21 low->high before I2C init.
// Output has no Drop impl; pin stays HIGH after this block.
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
+15 -10
View File
@@ -6,9 +6,7 @@ use super::traits::{BoardParts, LoRaBoard};
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
};
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
@@ -44,14 +42,21 @@ impl LoRaBoard for Board {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
// Vext power: GPIO36, active LOW to enable peripherals.
// Output has no Drop impl, so the pin stays configured for program lifetime.
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO8,
p.GPIO12,
p.GPIO14,
p.GPIO13,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
);
// Note: switches internal USB PHY from Serial-JTAG to OTG.
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
@@ -65,12 +70,12 @@ impl LoRaBoard for Board {
),
};
// SSD1315 display reset: pulse GPIO21 low->high before I2C init
// SSD1315 display reset: pulse GPIO21 low->high before I2C init.
// Output has no Drop impl; pin stays HIGH after this block.
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
+140
View File
@@ -0,0 +1,140 @@
//! LilyGo T-Beam (classic) — ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC.
//!
//! First-generation T-Beam. ESP32 (dual-core Xtensa LX6, no native USB),
//! SX1276 module with XTAL (no TCXO), 128×64 SSD1306 OLED, AXP192 PMIC
//! gating the LoRa and OLED rails, CP2102 USB-UART bridge for host I/O.
//!
//! The AXP192 shares an I²C bus with the OLED (SDA=21, SCL=22). PMIC init
//! runs blocking on that bus first, then the same pins are re-initialized
//! in async mode for the display task.
//!
//! Pin map (verified from MeshCore variants/lilygo_tbeam_SX1276/):
//! LoRa SPI: SCK=5, MOSI=27, MISO=19, CS=18
//! LoRa ctl: RESET=23, DIO0=26 (primary IRQ — SX127x has no BUSY)
//! OLED I²C: SDA=21, SCL=22, SSD1306 @ 0x3C (shared with AXP192 @ 0x34)
//! UART host: TX=1, RX=3 via CP2102 bridge (USB 10c4:ea60)
//! LED: GPIO4 (active LOW — red LED under the OLED, wrapped in
//! InvertedPin so SimpleLed sees normal polarity)
//!
//! GPS / battery / IMU / PCF8563 RTC are populated on the board but unused
//! by this firmware.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use super::esp32;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::axp;
use crate::driver::inverted_pin::InvertedPin;
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32 as mcu;
use crate::protocol::RadioChipId;
pub use super::esp32::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
// ── Concrete peripheral types ───────────────────────────────────────
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
pub type LedDriver = SimpleLed<InvertedPin<Output<'static>>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct UartParts {
pub driver: UartDriver,
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "LilyGo T-Beam";
// SX1276 PA_BOOST range per the datasheet: +2 dBm min, +20 dBm max.
const TX_POWER_RANGE: (i8, i8) = (2, 20);
type RadioParts = RadioParts;
type CommParts = UartParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn radio_chip_id() -> u16 {
RadioChipId::Sx1276.as_u16()
}
fn freq_range_hz() -> (u32, u32) {
// SX1276 tuning window per datasheet (LF + HF bands combined).
(137_000_000, 1_020_000_000)
}
fn supported_sf_bitmap() -> u16 {
// SX127x supports SF6–SF12 (no SF5 like the SX126x).
0x1FC0
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// AXP192 and SSD1306 share I²C0 (SDA=21, SCL=22). Build the bus
// once as blocking, drive the PMIC init, then convert to async for
// the display task — avoids re-consuming the I²C0 peripheral.
// PMIC failure isn't fatal to boot: log and proceed so the rest of
// the firmware can still emit diagnostics.
let mut i2c_blocking = I2c::new(p.I2C0, I2cConfig::default())
.expect("I2C0 init")
.with_sda(p.GPIO21)
.with_scl(p.GPIO22);
if let Err(_e) = axp::axp192_init_lora_oled(&mut i2c_blocking) {
defmt::error!("AXP192 init failed — LoRa rail may be dead");
}
// Let DCDC1 / LDO2 settle before SPI or display traffic.
esp_hal::delay::Delay::new().delay_millis(20);
// SPI2 for SX1276: SCK=5, MOSI=27, MISO=19
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_SPI2, p.GPIO5, p.GPIO27, p.GPIO19);
// SX1276: CS=18, RESET=23, DIO0=26 (primary IRQ)
let radio = esp32::init_radio(spi_bus, p.GPIO18, p.GPIO23, p.GPIO26);
// UART0 via CP2102 bridge: TX=1, RX=3 (ESP32 default console)
use esp_hal::uart::{Config as UartConfig, Uart};
let host = UartParts {
driver: Uart::new(p.UART0, UartConfig::default())
.expect("UART init")
.with_tx(p.GPIO1)
.with_rx(p.GPIO3)
.into_async(),
};
// Hand the (now async) I²C bus to the SSD1306 display task.
let display = Some(DisplayParts {
i2c: i2c_blocking.into_async(),
});
// TX activity LED on GPIO4 (active LOW — pin idles HIGH = LED off).
// InvertedPin flips set_high/set_low so SimpleLed stays polarity-agnostic.
let led_pin = Output::new(p.GPIO4, Level::High, OutputConfig::default());
let led = SimpleLed(InvertedPin(led_pin));
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
+153
View File
@@ -0,0 +1,153 @@
//! LilyGo T-Beam S3 Supreme — ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC.
//!
//! Big-brother of the classic T-Beam: ESP32-S3-WROOM-1 module, SX1262 with a
//! TCXO, 128×64 SH1106 OLED, AXP2101 PMIC on a dedicated I²C1 bus. Native USB
//! (no UART bridge chip).
//!
//! The AXP2101 gates power to the SX1262 and OLED. Without its init sequence
//! the LoRa chip never responds on SPI and `LoRa::new` fails.
//!
//! Pin map (verified from MeshCore variants/lilygo_tbeam_supreme_SX1262/):
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
//! LoRa ctl: RESET=5, DIO1=1, BUSY=4 (TCXO @ 1.6 V via DIO3)
//! OLED I²C: SDA=17, SCL=18, SH1106 @ 0x3C (I²C0)
//! PMIC I²C: SDA=42, SCL=41, AXP2101 @ 0x34 (I²C1, blocking, PMIC-only)
//! USB: native USB-C on GPIO19/20 (fixed ESP32-S3 D+/D-)
//! LED: GPIO6 (active HIGH indicator, reuses MeshCore's P_LORA_TX_LED)
//!
//! GPS / battery / IMU / SD / BME280 are unused — DongLoRa is a transparent
//! LoRa-over-USB bridge. The PMIC init only enables ALDO1 (OLED) and ALDO3
//! (LoRa); other rails are left at their AXP2101 power-on defaults.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::axp;
use crate::driver::sh1106::Sh1106;
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
// ── Concrete peripheral types ───────────────────────────────────────
pub type DisplayDriver = Sh1106<DisplayI2c>;
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
let mut display = Sh1106::new(parts.i2c, 0x3C);
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SH1106 display init failed");
return None;
}
let _ = display.set_brightness(0xFF).await;
Some(display)
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "LilyGo T-Beam S3 Supreme";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// AXP2101 PMIC on I²C1 (SDA=42, SCL=41). Blocking, one-shot: we only
// need to flip the LoRa/OLED rails on, then the bus can be dropped.
// If this fails the LoRa chip stays unpowered — log and keep going so
// the rest of the boot path can still produce diagnostics.
{
let mut pmic = I2c::new(p.I2C1, I2cConfig::default())
.expect("I2C1 init")
.with_sda(p.GPIO42)
.with_scl(p.GPIO41);
if let Err(_e) = axp::axp2101_init_lora_oled(&mut pmic) {
defmt::error!("AXP2101 init failed — LoRa rail may be dead");
}
}
// Let ALDO1/ALDO3 settle before touching the LoRa SPI or OLED I²C.
esp_hal::delay::Delay::new().delay_millis(20);
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
// SX1262: CS=10, RESET=5, DIO1=1, BUSY=4, TCXO @ 1.6 V via DIO3.
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO10,
p.GPIO5,
p.GPIO1,
p.GPIO4,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V6,
);
// Native USB-C on GPIO19/20. Switches the internal USB PHY from
// Serial-JTAG to OTG.
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
let host = UsbParts {
driver: esp_hal::otg_fs::asynch::Driver::new(
usb_inst,
ep_out_buf,
esp_hal::otg_fs::asynch::Config::default(),
),
};
// I2C0 for SH1106 OLED: SDA=17, SCL=18 (async, handed to display task).
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// Activity LED on GPIO6 (active HIGH).
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
+4
View File
@@ -3,7 +3,11 @@ pub mod traits;
pub use traits::*;
{% for helper, helper_features in helpers|items -%}
{% if helper_features|length == 1 -%}
#[cfg(feature = "{{ helper_features[0] }}")]
{% else -%}
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
{% endif -%}
mod {{ helper }};
{% endfor %}
cfg_if::cfg_if! {
+67 -28
View File
@@ -20,13 +20,20 @@ type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
pub type LedDriver = ();
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
// SH1106 hand-rolled driver instead of the ssd1306 crate. The RAK1921
// OLED ships under various Chinese-OEM packaging (e.g. JMD0.96C) that
// can be either SSD1306 or SH1106 silicon — physically and pin
// identical, but use different addressing modes. The bigger reason
// though: ssd1306 0.10's display-interface-i2c emits I2C transaction
// patterns (consecutive writes, 3+ ops) that embassy_nrf::twim's
// `unreachable!()` rejects, so even on real SSD1306 silicon the panic
// would fire. Our hand-rolled SH1106 driver issues only single writes
// per logical op and is twim-safe. SH1106 init opcodes are similar
// enough that real SSD1306 silicon mostly works (worst case: mirrored
// display until proper runtime detection lands).
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
// ── Peripheral bundles ──────────────────────────────────────────────
@@ -45,23 +52,30 @@ pub struct DisplayParts {
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3C);
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 display init failed");
return None;
// Retry 3× with 100ms backoff. The SH1106's first transaction
// after power-on can return Err if the chip's internal voltage
// converter hasn't fully settled — observed empirically on
// RAK4631/RAK1921. Each attempt is independent (full init
// sequence). Display works visibly even if the FIRST attempt
// failed, because subsequent attempts succeed.
for attempt in 0..3u8 {
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_ok() {
let _ = display.set_brightness(0xFF).await;
return Some(display);
}
if attempt < 2 {
defmt::warn!(
"SH1106 display init attempt {=u8}/3 failed; retrying",
attempt + 1
);
}
}
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
Some(display)
defmt::error!("SH1106 display init failed after 3 attempts");
None
}
// ── Board init ──────────────────────────────────────────────────────
@@ -78,10 +92,14 @@ impl LoRaBoard for Board {
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = ();
type LedDriver = LedDriver;
fn init() -> Self {
let p = embassy_nrf::init(Default::default());
// USB requires the 32 MHz HFXO (±50 ppm); the RC oscillator is out of spec.
let mut config = embassy_nrf::config::Config::default();
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
let p = embassy_nrf::init(config);
Self { p }
}
@@ -89,12 +107,18 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, ()> {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
// SX1262 is power-gated on P1.05 — must be HIGH before any SPI access.
let sx_power_en = Output::new(p.P1_05, Level::High, OutputDrive::Standard);
core::mem::forget(sx_power_en);
// ── SPI bus for SX1262 ──────────────────────────────────
let mut spi_cfg = spim::Config::default();
spi_cfg.frequency = spim::Frequency::M1;
// embassy_nrf::spim::Spim::new args: (spim, irq, sck, miso, mosi, config).
// Per RAK4631 BSP: SCK=P1.11, MISO=P1.13, MOSI=P1.12.
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
let spi_bus = mcu::share_spi_bus(spi);
@@ -103,7 +127,11 @@ impl LoRaBoard for Board {
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
let dio1 = Input::new(p.P1_15, Pull::Down);
let busy = Input::new(p.P1_14, Pull::Down);
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
// p. 53); an MCU-side Pull::Down fights it and leaves the line at
// an indeterminate voltage, making wait_on_busy unreliable. See
// lora-rs/lora-rs#260.
let busy = Input::new(p.P1_14, Pull::None);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
.expect("SX1262 interface init");
@@ -121,7 +149,9 @@ impl LoRaBoard for Board {
};
// ── USB ─────────────────────────────────────────────────
let vbus = mcu::alloc_vbus_detect(true, false);
// SoftwareVbusDetect(vbus_detected, power_ready). Mark both true so the
// driver doesn't block waiting for a power-ready signal we never raise.
let vbus = mcu::alloc_vbus_detect(true, true);
let host = UsbParts {
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
};
@@ -129,15 +159,24 @@ impl LoRaBoard for Board {
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
let twim_buf = mcu::alloc_i2c_buffer();
let i2c = embassy_nrf::twim::Twim::new(
p.TWISPI0, mcu::Irqs, p.P0_13, p.P0_14, twim::Config::default(), twim_buf,
p.TWISPI0,
mcu::Irqs,
p.P0_13,
p.P0_14,
twim::Config::default(),
twim_buf,
);
let display = Some(DisplayParts { i2c });
// Green LED on P1.03 (RAK19007 Base, active HIGH).
let led_pin = Output::new(p.P1_03, Level::Low, OutputDrive::Standard);
let led = crate::driver::simple_led::SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led: (),
led,
mac: Self::mac_address(),
}
}
+38 -5
View File
@@ -6,8 +6,7 @@
//!
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::pixelcolor::BinaryColor;
use crate::display::BoardDisplay;
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
pub trait RgbLed {
@@ -35,8 +34,10 @@ pub trait LoRaBoard: Sized {
/// Display peripheral bundle (I2C bus for display init).
type DisplayParts;
/// Concrete display driver type (must implement DrawTarget for rendering).
type DisplayDriver: DrawTarget<Color = BinaryColor>;
/// Concrete display driver type. Each board's driver implements
/// [`BoardDisplay`] to render the splash + dashboard content into its
/// native pixel format and resolution and to flush / dim the panel.
type DisplayDriver: BoardDisplay;
/// RGB LED driver. Boards without an LED use `()`.
type LedDriver: RgbLed;
@@ -47,8 +48,40 @@ pub trait LoRaBoard: Sized {
/// Read the board's unique hardware address (MAC, device ID, etc.).
fn mac_address() -> [u8; 6];
/// Radio chip identifier reported in `GET_INFO`. Defaults to SX1262
/// (`0x0002`), matching every currently-supported board. Override for
/// boards that carry a different chip (LLCC68, SX1261, LR11xx, …).
fn radio_chip_id() -> u16 {
// SX1262 — see PROTOCOL.md §8.
0x0002
}
/// (freq_min_hz, freq_max_hz) — the RF front-end's effective tuning
/// window on this specific board. Reported in `GET_INFO`. Defaults
/// to the SX1262's nominal 150–960 MHz range.
fn freq_range_hz() -> (u32, u32) {
(150_000_000, 960_000_000)
}
/// Bitmap of supported LoRa spreading factors — bit N = SF N. Defaults
/// to SX126x's SF5–SF12 (`0x1FE0`). SX127x boards must override to
/// `0x1FC0` (SF6–SF12); LLCC68 boards to `0x0FE0` (SF5–SF11).
fn supported_sf_bitmap() -> u16 {
0x1FE0
}
/// Bitmap of supported LoRa bandwidths. Bit positions match
/// `LoRaBandwidth::as_u8()` (sub-GHz BW enum values 0..9). Defaults
/// to all sub-GHz BWs (`0x03FF`). SX128x boards flip the 2.4 GHz
/// bits when we add them.
fn supported_bw_bitmap() -> u16 {
0x03FF
}
/// Decompose initialized board into peripheral bundles for each task.
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
fn into_parts(
self,
) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
}
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
+154
View File
@@ -0,0 +1,154 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_rp::spi;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::{BoardParts, LoRaBoard};
use crate::hal::rp2040 as mcu;
// ── Concrete peripheral types ──────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbRpDriver;
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
// Display types — never instantiated (no OLED on this board), but the
// trait requires concrete associated types that satisfy the bounds.
pub type DisplayI2c =
embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
// ── Peripheral bundles ─────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
// No display on this board, but the trait still wants concrete
// peripheral types. The i2c bus type is reserved for the future
// possibility of an external OLED add-on.
#[allow(dead_code)]
pub i2c: DisplayI2c,
}
// ── Display init ───────────────────────────────────────────────────
pub async fn create_display(_parts: DisplayParts) -> Option<DisplayDriver> {
None
}
// ── Board init ─────────────────────────────────────────────────────
pub struct Board {
p: embassy_rp::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Waveshare RP2040-LoRa";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = embassy_rp::init(Default::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
// ── MAC address from flash unique ID ───────────────────
let mut flash =
embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
mcu::init_mac(&mut flash);
// ── SPI1 bus for SX1262 ────────────────────────────────
//
// Pin mapping from Meshtastic & MeshCore firmware (confirmed working):
// SCK = GPIO14, MOSI = GPIO15, MISO = GPIO24, CS = GPIO13
//
// The Waveshare wiki (https://www.waveshare.com/wiki/RP2040-LoRa) lists
// different pins (MOSI=11, MISO=8, CS=9) but those don't work in practice.
// Both sets are valid SPI1 alternate functions on RP2040.
let mut spi_cfg = spi::Config::default();
spi_cfg.frequency = 1_000_000;
let spi = spi::Spi::new(
p.SPI1,
p.PIN_14,
p.PIN_15,
p.PIN_24,
p.DMA_CH0,
p.DMA_CH1,
mcu::Irqs,
spi_cfg,
);
let spi_bus = mcu::share_spi_bus(spi);
let nss = Output::new(p.PIN_13, Level::High);
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(p.PIN_23, Level::High);
let dio1 = Input::new(p.PIN_16, Pull::Down);
let busy = Input::new(p.PIN_18, Pull::Down);
// PE4259 antenna switch: GPIO17 = !CTRL (rx_enable).
// DIO2 is internally wired as CTRL (tx_enable), managed by the SX1262.
let rx_enable = Output::new(p.PIN_17, Level::Low);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: None, // No TCXO on this board
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ────────────────────────────────────────────────
let host = UsbParts {
driver: embassy_rp::usb::Driver::new(p.USB, mcu::Irqs),
};
// ── LED on GPIO25 ──────────────────────────────────────
let led_pin = Output::new(p.PIN_25, Level::Low);
let led = crate::driver::simple_led::SimpleLed(led_pin);
BoardParts {
radio,
host,
display: None,
led,
mac: Self::mac_address(),
}
}
}
+13 -4
View File
@@ -42,8 +42,8 @@ pub struct DisplayParts {
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3D);
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3D);
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
@@ -96,7 +96,11 @@ impl LoRaBoard for Board {
let reset = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
let dio1 = Input::new(p.P0_07, Pull::Down);
let busy = Input::new(p.P1_10, Pull::Down);
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
// p. 53); an MCU-side Pull::Down fights it and leaves the line at
// an indeterminate voltage, making wait_on_busy unreliable. See
// lora-rs/lora-rs#260.
let busy = Input::new(p.P1_10, Pull::None);
// RF switch: RXEN on P1.08 (GPIO, toggled by lora_phy), TX via DIO2 (SX1262 internal).
let rx_enable = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
@@ -128,7 +132,12 @@ impl LoRaBoard for Board {
// ── Display (SH1106 OLED on I2C) ────────────────────────
let twim_buf = mcu::alloc_i2c_buffer();
let i2c = embassy_nrf::twim::Twim::new(
p.TWISPI0, mcu::Irqs, p.P0_06, p.P0_05, twim::Config::default(), twim_buf,
p.TWISPI0,
mcu::Irqs,
p.P0_06,
p.P0_05,
twim::Config::default(),
twim_buf,
);
let display = Some(DisplayParts { i2c });
+132 -51
View File
@@ -1,67 +1,148 @@
//! Inter-task communication channels.
//! Inter-task communication channels for DongLoRa Protocol v2.
//!
//! Each channel connects exactly two tasks:
//! - [`CommandChannel`]: USB → Radio (host commands)
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
//! - [`StatusWatch`]: Radio → Display (observable radio state)
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
//! and tag-0 async RX/ERR events in one queue)
//! - [`DisplayCommandChannel`]: host_task → display_task (internal only —
//! `On` on connect, `Reset` on disconnect; no longer driven from the wire)
//! - [`RadioEventChannel`]: radio_task → display_task (typed mode/packet events)
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_sync::watch::Watch;
use portable_atomic::AtomicBool;
use crate::protocol::{Command, RadioConfig, Response};
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
/// Display commands routed from host_task to display_task.
#[derive(Debug, Clone, Copy, defmt::Format)]
pub enum DisplayCommand {
On,
Off,
/// True while a Reset has been queued in `CommandChannel` but not yet
/// consumed by the radio task. Dedupes injections from any host chart
/// (USB DTR-driven OR UART inactivity-driven) that fires `reset_radio`
/// repeatedly during quiet/inactive periods. Without this dedup, a slow
/// or wedged radio causes the host chart's exit action to inject Reset
/// every cycle, filling the commands channel and producing a "queue
/// full" warn flood. Cleared by the radio chart's Reset handler
/// (`reset_to_unconfigured`).
///
/// Uses `portable_atomic::AtomicBool` (not `core::sync::atomic`) because
/// Cortex-M0+ targets (RP2040) lack native CAS for `swap`. portable-atomic
/// transparently uses native atomics on M3+/M4 and critical-section
/// emulation on M0/M0+.
pub static RESET_PENDING: AtomicBool = AtomicBool::new(false);
/// True while the host transport's USB IN endpoint is observed-dead
/// (write_packet returned Err). Set by `host_task::protocol_loop` on the
/// first failed write; cleared on the next successful write. Producers of
/// **async** D→H events — `radio_task::record_packet` (RX) and
/// `host::framing::emit_async_err` — check this flag before pushing into
/// `OutboundChannel` and silently skip when set, so the queue does not
/// fill while the host is gone.
///
/// Tag-correlated responses (OK / sync ERR / TX_DONE) are NOT gated by
/// this flag: they're a small, bounded volume and the protocol contract
/// requires them to be delivered if we ever reach the host again.
///
/// Same `portable_atomic::AtomicBool` rationale as `RESET_PENDING`.
///
/// **Initial value `true`**: at boot we have no proof the host can
/// drain the outbound queue. Until host_task observes its first
/// successful `write_packet` (and clears this), async-RX producers
/// silently skip pushing. Otherwise — at boot, with no client
/// attached or with mux-rs idle — RX events would fill the queue
/// faster than host_task could drain it (which it can't: `write_packet`
/// blocks until the host actually polls), and the depth-64 ring would
/// flood `[WARN] RX dropped: outbound queue full` hundreds of times
/// per second. Pre-throttle is the structural fix.
pub static RADIO_THROTTLED: AtomicBool = AtomicBool::new(true);
/// Set by `radio::lora_setup` / `lora_setup_with` when a SHORT-DURATION
/// lora-phy op blew through its wall-clock budget (almost always BUSY
/// stuck high — chip is wedged). Read by chart actions and `during:`
/// activities that called the helper: when they observe a downstream
/// `Err(_)` AND see this flag set, they emit `WedgeDetected` (which
/// drives the radio chart into `Recovering` for an in-place NRESET +
/// `lora.init()` retry) instead of the per-action failure event. The
/// flag is consumed (`swap(false)`) by whoever reads it so the next
/// independent op gets a fresh signal.
///
/// This is the cleanest plumbing path for "wedge timeout vs ordinary
/// driver error" given the helper sits below the chart-action layer
/// (free function, no chart-emit access). One atomic, one concern,
/// matching the established `RESET_PENDING` / `RADIO_THROTTLED` pattern.
pub static LORA_WEDGED: AtomicBool = AtomicBool::new(false);
// ── Host → Radio: tag-correlated commands ───────────────────────────
/// An event flowing from the host task into the radio task.
///
/// `Command` carries a non-zero correlation tag (the host framing layer
/// rejects tag=0 before it reaches here). `Reset` is a lifecycle signal
/// from the host transport layer (DTR drop, UART teardown, or spec §3.4
/// inactivity timeout) telling the radio to park back to Unconfigured
/// and drain queues silently.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum InboundEvent {
Command { tag: u16, cmd: Command },
Reset,
}
/// USB-to-display command channel (depth 4: display commands are rare events).
/// Host-to-radio event channel (depth 8).
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
/// One outbound D→H frame carrying its tag. Tag-correlated messages
/// (OK, ERR in response, TX_DONE) carry the originating command's tag;
/// async events (RX, async ERR) carry `tag = 0x0000`.
#[derive(Debug, Clone)]
pub struct OutboundFrame {
pub tag: u16,
pub msg: DeviceMessage,
}
/// Radio-to-host outbound channel.
///
/// Depth 64: absorbs bursts of RX events plus queued command responses
/// plus async ERRs while the host transport drains. Each slot is ~280 B
/// (max RxPayload + envelope), so 64 slots is ~18 KB — well within RAM
/// budget on every supported MCU. The previous depth-32 was provoking
/// "outbound queue full" floods during host stalls; the larger queue
/// plus `RADIO_THROTTLED` backpressure flag (set by host_task on a
/// failed write_packet) together stop the cascade.
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 64>;
// ── Internal: display commands (no longer wire-visible) ────────────
/// Internal display command routed from host_task to display_task. The
/// wire protocol no longer carries DisplayOn/Off (`PROTOCOL.md §15.2`);
/// these values drive the display statechart from local events only.
#[derive(Debug, Clone, Copy, defmt::Format)]
#[allow(dead_code)] // `Off` is reserved for future diagnostic paths.
pub enum DisplayCommand {
/// Host connected (DTR rose on USB, or UART came up). Wake display.
On,
/// Host disconnected. Park display.
Off,
/// Reset cumulative counters (e.g., on config change).
Reset,
}
/// Host-to-display command channel.
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
// ── Radio → Display: typed packet/mode events ──────────────────────
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
/// Observable radio status for the display task (2 receivers max).
/// Display always sees the latest state — intermediate updates may be
/// skipped, which is fine for a status display.
pub type StatusWatch = Watch<CriticalSectionRawMutex, RadioStatus, 2>;
/// Current radio state exposed to observers (e.g. display).
/// Radio → downstream typed events. These drive the display; they are
/// NOT the same thing as `OutboundFrame` (which goes out the host
/// transport). Mode events describe what the radio is doing now; packet
/// events describe what just happened.
#[derive(Debug, Clone, defmt::Format)]
pub struct RadioStatus {
pub state: RadioState,
pub config: Option<RadioConfig>,
pub rx_count: u32,
pub tx_count: u32,
pub last_rssi: Option<i16>,
pub last_snr: Option<i16>,
}
/// Radio state machine states.
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
pub enum RadioState {
pub enum RadioEvent {
EnteredRx,
EnteredTx,
Idle,
Receiving,
Transmitting,
PacketRx { rssi: i16, snr: Option<i16> },
PacketTx,
ConfigChanged(LoRaConfig),
}
impl Default for RadioStatus {
fn default() -> Self {
Self {
state: RadioState::Idle,
config: None,
rx_count: 0,
tx_count: 0,
last_rssi: None,
last_snr: None,
}
}
}
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
+230
View File
@@ -0,0 +1,230 @@
//! LED-based diagnostic blinker (opt-in via the `debug-checkpoint` cargo
//! feature).
//!
//! On boards whose UART is also the host protocol (heltec_v3_uart, any
//! UART-only variant), defmt output would corrupt the COBS-framed host
//! protocol. We can't use `defmt::info!` for live debugging on those
//! boards while the host is connected. Instead we expose a single global
//! atomic checkpoint; code sets it at interesting points in the boot /
//! dispatch flow, and a dedicated task continuously flashes the board
//! LED N times, pauses, and repeats — so a human can count the flashes
//! and report "it got to N."
//!
//! Blink pattern:
//! * Each flash: 400 ms ON, 400 ms OFF.
//! * After N flashes: 2000 ms pause (LED held OFF).
//! * Repeat indefinitely with the current `CHECKPOINT` value.
//!
//! Usage:
//! ```ignore
//! #[cfg(feature = "debug-checkpoint")]
//! crate::debug_blink::set(3); // "we reached checkpoint 3"
//! ```
//!
//! The task replaces `display_task` when the `debug-checkpoint` feature is
//! enabled — it takes the board LED and drives it exclusively.
//!
//! # Current checkpoint map
//!
//! Defined by the `crate::radio` module. Updated here any time the
//! numbering changes there.
//!
//! | Flashes | Meaning |
//! |--------:|--------------------------------------------------------|
//! | 1 | `radio_task` entered (pre-`LoRa::new`) |
//! | 2 | `LoRa::new` succeeded |
//! | 3 | Machine constructed; about to enter `run()` |
//! | 4 | In `Idle` state |
//! | 5 | In `Receiving` state |
//! | 6 | TX phase 1: entered `perform_tx` (engages latch) |
//! | 7 | TX phase 2: in CAD |
//! | 8 | TX phase 3: in `prepare_for_tx` |
//! | 9 | TX phase 4: awaiting `tx()` |
//! | 10 | TX phase 5: `do_tx` returned OK |
//! | 11 | TX phase 6: `respond_tx_done` handler entered |
//! | 12 | TX phase 7: `responses.send(TxDone)` returned |
//! | 13 | TX phase 8: `host_task` received the response from chan |
//! | 14 | TX phase 9: `host_task` wrote response bytes to UART — TERMINAL SUCCESS |
//! | 15 | TX errored somewhere — TERMINAL ERROR |
//! | 16 | Boot error: `LoRa::new` failed |
//! | 17 | Boot error: `start_rx` failed inside `start_rx_hw` |
//!
//! # Latching
//!
//! * Latch engages **only** on `set(6)` (entry to `perform_tx`).
//! * While latched, only TX-phase values (6–15) can update `CHECKPOINT`,
//! and only if strictly deeper than the current value.
//! * Terminals (14 success, 15 error) freeze the latch until reboot.
//! * `set(13)` and `set(14)` called from `host_task` are **no-ops before
//! the latch engages** — this prevents unrelated SetConfig/Ok responses
//! from polluting the display before any TX has happened.
//!
//! # Diagnostic logic
//!
//! If ai-bot reports a timeout but the LED shows:
//! * **10** → `do_tx` ok, but `TransmitDone` event never dispatched.
//! * **11** → handler entered, but `responses.send(...)` hung.
//! * **12** → send pushed to channel, but `host_task` never picked it up.
//! * **13** → `host_task` picked it up, but `tx.write_all` to UART hung.
//! * **14** → full end-to-end firmware success. Hang is on the host side
//! of the UART (mux or wire).
//! * **15** → TX errored somewhere along the way.
//!
//! # Building with the feature
//!
//! The `just` build/flash recipes pass a fixed feature list and don't
//! surface an `extra-features` argument. To build a debug image:
//!
//! ```sh
//! cd firmware
//! . ~/export-esp.sh # if building for xtensa (esp32s3)
//! cargo +esp build --release \
//! --target xtensa-esp32s3-none-elf \
//! --features heltec_v3_uart,debug-checkpoint \
//! -Zbuild-std=core,alloc
//! cp target/xtensa-esp32s3-none-elf/release/donglora \
//! builds/donglora-heltec_v3_uart-v$(sed -n 's/^version = "\(.*\)"/\1/p' \
//! Cargo.toml).elf
//! espflash flash -p /dev/ttyUSB0 builds/donglora-heltec_v3_uart-v0.4.0.elf
//! ```
//!
//! The normal `just flash heltec_v3_uart` command builds without the
//! feature, so the debug code has zero footprint on release firmware.
use core::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use embassy_executor::task;
use embassy_time::{Duration, Timer};
use crate::board::{LedDriver, RgbLed};
/// Current diagnostic checkpoint — read by the blinker task. Zero means
/// "no checkpoint set yet."
///
/// Write policy (see [`set`]): once a TX-phase value (6, 11, 12, 13) is
/// stored, the latch engages and:
/// * subsequent TX-phase writes update the value only if strictly deeper,
/// * non-TX-phase writes (including the 7/10 terminal markers and the
/// steady-state 4/5 post-TX writes) are **ignored** so the deepest phase
/// reached on the FIRST TX attempt persists.
///
/// Rationale: a stuck-then-recovered TX attempt would normally flicker
/// through the display too quickly to count. Latching preserves the
/// evidence until reboot.
pub static CHECKPOINT: AtomicU8 = AtomicU8::new(0);
/// Has the TX-phase latch engaged yet? Written by [`set`], checked by
/// [`set`]. Not intended for external inspection.
static HAS_LATCHED: AtomicBool = AtomicBool::new(false);
#[inline]
fn is_host_phase(n: u8) -> bool {
// Values set only from `host_task` — meaningless before a TX has
// started, so we refuse to overwrite with them when the latch is
// not yet engaged.
n == 13 || n == 14
}
/// Ordered depth of each TX-related checkpoint. Returns `0` for values
/// that are NOT TX phases (e.g. boot / steady-state / error indicators).
///
/// Value 18 ("next_command received CmdTransmit") is intentionally the
/// SHALLOWEST TX phase — it fires before `perform_tx` entry (value 6)
/// but is numerically higher because it was added last. The latch uses
/// `phase_depth` rather than raw numeric comparison so 18 can engage
/// the latch at depth 1 and later TX phases (6..=15) deepen over it.
#[inline]
fn phase_depth(n: u8) -> u8 {
match n {
18 => 1, // pre-TX: CmdTransmit just read from commands channel
6 => 2, // perform_tx entered
7 => 3, // in CAD (before prepare_for_cad)
19 => 4, // after prepare_for_cad (awaiting lora.cad IRQ)
8 => 5, // in prepare_for_tx
9 => 6, // in tx().await
10 => 7, // do_tx returned Ok
11 => 8, // respond_tx_done entered
12 => 9, // respond_tx_done: responses.send returned
13 => 10, // host_task received response from channel
14 => 11, // host_task wrote response to UART (terminal success)
15 => 12, // TX errored (terminal error)
_ => 0, // not a TX phase
}
}
#[inline]
fn is_tx_phase(n: u8) -> bool {
phase_depth(n) > 0
}
#[inline]
fn is_tx_terminal(n: u8) -> bool {
n == 14 || n == 15
}
/// Record that we reached checkpoint `n`. A no-op in terms of visible
/// behavior unless the blinker task is spawned; the store itself is very
/// cheap so leaving these calls in non-debug builds is harmless.
///
/// Latch semantics:
/// * `set(18)` (CmdTransmit received by `next_command`) or `set(6)`
/// (entry to `perform_tx`) engage the latch.
/// * While latched, only strictly-deeper TX phases (by `phase_depth`)
/// update the checkpoint.
/// * Terminal values (14 success, 15 error) freeze the latch until
/// reboot.
/// * Host-phase values (13, 14) called from `host_task` are ignored
/// before the latch engages, so unrelated `Response::Ok` writes from
/// SetConfig don't pollute the LED reading before any TX.
#[inline]
pub fn set(n: u8) {
if HAS_LATCHED.load(Ordering::Relaxed) {
let cur = CHECKPOINT.load(Ordering::Relaxed);
if is_tx_terminal(cur) {
return;
}
if phase_depth(n) > phase_depth(cur) {
CHECKPOINT.store(n, Ordering::Relaxed);
}
return;
}
// Latch not yet engaged.
if is_host_phase(n) {
// `host_task` is draining the response channel before any TX —
// these values would be misleading. Skip them.
return;
}
CHECKPOINT.store(n, Ordering::Relaxed);
if is_tx_phase(n) {
HAS_LATCHED.store(true, Ordering::Relaxed);
}
}
/// Dedicated task that continuously flashes the LED to show the current
/// checkpoint. Owns the board's LED driver exclusively — on boards where
/// the display task would normally own the LED for RX/TX blips, spawn
/// *either* this task *or* `display_task`, not both.
#[task]
pub async fn debug_blink_task(mut led: LedDriver) {
// Start with the LED off so the first flash is unambiguous.
led.set_rgb(0, 0, 0).await;
loop {
let n = CHECKPOINT.load(Ordering::Relaxed);
if n == 0 {
// No checkpoint yet — wait and re-check.
Timer::after(Duration::from_millis(200)).await;
continue;
}
for _ in 0..n {
led.set_rgb(255, 255, 255).await;
Timer::after(Duration::from_millis(400)).await;
led.set_rgb(0, 0, 0).await;
Timer::after(Duration::from_millis(400)).await;
}
// Long pause to separate cycles so the human can count.
Timer::after(Duration::from_millis(2000)).await;
}
}
+775 -273
View File
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
//! Shared QR-code blit for splash screens.
//!
//! The 512-byte QR bitmap in the parent module is laid out asymmetrically
//! within its 64×64 frame: rows 0-6 and 57-63 are top/bottom quiet zones,
//! but the QR cells start at col 0 (no left quiet zone) and extend to
//! col ~49, leaving ~14 px of white padding on the right plus one stray
//! black pixel at col 63. Rendering the bitmap as-is leaves the QR
//! visually shifted toward the left of its rendering area, which looks
//! wrong on any layout that doesn't tuck the bitmap flush against a
//! screen edge.
//!
//! [`draw`] re-centers the QR data: it skips the bitmap's right padding
//! (cols 50-63), fills the rendering area's left and right 7 px with
//! the foreground "quiet zone" color, and renders the actual QR cells
//! (bitmap cols 0..50) at +7 horizontal offset. Rows are rendered as-is
//! so the bitmap's existing top/bottom quiet zones land where expected.
//!
//! Generic over color so the mono renderer (`BinaryColor`) and the
//! T114's color renderer (`Rgb565`) share a single implementation —
//! they pass their own foreground (white / lit) and background (black /
//! unlit) values.
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::geometry::{Point, Size};
use embedded_graphics::pixelcolor::PixelColor;
use embedded_graphics::primitives::Rectangle;
use embedded_graphics::Pixel;
use super::{QR_BITMAP, QR_WIDTH_PX};
/// Width of the actual QR data within the bitmap (cols 0..QR_DATA_W).
const QR_DATA_W: i32 = 50;
/// White padding on left + right to center the QR cells. With
/// QR_DATA_W=50 and total area=64 (= QR_WIDTH_PX), padding = 7 each side.
const PAD_X: i32 = (QR_WIDTH_PX as i32 - QR_DATA_W) / 2;
/// Render the 64×64 QR with proper quiet zones on all four sides.
///
/// `fg` is the "quiet zone / light cell" color (white on a TFT, "on"
/// on a mono OLED). `bg` is the "dark cell" color. Bitmap rows 0-6 and
/// 57-63 already encode top/bottom quiet zones in `fg`; this function
/// fills the missing left/right padding columns explicitly.
pub fn draw<D, C>(target: &mut D, origin: Point, fg: C, bg: C)
where
D: DrawTarget<Color = C>,
C: PixelColor,
{
// Fill left padding (cols 0..PAD_X) with fg.
let _ = target.fill_solid(
&Rectangle::new(origin, Size::new(PAD_X as u32, QR_WIDTH_PX)),
fg,
);
// Fill right padding (cols PAD_X+QR_DATA_W..QR_WIDTH_PX) with fg.
let right_pad_w = QR_WIDTH_PX as i32 - PAD_X - QR_DATA_W;
let _ = target.fill_solid(
&Rectangle::new(
Point::new(origin.x + PAD_X + QR_DATA_W, origin.y),
Size::new(right_pad_w as u32, QR_WIDTH_PX),
),
fg,
);
// Render bitmap cols 0..QR_DATA_W at +PAD_X horizontal offset.
let bytes_per_row = (QR_WIDTH_PX / 8) as usize;
let pixels = (0..QR_WIDTH_PX as i32).flat_map(move |y| {
(0..QR_DATA_W).map(move |x| {
let byte = QR_BITMAP[y as usize * bytes_per_row + (x as usize) / 8];
let bit = (byte >> (7 - (x as u8 & 7))) & 1;
let color = if bit == 1 { fg } else { bg };
Pixel(Point::new(origin.x + PAD_X + x, origin.y + y), color)
})
});
let _ = target.draw_iter(pixels);
}
+422
View File
@@ -0,0 +1,422 @@
//! Native 240×135 Rgb565 renderer for the Heltec T114 ST7789 panel.
//!
//! Mirrors the content shape of the mono OLED renderer in `display::render`
//! (same `BoardInfo` / `DashboardCtx` inputs) but lays things out for the
//! T114's bigger color screen with native font sizes and colored badges.
//! Each board's `BoardDisplay` impl picks the renderer that matches its
//! native pixel format — no per-pixel color quantization, no scaling,
//! no aliasing.
use core::fmt::Write;
use embedded_graphics::mono_font::ascii::{
FONT_6X10, FONT_8X13, FONT_8X13_BOLD, FONT_9X15_BOLD, FONT_10X20,
};
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle};
use embedded_graphics::text::renderer::CharacterStyle;
use embedded_graphics::text::{Alignment, Text};
use heapless::String;
use super::{qr, Badge, BoardInfo, DashboardCtx, QR_WIDTH_PX, RSSI_HISTORY_LEN};
use crate::protocol::LoRaBandwidth;
// ── Panel dimensions ──────────────────────────────────────────────────
const W: i32 = 240;
const H: i32 = 135;
// ── Palette ───────────────────────────────────────────────────────────
const BG: Rgb565 = Rgb565::BLACK;
const FG: Rgb565 = Rgb565::WHITE;
const DIM: Rgb565 = Rgb565::new(12, 24, 12); // muted gray
const ACCENT: Rgb565 = Rgb565::new(8, 30, 31); // bright cyan
const RX_COLOR: Rgb565 = Rgb565::new(8, 60, 8); // bright green
const TX_COLOR: Rgb565 = Rgb565::new(31, 16, 8); // amber-red
const SPARK_COLOR: Rgb565 = Rgb565::new(20, 50, 24); // soft green
// ── Sparkline ────────────────────────────────────────────────────────
const RSSI_MIN: i16 = -120;
const RSSI_MAX: i16 = 0;
/// Sentinel: no packet received in this slot. Mirrors `display::NO_SIGNAL`.
const NO_SIGNAL: i16 = -121;
// ── Public render entry points ────────────────────────────────────────
pub fn blank<D: DrawTarget<Color = Rgb565>>(target: &mut D) {
let _ = target.clear(BG);
}
pub fn splash_learn_more<D: DrawTarget<Color = Rgb565>>(target: &mut D) {
let _ = target.clear(BG);
// QR pinned to the right side, vertically centered. 64×64 fits with
// ~36 px headroom top/bottom on the 135-tall panel.
let qr_w = QR_WIDTH_PX as i32;
let qr_x = W - qr_w - 8;
let qr_y = (H - qr_w) / 2;
qr::draw(target, Point::new(qr_x, qr_y), FG, BG);
// Text column to the left of the QR.
let col_w = qr_x - 8;
let col_center = col_w / 2 + 4;
let title_style = MonoTextStyle::new(&FONT_10X20, ACCENT);
let url_style = MonoTextStyle::new(&FONT_8X13, FG);
let caption_style = MonoTextStyle::new(&FONT_6X10, DIM);
let _ = Text::with_alignment(
"DongLoRa",
Point::new(col_center, 45),
title_style,
Alignment::Center,
)
.draw(target);
let _ = Text::with_alignment(
"donglora.com",
Point::new(col_center, 73),
url_style,
Alignment::Center,
)
.draw(target);
let _ = Text::with_alignment(
"Scan for docs",
Point::new(col_center, 95),
caption_style,
Alignment::Center,
)
.draw(target);
}
pub fn splash_info<D: DrawTarget<Color = Rgb565>>(target: &mut D, info: &BoardInfo<'_>) {
let _ = target.clear(BG);
let title_style = MonoTextStyle::new(&FONT_10X20, ACCENT);
let board_style = MonoTextStyle::new(&FONT_9X15_BOLD, FG);
let mac_style = MonoTextStyle::new(&FONT_8X13, FG);
let small_style = MonoTextStyle::new(&FONT_6X10, DIM);
// Title at top.
let _ = Text::with_alignment(
"DongLoRa",
Point::new(W / 2, 22),
title_style,
Alignment::Center,
)
.draw(target);
// Board name.
let _ = Text::with_alignment(
info.name,
Point::new(W / 2, 50),
board_style,
Alignment::Center,
)
.draw(target);
// MAC.
let _ = Text::with_alignment(
info.mac,
Point::new(W / 2, 75),
mac_style,
Alignment::Center,
)
.draw(target);
// Version.
let mut version_buf: String<24> = String::new();
let _ = write!(version_buf, "v{}", info.version);
let _ = Text::with_alignment(
&version_buf,
Point::new(W / 2, 95),
small_style,
Alignment::Center,
)
.draw(target);
// Waiting line.
let _ = Text::with_alignment(
"Waiting for host...",
Point::new(W / 2, 120),
small_style,
Alignment::Center,
)
.draw(target);
}
pub fn dashboard<D: DrawTarget<Color = Rgb565>>(target: &mut D, ctx: &DashboardCtx<'_>) {
let _ = target.clear(BG);
// ── Top bar (badge + radio config) ──────────────────────────
let top_h = 24;
let badge_w = 38;
let (badge_color, badge_text) = match ctx.badge {
Badge::Rx => (RX_COLOR, "RX"),
Badge::Tx => (TX_COLOR, "TX"),
};
let badge_rect = Rectangle::new(Point::zero(), Size::new(badge_w as u32, top_h as u32));
let _ = badge_rect
.into_styled(PrimitiveStyle::with_fill(badge_color))
.draw(target);
let mut badge_style = MonoTextStyle::new(&FONT_8X13_BOLD, FG);
badge_style.set_background_color(Some(badge_color));
let _ = Text::with_alignment(
badge_text,
Point::new(badge_w / 2, 16),
badge_style,
Alignment::Center,
)
.draw(target);
// Radio config text right of the badge.
let cfg_style = MonoTextStyle::new(&FONT_8X13, FG);
let mut buf: String<48> = String::new();
if let Some(cfg) = ctx.config {
let bw_str = bw_label(cfg.bw);
let cr_str = cr_label(cfg.cr);
let freq_mhz = cfg.freq_hz / 1_000_000;
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
let _ = write!(
buf,
"{}.{:03} {} SF{} CR{}",
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
);
} else {
let _ = write!(buf, "Awaiting config");
}
let _ = Text::with_alignment(
&buf,
Point::new(W - 4, 16),
cfg_style,
Alignment::Right,
)
.draw(target);
// Separator line under top bar.
let _ = Line::new(Point::new(0, top_h + 1), Point::new(W - 1, top_h + 1))
.into_styled(PrimitiveStyle::with_stroke(DIM, 1))
.draw(target);
// ── Bottom bar (counters + RSSI) ───────────────────────────
let bottom_h = 30;
let bottom_top = H - bottom_h;
let _ = Line::new(
Point::new(0, bottom_top - 1),
Point::new(W - 1, bottom_top - 1),
)
.into_styled(PrimitiveStyle::with_stroke(DIM, 1))
.draw(target);
let stat_style = MonoTextStyle::new(&FONT_8X13, FG);
let mut rxtx_buf: String<24> = String::new();
let _ = write!(
rxtx_buf,
"RX {} TX {}",
compact_count(ctx.rx_count).as_str(),
compact_count(ctx.tx_count).as_str()
);
let _ = Text::with_alignment(
&rxtx_buf,
Point::new(4, bottom_top + 11),
stat_style,
Alignment::Left,
)
.draw(target);
let mut rssi_buf: String<32> = String::new();
match (ctx.last_rssi, ctx.last_snr) {
(Some(rssi), Some(snr)) => {
let _ = write!(rssi_buf, "{} dBm SNR {}", rssi, snr);
}
(Some(rssi), None) => {
let _ = write!(rssi_buf, "{} dBm", rssi);
}
_ => {
let _ = write!(rssi_buf, "no signal");
}
}
let _ = Text::with_alignment(
&rssi_buf,
Point::new(W - 4, bottom_top + 11),
stat_style,
Alignment::Right,
)
.draw(target);
let secondary = MonoTextStyle::new(&FONT_6X10, DIM);
let _ = Text::with_alignment(
ctx.board.mac,
Point::new(W / 2, bottom_top + 25),
secondary,
Alignment::Center,
)
.draw(target);
// ── Sparkline ──────────────────────────────────────────────
let spark_top = top_h + 4;
let spark_h = (bottom_top - 2) - spark_top;
if spark_h > 0 {
// 240 wide / 2 px per bar = 120 visible bars (subset of the
// 128-slot ring). Matches the mono renderer's
// `visible_bars = w / 2` heuristic.
let visible_bars = ((W / 2) as usize).min(RSSI_HISTORY_LEN);
rssi_sparkline(
target,
ctx.rssi_history,
ctx.tx_history,
ctx.rssi_count,
ctx.current_slot_rssi,
ctx.current_slot_tx,
spark_top,
spark_h,
visible_bars,
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
fn bw_label(bw: LoRaBandwidth) -> &'static str {
match bw {
LoRaBandwidth::Khz7 => "7.8k",
LoRaBandwidth::Khz10 => "10.4k",
LoRaBandwidth::Khz15 => "15.6k",
LoRaBandwidth::Khz20 => "20.8k",
LoRaBandwidth::Khz31 => "31.2k",
LoRaBandwidth::Khz41 => "41.7k",
LoRaBandwidth::Khz62 => "62.5k",
LoRaBandwidth::Khz125 => "125k",
LoRaBandwidth::Khz250 => "250k",
LoRaBandwidth::Khz500 => "500k",
LoRaBandwidth::Khz200 => "200k",
LoRaBandwidth::Khz400 => "400k",
LoRaBandwidth::Khz800 => "800k",
LoRaBandwidth::Khz1600 => "1.6M",
}
}
fn cr_label(cr: crate::protocol::LoRaCodingRate) -> &'static str {
match cr {
crate::protocol::LoRaCodingRate::Cr4_5 => "5",
crate::protocol::LoRaCodingRate::Cr4_6 => "6",
crate::protocol::LoRaCodingRate::Cr4_7 => "7",
crate::protocol::LoRaCodingRate::Cr4_8 => "8",
}
}
fn compact_count(n: u32) -> String<10> {
let mut s: String<10> = String::new();
if n > 9_999_999 {
let _ = write!(s, "{}M", n / 1_000_000);
} else if n > 999_999 {
let _ = write!(s, "{}k", n / 1_000);
} else {
let _ = write!(s, "{}", n);
}
s
}
#[allow(clippy::too_many_arguments)]
fn rssi_sparkline<D: DrawTarget<Color = Rgb565>>(
target: &mut D,
history: &[i16; RSSI_HISTORY_LEN],
tx_history: &[bool; RSSI_HISTORY_LEN],
count: usize,
current_rssi: i16,
current_tx: bool,
spark_top: i32,
spark_h: i32,
visible_bars: usize,
) {
let live = current_rssi > RSSI_MIN || current_tx;
let committed = count.min(RSSI_HISTORY_LEN);
let hist_slots = if live {
committed.min(visible_bars.saturating_sub(1))
} else {
committed.min(visible_bars)
};
let total = hist_slots + if live { 1 } else { 0 };
if total == 0 {
return;
}
for i in 0..hist_slots {
let idx = if count <= RSSI_HISTORY_LEN {
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
i + committed.saturating_sub(effective_bars)
} else {
let start = count - RSSI_HISTORY_LEN;
let skip = RSSI_HISTORY_LEN.saturating_sub(if live {
visible_bars - 1
} else {
visible_bars
});
(start + skip + i) % RSSI_HISTORY_LEN
};
let is_tx = tx_history[idx];
let rssi = history[idx];
if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) {
let x = (visible_bars - total + i) as i32 * 2;
draw_bar(target, x, bar_h, is_tx, spark_top, spark_h);
}
}
if live {
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
let x = (visible_bars - 1) as i32 * 2;
draw_bar(target, x, bar_h, current_tx, spark_top, spark_h);
}
}
}
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
let h = if rssi <= RSSI_MIN || rssi == NO_SIGNAL {
if is_tx {
spark_h / 3
} else {
return None;
}
} else {
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
};
if h == 0 { None } else { Some(h) }
}
fn draw_bar<D: DrawTarget<Color = Rgb565>>(
target: &mut D,
x: i32,
bar_h: i32,
is_tx: bool,
spark_top: i32,
spark_h: i32,
) {
let y = spark_top + spark_h - bar_h;
let color = if is_tx { TX_COLOR } else { SPARK_COLOR };
if is_tx {
// Hatched bar — every other pixel row — mirrors the mono
// renderer's TX visual cue.
for row in 0..bar_h {
if row % 2 == 0 {
let _ = Rectangle::new(Point::new(x, y + row), Size::new(2, 1))
.into_styled(PrimitiveStyle::with_fill(color))
.draw(target);
}
}
} else {
let _ = Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
.into_styled(PrimitiveStyle::with_fill(color))
.draw(target);
}
}
+121
View File
@@ -0,0 +1,121 @@
//! AXP PMIC init — turn on the rails that feed LoRa and the OLED.
//!
//! T-Beam–class boards gate LoRa, OLED (and GPS/sensors, which this firmware
//! does not use) through an X-Powers PMIC on I²C. Until the PMIC is told to
//! enable the right LDOs/DCDCs, the SX126x/SX127x chip sits unpowered and
//! `LoRa::new` fails. This module's only job is to flip those switches.
//!
//! Two PMICs are covered:
//!
//! * [`axp2101_init_lora_oled`] — LilyGo T-Beam S3 Supreme. ALDO3 = 3.3 V
//! feeds the SX1262; ALDO1 = 3.3 V feeds the SH1106 OLED. Both live on a
//! dedicated I²C1 bus (separate from the display).
//! * [`axp192_init_lora_oled`] — LilyGo T-Beam (classic). LDO2 = 3.3 V feeds
//! the SX1276; DCDC1 = 3.3 V feeds the SSD1306 OLED. PMIC shares the
//! display I²C bus.
//!
//! Everything unrelated to LoRa + OLED (GPS, sensors, SD, batt ADC) is left
//! alone. Out-of-scope for a transparent LoRa-over-USB bridge.
//!
//! Init is blocking on purpose. It runs once during `into_parts`, which is a
//! synchronous function called before task spawning. Using the blocking
//! `embedded_hal` trait keeps the PMIC wiring out of the async machinery and
//! lets the caller drop the I²C bus immediately after init (no bus handoff).
use embedded_hal::i2c::I2c;
/// Both chips respond at this 7-bit I²C address.
const PMIC_ADDR: u8 = 0x34;
// ── AXP2101 (T-Beam S3 Supreme) ──────────────────────────────────────
#[cfg(feature = "lilygo_tbeam_supreme")]
mod axp2101 {
use super::{I2c, PMIC_ADDR};
/// LDO on/off control register. Bits:
/// 0 = ALDO1, 1 = ALDO2, 2 = ALDO3, 3 = ALDO4, 4 = BLDO1, 5 = BLDO2.
const REG_LDO_ONOFF: u8 = 0x90;
/// ALDO1 voltage register (OLED rail on T-Beam Supreme).
const REG_ALDO1_V: u8 = 0x92;
/// ALDO3 voltage register (LoRa rail on T-Beam Supreme).
const REG_ALDO3_V: u8 = 0x94;
/// Voltage code for 3.3 V on ALDOx: 0.5 V + 28 × 100 mV.
const V_3V3: u8 = 28;
/// Mask enabling ALDO1 + ALDO3 in [`REG_LDO_ONOFF`].
const ALDO1_ALDO3: u8 = 0b0000_0101;
/// Bring up LoRa (ALDO3) and OLED (ALDO1) rails at 3.3 V.
///
/// Voltage registers are set before the enable bits to avoid glitching
/// the rails through the reset-default voltage. Other rails are left
/// untouched (read-modify-write on the enable register) so any
/// board-specific OTP defaults stay intact.
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
i2c.write(PMIC_ADDR, &[REG_ALDO3_V, V_3V3])?;
i2c.write(PMIC_ADDR, &[REG_ALDO1_V, V_3V3])?;
let mut onoff = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[REG_LDO_ONOFF], &mut onoff)?;
i2c.write(PMIC_ADDR, &[REG_LDO_ONOFF, onoff[0] | ALDO1_ALDO3])?;
Ok(())
}
}
#[cfg(feature = "lilygo_tbeam_supreme")]
pub use axp2101::init_lora_oled as axp2101_init_lora_oled;
// ── AXP192 (T-Beam classic) ──────────────────────────────────────────
#[cfg(feature = "lilygo_tbeam")]
mod axp192 {
use super::{I2c, PMIC_ADDR};
/// DCDC/LDO enable register. Bit layout:
/// 0 = DCDC1, 1 = DCDC3, 2 = LDO2, 3 = LDO3, 4 = DCDC2, 6 = EXTEN.
const REG_ONOFF: u8 = 0x12;
/// DCDC1 output voltage register (OLED rail on T-Beam classic).
/// 7-bit code, 25 mV step, 0.7 V offset → 3.3 V = 104 = `0x68`.
const REG_DCDC1_V: u8 = 0x26;
/// LDO2/LDO3 voltage register. Upper nibble = LDO2 (LoRa), lower nibble
/// = LDO3 (GPS). 100 mV step, 1.8 V offset → 3.3 V = 15 = `0xF`.
const REG_LDO23_V: u8 = 0x28;
/// DCDC1 code for 3.3 V.
const DCDC1_3V3: u8 = 0x68;
/// LDO2 nibble for 3.3 V in the upper half of [`REG_LDO23_V`].
const LDO2_3V3_UPPER: u8 = 0xF0;
/// Mask enabling DCDC1 + LDO2 in [`REG_ONOFF`].
const DCDC1_LDO2: u8 = 0b0000_0101;
/// Bring up LoRa (LDO2) and OLED (DCDC1) rails at 3.3 V on the
/// classic T-Beam's AXP192.
///
/// GPS (LDO3) and EXTEN are not touched — this firmware has no GPS,
/// and any board-default for LDO3 voltage is preserved by
/// read-modify-write. Voltage is programmed before the enable bit
/// flips so the rail doesn't glitch through its reset default.
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
let mut ldo23 = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[REG_LDO23_V], &mut ldo23)?;
i2c.write(
PMIC_ADDR,
&[REG_LDO23_V, (ldo23[0] & 0x0F) | LDO2_3V3_UPPER],
)?;
i2c.write(PMIC_ADDR, &[REG_DCDC1_V, DCDC1_3V3])?;
let mut onoff = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[REG_ONOFF], &mut onoff)?;
i2c.write(PMIC_ADDR, &[REG_ONOFF, onoff[0] | DCDC1_LDO2])?;
Ok(())
}
}
#[cfg(feature = "lilygo_tbeam")]
pub use axp192::init_lora_oled as axp192_init_lora_oled;
+30
View File
@@ -0,0 +1,30 @@
//! Generic `OutputPin` wrapper that inverts logic levels.
//!
//! When a peripheral is wired active-low (LED anode to V+, cathode to GPIO;
//! reset line idle-high, etc.) the physical polarity is a property of the
//! board, not of the driver. Wrapping the pin in `InvertedPin` lets drivers
//! keep "set_high = assert" semantics while the board file expresses the
//! wiring reality at construction time.
//!
//! ```ignore
//! let pin = Output::new(p.GPIO4, Level::High, OutputConfig::default()); // off
//! let led = SimpleLed(InvertedPin(pin)); // driver sees normal polarity
//! ```
use embedded_hal::digital::{ErrorType, OutputPin};
pub struct InvertedPin<P>(pub P);
impl<P: ErrorType> ErrorType for InvertedPin<P> {
type Error = P::Error;
}
impl<P: OutputPin> OutputPin for InvertedPin<P> {
fn set_low(&mut self) -> Result<(), Self::Error> {
self.0.set_high()
}
fn set_high(&mut self) -> Result<(), Self::Error> {
self.0.set_low()
}
}
+85 -2
View File
@@ -1,5 +1,88 @@
#[cfg(feature = "wio_tracker_l1")]
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
pub mod sh1106;
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "wio_tracker_l1"))]
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
pub mod axp;
#[cfg(any(feature = "lilygo_tbeam", feature = "heltec_mesh_node_t114"))]
pub mod inverted_pin;
#[cfg(feature = "heltec_mesh_node_t114")]
pub mod st7789;
#[cfg(any(
feature = "heltec_mesh_node_t114",
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme",
feature = "lilygo_tbeam",
feature = "rak_wisblock_4631",
feature = "wio_tracker_l1",
feature = "waveshare_rp2040_lora"
))]
pub mod simple_led;
// ── Display brightness ────────────────────────────────────────────────
//
// The display statechart dims the OLED a few seconds after the last
// activity. Our two driver families (`ssd1306` crate and our in-tree
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
// single async trait that the state-machine actions can call uniformly.
/// Two-level backlight control used by the display dashboard.
pub trait DisplayBrightness {
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
}
// Contrast values for the two brightness levels, picked to be common across
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
const CONTRAST_BRIGHT: u8 = 0xFF;
const CONTRAST_DIM: u8 = 0x08;
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
where
DI: display_interface::AsyncWriteOnlyDataCommand,
SIZE: ssd1306::size::DisplaySizeAsync,
{
async fn set_bright(&mut self) {
// `Brightness::custom(precharge, contrast)`. Matches the shape of
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
let _ = self
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
.await;
}
async fn set_dim(&mut self) {
// Low precharge pairs with low contrast — same combination the
// crate's DIMMEST preset uses.
let _ = self
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
.await;
}
}
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
impl<I> DisplayBrightness for sh1106::Sh1106<I>
where
I: embedded_hal_async::i2c::I2c,
{
async fn set_bright(&mut self) {
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
}
async fn set_dim(&mut self) {
let _ = self.set_brightness(CONTRAST_DIM).await;
}
}
+21 -4
View File
@@ -37,6 +37,12 @@ impl<I: I2c> Sh1106<I> {
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
for &c in commands {
#[cfg(feature = "debug-twim")]
defmt::info!(
"[sh1106] cmd write: addr=0x{=u8:02X} bytes=[0x00, 0x{=u8:02X}]",
self.addr,
c,
);
self.i2c.write(self.addr, &[0x00, c]).await?;
}
Ok(())
@@ -44,6 +50,8 @@ impl<I: I2c> Sh1106<I> {
/// Initialize the display with sensible defaults.
pub async fn init(&mut self) -> Result<(), I::Error> {
#[cfg(feature = "debug-twim")]
defmt::info!("[sh1106] init: starting");
self.cmd(&[
0xAE, // display off
0xD5, 0x80, // clock div: default
@@ -71,16 +79,24 @@ impl<I: I2c> Sh1106<I> {
page_buf[0] = 0x40;
for page in 0..PAGES {
#[cfg(feature = "debug-twim")]
defmt::info!("[sh1106] flush page {=usize}: addr cmds (3 bytes)", page);
// Set page address and column start (with SH1106 2-column offset).
self.cmd(&[
0xB0 | page as u8, // page address
COL_OFFSET & 0x0F, // column low nibble
0x10 | (COL_OFFSET >> 4), // column high nibble
0xB0 | page as u8, // page address
COL_OFFSET & 0x0F, // column low nibble
0x10 | (COL_OFFSET >> 4), // column high nibble
])
.await?;
let start = page * WIDTH;
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
#[cfg(feature = "debug-twim")]
defmt::info!(
"[sh1106] flush page {=usize}: data write {=usize} bytes (0x40 prefix + 128 pixels)",
page,
page_buf.len(),
);
self.i2c.write(self.addr, &page_buf).await?;
}
Ok(())
@@ -88,9 +104,10 @@ impl<I: I2c> Sh1106<I> {
/// Set display brightness (contrast).
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
#[cfg(feature = "debug-twim")]
defmt::info!("[sh1106] set_brightness: 0x{=u8:02X}", value);
self.cmd(&[0x81, value]).await
}
}
impl<I: I2c> DrawTarget for Sh1106<I> {
+123
View File
@@ -0,0 +1,123 @@
//! ST7789 240×135 IPS TFT driver wrapper for the Heltec Mesh Node T114.
//!
//! Wraps `lcd_async::Display<…, models::ST7789, …>` (async fork of
//! mipidsi) with an in-RAM raw byte framebuffer (240×135 × 2 bytes
//! Rgb565 BE = 64,800 bytes) and an active-LOW PWM backlight on PWM0.
//!
//! Renderers draw into the framebuffer via `lcd_async::raw_framebuf::RawFrameBuf`;
//! `present()` awaits a single async DMA blit of the entire buffer to
//! the panel via `panel.show_raw_data(...).await`. The await lets
//! embassy-nrf's DMA-backed Spim yield the executor for the duration
//! of the ~65 ms SPI transfer, so the radio task can run alongside
//! and the SX1262 IRQ handler isn't starved during repaints.
use embassy_nrf::pwm::{DutyCycle, SimplePwm};
use embedded_graphics::pixelcolor::Rgb565;
use lcd_async::interface::Interface;
use lcd_async::models::ST7789;
use lcd_async::raw_framebuf::RawFrameBuf;
use lcd_async::Display;
use crate::display::{render_color, BoardDisplay, BoardInfo, DashboardCtx};
/// PWM countertop. Drives the backlight at ~1 kHz (well above flicker).
pub const BL_MAX: u16 = 1000;
fn bl_bright() -> DutyCycle {
// Inverted polarity: HIGH for 0 counts → always LOW → full bright.
DutyCycle::inverted(0)
}
fn bl_dim() -> DutyCycle {
// ~80% HIGH → ~20% LOW → 20% brightness.
DutyCycle::inverted((BL_MAX * 4) / 5)
}
#[allow(dead_code)]
fn bl_off() -> DutyCycle {
DutyCycle::inverted(BL_MAX)
}
// ── Framebuffer ───────────────────────────────────────────────────────
pub const FB_W: usize = 240;
pub const FB_H: usize = 135;
/// Bytes for one full Rgb565 frame (2 bytes/pixel, big-endian — matches
/// what ST7789 expects in 16-bit color mode).
pub const FB_BYTES: usize = FB_W * FB_H * 2;
// ── St7789Color ───────────────────────────────────────────────────────
pub struct St7789Color<DI, RST>
where
DI: Interface,
RST: embedded_hal::digital::OutputPin,
{
panel: Display<DI, ST7789, RST>,
backlight: SimplePwm<'static>,
fb: &'static mut [u8; FB_BYTES],
}
impl<DI, RST> St7789Color<DI, RST>
where
DI: Interface,
RST: embedded_hal::digital::OutputPin,
{
pub fn new(
panel: Display<DI, ST7789, RST>,
backlight: SimplePwm<'static>,
fb: &'static mut [u8; FB_BYTES],
) -> Self {
Self { panel, backlight, fb }
}
/// Wrap the raw byte buffer as a `RawFrameBuf<Rgb565, _>` so
/// `embedded-graphics` primitives can draw into it, then run `f`.
/// The borrow ends when this returns, freeing `self.fb` for the
/// subsequent blit in `present()`.
fn with_fb<F: FnOnce(&mut RawFrameBuf<Rgb565, &mut [u8]>)>(&mut self, f: F) {
let mut fbuf = RawFrameBuf::<Rgb565, _>::new(&mut self.fb[..], FB_W, FB_H);
f(&mut fbuf);
}
}
impl<DI, RST> BoardDisplay for St7789Color<DI, RST>
where
DI: Interface<Word = u8>,
RST: embedded_hal::digital::OutputPin,
{
/// Blit the entire framebuffer to the panel via the async SPI
/// interface. embassy-nrf's Spim runs the transfer over EasyDMA;
/// the `.await` yields the executor for the duration so the radio
/// task can process SX1262 IRQs concurrently.
async fn present(&mut self) {
let _ = self
.panel
.show_raw_data(0, 0, FB_W as u16, FB_H as u16, self.fb.as_ref())
.await;
}
async fn set_bright(&mut self) {
self.backlight.set_duty(0, bl_bright());
}
async fn set_dim(&mut self) {
self.backlight.set_duty(0, bl_dim());
}
fn render_splash_learn_more(&mut self) {
self.with_fb(|fb| render_color::splash_learn_more(fb));
}
fn render_splash_info(&mut self, info: &BoardInfo<'_>) {
self.with_fb(|fb| render_color::splash_info(fb, info));
}
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) {
self.with_fb(|fb| render_color::dashboard(fb, ctx));
}
fn render_blank(&mut self) {
self.with_fb(|fb| render_color::blank(fb));
}
}
+80
View File
@@ -0,0 +1,80 @@
//! ESP32 (classic / non-S3) MCU initialization primitives.
//!
//! Mirror of `hal::esp32s3` with a single delta: the classic ESP32 uses a
//! peripheral-bound DMA (`DMA_SPI2`) rather than the S3's channel-addressed
//! GDMA (`DMA_CH0`). Timer, I²C, and eFuse access are otherwise identical
//! between the two Xtensa parts in this esp-hal vintage.
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
use esp_hal::i2c::master::I2c;
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
use esp_hal::spi::Mode as SpiMode;
use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup;
use static_cell::StaticCell;
// ── MCU-level types ─────────────────────────────────────────────────
pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
pub type I2cBus = I2c<'static, esp_hal::Async>;
// ── Timer ───────────────────────────────────────────────────────────
/// Start the ESP-RTOS scheduler and Embassy time driver.
pub fn start_timer(
timg0: esp_hal::peripherals::TIMG0<'static>,
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
) {
let timg0 = TimerGroup::new(timg0);
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
}
// ── SPI bus ─────────────────────────────────────────────────────────
/// Initialize SPI2 with peripheral-bound DMA and wrap in a shared bus
/// (`StaticCell` + `Mutex`).
///
/// Generic over pin type. The classic ESP32's matrix of GPIO → peripheral
/// routes lets any GPIO serve as any SPI signal — same as the S3.
pub fn init_spi(
spi2: esp_hal::peripherals::SPI2<'static>,
dma_spi2: esp_hal::peripherals::DMA_SPI2<'static>,
sck: impl PeripheralOutput<'static>,
mosi: impl PeripheralOutput<'static>,
miso: impl PeripheralInput<'static>,
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
let spi = Spi::new(
spi2,
SpiConfig::default()
.with_frequency(Rate::from_mhz(1))
.with_mode(SpiMode::_0),
)
.expect("SPI init")
.with_sck(sck)
.with_mosi(mosi)
.with_miso(miso)
.with_dma(dma_spi2);
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
}
// ── MAC address ─────────────────────────────────────────────────────
/// Read the factory-programmed MAC address from eFuse.
pub fn mac_address() -> [u8; 6] {
esp_hal::efuse::base_mac_address()
.as_bytes()
.try_into()
.expect("MAC is 6 bytes")
}
+23 -8
View File
@@ -6,6 +6,7 @@
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
use esp_hal::spi::Mode as SpiMode;
@@ -21,20 +22,28 @@ pub type I2cBus = I2c<'static, esp_hal::Async>;
// ── Timer ───────────────────────────────────────────────────────────
/// Start the ESP-RTOS scheduler and Embassy time driver.
pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) {
pub fn start_timer(
timg0: esp_hal::peripherals::TIMG0<'static>,
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
) {
let timg0 = TimerGroup::new(timg0);
esp_rtos::start(timg0.timer0);
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
}
// ── SPI bus ─────────────────────────────────────────────────────────
/// Initialize SPI2 with DMA and wrap in a shared bus (StaticCell + Mutex).
///
/// Generic over pin type so boards with different SPI pin assignments can
/// share this helper. Any GPIO that implements the esp-hal interconnect
/// traits (which is every GPIO on the ESP32-S3) can be passed directly.
pub fn init_spi(
spi2: esp_hal::peripherals::SPI2<'static>,
dma_ch0: esp_hal::peripherals::DMA_CH0<'static>,
sck: esp_hal::peripherals::GPIO9<'static>,
mosi: esp_hal::peripherals::GPIO10<'static>,
miso: esp_hal::peripherals::GPIO11<'static>,
sck: impl PeripheralOutput<'static>,
mosi: impl PeripheralOutput<'static>,
miso: impl PeripheralInput<'static>,
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
let spi = Spi::new(
spi2,
@@ -62,10 +71,13 @@ pub fn init_spi(
// ── I2C bus ─────────────────────────────────────────────────────────
/// Initialize I2C0 for peripherals (display, sensors, etc.).
///
/// Generic over pin type. I2C lines are bidirectional (open-drain), so both
/// `PeripheralInput` and `PeripheralOutput` bounds are required.
pub fn init_i2c(
i2c0: esp_hal::peripherals::I2C0<'static>,
sda: esp_hal::peripherals::GPIO17<'static>,
scl: esp_hal::peripherals::GPIO18<'static>,
sda: impl PeripheralInput<'static> + PeripheralOutput<'static>,
scl: impl PeripheralInput<'static> + PeripheralOutput<'static>,
) -> I2cBus {
I2c::new(i2c0, I2cConfig::default())
.expect("I2C init")
@@ -78,5 +90,8 @@ pub fn init_i2c(
/// Read the factory-programmed MAC address from eFuse.
pub fn mac_address() -> [u8; 6] {
esp_hal::efuse::Efuse::mac_address()
esp_hal::efuse::base_mac_address()
.as_bytes()
.try_into()
.expect("MAC is 6 bytes")
}
+18 -2
View File
@@ -3,8 +3,24 @@
//! Each submodule provides low-level bus and peripheral init for one MCU family.
//! Board files call these with specific pins and construct higher-level drivers.
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
))]
pub mod esp32s3;
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
#[cfg(feature = "lilygo_tbeam")]
pub mod esp32;
#[cfg(any(
feature = "heltec_mesh_node_t114",
feature = "rak_wisblock_4631",
feature = "wio_tracker_l1"
))]
pub mod nrf52840;
#[cfg(feature = "waveshare_rp2040_lora")]
pub mod rp2040;
+85 -3
View File
@@ -13,11 +13,23 @@ bind_interrupts!(pub struct Irqs {
USBD => embassy_nrf::usb::InterruptHandler<embassy_nrf::peripherals::USBD>;
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
TWISPI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
// SPIM2 is the second free SPI peripheral on nRF52840. Only used
// today by the Heltec T114 (ST7789 TFT panel on a write-only bus
// that can't share with the SX1262 on SPIM3 because the pins
// differ). Boards that don't use SPI2 don't construct anything
// against it and this binding is a no-op for them.
SPI2 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI2>;
});
// ── MCU-level types ─────────────────────────────────────────────────
pub type SpiBus = embassy_nrf::spim::Spim<'static>;
// I2C is used by some nRF boards (RAK4631 / Wio Tracker L1 OLED) but not
// others (T114 uses SPI for the TFT). The `#[allow(dead_code)]` keeps
// the type available for any board that wants it without per-feature
// gating; rustc otherwise fires a dead-code lint when the active board
// doesn't construct one.
#[allow(dead_code)]
pub type I2cBus = embassy_nrf::twim::Twim<'static>;
pub type UsbNrfDriver =
embassy_nrf::usb::Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
@@ -25,9 +37,7 @@ pub type UsbNrfDriver =
// ── SPI bus sharing ─────────────────────────────────────────────────
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
pub fn share_spi_bus(
spi: SpiBus,
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
@@ -39,6 +49,8 @@ pub fn share_spi_bus(
///
/// Every nRF52840 Twim user needs a `&'static mut [u8]` buffer.
/// This provides one via StaticCell so board files don't repeat the pattern.
/// Unused on nRF boards that don't expose I2C (e.g. T114 uses SPI for the TFT).
#[allow(dead_code)]
pub fn alloc_i2c_buffer() -> &'static mut [u8; 256] {
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
TWIM_BUF.init([0u8; 256])
@@ -58,6 +70,76 @@ pub fn alloc_vbus_detect(
))
}
// ── Reset reason ────────────────────────────────────────────────────
/// Read the POWER.RESETREAS register, log a human-readable summary,
/// then clear it so the next boot reflects only the next reset cause.
///
/// This is the post-mortem diagnostic for soak runs: if the firmware
/// crashes and the chip resets, the next boot's RESETREAS tells us
/// what happened (cortex-m lockup, watchdog, soft reset, debugger,
/// pin reset, etc.). Without this, a self-rebooting firmware just
/// looks like log restarts with no signal as to why.
///
/// nRF52840 `POWER.RESETREAS` (PS §15.4.1): bits are write-1-to-clear.
/// The register accumulates causes across resets until cleared, so
/// always clear after reading.
pub fn dump_reset_reason() {
// POWER.RESETREAS lives at 0x40000400 (POWER base 0x40000000 +
// offset 0x400). Direct memory access avoids requiring embassy_nrf's
// `unstable-pac` feature. Bit layout per nRF52840 PS §15.4.1:
// 0 RESETPIN - external NRESET pin asserted
// 1 DOG - watchdog reset
// 2 SREQ - software-requested reset (cortex SYSRESETREQ)
// 3 LOCKUP - cortex-m core lockup
// 16 OFF - wake from System OFF via DETECT
// 17 LPCOMP - wake from System OFF via ANADETECT
// 18 DIF - wake from System OFF via debug interface
// 24 NFC - wake from System OFF via NFC field
const RESETREAS_ADDR: *mut u32 = 0x4000_0400 as *mut u32;
// SAFETY: 0x40000400 is the POWER.RESETREAS register on nRF52840;
// read/write_volatile is correct for memory-mapped hardware.
let raw = unsafe { core::ptr::read_volatile(RESETREAS_ADDR) };
let dog = (raw & (1 << 1)) != 0;
let lockup = (raw & (1 << 3)) != 0;
if raw == 0 {
defmt::info!("boot: cold POR (RESETREAS=0)");
} else if dog || lockup {
// Real problems: watchdog timeout (hung task) OR CPU lockup
// (HardFault → escalated). Worth a WARN — these are bugs.
defmt::warn!(
"boot: ABNORMAL reset RESETREAS=0x{=u32:08x} resetpin={=bool} dog={=bool} sreq={=bool} lockup={=bool} off={=bool} lpcomp={=bool} dif={=bool} nfc={=bool}",
raw,
(raw & (1 << 0)) != 0,
dog,
(raw & (1 << 2)) != 0,
lockup,
(raw & (1 << 16)) != 0,
(raw & (1 << 17)) != 0,
(raw & (1 << 18)) != 0,
(raw & (1 << 24)) != 0,
);
} else {
// Normal reset paths: RESETPIN (UF2 bootloader pulses NRESET on
// app start; user reset button), SREQ (deliberate sys_reset),
// OFF/LPCOMP/DIF/NFC (wake from System OFF). All expected
// operational reasons; INFO not WARN.
defmt::info!(
"boot: RESETREAS=0x{=u32:08x} resetpin={=bool} sreq={=bool} off={=bool} lpcomp={=bool} dif={=bool} nfc={=bool}",
raw,
(raw & (1 << 0)) != 0,
(raw & (1 << 2)) != 0,
(raw & (1 << 16)) != 0,
(raw & (1 << 17)) != 0,
(raw & (1 << 18)) != 0,
(raw & (1 << 24)) != 0,
);
}
// Clear all bits — write-1-to-clear per nRF52840 PS §15.4.1.
// SAFETY: same justification as the read above.
unsafe { core::ptr::write_volatile(RESETREAS_ADDR, 0xFFFF_FFFF) };
}
// ── MAC address ─────────────────────────────────────────────────────
/// Read the factory-programmed device address from FICR registers.
+80
View File
@@ -0,0 +1,80 @@
//! RP2040 MCU initialization primitives.
//!
//! Provides shared boilerplate for RP2040 boards: interrupt bindings,
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
use embassy_rp::bind_interrupts;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use static_cell::StaticCell;
// ── Interrupts (shared by all RP2040 boards) ──────────────────────
bind_interrupts!(pub struct Irqs {
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<embassy_rp::peripherals::USB>;
DMA_IRQ_0 => embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH0>,
embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH1>;
});
// ── MCU-level types ────────────────────────────────────────────────
pub type SpiBus =
embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
pub type UsbRpDriver = embassy_rp::usb::Driver<'static, embassy_rp::peripherals::USB>;
// ── SPI bus sharing ────────────────────────────────────────────────
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
}
// ── MAC address ────────────────────────────────────────────────────
/// Store for the MAC address derived from flash unique ID.
/// Populated once during `init_mac()`, read by `mac_address()`.
static MAC: StaticCell<[u8; 6]> = StaticCell::new();
static mut MAC_REF: Option<&'static [u8; 6]> = None;
/// Read the flash chip's unique ID and derive a 6-byte MAC address.
///
/// Must be called once during board init before `mac_address()`.
pub fn init_mac<const FLASH_SIZE: usize>(
flash: &mut embassy_rp::flash::Flash<
'_,
embassy_rp::peripherals::FLASH,
embassy_rp::flash::Blocking,
FLASH_SIZE,
>,
) {
let mut uid = [0u8; 8];
flash.blocking_unique_id(&mut uid).expect("flash unique ID");
// XOR the first 2 bytes into bytes 2..8 to fold 8 bytes into 6,
// then set the locally-administered bit (byte 0, bit 1).
let mac = [
uid[2] ^ uid[0] | 0x02,
uid[3] ^ uid[1],
uid[4],
uid[5],
uid[6],
uid[7],
];
let stored = MAC.init(mac);
// SAFETY: Written once during single-threaded init, before the executor
// starts. All subsequent reads are from &'static reference.
unsafe {
MAC_REF = Some(stored);
}
}
/// Return the MAC address derived from the flash unique ID.
///
/// Panics if `init_mac()` has not been called.
pub fn mac_address() -> [u8; 6] {
// SAFETY: MAC_REF is set once during init before the executor starts,
// and only read (never written) after that.
unsafe { *MAC_REF.expect("init_mac() must be called before mac_address()") }
}
+133 -111
View File
@@ -1,124 +1,146 @@
//! Shared COBS protocol helpers for USB and UART transports.
//! Shared framing helpers for USB and UART host transports.
//!
//! Both `usb_task` and `uart_task` use these helpers for frame
//! accumulation, response encoding, and command routing.
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
//! → `Command::parse` → forwarded to radio task along with the tag. On
//! parse failure we echo `ERR(ELENGTH/EPARAM/EMODULATION/EUNKNOWN_CMD)`
//! with the received tag. On CRC/COBS failure we emit async
//! `ERR(EFRAME)` with tag `0x0000` (`PROTOCOL.md §2.5`).
//!
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
//! → bytes ready for transport-level write.
use defmt::warn;
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::{Command, ErrorCode, Response};
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
use crate::protocol::{
encode_frame, Command, CommandParseError, DeviceMessage, ErrorCode, FrameEncodeError,
MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME,
};
/// Maximum COBS frame size (bytes).
pub const MAX_FRAME: usize = 512;
// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes.
// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room.
const _: () = assert!(
MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64,
"MAX_FRAME too small for max payload + COBS overhead"
);
/// Decodes COBS-framed commands from a byte stream.
pub struct CobsDecoder {
buf: [u8; MAX_FRAME],
len: usize,
}
impl CobsDecoder {
pub const fn new() -> Self {
Self {
buf: [0u8; MAX_FRAME],
len: 0,
}
}
/// Reset the accumulator (discard partial frame).
pub fn reset(&mut self) {
self.len = 0;
}
/// Feed a chunk of bytes, calling `on_command` for each decoded command.
pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) {
for &byte in data {
if byte == 0x00 {
// End of COBS frame — decode and dispatch
if self.len > 0 {
let mut decode_buf = [0u8; MAX_FRAME];
if let Some(decoded_len) =
ucobs::decode(&self.buf[..self.len], &mut decode_buf)
{
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
on_command(cmd);
}
}
}
self.len = 0;
} else if self.len < MAX_FRAME {
self.buf[self.len] = byte;
self.len += 1;
} else {
// Frame too large — discard
self.len = 0;
}
}
}
}
/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel.
/// Dispatch a frame-level decode into the appropriate downstream queue.
///
/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow.
pub fn cobs_encode_response<'a>(
response: Response,
write_buf: &mut [u8; MAX_FRAME],
encode_buf: &'a mut [u8; MAX_FRAME],
) -> Option<&'a [u8]> {
let raw_len = response.write_to(write_buf);
let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0);
if encoded_len < encode_buf.len() {
encode_buf[encoded_len] = 0x00;
Some(&encode_buf[..encoded_len + 1])
} else {
warn!("COBS encode buffer overflow");
None
}
}
/// Route a parsed command to the appropriate handler.
///
/// Display and MAC commands are handled locally (response sent immediately).
/// All other commands are forwarded to the radio task (response sent later).
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
pub async fn route_command(
cmd: Command,
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
/// - Known commands are forwarded to the radio task via `commands`.
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
/// `outbound`, so the host sees a concrete answer instead of timing out.
/// - The caller is responsible for rejecting `tag == 0` before calling
/// this; the spec says hosts MUST NOT send tag 0, and we classify such
/// frames as framing errors upstream.
pub async fn dispatch_frame(
type_id: u8,
tag: u16,
payload: &[u8],
commands: &CommandChannel,
responses: &ResponseChannel,
display_commands: &DisplayCommandChannel,
has_display: bool,
mac: [u8; 6],
outbound: &OutboundChannel,
) {
match cmd {
Command::DisplayOn => {
if has_display {
display_commands.send(DisplayCommand::On).await;
responses.send(Response::Ok).await;
} else {
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
if tag == 0 {
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
// framing error; do not route to radio. (Spec §2.2)
emit_async_err(outbound, ErrorCode::EFrame).await;
return;
}
match Command::parse(type_id, payload) {
Ok(cmd) => {
let envelope = InboundEvent::Command { tag, cmd };
if commands.try_send(envelope).is_err() {
emit_sync_err(outbound, tag, ErrorCode::EBusy).await;
}
}
Command::DisplayOff => {
if has_display {
display_commands.send(DisplayCommand::Off).await;
responses.send(Response::Ok).await;
} else {
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
}
}
Command::GetMac => {
responses.send(Response::MacAddress(mac)).await;
}
other => {
commands.send(other).await;
Err(e) => {
let code = parse_err_to_wire_code(e);
emit_sync_err(outbound, tag, code).await;
}
}
}
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
///
/// Backpressure: if the host transport's IN endpoint is observed-dead
/// (RADIO_THROTTLED set by host_task), drop silently. Without this gate,
/// a bad-frame burst from a wedged host fills `OutboundChannel` faster
/// than host_task can drain it (because it can't drain it at all when
/// write_packet keeps failing), producing the warn flood seen in soak.
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
if crate::channel::RADIO_THROTTLED.load(portable_atomic::Ordering::Acquire) {
return;
}
let frame = OutboundFrame {
tag: 0,
msg: DeviceMessage::Err(code),
};
if outbound.try_send(frame).is_err() {
warn!("async ERR dropped: outbound queue full");
}
}
/// Emit a synchronous error response echoing the originating tag.
async fn emit_sync_err(outbound: &OutboundChannel, tag: u16, code: ErrorCode) {
let frame = OutboundFrame {
tag,
msg: DeviceMessage::Err(code),
};
if outbound.try_send(frame).is_err() {
warn!("sync ERR dropped: outbound queue full");
}
}
fn parse_err_to_wire_code(e: CommandParseError) -> ErrorCode {
match e {
CommandParseError::UnknownType => ErrorCode::EUnknownCmd,
CommandParseError::WrongLength => ErrorCode::ELength,
CommandParseError::InvalidField => ErrorCode::EParam,
CommandParseError::ReservedBitSet => ErrorCode::EParam,
CommandParseError::UnknownModulation => ErrorCode::EModulation,
}
}
/// Encode an outbound frame into a single wire-ready byte buffer.
///
/// Returns the number of bytes written to `out`, or `None` on buffer
/// overflow / encode failure (caller may log and drop).
pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) -> Option<usize> {
let mut payload_buf = [0u8; MAX_PAYLOAD_FIELD];
let payload_len = match frame.msg.encode_payload(&mut payload_buf) {
Ok(n) => n,
Err(e) => {
warn!("encode_payload failed: {:?}", defmt::Debug2Format(&e));
return None;
}
};
match encode_frame(
frame.msg.type_id(),
frame.tag,
&payload_buf[..payload_len],
out.as_mut_slice(),
) {
Ok(n) => Some(n),
Err(FrameEncodeError::BufferTooSmall) => {
warn!(
"encode_frame: output buffer too small (type={=u8}, tag={=u16}, payload={=usize}B, out_buf={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
out.len(),
);
None
}
Err(FrameEncodeError::PayloadTooLarge) => {
warn!(
"encode_frame: payload exceeds MAX_PAYLOAD_FIELD (type={=u8}, tag={=u16}, payload={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
);
None
}
Err(FrameEncodeError::CobsEncode) => {
warn!(
"encode_frame: COBS encode refused (type={=u8}, tag={=u16}, payload={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
);
None
}
}
}
+5 -1
View File
@@ -6,7 +6,11 @@
pub mod framing;
cfg_if::cfg_if! {
if #[cfg(feature = "heltec_v3_uart")] {
if #[cfg(any(
feature = "heltec_v3_uart",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam"
))] {
mod uart;
pub use uart::host_task;
} else {
+161 -47
View File
@@ -1,75 +1,189 @@
//! UART host task: COBS-framed fixed-size LE command/response protocol.
//! UART host task for DongLoRa Protocol v2.
//!
//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3).
//! UART has no DTR line, so we assume a host is always present and
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
//! → Disconnected → drop radio to Unconfigured).
use defmt::warn;
use defmt::{info, warn};
use embassy_executor::task;
use embassy_time::{Duration, Instant};
use embedded_io_async::Write;
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use super::framing::{self, CobsDecoder, MAX_FRAME};
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
use crate::channel::{
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
};
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME};
const READ_BUF: usize = 64;
const INACTIVITY_MS: u64 = 1000;
#[task]
pub async fn host_task(
parts: crate::board::UartParts,
commands: &'static CommandChannel,
responses: &'static ResponseChannel,
outbound: &'static OutboundChannel,
display_commands: &'static DisplayCommandChannel,
has_display: bool,
mac: [u8; 6],
_mac: [u8; 6],
) {
let (mut rx, mut tx) = parts.driver.split();
// UART has no DTR — signal the display that a host is present at boot.
// UART has no DTR — wake display immediately and assume a host.
if has_display {
display_commands.send(DisplayCommand::Reset).await;
display_commands.send(DisplayCommand::On).await;
}
let mut read_buf = [0u8; 64];
let mut write_buf = [0u8; MAX_FRAME];
let mut cobs_encode_buf = [0u8; MAX_FRAME];
let mut decoder = CobsDecoder::new();
// Two sub-loops joined in the same task. Splitting read and write
// is what stops a long-running tx.write_all (~25 ms for a max-size
// frame at 115_200 baud, multiplied across a saturated outbound
// queue) from starving the inbound poll. While write_all sits in
// its FIFO-drain await, join re-polls the read future on the next
// wake, so last_frame_at keeps refreshing and the §3.4 watchdog
// does not spuriously fire under sustained bidirectional load.
// Same task = single executor + disjoint borrows on the split
// rx/tx halves, so no Mutex is needed.
let read_loop = async {
let mut read_buf = [0u8; READ_BUF];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
let mut connected = true;
loop {
use embassy_futures::select::{select, Either};
loop {
use embassy_futures::select::{select, Either};
match select(
embedded_io_async::Read::read(&mut rx, &mut read_buf),
responses.receive(),
)
.await
{
Either::First(result) => {
let n = match result {
Ok(0) => continue,
Ok(n) => n,
Err(_) => {
decoder.reset();
embassy_time::Timer::after_millis(100).await;
continue;
match select(
embedded_io_async::Read::read(&mut rx, &mut read_buf),
embassy_time::Timer::after(Duration::from_millis(100)),
)
.await
{
Either::First(Ok(0)) => {}
Either::First(Ok(n)) => {
last_frame_at = Instant::now();
if !connected {
if has_display {
let _ = display_commands.try_send(DisplayCommand::On);
}
connected = true;
}
};
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
}
Either::First(Err(_)) => {
// Byte-level error (FIFO overrun, UART framing,
// parity). Per PROTOCOL.md §3.4: only "byte stream
// activity that results in a complete frame
// arriving" resets the inactivity timer, so we do
// NOT touch last_frame_at here. The 100 ms sleep
// is anti-spin: a stuck driver pumping continuous
// Errs would otherwise burn the executor.
decoder.reset();
embassy_time::Timer::after_millis(100).await;
}
Either::Second(()) => {}
}
let mut cmds = heapless::Vec::<_, 4>::new();
decoder.feed(&read_buf[..n], |cmd| {
let _ = cmds.push(cmd);
});
for cmd in cmds {
framing::route_command(
cmd, commands, responses, display_commands, has_display, mac,
)
.await;
// Inactivity watchdog (PROTOCOL.md §3.4, 1000 ms).
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
warn!("UART host inactivity timeout fired");
let _ = commands.try_send(InboundEvent::Reset);
if has_display {
let _ = display_commands.try_send(DisplayCommand::Reset);
}
// Drop in-flight frame state. When the host resumes it
// may pick up mid-frame; without resetting the decoder
// we'd chew through garbage until the next 0x00
// sentinel. USB gets the equivalent for free via
// DTR-drop reset (host/usb.rs Disconnected entry); UART
// has no DTR.
decoder.reset();
connected = false;
}
}
};
let write_loop = async {
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
loop {
let frame = outbound.receive().await;
let Some(n) = encode_outbound(&frame, &mut write_buf) else {
continue;
};
// Mirror src/host/usb.rs:349-374. The throttle gate at
// src/radio.rs:2017 silently drops async RX into the
// outbound queue while RADIO_THROTTLED is true; clear it
// on every successful write and re-arm it on failure,
// same contract as USB.
match tx.write_all(&write_buf[..n]).await {
Ok(()) => {
if crate::channel::RADIO_THROTTLED
.load(portable_atomic::Ordering::Acquire)
{
info!("UART write recovered; clearing radio throttle");
crate::channel::RADIO_THROTTLED
.store(false, portable_atomic::Ordering::Release);
}
}
Err(_) => {
warn!("UART write failed, frame dropped, throttling radio");
crate::channel::RADIO_THROTTLED
.store(true, portable_atomic::Ordering::Release);
}
}
Either::Second(response) => {
if let Some(frame) =
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
{
if tx.write_all(frame).await.is_err() {
warn!("UART write failed, response dropped");
}
}
}
};
embassy_futures::join::join(read_loop, write_loop).await;
}
async fn dispatch_bytes(
decoder: &mut FrameDecoder,
bytes: &[u8],
commands: &'static CommandChannel,
outbound: &'static OutboundChannel,
) {
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
decoder.feed(bytes, |res| match res {
FrameResult::Ok {
type_id,
tag,
payload,
} => {
let mut p: heapless::Vec<u8, MAX_PAYLOAD_FIELD> = heapless::Vec::new();
let _ = p.extend_from_slice(payload);
let _ = work.push(FrameWork::Ok {
type_id,
tag,
payload: p,
});
}
FrameResult::Err(_) => {
let _ = work.push(FrameWork::Frame);
}
});
for w in work {
match w {
FrameWork::Ok {
type_id,
tag,
payload,
} => {
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
}
FrameWork::Frame => {
emit_async_err(outbound, ErrorCode::EFrame).await;
}
}
}
}
#[allow(clippy::large_enum_variant)]
enum FrameWork {
Ok {
type_id: u8,
tag: u16,
payload: heapless::Vec<u8, MAX_PAYLOAD_FIELD>,
},
Frame,
}
+384 -58
View File
@@ -1,23 +1,172 @@
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
//! USB CDC-ACM host task for DongLoRa Protocol v2.
//!
//! ```text
//! Host
//! ├── Disconnected (default) no DTR
//! └── Connected host attached, DTR up
//! │ resting here means no traffic yet —
//! │ NO inactivity timer (boot quiet is
//! │ normal; chart structurally blocks the
//! │ spurious 1s fire).
//! └── Active ≥1 frame seen since connect
//! inactivity timer (1s) armed and
//! restarted by every frame via
//! self-transition.
//! ```
//!
//! Two chart-level invariants made structurally impossible by the shape:
//! 1. **No spurious inactivity at boot.** The 1-second timer only exists
//! in `Active`, which requires a `FrameReceived` event to enter.
//! A freshly-connected host that holds DTR high but sends nothing
//! rests at `Connected` indefinitely (hsmc 0.5 optional-default
//! "microwave Standby" pattern) — no timer, no warn, no Reset cascade.
//! 2. **Chart-owned timer scope.** Per hsmc README rule §7 (timers
//! armed by state lifecycle), the inactivity timer is cancelled on
//! every exit of `Active`, restarted on every entry. No global
//! `last_frame_at` instant, no manual elapsed check in the loop.
//!
//! The data plane (frame decode, command dispatch, frame encode) stays
//! imperative in `protocol_loop` because `read_packet` blocks.
use defmt::warn;
use embassy_executor::task;
use embassy_futures::join::join;
use embassy_time::Duration;
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
use embassy_usb::Builder;
use hsmc::{statechart, Duration as HsmcDuration};
use static_cell::StaticCell;
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::Command;
use super::framing::{self, CobsDecoder, MAX_FRAME};
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
use crate::channel::{
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
};
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_WIRE_FRAME};
const MAX_PACKET: usize = 64;
const MAX_USB_PACKET: usize = 64;
/// Spec §3.4: inactivity timer period. The device must see at least one
/// frame (good or bad) every 1000 ms or the session is considered lost.
/// Declared in the chart on `Active` — chart owns timer lifecycle.
const INACTIVITY_MS: u64 = 1000;
/// Poll interval for the DTR edge detector. Chart owns the inactivity
/// timer; this is only the cadence at which protocol_loop wakes when
/// no other event is pending, to re-check DTR.
const POLL_INTERVAL_MS: u64 = 100;
#[derive(Debug, Clone)]
pub enum HostInput {
DtrRaised,
DtrDropped,
/// A USB read returned ≥1 byte. Drives `Connected → Active` on
/// the first frame after DTR up, and the `Active → Active`
/// self-transition that restarts the inactivity timer per
/// hsmc README rule §4 (self-transition exits + re-enters target,
/// re-arming its timer).
FrameReceived,
}
pub struct HostContext {
commands: &'static CommandChannel,
display_commands: &'static DisplayCommandChannel,
has_display: bool,
}
impl HostContext {
async fn wake_display_if_present(&self) {
if self.has_display {
self.display_commands.send(DisplayCommand::On).await;
}
}
async fn reset_display_if_present(&self) {
if self.has_display {
self.display_commands.send(DisplayCommand::Reset).await;
}
}
fn send_radio_reset(&self) {
use portable_atomic::Ordering;
// Dedupe: if a Reset is already queued (and not yet consumed by
// the radio task), don't queue another. Without this, a slow or
// wedged radio + repeated chart exits (DTR drop, inactivity,
// both transports) fill the commands channel and produce the
// "queue full" warn flood. The radio chart's reset_to_unconfigured
// clears the flag once it processes the queued Reset.
if crate::channel::RESET_PENDING.swap(true, Ordering::AcqRel) {
return;
}
if self.commands.try_send(InboundEvent::Reset).is_err() {
// Channel genuinely full of non-Reset commands; this is the
// edge case worth knowing about. Clear the flag so future
// Reset attempts can re-try once the channel drains.
defmt::warn!("radio Reset inject: command queue full");
crate::channel::RESET_PENDING.store(false, Ordering::Release);
}
}
}
statechart! {
Host {
context: HostContext;
events: HostInput;
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
trace;
default(Disconnected);
state Disconnected {
on(DtrRaised) => Connected;
}
state Connected {
entry: wake_display;
exit: reset_radio, reset_display;
on(DtrDropped) => Disconnected;
// hsmc 0.5 microwave-Standby pattern: Connected itself is the
// resting "DTR up, no traffic yet" state. No inactivity timer
// here — boot quiet is normal (mux-rs holds DTR high before
// any client attaches), so the chart structurally cannot fire
// a 1 s warn before the first byte. The first FrameReceived
// descends into Active, which owns the inactivity timer.
on(FrameReceived) => Active;
// Inactivity timer is chart-scoped: armed on every entry to
// Active (from Connected on the first frame, or from the
// self-transition below on each subsequent frame), cancelled
// on every exit (per hsmc README rule §7). The self-transition
// re-enters Active per rule §4, re-arming the timer —
// equivalent to the "reset last_frame_at on each byte" pattern
// but expressed in chart shape.
//
// hsmc's `after` triggers want `hsmc::Duration` (re-export
// of `core::time::Duration`), not `embassy_time::Duration`.
state Active {
on(after HsmcDuration::from_millis(INACTIVITY_MS)) => Disconnected;
on(FrameReceived) => Active;
}
}
}
}
impl HostActions for HostActionContext<'_> {
async fn wake_display(&mut self) {
self.wake_display_if_present().await;
}
async fn reset_radio(&mut self) {
self.send_radio_reset();
}
async fn reset_display(&mut self) {
self.reset_display_if_present().await;
}
}
#[task]
pub async fn host_task(
parts: crate::board::UsbParts,
commands: &'static CommandChannel,
responses: &'static ResponseChannel,
outbound: &'static OutboundChannel,
display_commands: &'static DisplayCommandChannel,
has_display: bool,
mac: [u8; 6],
@@ -39,27 +188,48 @@ pub async fn host_task(
}
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
// CTRL_BUF was 64 bytes; bumped to 128 because the previous size
// matches the panic site "control buffer too small" in
// embassy_usb/src/lib.rs:758. CdcAcmClass only requires >= 7 for
// SET_LINE_CODING, but composite descriptor handlers and string
// descriptor responses may overrun a 64 B buffer for some host
// enumerations. 128 is safely above the largest CDC-ACM control
// request and well within RAM budget.
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
static CTRL_BUF: StaticCell<[u8; 128]> = StaticCell::new();
static CDC_STATE: StaticCell<State> = StaticCell::new();
let desc_buf = DESC_BUF.init([0; 256]);
let conf_buf = CONF_BUF.init([0; 256]);
let bos_buf = BOS_BUF.init([0; 256]);
let ctrl_buf = CTRL_BUF.init([0; 64]);
let ctrl_buf = CTRL_BUF.init([0; 128]);
let cdc_state = CDC_STATE.init(State::new());
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
// Note: when this device enumerates on Linux, modem-manager's
// CDC-ACM probe issues SET_INTERFACE with alt settings beyond what
// we declare, triggering a "SET_INTERFACE: alt setting out of
// range" WARN from embassy_usb. The probe is normal Linux
// behavior; embassy_usb's STALL response is correct USB negotiation.
// The WARN is benign noise — do NOT add dummy alt settings just to
// silence it (risks breaking Windows/macOS enumeration).
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
let mut usb_dev = builder.build();
let (sender, receiver) = class.split();
join(
usb_dev.run(),
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
protocol_loop(
sender,
receiver,
commands,
outbound,
display_commands,
has_display,
),
)
.await;
}
@@ -68,74 +238,230 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
mut sender: Sender<'d, D>,
mut receiver: Receiver<'d, D>,
commands: &'static CommandChannel,
responses: &'static ResponseChannel,
outbound: &'static OutboundChannel,
display_commands: &'static DisplayCommandChannel,
has_display: bool,
mac: [u8; 6],
) {
use embassy_futures::select::{select3, Either3};
let mut read_buf = [0u8; MAX_PACKET];
let mut write_buf = [0u8; MAX_FRAME];
let mut cobs_encode_buf = [0u8; MAX_FRAME];
let mut decoder = CobsDecoder::new();
let mut was_connected = false;
let mut machine = Host::new_local(HostContext {
commands,
display_commands,
has_display,
});
machine.step(HsmcDuration::ZERO).await;
let mut read_buf = [0u8; MAX_USB_PACKET];
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
loop {
match select3(
receiver.read_packet(&mut read_buf),
responses.receive(),
embassy_time::Timer::after_millis(250),
outbound.receive(),
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
)
.await
{
Either3::First(result) => {
let n = match result {
Ok(0) => continue,
Ok(n) => n,
Err(_) => {
decoder.reset();
embassy_time::Timer::after_millis(100).await;
continue;
}
};
let mut cmds = heapless::Vec::<_, 4>::new();
decoder.feed(&read_buf[..n], |cmd| {
let _ = cmds.push(cmd);
});
for cmd in cmds {
framing::route_command(
cmd, commands, responses, display_commands, has_display, mac,
)
.await;
}
Either3::First(Ok(0)) => {
defmt::info!("host_task: read_packet returned 0 bytes");
}
Either3::Second(response) => {
if let Some(frame) =
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
{
for chunk in frame.chunks(MAX_PACKET) {
Either3::First(Ok(n)) => {
defmt::info!(
"host_task: read_packet got {=usize}B (DTR={=bool})",
n,
receiver.dtr(),
);
// Drives Connected → Active on first byte and
// Active → Active (timer-restart) on every byte after.
// The chart owns inactivity from here.
let _ = machine.dispatch(HostInput::FrameReceived).await;
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
}
Either3::First(Err(e)) => {
// Per embassy_usb_driver::EndpointError, `read_packet`
// returns one of two errors:
// - `Disabled`: OUT endpoint not enabled by the host
// (USB enumeration in progress, host
// suspended, or interface not yet
// configured). Completely normal during
// bring-up + every host disconnect.
// - `BufferOverflow`: incoming packet larger than read_buf.
// read_buf is sized to MAX_USB_PACKET
// (64) which matches the CDC-ACM EP
// max packet size, so this should
// never happen in practice.
//
// `Disabled` floods at boot before USB enumeration finishes
// (the OS hasn't sent SET_CONFIGURATION yet) — was previously
// logging dozens of "read_packet error" lines per second.
// Demote to debug-level since it's expected; reserve WARN
// for BufferOverflow which IS a real bug.
use embassy_usb_driver::EndpointError;
match e {
EndpointError::Disabled => {
defmt::debug!(
"host_task: read_packet returned Disabled (USB endpoint not yet enabled by host — normal during enumeration)"
);
}
EndpointError::BufferOverflow => {
defmt::warn!(
"host_task: read_packet BufferOverflow — host sent a packet larger than {=usize}B (max EP packet size); decoder reset",
MAX_USB_PACKET,
);
}
}
decoder.reset();
embassy_time::Timer::after_millis(100).await;
}
Either3::Second(frame) => {
let frame_tag = frame.tag;
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
defmt::info!(
"host_task: sending {=usize}B frame tag={=u16}",
n,
frame_tag,
);
// Per embassy_usb 0.6: write_packet only returns
// EndpointError::Disabled when the IN endpoint is
// dead (host gone or USB suspended). If a chunk
// mid-frame fails, the partial bytes already sent
// are unrecoverable — host's framing layer will see
// corruption until a clean frame arrives. Bail on
// the rest of THIS frame and set RADIO_THROTTLED so
// the radio task stops generating async events
// until the host comes back. Without the throttle,
// every subsequent frame would also fail mid-write
// and produce more wire corruption.
let mut frame_failed = false;
for (chunk_idx, chunk) in
write_buf[..n].chunks(MAX_USB_PACKET).enumerate()
{
defmt::info!(
"host_task: write_packet chunk {=usize} ({=usize}B) tag={=u16}",
chunk_idx,
chunk.len(),
frame_tag,
);
if sender.write_packet(chunk).await.is_err() {
warn!("USB write failed, response dropped");
defmt::warn!(
"USB write failed (host stalled or gone); frame dropped tag={=u16}, throttling radio",
frame_tag,
);
crate::channel::RADIO_THROTTLED
.store(true, portable_atomic::Ordering::Release);
frame_failed = true;
break;
}
defmt::info!(
"host_task: write_packet OK chunk {=usize} tag={=u16}",
chunk_idx,
frame_tag,
);
}
if !frame_failed
&& crate::channel::RADIO_THROTTLED
.load(portable_atomic::Ordering::Acquire)
{
defmt::info!(
"USB write recovered (tag={=u16}); clearing radio throttle",
frame_tag,
);
crate::channel::RADIO_THROTTLED
.store(false, portable_atomic::Ordering::Release);
}
defmt::info!("host_task: frame done tag={=u16}", frame_tag);
}
}
Either3::Third(()) => {}
}
let connected = receiver.dtr();
if was_connected && !connected {
let _ = commands.try_send(Command::StopRx);
if has_display {
display_commands.send(DisplayCommand::Reset).await;
Either3::Third(()) => {
// Heartbeat tick — drives DTR edge polling + inactivity
// watchdog below. No log: it would fire every
// POLL_INTERVAL_MS and bury the interesting traffic.
}
}
if !was_connected && connected && has_display {
display_commands.send(DisplayCommand::On).await;
// DTR edge detection — drives Connected / Disconnected
// transitions. The chart's `current_state()` may be
// Disconnected, Connected (resting), or Connected/Active.
// We're connected if current state is anywhere inside
// Connected.
let dtr_high = receiver.dtr();
let was_connected = !matches!(
machine.current_state(),
HostState::Disconnected
);
match (was_connected, dtr_high) {
(false, true) => {
defmt::info!("host_task: DTR raised");
let _ = machine.dispatch(HostInput::DtrRaised).await;
}
(true, false) => {
defmt::info!("host_task: DTR dropped");
decoder.reset();
let _ = machine.dispatch(HostInput::DtrDropped).await;
}
_ => {}
}
was_connected = connected;
}
}
/// Feed raw bytes into the frame decoder. For each complete frame:
/// - Ok → route into the command channel (or emit sync ERR on parse fail)
/// - Err(Crc | Cobs | TooShort) → emit async ERR(EFRAME)
///
/// Borrow-scoped per the `FrameDecoder::feed` callback contract: the
/// payload slice is valid only for the duration of the callback.
async fn dispatch_bytes(
decoder: &mut FrameDecoder,
bytes: &[u8],
commands: &'static CommandChannel,
outbound: &'static OutboundChannel,
) {
// `FrameDecoder::feed` hands payloads by reference; we must synchronously
// copy any data we need before the callback returns. To avoid fighting
// lifetimes we accumulate per-frame work as an enum.
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
decoder.feed(bytes, |res| match res {
FrameResult::Ok {
type_id,
tag,
payload,
} => {
let mut p: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }> =
heapless::Vec::new();
let _ = p.extend_from_slice(payload);
let _ = work.push(FrameWork::Ok {
type_id,
tag,
payload: p,
});
}
FrameResult::Err(_e) => {
let _ = work.push(FrameWork::Frame);
}
});
for w in work {
match w {
FrameWork::Ok {
type_id,
tag,
payload,
} => {
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
}
FrameWork::Frame => {
emit_async_err(outbound, ErrorCode::EFrame).await;
}
}
}
}
#[allow(clippy::large_enum_variant)]
enum FrameWork {
Ok {
type_id: u8,
tag: u16,
payload: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }>,
},
Frame,
}
+285
View File
@@ -0,0 +1,285 @@
//! Pure-math helpers for Listen-Before-Talk (CAD) retry timing.
//!
//! The CAD retry loop in `radio::do_tx` scales its backoff to the active
//! LoRa configuration: per-retry sleep is one time-on-air of an assumed
//! peer packet, total retry budget is capped at `CAD_BUDGET_US`. This
//! module owns the ToA math (Semtech AN1200.13) and the retry-count
//! derivation, split out so it can be unit-tested host-side without the
//! radio module's hardware dependencies.
use crate::protocol::{LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
/// Total LBT budget: the maximum time the CAD retry loop is allowed to
/// tie up the radio before returning `ChannelBusy`.
pub const CAD_BUDGET_US: u32 = 2_500_000;
/// Upper bound on retries, independent of budget — caps the work on
/// very fast configs (SF7/BW500 would otherwise get 60+ retries).
pub const CAD_HARD_CAP: u8 = 10;
/// Assumed peer-packet payload (bytes) used to size the per-retry sleep.
/// 80 is a representative median for typical mesh traffic. Peers sending
/// larger packets may be partially under-waited (accepted trade-off —
/// host-level retry handles collisions).
#[allow(dead_code)] // consumed by `radio.rs`, which is gated out of test builds
pub const CAD_ASSUMED_PEER_PAYLOAD_B: u16 = 80;
/// Jitter applied to each sleep, in tenths of a percent (±20 % = 200).
pub const CAD_JITTER_TENTHS_PCT: u32 = 200;
/// ToA formula expects CR ∈ {1..4} for 4/5..4/8 (each coded symbol carries
/// `CR+4` bits). The protocol enum starts at 0, so map explicitly — using
/// `as u32` alone would underestimate airtime by one code bit everywhere.
pub fn cr_numeric(cr: LoRaCodingRate) -> u32 {
match cr {
LoRaCodingRate::Cr4_5 => 1,
LoRaCodingRate::Cr4_6 => 2,
LoRaCodingRate::Cr4_7 => 3,
LoRaCodingRate::Cr4_8 => 4,
}
}
/// Worst-case LoRa time-on-air in microseconds (Semtech AN1200.13).
///
/// Uses `N_preamble + 4.25` quarter-symbols for preamble duration; SX126x
/// uses 6.25 at SF5/SF6, so the result is low by 2·T_sym at those SFs
/// (sub-millisecond — negligible at the LBT-budget scale).
pub fn toa_us(cfg: &LoRaConfig, payload_len: u16) -> u32 {
let sf = cfg.sf as u64;
let bw_hz = cfg.bw.as_hz() as u64;
// Symbol time in µs: (2^SF / BW_Hz) * 1e6. u64 avoids overflow at
// SF12/BW7 where T_sym ≈ 524 ms.
let t_sym_us = (1u64 << sf) * 1_000_000 / bw_hz.max(1);
let de: i64 = if t_sym_us > 16_000 { 1 } else { 0 };
let crc: i64 = if cfg.payload_crc { 1 } else { 0 };
let h: i64 = if matches!(cfg.header_mode, LoRaHeaderMode::Implicit) {
1
} else {
0
};
let pl = payload_len as i64;
let sf_i = sf as i64;
// Payload symbol count (signed intermediates — `num` goes negative
// for small PL at SF12 with H=1, CRC=0).
let num = 8 * pl - 4 * sf_i + 28 + 16 * crc - 20 * h;
let den = 4 * (sf_i - 2 * de);
let payload_sym_ceil = if num <= 0 || den <= 0 {
0i64
} else {
(num + den - 1) / den
};
let cr_num = cr_numeric(cfg.cr) as i64;
let n_payload = 8 + (payload_sym_ceil * (cr_num + 4)).max(0);
let t_payload_us = n_payload as u64 * t_sym_us;
// Preamble duration: (N + 4.25) * T_sym = (4·N + 17) * T_sym / 4.
let t_preamble_us = (cfg.preamble_len as u64 * 4 + 17) * t_sym_us / 4;
(t_preamble_us + t_payload_us).min(u32::MAX as u64) as u32
}
/// Derive the CAD retry count and per-retry sleep from the cached
/// `cad_retry_delay_us`. Retries form: N CADs separated by N-1 sleeps.
/// When one assumed-packet wait exceeds the total budget, `retries = 1`
/// — a single CAD, bail on busy, no sleep.
pub fn cad_retries(cad_retry_delay_us: u32) -> (u8, u32) {
if cad_retry_delay_us == 0 {
// Unconfigured should never reach this path; fall back to a
// single CAD with no sleep rather than divide-by-zero.
return (1, 0);
}
let n_sleeps = CAD_BUDGET_US / cad_retry_delay_us;
let retries = (n_sleeps + 1).clamp(1, CAD_HARD_CAP as u32) as u8;
(retries, cad_retry_delay_us)
}
/// Apply ±`CAD_JITTER_TENTHS_PCT`/10 % jitter around `base_us`.
/// `entropy` is a raw 32-bit source; caller provides it so this stays
/// pure (radio task passes `Instant::now().as_ticks() as u32`).
pub fn jittered_sleep_us(base_us: u32, entropy: u32) -> u32 {
let mixed = entropy.wrapping_mul(0x9E37_79B9);
let span = 2 * CAD_JITTER_TENTHS_PCT + 1; // e.g. 401 for ±20 %
let r = mixed % span; // 0..=2·CAD_JITTER_TENTHS_PCT
// factor_thousandths ∈ [1000 − CAD_JITTER_TENTHS_PCT, 1000 + CAD_JITTER_TENTHS_PCT]
let factor_thousandths = 1000 - CAD_JITTER_TENTHS_PCT + r;
((base_us as u64 * factor_thousandths as u64) / 1000) as u32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
fn cfg(sf: u8, bw: LoRaBandwidth, cr: LoRaCodingRate, preamble: u16, implicit: bool, crc: bool) -> LoRaConfig {
LoRaConfig {
freq_hz: 915_000_000,
sf,
bw,
cr,
preamble_len: preamble,
sync_word: 0x1424,
tx_power_dbm: 0,
header_mode: if implicit {
LoRaHeaderMode::Implicit
} else {
LoRaHeaderMode::Explicit
},
payload_crc: crc,
iq_invert: false,
}
}
/// Helper: assert `actual` is within `tolerance_pct`% of `expected_us`.
fn approx(actual_us: u32, expected_us: u32, tolerance_pct: u32) {
let diff = actual_us.abs_diff(expected_us);
let tol = expected_us * tolerance_pct / 100;
assert!(
diff <= tol,
"actual {} us not within {}% of expected {} us (diff {}, tol {})",
actual_us,
tolerance_pct,
expected_us,
diff,
tol
);
}
// Reference values sanity-checked against Semtech's LoRa calculator
// and AN1200.13.
#[test]
fn toa_sf7_bw125_cr45_pl10_explicit_crc() {
let c = cfg(7, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
// Hand-computed reference from AN1200.13:
// T_sym = 128/125 kHz = 1.024 ms
// T_preamble = (8 + 4.25) · 1.024 = 12.544 ms
// n_payload = 8 + ceil((80 − 28 + 28 + 16) / 28) · 5 = 8 + 4·5 = 28
// T_payload = 28 · 1.024 = 28.672 ms
// Total ≈ 41.22 ms
approx(toa_us(&c, 10), 41_216, 2);
}
#[test]
fn toa_sf12_bw125_cr45_pl10_explicit_crc() {
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
// Reference: ~991 ms for PL=10 at SF12/BW125/CR4/5 (LDRO on).
approx(toa_us(&c, 10), 991_000, 2);
}
#[test]
fn toa_sf10_bw125_cr45_pl255_max() {
let c = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
// Reference: ~2.34 s for PL=255 at SF10/BW125/CR4/5.
approx(toa_us(&c, 255), 2_335_000, 2);
}
#[test]
fn toa_ldro_activates_at_sf11_and_sf12_bw125() {
// T_sym > 16 ms → DE=1. SF11/BW125: T_sym = 16.384 ms. SF10/BW125: 8.192 ms.
let sf10 = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
let sf11 = cfg(11, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
let sf12 = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
// Crude check: each step roughly doubles airtime (LDRO doesn't
// break the doubling trend — it just shifts constants).
let t10 = toa_us(&sf10, 80);
let t11 = toa_us(&sf11, 80);
let t12 = toa_us(&sf12, 80);
assert!(t11 > t10 * 15 / 10, "SF11 should be > 1.5x SF10, got {} vs {}", t11, t10);
assert!(t12 > t11 * 15 / 10, "SF12 should be > 1.5x SF11, got {} vs {}", t12, t11);
}
#[test]
fn toa_assumed_peer_80b_matches_plan_table() {
let mk = |sf, bw| cfg(sf, bw, LoRaCodingRate::Cr4_5, 8, false, true);
// Plan table values (approximate, within 5%).
approx(toa_us(&mk(7, LoRaBandwidth::Khz500), 80), 36_000, 5);
approx(toa_us(&mk(7, LoRaBandwidth::Khz250), 80), 72_000, 5);
approx(toa_us(&mk(7, LoRaBandwidth::Khz125), 80), 144_000, 5);
approx(toa_us(&mk(8, LoRaBandwidth::Khz125), 80), 257_000, 5);
approx(toa_us(&mk(9, LoRaBandwidth::Khz125), 80), 452_000, 5);
approx(toa_us(&mk(10, LoRaBandwidth::Khz125), 80), 862_000, 5);
approx(toa_us(&mk(11, LoRaBandwidth::Khz125), 80), 1_806_000, 5);
approx(toa_us(&mk(12, LoRaBandwidth::Khz125), 80), 3_285_000, 5);
}
#[test]
fn toa_no_underflow_at_small_pl_sf12_implicit_no_crc() {
// num = 8·PL − 4·SF + 28 + 16·CRC − 20·H goes negative here; the
// i64 + max(0) guard must kick in without panicking or wrapping.
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, true, false);
let t = toa_us(&c, 1);
// Only 8 payload symbols survive (the formula floors to 0 extra);
// should be preamble + 8·T_sym ≈ 401 + 262 = 663 ms roughly.
approx(t, 663_000, 5);
}
// Retry-count derivation table from the plan.
#[test]
fn retries_sf7_bw500_hits_hard_cap() {
let (n, s) = cad_retries(36_000);
assert_eq!(n, 10);
assert_eq!(s, 36_000);
}
#[test]
fn retries_sf10_bw125() {
// T_assumed ≈ 862 ms; budget 2.5 s ⇒ 2 sleeps ⇒ 3 retries.
let (n, _) = cad_retries(862_000);
assert_eq!(n, 3);
}
#[test]
fn retries_sf11_bw125() {
// T_assumed ≈ 1.81 s; budget 2.5 s ⇒ 1 sleep ⇒ 2 retries.
let (n, _) = cad_retries(1_806_000);
assert_eq!(n, 2);
}
#[test]
fn retries_sf12_bw125_degraded() {
// T_assumed ≈ 3.29 s > 2.5 s budget ⇒ 0 sleeps ⇒ 1 retry (bail on busy).
let (n, _) = cad_retries(3_285_000);
assert_eq!(n, 1);
}
#[test]
fn retries_zero_delay_falls_back_to_single_cad() {
// Unconfigured safety: avoid divide-by-zero.
let (n, s) = cad_retries(0);
assert_eq!(n, 1);
assert_eq!(s, 0);
}
// Jitter sanity.
#[test]
fn jitter_stays_within_pm_20_pct() {
let base = 1_000_000u32;
// Exhaustive over the low byte — enough to cover the `% span` range.
let mut saw_low = false;
let mut saw_high = false;
for e in 0u32..=u16::MAX as u32 {
let j = jittered_sleep_us(base, e);
// ±20% ⇒ [800_000, 1_200_000].
assert!(
(800_000..=1_200_000).contains(&j),
"jitter {} us out of ±20% bounds for entropy {}",
j,
e
);
if j <= 850_000 {
saw_low = true;
}
if j >= 1_150_000 {
saw_high = true;
}
}
assert!(saw_low && saw_high, "jitter didn't span the expected range");
}
#[test]
fn jitter_zero_base_is_zero() {
for e in [0u32, 1, 42, 0xDEAD_BEEF] {
assert_eq!(jittered_sleep_us(0, e), 0);
}
}
}
+123 -18
View File
@@ -19,6 +19,8 @@
mod board;
#[cfg(not(test))]
mod channel;
#[cfg(all(not(test), feature = "debug-checkpoint"))]
mod debug_blink;
#[cfg(not(test))]
mod display;
#[cfg(not(test))]
@@ -27,6 +29,7 @@ mod driver;
mod hal;
#[cfg(not(test))]
mod host;
mod lbt;
mod protocol;
#[cfg(not(test))]
mod radio;
@@ -37,36 +40,43 @@ use embassy_executor::Spawner;
#[cfg(not(test))]
use crate::board::LoRaBoard;
#[cfg(not(test))]
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel};
#[cfg(not(test))]
use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
use defmt_rtt as _;
use panic_probe as _;
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] {
use esp_backtrace as _;
use esp_println as _;
// Emits the IDF app descriptor into `.flash.appdesc`. espflash v4
// refuses any ESP image that lacks it.
esp_bootloader_esp_idf::esp_app_desc!();
}
}
#[cfg(not(test))]
static COMMANDS: CommandChannel = CommandChannel::new();
#[cfg(not(test))]
static RESPONSES: ResponseChannel = ResponseChannel::new();
static OUTBOUND: OutboundChannel = OutboundChannel::new();
#[cfg(not(test))]
static STATUS: StatusWatch = StatusWatch::new();
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
#[cfg(not(test))]
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
#[cfg(not(test))]
static mut INFO: Option<Info> = None;
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
#[embassy_executor::main]
async fn main(spawner: Spawner) {
run(spawner).await;
}
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] {
#[esp_rtos::main]
async fn main(spawner: Spawner) {
run(spawner).await;
@@ -77,26 +87,121 @@ cfg_if::cfg_if! {
#[cfg(not(test))]
async fn run(spawner: Spawner) {
let board = <board::Board as LoRaBoard>::init();
// Read + log + clear POWER.RESETREAS before any other boot output so
// the cause of the *previous* reset (if any) lands at the very top
// of the RTT log. Critical for soak-debugging silent crash-reboots:
// a self-rebooting firmware otherwise looks like the log just
// restarted with no explanation. nRF52840-only.
#[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
crate::hal::nrf52840::dump_reset_reason();
let parts = board.into_parts();
spawner
.spawn(radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS))
.expect("spawn radio_task");
// Build the GET_INFO struct once at boot. This is the authoritative
// snapshot of device capabilities reported over the wire.
let info = build_info(parts.mac);
// SAFETY: touched only here at boot, before any task is spawned.
let info_ref: &'static Info = unsafe {
INFO = Some(info);
#[allow(static_mut_refs)]
INFO.as_ref().expect("INFO just initialized")
};
spawner
.spawn(host::host_task(
spawner.spawn(
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
.expect("spawn radio_task"),
);
spawner.spawn(
host::host_task(
parts.host,
&COMMANDS,
&RESPONSES,
&OUTBOUND,
&DISPLAY_COMMANDS,
parts.display.is_some(),
parts.mac,
))
.expect("spawn host_task");
)
.expect("spawn host_task"),
);
// Suppress "assigned but never read" warning on non-USB/UART path.
let _ = info_ref;
if let Some(dp) = parts.display {
spawner
.spawn(display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS))
.expect("spawn display_task");
#[cfg(feature = "debug-checkpoint")]
{
// LED is taken over by the checkpoint blinker; the display
// init struct is dropped unused for this debug build.
let _ = dp;
spawner
.spawn(debug_blink::debug_blink_task(parts.led).expect("spawn debug_blink_task"));
}
#[cfg(not(feature = "debug-checkpoint"))]
{
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
spawner.spawn(
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
.expect("spawn display_task"),
);
}
}
}
#[cfg(not(test))]
fn build_info(mac: [u8; 6]) -> Info {
let (fmin, fmax) = <board::Board as LoRaBoard>::freq_range_hz();
let chip_id = <board::Board as LoRaBoard>::radio_chip_id();
let (tx_min, tx_max) = board::Board::TX_POWER_RANGE;
let supported_sf = <board::Board as LoRaBoard>::supported_sf_bitmap();
let supported_bw = <board::Board as LoRaBoard>::supported_bw_bitmap();
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
mcu_uid[..6].copy_from_slice(&mac);
Info {
proto_major: 1,
proto_minor: 0,
fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0),
fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0),
fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0),
radio_chip_id: chip_id,
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
supported_sf_bitmap: supported_sf,
supported_bw_bitmap: supported_bw,
max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16,
// Real channel depths from channel.rs.
rx_queue_capacity: 32,
tx_queue_capacity: 1,
freq_min_hz: fmin,
freq_max_hz: fmax,
tx_power_min_dbm: tx_min,
tx_power_max_dbm: tx_max,
mcu_uid_len: 6,
mcu_uid,
radio_uid_len: 0,
radio_uid: [0u8; MAX_RADIO_UID_LEN],
}
}
#[cfg(not(test))]
const fn parse_u8_or(s: &str, default: u8) -> u8 {
// CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at
// compile time via a tiny constant function instead of pulling in a
// dependency.
let bytes = s.as_bytes();
let mut i = 0;
let mut out: u32 = 0;
while i < bytes.len() {
let b = bytes[i];
if b < b'0' || b > b'9' {
return default;
}
out = out * 10 + (b - b'0') as u32;
if out > u8::MAX as u32 {
return default;
}
i += 1;
}
out as u8
}
+15 -706
View File
@@ -1,707 +1,16 @@
//! Wire protocol types and fixed-size little-endian serialization.
//! Wire protocol types for DongLoRa Protocol v2.
//!
//! Every integer is fixed-width LE. No varints, no zigzag.
//! See `docs/PROTOCOL.md` for the complete specification.
use heapless::Vec;
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
/// RadioConfig wire size (fixed).
pub const RADIO_CONFIG_SIZE: usize = 13;
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
pub const PREAMBLE_DEFAULT: u16 = 0;
/// LoRa signal bandwidth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(test), derive(defmt::Format))]
#[repr(u8)]
pub enum Bandwidth {
Khz7 = 0,
Khz10 = 1,
Khz15 = 2,
Khz20 = 3,
Khz31 = 4,
Khz41 = 5,
Khz62 = 6,
Khz125 = 7,
Khz250 = 8,
Khz500 = 9,
}
impl Bandwidth {
fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::Khz7),
1 => Some(Self::Khz10),
2 => Some(Self::Khz15),
3 => Some(Self::Khz20),
4 => Some(Self::Khz31),
5 => Some(Self::Khz41),
6 => Some(Self::Khz62),
7 => Some(Self::Khz125),
8 => Some(Self::Khz250),
9 => Some(Self::Khz500),
_ => None,
}
}
}
/// Complete LoRa radio configuration.
///
/// Wire layout (13 bytes, all little-endian):
/// ```text
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(test), derive(defmt::Format))]
pub struct RadioConfig {
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
pub freq_hz: u32,
pub bw: Bandwidth,
/// Spreading factor (5-12).
pub sf: u8,
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
pub cr: u8,
pub sync_word: u16,
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
/// board's maximum.
pub tx_power_dbm: i8,
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
/// the firmware default (16 symbols). Valid range: 6–65535.
pub preamble_len: u16,
/// Channel Activity Detection (listen-before-talk) before TX.
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
pub cad: u8,
}
impl RadioConfig {
/// Validate fields against hardware limits.
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
return Err("frequency out of range (150-960 MHz)");
}
if !(5..=12).contains(&self.sf) {
return Err("spreading factor out of range (5-12)");
}
if !(5..=8).contains(&self.cr) {
return Err("coding rate out of range (5-8)");
}
if self.tx_power_dbm != TX_POWER_MAX
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
{
return Err("TX power out of range for this board");
}
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
return Err("preamble length too short (min 6)");
}
Ok(())
}
/// Resolve sentinel values to concrete defaults.
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
if self.tx_power_dbm == TX_POWER_MAX {
self.tx_power_dbm = power_range.1;
}
if self.preamble_len == PREAMBLE_DEFAULT {
self.preamble_len = 16;
}
self
}
/// Whether CAD (listen-before-talk) is enabled.
pub fn cad_enabled(&self) -> bool {
self.cad != 0
}
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
pub fn write_to(self, buf: &mut [u8]) -> usize {
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bw as u8;
buf[5] = self.sf;
buf[6] = self.cr;
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
buf[9] = self.tx_power_dbm as u8;
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
buf[12] = self.cad;
RADIO_CONFIG_SIZE
}
/// Deserialize from fixed-size LE bytes.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() < RADIO_CONFIG_SIZE {
return None;
}
Some(Self {
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
bw: Bandwidth::from_u8(buf[4])?,
sf: buf[5],
cr: buf[6],
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
tx_power_dbm: buf[9] as i8,
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
cad: buf[12],
})
}
}
/// Host → firmware commands.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, PartialEq)]
pub enum Command {
Ping,
GetConfig,
SetConfig(RadioConfig),
StartRx,
StopRx,
Transmit {
config: Option<RadioConfig>,
payload: Vec<u8, MAX_PAYLOAD>,
},
DisplayOn,
DisplayOff,
GetMac,
}
impl Command {
/// Deserialize from a COBS-decoded frame.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Ping),
1 => Some(Self::GetConfig),
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
3 => Some(Self::StartRx),
4 => Some(Self::StopRx),
5 => {
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
if rest.is_empty() {
return None;
}
let (config, pos) = if rest[0] == 0 {
(None, 1)
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
(
Some(RadioConfig::from_bytes(&rest[1..])?),
1 + RADIO_CONFIG_SIZE,
)
} else {
return None;
};
if rest.len() < pos + 2 {
return None;
}
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
let data_start = pos + 2;
if rest.len() < data_start + len {
return None;
}
let mut payload = Vec::new();
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
Some(Self::Transmit { config, payload })
}
6 => Some(Self::DisplayOn),
7 => Some(Self::DisplayOff),
8 => Some(Self::GetMac),
_ => None,
}
}
}
/// Firmware → host responses.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, PartialEq)]
pub enum Response {
Pong,
Config(RadioConfig),
RxPacket {
rssi: i16,
snr: i16,
payload: Vec<u8, MAX_PAYLOAD>,
},
TxDone,
Ok,
Error(ErrorCode),
MacAddress([u8; 6]),
}
impl Response {
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
pub fn write_to(self, buf: &mut [u8]) -> usize {
match self {
Self::Pong => {
buf[0] = 0;
1
}
Self::Config(cfg) => {
buf[0] = 1;
1 + cfg.write_to(&mut buf[1..])
}
Self::RxPacket { rssi, snr, payload } => {
buf[0] = 2;
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
buf[3..5].copy_from_slice(&snr.to_le_bytes());
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[7..7 + payload.len()].copy_from_slice(&payload);
7 + payload.len()
}
Self::TxDone => {
buf[0] = 3;
1
}
Self::Ok => {
buf[0] = 4;
1
}
Self::Error(code) => {
buf[0] = 5;
buf[1] = code as u8;
2
}
Self::MacAddress(mac) => {
buf[0] = 6;
buf[1..7].copy_from_slice(&mac);
7
}
}
}
}
/// Error codes reported to the host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(test), derive(defmt::Format))]
#[repr(u8)]
pub enum ErrorCode {
InvalidConfig = 0,
RadioBusy = 1,
TxTimeout = 2,
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
NotConfigured = 4,
NoDisplay = 5,
}
// ── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> RadioConfig {
RadioConfig {
freq_hz: 915_000_000,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: 0x3444,
tx_power_dbm: 22,
preamble_len: 16,
cad: 1,
}
}
// ── RadioConfig roundtrip ───────────────────────────────────────
#[test]
fn radio_config_roundtrip() {
let cfg = make_config();
let mut buf = [0u8; RADIO_CONFIG_SIZE];
let n = cfg.write_to(&mut buf);
assert_eq!(n, RADIO_CONFIG_SIZE);
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
#[test]
fn radio_config_roundtrip_all_bandwidths() {
for bw_val in 0u8..=9 {
let bw = Bandwidth::from_u8(bw_val).unwrap();
let cfg = RadioConfig {
freq_hz: 433_000_000,
bw,
sf: 12,
cr: 8,
sync_word: 0x1234,
tx_power_dbm: -9,
preamble_len: 16,
cad: 1,
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
}
#[test]
fn radio_config_roundtrip_negative_power() {
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
#[test]
fn radio_config_from_short_buffer() {
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
assert!(RadioConfig::from_bytes(&buf).is_none());
}
#[test]
fn radio_config_from_empty_buffer() {
assert!(RadioConfig::from_bytes(&[]).is_none());
}
#[test]
fn radio_config_invalid_bandwidth() {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
make_config().write_to(&mut buf);
buf[4] = 255; // invalid bandwidth
assert!(RadioConfig::from_bytes(&buf).is_none());
}
// ── RadioConfig::validate ───────────────────────────────────────
#[test]
fn validate_freq_boundaries() {
let power_range = (-9, 22);
let base = make_config();
let mut cfg = RadioConfig {
freq_hz: 150_000_000,
..base
};
assert!(cfg.validate(power_range).is_ok());
cfg.freq_hz = 960_000_000;
assert!(cfg.validate(power_range).is_ok());
cfg.freq_hz = 149_999_999;
assert!(cfg.validate(power_range).is_err());
cfg.freq_hz = 960_000_001;
assert!(cfg.validate(power_range).is_err());
}
#[test]
fn validate_sf_boundaries() {
let power_range = (-9, 22);
let base = make_config();
for sf in 5..=12 {
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
}
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
assert!(RadioConfig { sf: 13, ..base }
.validate(power_range)
.is_err());
}
#[test]
fn validate_cr_boundaries() {
let power_range = (-9, 22);
let base = make_config();
for cr in 5..=8 {
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
}
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
}
#[test]
fn validate_tx_power_max_sentinel() {
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_ok());
}
#[test]
fn validate_tx_power_out_of_range() {
let cfg = RadioConfig {
tx_power_dbm: 23,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_err());
let cfg = RadioConfig {
tx_power_dbm: -10,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_err());
}
#[test]
fn validate_preamble_default_sentinel() {
let cfg = RadioConfig {
preamble_len: PREAMBLE_DEFAULT,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_ok());
}
#[test]
fn validate_preamble_boundaries() {
let power_range = (-9, 22);
let base = make_config();
assert!(RadioConfig {
preamble_len: 6,
..base
}
.validate(power_range)
.is_ok());
assert!(RadioConfig {
preamble_len: 5,
..base
}
.validate(power_range)
.is_err());
}
// ── RadioConfig::resolve ──────────────────────────────────────
#[test]
fn resolve_power_max_sentinel() {
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
}
#[test]
fn resolve_power_explicit_unchanged() {
let cfg = RadioConfig {
tx_power_dbm: 10,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
}
#[test]
fn resolve_preamble_default() {
let cfg = RadioConfig {
preamble_len: PREAMBLE_DEFAULT,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
}
#[test]
fn resolve_preamble_explicit_unchanged() {
let cfg = RadioConfig {
preamble_len: 32,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
}
// ── Command::from_bytes ─────────────────────────────────────────
#[test]
fn command_ping() {
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
}
#[test]
fn command_get_config() {
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
}
#[test]
fn command_set_config() {
let cfg = make_config();
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
buf[0] = 2;
cfg.write_to(&mut buf[1..]);
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
}
#[test]
fn command_start_stop_rx() {
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
}
#[test]
fn command_transmit_no_config() {
let payload = b"hello";
let mut buf = [0u8; 64];
buf[0] = 5; // tag
buf[1] = 0; // has_config = false
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[4..9].copy_from_slice(payload);
match Command::from_bytes(&buf[..9]).unwrap() {
Command::Transmit { config, payload: p } => {
assert!(config.is_none());
assert_eq!(p.as_slice(), b"hello");
}
_ => panic!("expected Transmit"),
}
}
#[test]
fn command_transmit_with_config() {
let cfg = make_config();
let mut buf = [0u8; 64];
buf[0] = 5; // tag
buf[1] = 1; // has_config = true
cfg.write_to(&mut buf[2..]);
let payload = b"test";
let pos = 2 + RADIO_CONFIG_SIZE;
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[pos + 2..pos + 6].copy_from_slice(payload);
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
Command::Transmit { config, payload: p } => {
assert_eq!(config, Some(cfg));
assert_eq!(p.as_slice(), b"test");
}
_ => panic!("expected Transmit"),
}
}
#[test]
fn command_transmit_empty_payload() {
let mut buf = [0u8; 4];
buf[0] = 5; // tag
buf[1] = 0; // has_config = false
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
match Command::from_bytes(&buf).unwrap() {
Command::Transmit { config, payload } => {
assert!(config.is_none());
assert!(payload.is_empty());
}
_ => panic!("expected Transmit"),
}
}
#[test]
fn command_transmit_truncated() {
// Tag only — missing has_config byte
assert!(Command::from_bytes(&[5]).is_none());
// has_config=1 but no config bytes
assert!(Command::from_bytes(&[5, 1]).is_none());
// has_config=0 but no length bytes
assert!(Command::from_bytes(&[5, 0]).is_none());
// has_config=0, length says 5 but only 2 bytes of payload
let mut buf = [0u8; 6];
buf[0] = 5;
buf[1] = 0;
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
buf[4] = 0xAA;
buf[5] = 0xBB;
assert!(Command::from_bytes(&buf).is_none());
}
#[test]
fn command_display_and_mac() {
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
}
#[test]
fn command_invalid_tag() {
assert!(Command::from_bytes(&[9]).is_none());
assert!(Command::from_bytes(&[255]).is_none());
}
#[test]
fn command_empty_buffer() {
assert!(Command::from_bytes(&[]).is_none());
}
// ── Response::write_to ──────────────────────────────────────────
#[test]
fn response_pong() {
let mut buf = [0u8; 1];
assert_eq!(Response::Pong.write_to(&mut buf), 1);
assert_eq!(buf[0], 0);
}
#[test]
fn response_config() {
let cfg = make_config();
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
let n = Response::Config(cfg).write_to(&mut buf);
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
assert_eq!(buf[0], 1);
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
}
#[test]
fn response_rx_packet() {
let mut payload = Vec::new();
let _ = payload.extend_from_slice(b"data");
let mut buf = [0u8; 64];
let n = Response::RxPacket {
rssi: -80,
snr: 10,
payload,
}
.write_to(&mut buf);
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
assert_eq!(buf[0], 2);
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
assert_eq!(&buf[7..11], b"data");
}
#[test]
fn response_tx_done() {
let mut buf = [0u8; 1];
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
assert_eq!(buf[0], 3);
}
#[test]
fn response_ok() {
let mut buf = [0u8; 1];
assert_eq!(Response::Ok.write_to(&mut buf), 1);
assert_eq!(buf[0], 4);
}
#[test]
fn response_error_codes() {
let mut buf = [0u8; 2];
for (code, val) in [
(ErrorCode::InvalidConfig, 0),
(ErrorCode::RadioBusy, 1),
(ErrorCode::TxTimeout, 2),
(ErrorCode::NotConfigured, 4),
(ErrorCode::NoDisplay, 5),
] {
let n = Response::Error(code).write_to(&mut buf);
assert_eq!(n, 2);
assert_eq!(buf[0], 5);
assert_eq!(buf[1], val);
}
}
#[test]
fn response_mac_address() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
let mut buf = [0u8; 7];
let n = Response::MacAddress(mac).write_to(&mut buf);
assert_eq!(n, 7);
assert_eq!(buf[0], 6);
assert_eq!(&buf[1..7], &mac);
}
}
//! Wire definitions live in the `donglora-protocol` crate so host-side
//! Rust projects can share them with the firmware without duplicating
//! wire layouts. This module re-exports them unchanged so firmware
//! modules can `use crate::protocol::*;`.
#[allow(unused_imports)]
pub use donglora_protocol::{
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
DeviceMessageParseError, ErrorCode, FlrcConfig, FrameDecoder, FrameEncodeError, FrameResult,
FskConfig, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode,
LrFhssConfig, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload,
SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, MAX_MCU_UID_LEN,
MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_WIRE_FRAME,
};
+2159 -314
View File
File diff suppressed because it is too large. Load diff