19 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
38 changed files with 5202 additions and 2822 deletions

No files matched your search

+231
View File
@@ -7,6 +7,237 @@ 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
Generated
+215 -122
View File
@@ -125,9 +125,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
@@ -167,9 +167,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.2.60"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -210,7 +210,7 @@ checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e"
dependencies = [
"cipher",
"dbl",
"digest 0.11.2",
"digest 0.11.3",
]
[[package]]
@@ -274,9 +274,9 @@ dependencies = [
[[package]]
name = "cpubits"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
@@ -506,12 +506,12 @@ dependencies = [
[[package]]
name = "defmt-rtt"
version = "1.1.0"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e"
checksum = "c6eca0aae8aa2cf8333200ecbd236274697bc0a394765c858b3d9372eb1abcfa"
dependencies = [
"critical-section",
"defmt 1.0.1",
"defmt 0.3.100",
]
[[package]]
@@ -537,9 +537,9 @@ dependencies = [
[[package]]
name = "digest"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"crypto-common 0.2.1",
@@ -586,13 +586,13 @@ dependencies = [
[[package]]
name = "donglora"
version = "1.4.0"
version = "1.5.2"
dependencies = [
"cfg-if",
"cortex-m",
"cortex-m-rt",
"critical-section",
"defmt 1.0.1",
"defmt 0.3.100",
"defmt-rtt",
"display-interface",
"donglora-protocol",
@@ -608,13 +608,16 @@ dependencies = [
"embedded-graphics",
"embedded-hal 1.0.0",
"embedded-hal-async",
"embedded-hal-bus",
"embedded-io-async 0.7.0",
"esp-backtrace",
"esp-bootloader-esp-idf",
"esp-hal",
"esp-println",
"esp-rtos",
"heapless 0.9.2",
"heapless 0.9.3",
"hsmc",
"lcd-async",
"lora-phy",
"minijinja",
"owo-colors",
@@ -627,12 +630,12 @@ dependencies = [
[[package]]
name = "donglora-protocol"
version = "1.1.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
checksum = "0eea2a87a5ad31629140accc2bae6c931434bf1946706f2184dec9b3d356505d"
dependencies = [
"defmt 1.0.1",
"heapless 0.9.2",
"heapless 0.9.3",
"ucobs",
]
@@ -743,7 +746,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b715380574504335c40ec3016bc44598fac3c385f82959b26c41b25c30b7648d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cfg-if",
"cortex-m",
"cortex-m-rt",
@@ -850,7 +853,7 @@ dependencies = [
"embedded-io-async 0.7.0",
"futures-core",
"futures-sink",
"heapless 0.9.2",
"heapless 0.9.3",
]
[[package]]
@@ -880,12 +883,12 @@ dependencies = [
[[package]]
name = "embassy-time-queue-utils"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454"
checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693"
dependencies = [
"embassy-executor-timer-queue",
"heapless 0.8.0",
"heapless 0.9.3",
]
[[package]]
@@ -894,7 +897,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"defmt 1.0.1",
"embassy-futures",
"embassy-net-driver-channel",
@@ -902,30 +905,32 @@ dependencies = [
"embassy-time",
"embassy-usb-driver",
"embedded-io-async 0.7.0",
"heapless 0.9.2",
"heapless 0.9.3",
"ssmarshal",
"usbd-hid",
]
[[package]]
name = "embassy-usb-driver"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a"
checksum = "6a189cbf1d8ab3c591f88632f85c1df892b6ab04116037cbee39dc37cbdcd79f"
dependencies = [
"defmt 1.0.1",
"embedded-io-async 0.6.1",
"embedded-io-async 0.7.0",
]
[[package]]
name = "embassy-usb-synopsys-otg"
version = "0.3.2"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbe46f4083109c7ea12a03ca61095d1e87c76fec52c7ca9ee06a42935606dacb"
checksum = "fb205e26d59e483e8c48f91f637ec217ec7e2cfb0b61d50482301b991b4ff431"
dependencies = [
"critical-section",
"embassy-sync 0.8.0",
"embassy-time",
"embassy-usb-driver",
"portable-atomic",
]
[[package]]
@@ -985,6 +990,17 @@ dependencies = [
"embedded-hal 1.0.0",
]
[[package]]
name = "embedded-hal-bus"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22"
dependencies = [
"critical-section",
"embedded-hal 1.0.0",
"embedded-hal-async",
]
[[package]]
name = "embedded-hal-nb"
version = "1.0.0"
@@ -1057,18 +1073,18 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]]
name = "enumset"
version = "1.1.10"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634"
checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de"
dependencies = [
"enumset_derive",
]
[[package]]
name = "enumset_derive"
version = "0.14.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
dependencies = [
"darling 0.21.3",
"proc-macro2",
@@ -1084,8 +1100,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "esp-backtrace"
version = "0.18.1"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f"
dependencies = [
"cfg-if",
"defmt 1.0.1",
@@ -1093,16 +1110,35 @@ dependencies = [
"esp-config",
"esp-metadata-generated",
"esp-println",
"heapless 0.9.2",
"heapless 0.9.3",
"riscv",
"semihosting",
"xtensa-lx",
]
[[package]]
name = "esp-bootloader-esp-idf"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35ffc117c3a9859835d89d0e90f5ee9886ce2264a71a849a7a22ab5308f6653c"
dependencies = [
"cfg-if",
"defmt 1.0.1",
"document-features",
"embedded-storage",
"esp-config",
"esp-hal-procmacros",
"esp-metadata-generated",
"esp-rom-sys",
"jiff",
"strum",
]
[[package]]
name = "esp-config"
version = "0.6.1"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80"
dependencies = [
"document-features",
"esp-metadata-generated",
@@ -1113,11 +1149,12 @@ dependencies = [
[[package]]
name = "esp-hal"
version = "1.0.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72"
dependencies = [
"bitfield 0.19.4",
"bitflags 2.11.0",
"bitflags 2.11.1",
"bytemuck",
"cfg-if",
"critical-section",
@@ -1156,7 +1193,7 @@ dependencies = [
"nb 1.1.0",
"paste",
"portable-atomic",
"rand_core 0.10.0",
"rand_core 0.10.1",
"rand_core 0.6.4",
"rand_core 0.9.5",
"riscv",
@@ -1170,8 +1207,9 @@ dependencies = [
[[package]]
name = "esp-hal-procmacros"
version = "0.21.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105"
dependencies = [
"document-features",
"object",
@@ -1184,13 +1222,15 @@ dependencies = [
[[package]]
name = "esp-metadata-generated"
version = "0.3.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a"
[[package]]
name = "esp-println"
version = "0.16.1"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6"
dependencies = [
"defmt 1.0.1",
"document-features",
@@ -1202,8 +1242,9 @@ dependencies = [
[[package]]
name = "esp-riscv-rt"
version = "0.13.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79"
dependencies = [
"document-features",
"riscv",
@@ -1212,8 +1253,9 @@ dependencies = [
[[package]]
name = "esp-rom-sys"
version = "0.1.3"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3"
dependencies = [
"cfg-if",
"document-features",
@@ -1224,8 +1266,9 @@ dependencies = [
[[package]]
name = "esp-rtos"
version = "0.2.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712"
dependencies = [
"cfg-if",
"document-features",
@@ -1246,8 +1289,9 @@ dependencies = [
[[package]]
name = "esp-sync"
version = "0.1.1"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224"
dependencies = [
"cfg-if",
"document-features",
@@ -1274,64 +1318,64 @@ dependencies = [
[[package]]
name = "esp32"
version = "0.39.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "esp32c2"
version = "0.28.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "esp32c3"
version = "0.31.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "esp32c6"
version = "0.22.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "esp32h2"
version = "0.18.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "esp32s2"
version = "0.30.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "esp32s3"
version = "0.34.0"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a"
dependencies = [
"critical-section",
"vcell",
]
@@ -1511,9 +1555,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.1"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heapless"
@@ -1527,9 +1571,9 @@ dependencies = [
[[package]]
name = "heapless"
version = "0.9.2"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [
"defmt 1.0.1",
"hash32",
@@ -1550,10 +1594,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hsmc"
version = "0.2.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
checksum = "18bb06fda289e60e17bc7483a222fc6d9d6865b92648f836242b7a79115cc91d"
dependencies = [
"defmt 0.3.100",
"embassy-futures",
"embassy-sync 0.8.0",
"embassy-time",
@@ -1563,9 +1608,9 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.2.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
checksum = "289a7c9f1b677168229c8ad4e992380c3951d0908b52b1d2bfae8ff3c980c35d"
dependencies = [
"proc-macro2",
"quote",
@@ -1574,9 +1619,9 @@ dependencies = [
[[package]]
name = "hybrid-array"
version = "0.4.10"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
@@ -1589,12 +1634,12 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.13.0"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -1643,6 +1688,30 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "keccak"
version = "0.1.6"
@@ -1691,10 +1760,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.184"
name = "lcd-async"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "359805d2afdff45f3abad226ae27a61ee980309562d14084842c542af29b422a"
dependencies = [
"embedded-graphics",
"embedded-graphics-core",
"embedded-hal 1.0.0",
"embedded-hal-async",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "litrs"
@@ -1733,7 +1814,7 @@ dependencies = [
[[package]]
name = "lora-modulation"
version = "0.1.5"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"defmt 0.3.100",
]
@@ -1741,7 +1822,7 @@ dependencies = [
[[package]]
name = "lora-phy"
version = "3.0.2-alpha"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"defmt 0.3.100",
"document-features",
@@ -1755,7 +1836,7 @@ dependencies = [
[[package]]
name = "lorawan"
version = "0.9.1"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"aes",
"cmac",
@@ -1767,13 +1848,13 @@ dependencies = [
[[package]]
name = "lorawan-device"
version = "0.12.2"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"defmt 0.3.100",
"document-features",
"fastrand",
"futures",
"heapless 0.9.2",
"heapless 0.9.3",
"lora-modulation",
"lorawan",
"rand_core 0.6.4",
@@ -1782,7 +1863,7 @@ dependencies = [
[[package]]
name = "lorawan-macros"
version = "0.1.0"
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
dependencies = [
"proc-macro2",
"quote",
@@ -1831,9 +1912,9 @@ checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815"
[[package]]
name = "minijinja"
version = "2.18.0"
version = "2.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"
dependencies = [
"memo-map",
"serde",
@@ -2057,6 +2138,15 @@ dependencies = [
"critical-section",
]
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2133,7 +2223,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"memchr",
"unicase",
]
@@ -2176,9 +2266,9 @@ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
[[package]]
name = "rand_core"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "redox_syscall"
@@ -2186,7 +2276,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
]
[[package]]
@@ -2438,9 +2528,9 @@ checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9"
[[package]]
name = "sha3"
version = "0.10.8"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest 0.10.7",
"keccak",
@@ -2463,9 +2553,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "smallvec"
@@ -2665,9 +2755,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.9+spec-1.1.0"
version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap",
"toml_datetime",
@@ -2677,9 +2767,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
@@ -2747,9 +2837,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "ucobs"
@@ -2916,9 +3006,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [
"memchr",
]
@@ -2926,15 +3016,17 @@ dependencies = [
[[package]]
name = "xtensa-lx"
version = "0.13.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b"
dependencies = [
"critical-section",
]
[[package]]
name = "xtensa-lx-rt"
version = "0.21.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905"
dependencies = [
"document-features",
"xtensa-lx",
@@ -2944,7 +3036,8 @@ dependencies = [
[[package]]
name = "xtensa-lx-rt-proc-macros"
version = "0.5.0"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450"
dependencies = [
"proc-macro2",
"quote",
+124 -31
View File
@@ -3,7 +3,7 @@ members = ["."]
[package]
name = "donglora"
version = "1.4.0"
version = "1.5.2"
edition = "2021"
build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -14,12 +14,10 @@ homepage = "https://github.com/donglora/firmware"
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
categories = ["embedded", "hardware-support", "no-std"]
# NOTE: esp-rtos patched from GitHub main (see [patch.crates-io] below).
# Remove patches once esp-rtos publishes with embassy-executor 0.10 support.
[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"
@@ -31,18 +29,32 @@ embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
donglora-protocol = "1"
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
# Pulled from swaits/lora-rs fork until lora-rs/lora-rs#428 (pre-command
# BUSY check) is merged + released on crates.io. Switch back to a normal
# crates.io version then.
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fix/sx126x-busy-race", features = ["defmt-03"] }
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
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"
@@ -51,31 +63,47 @@ embassy-futures = "0.1"
# 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.18", optional = true, features = [
esp-backtrace = { version = "0.19", optional = true, features = [
"panic-handler",
"defmt",
] }
esp-hal = { version = "1.0", optional = true, features = [
esp-hal = { version = "1.1", optional = true, features = [
"unstable",
] }
esp-println = { version = "0.16", optional = true, features = [
esp-println = { version = "0.17", optional = true, features = [
"defmt-espflash",
] }
esp-rtos = { version = "0.2", optional = true, features = [
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 }
defmt-rtt = { version = "1.0", 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"] }
@@ -91,13 +119,22 @@ minijinja = "2.18"
owo-colors = "4.3"
[features]
# The `embassy` feature gates the async-actions codepath inside the `hsmc`
# statechart macro. Its `#[cfg(feature = "embassy")]` attributes are
# evaluated in *this* crate's context (a proc-macro cfg quirk), so every
# board feature must enable it. `tokio` is declared only to silence the
# macro's `#[cfg(feature = "tokio")]` checks.
# 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
@@ -105,74 +142,110 @@ tokio = []
# 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",
@@ -184,6 +257,7 @@ rak_wisblock_4631 = [
]
wio_tracker_l1 = [
"embassy",
"trace-defmt",
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
@@ -193,27 +267,42 @@ wio_tracker_l1 = [
"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",
"dep:portable-atomic",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
# REVISIT: esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9. The
# esp-rs/esp-hal main branch already supports 0.10. Remove these patches once
# esp-rtos publishes a release with embassy-executor 0.10 support. Track:
# https://github.com/esp-rs/esp-hal/tree/main/esp-rtos
[patch.crates-io]
esp-hal = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-rtos = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
[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"
@@ -223,4 +312,8 @@ codegen-units = 1
lto = "fat"
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
-1923
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -20,6 +20,7 @@ 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 |
@@ -62,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.
+26 -154
View File
@@ -1,165 +1,37 @@
# Firmware TODO List
## `lora-phy` fix: drop fork pin once PR #428 merges + releases
## `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).
Upstream PR: [lora-rs/lora-rs#428 — *phy: add pre-command BUSY checks
per Semtech datasheets*](https://github.com/lora-rs/lora-rs/pull/428)
(open).
Fork branch we ship from:
[`swaits/lora-rs @ fix/sx126x-busy-race`](https://github.com/swaits/lora-rs/tree/fix/sx126x-busy-race).
[`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 fix via a direct `git` dependency in
Firmware consumes the fixes via a direct `git` dependency in
`Cargo.toml`:
```toml
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fix/sx126x-busy-race", features = ["defmt-03"] }
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
```
When PR #428 merges and a `lora-phy` release ships on crates.io that
contains it, switch this line back to a normal `lora-phy = "X.Y"`
crates.io version and drop the explanatory comment block above it.
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-verified on Wio Tracker L1: 20/20 clean-boot TX after the fix
(vs ~50% before). The fix adds a pre-command BUSY check before each
`SpiInterface` read/write op, keeping the existing post-command wait
in place — belt-and-suspenders bracketing that closes the
BUSY-assertion latency window (T_SW ≤ 600 ns) a single level-poll can
miss.
---
## Drop `[patch.crates-io]` + upgrade esp-hal / esp-rtos + add `esp_app_desc!()` + bump espflash to v4
Single coordinated change that clears our last remaining dep-graph debt
on the ESP boards. All four pieces are coupled, so they land together.
### Why
Today `Cargo.toml` overrides `esp-hal`, `esp-rtos`, `esp-backtrace`, and
`esp-println` with a git pin to `esp-rs/esp-hal#main`. The note in that
block explains the historical reason:
> REVISIT: esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9. The
> esp-rs/esp-hal main branch already supports 0.10. Remove these
> patches once esp-rtos publishes a release with embassy-executor 0.10
> support.
**That condition is now met.** `esp-rtos 0.3.0` is published on crates.io
and depends on `embassy-executor ^0.10` and `esp-hal ~1.1.0-rc.0` — exactly
what our patches were pulling unofficially. Keeping the patches is no
longer free: it blocks `espflash` v4, blocks `esp-bootloader-esp-idf`, and
ties reproducible builds to whatever commit happens to be on
`esp-rs/esp-hal#main`.
The immediate trigger is `espflash` v4's required
`esp_bootloader_esp_idf::esp_app_desc!()` check. `save-image` refuses any
ESP binary that lacks the IDF app descriptor. The crate that provides the
macro (`esp-bootloader-esp-idf`) uses the same `esp-rom-sys` native
library as `esp-hal` — two copies of the same `links = "esp_rom_sys"` is
a hard cargo error, so `esp-bootloader-esp-idf` must come from the _same
source_ as `esp-hal`. Easiest way to guarantee that: both from crates.io,
no patches.
### What changes
1. **`Cargo.toml`** — delete the entire `[patch.crates-io]` block and the
preceding `REVISIT:` comment. Bump the four esp-rs deps to published
versions:
- `esp-hal = "1.0"` → `"1.1.0-rc.0"` (pre-release selector — see Gotchas)
- `esp-rtos = "0.2"` → `"0.3"`
- `esp-backtrace = "0.18"` → whatever matches esp-rtos 0.3 (check its deps)
- `esp-println = "0.16"` → same
Add a new optional dep + feature wiring for
`esp-bootloader-esp-idf = "0.5"` with `defmt` feature, plus per-board
chip feature (`esp-bootloader-esp-idf/esp32s3` for the S3 boards,
`esp-bootloader-esp-idf/esp32` for LilyGo T-Beam classic).
2. **`src/main.rs`** — inside the ESP-family branch of the existing
`cfg_if!` block (around line 51), invoke the macro at module scope:
```rust
esp_bootloader_esp_idf::esp_app_desc!();
```
Zero runtime cost. Emits ~256 bytes in `.rodata_desc` with app
metadata (name, version, build date) that the ESP-IDF 2nd-stage
bootloader + `espflash save-image` look for.
3. **`mise.toml`** — `"cargo:espflash" = "3"` → `"4"`. Drop the deferral
comment on that line.
4. **Possible API drift fixes** — esp-hal's pre-release versions
occasionally rename or move items. Most likely touch points:
- `src/board/esp32.rs`, `src/board/esp32s3.rs`
- Any board file in `src/board/heltec_*.rs`, `lilygo_*.rs`,
`elecrow_*.rs` that calls into esp-hal / esp-rtos init
Run `just check-all` after steps 1–3 and patch up whatever surfaces.
Don't guess — read the actual compiler errors.
### How (step by step)
1. `jj new -m "chore(esp): drop esp-hal patches, bump to published versions, add esp_app_desc!()"`
2. Edit `Cargo.toml` — delete `[patch.crates-io]` + `REVISIT:` comment;
bump the four versions; add `esp-bootloader-esp-idf` dep + feature
wiring for each ESP board feature block.
3. Edit `src/main.rs` — add `esp_bootloader_esp_idf::esp_app_desc!();`
to the ESP branch of the top-level `cfg_if!`.
4. Edit `mise.toml` — espflash `"3"` → `"4"`.
5. `mise install` to pull espflash 4.x.
6. `cargo update` to let the resolver pick a consistent tree with the
new requirements.
7. `just check-all` — expect some compilation errors from API drift on
ESP boards. Fix them one at a time; consult the official examples
in `esp-rs/esp-hal` if in doubt.
8. `just build heltec_v3` (or any ESP board you have) — verify
`espflash save-image` now succeeds end-to-end producing a valid
`.bin`.
9. Flash to real hardware if available. **The RAK4631 and Wio Tracker
L1 (nRF52) boards should be untouched.**
### Verification
- `just check-all` clean on all 9 boards.
- `just test` (host-side protocol tests) still green.
- For each ESP board that has been physically validated before
(Heltec V3, LilyGo T-Beam units): flash + confirm radio TX works
end-to-end via ai-bot or a known peer.
- RAK4631 + Wio Tracker L1 unchanged; re-check via `just check` on
both, but no re-flash needed (they don't go through the ESP code
path).
### Gotchas
- **Pre-release version selector**: `esp-hal = "1.1.0-rc.0"` by itself
does NOT match pre-release versions in cargo's default semver rules.
Use an exact pre-release like `"1.1.0-rc.0"`, `"~1.1.0-rc.0"` (only
`1.1.0-rc.0`, `1.1.0-rc.1`, … with any patch), or `">=1.1.0-rc.0"`.
`"1.1"` will NOT resolve to a pre-release.
- **Two copies of `esp-rom-sys`**: the root cause of the first failed
attempt at this task. If cargo errors with _"Only one package in the
dependency graph may specify the same links value"_ for
`esp_rom_sys`, it means `esp-hal` and `esp-bootloader-esp-idf` are
being pulled from different sources (one crates.io, one git). Either
both from crates.io (this task's plan) or both from the same git
commit.
- **`esp-bootloader-esp-idf` chip features**: the crate needs exactly
one of `esp32 / esp32s3 / esp32c3 / esp32c6 / …` to be enabled.
Without it, the build panics at its `build.rs`: _"Expected exactly
one of the following features to be enabled: esp32, esp32c2, ..."_.
Wire per-board the same way we wire `esp-hal/esp32s3` etc.
- **Probe-less repro**: `mise install` after bumping espflash to "4"
only installs mise's copy. If the user's global
`~/.cargo/bin/espflash` is stale/different, it still wins on PATH.
The justfile `set shell := ["mise", "exec", "--", "sh", "-c"]`
prepends mise's paths but does NOT reorder `~/.cargo/bin` ahead of
them. Either `cargo uninstall espflash` globally, or change the
justfile recipes to call `$(mise which espflash)` explicitly.
### Why not now
Tried it alongside the RAK4631 bring-up in April 2026 and bounced off
the `esp-rom-sys` two-copies problem. Backed out the attempt and left
espflash pinned at v3 + the patches in place so the bring-up commit
stayed atomic and shippable. This task is what's needed to unblock v4
properly, but it deserves its own focused session — not as
"while-I'm-here" scope on a board bring-up.
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.
+19 -7
View File
@@ -108,19 +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 the correct layout
// into OUT_DIR based on the target MCU family.
// 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();
let memory_x = match target.as_str() {
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x"),
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x"),
_ => None,
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(src, format!("{out_dir}/memory.x")).expect("failed to copy 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 renamed without changes.
File diff suppressed because it is too large. Load diff
+49 -10
View File
@@ -13,16 +13,17 @@ version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
# 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_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
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 a rustup component — installed out-of-band by espup)
@# 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
# Build release firmware for all boards, installing toolchains as needed
@@ -82,12 +83,48 @@ flash board extra_features="":
# 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 "{{extra_features}}"
@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"
@@ -118,7 +155,6 @@ _cargo board cmd extra_args="" extra_features="":
extra=""; buildstd=""; \
case "$target" in xtensa-*) \
just _require_esp_toolchain; \
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
esac; \
cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
@@ -132,15 +168,17 @@ _can_build board:
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
esac
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise).
# The esp toolchain ships with its own cargo, rustc, and rust-src — no extra
# 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; }; \
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)
@@ -208,6 +246,7 @@ _flash_uf2 board:
[private]
_info name:
@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" ;; \
+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
}
+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
}
+25 -3
View File
@@ -4,8 +4,8 @@ just = "latest"
# 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" = "latest" # bootstraps the ESP Xtensa toolchain
"cargo:espflash" = "3" # flash + save-image for ESP targets (4.x requires esp_app_desc!() in app — revisit alongside esp-hal 1.1+ / drop-the-patches cleanup)
"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
@@ -13,7 +13,7 @@ just = "latest"
# extra `rustup target add` step at setup time.
#
# Xtensa ESP targets are NOT listed here — they use the self-contained `esp`
# toolchain installed out-of-band by `espup` (see `just setup`). That
# 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)
@@ -22,3 +22,25 @@ just = "latest"
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"
+4 -2
View File
@@ -70,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()`.
}
```
+2 -2
View File
@@ -48,8 +48,8 @@ pub struct DisplayParts {
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
let mut display = Sh1106::new(i2c, 0x3C);
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() {
+2 -2
View File
@@ -47,13 +47,13 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
>;
/// Construct and initialize an SSD1306 display from raw I²C.
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
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();
+2 -2
View File
@@ -63,13 +63,13 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
feature = "heltec_v3_uart",
feature = "heltec_v4"
))]
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
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();
+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(),
}
}
}
+2 -2
View File
@@ -50,8 +50,8 @@ pub struct DisplayParts {
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
let mut display = Sh1106::new(i2c, 0x3C);
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() {
+34 -20
View File
@@ -22,11 +22,18 @@ pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
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 ──────────────────────────────────────────────────────
+5 -7
View File
@@ -6,10 +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::driver::DisplayBrightness;
use crate::display::BoardDisplay;
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
pub trait RgbLed {
@@ -37,9 +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
/// and DisplayBrightness for the two-level dim control).
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
/// 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;
+5 -1
View File
@@ -41,12 +41,16 @@ pub struct UsbParts {
}
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(_i2c: DisplayI2c) -> Option<DisplayDriver> {
pub async fn create_display(_parts: DisplayParts) -> Option<DisplayDriver> {
None
}
+2 -2
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() {
+67 -3
View File
@@ -9,9 +9,66 @@
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use portable_atomic::AtomicBool;
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
/// 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.
@@ -42,9 +99,16 @@ pub struct OutboundFrame {
pub msg: DeviceMessage,
}
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
/// RX events + queued command responses without stalling the radio ISR).
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
/// 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) ────────────
+275 -111
View File
@@ -38,18 +38,79 @@ use hsmc::statechart;
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
use crate::driver::DisplayBrightness;
use crate::protocol::LoRaConfig;
use render::{BoardInfo, RSSI_HISTORY_LEN};
const BOARD_NAME: &str = Board::NAME;
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Number of slots in the dashboard's RSSI/TX sparkline ring buffer.
pub const RSSI_HISTORY_LEN: usize = 128;
// Mono splash board-name font selection. The mono OLEDs we drive are
// 128 px wide; the primary font (FONT_6X10, 6 px advance) fits up to 21
// ASCII chars across, the fallback (FONT_5X8, 5 px advance) fits up to
// 25. If even the fallback can't fit on a mono OLED, fail at compile
// time so the board porter shortens NAME rather than silently
// overflowing the splash. Color renderers have their own size budget
// and simply use the same NAME with their own font choice.
const SPLASH_SCREEN_W_PX: u32 = 128;
const SPLASH_FONT_PRIMARY_W_PX: u32 = 6;
const SPLASH_FONT_FALLBACK_W_PX: u32 = 5;
pub(crate) const BOARD_NAME_FITS_PRIMARY_FONT: bool =
(BOARD_NAME.len() as u32) * SPLASH_FONT_PRIMARY_W_PX <= SPLASH_SCREEN_W_PX;
const _: () = assert!(
(BOARD_NAME.len() as u32) * SPLASH_FONT_FALLBACK_W_PX <= SPLASH_SCREEN_W_PX,
"Board::NAME is too long for the 128 px mono splash screen even with the FONT_5X8 fallback \
(max 25 ASCII chars). Shorten the NAME constant in src/board/<board>.rs.",
);
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
const NO_SIGNAL: i16 = -121;
/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash
/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is
/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so when
/// drawn with `BinaryColor::On` = lit, the QR appears with the
/// traditional dark-on-light look including a proper quiet zone. The
/// color renderer remaps bit 1 → white, bit 0 → black for the same
/// visual contrast on a black-background TFT.
pub(crate) const QR_WIDTH_PX: u32 = 64;
pub(crate) const QR_BITMAP: [u8; 512] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE,
0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE,
0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE,
0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE,
0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE,
0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE,
0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE,
0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE,
0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE,
0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF,
0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF,
0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFF,
0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE,
0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE,
0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE,
0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE,
0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF,
0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE,
0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE,
0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE,
0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE,
0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE,
0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE,
0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE,
0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
];
/// Which dashboard badge is in view (drives the RX/TX indicator only).
#[derive(Debug, Clone, Copy)]
pub enum Badge {
@@ -57,6 +118,136 @@ pub enum Badge {
Tx,
}
// ── Display content types (shared across all renderers) ───────────────
//
// `BoardInfo` and `DashboardCtx` describe *what* to draw on the splash
// and dashboard screens. Each board's `BoardDisplay` impl decides *how*
// to render that content for its native pixel format and resolution.
/// Static-ish board identity rendered on the splash + dashboard.
pub struct BoardInfo<'a> {
pub name: &'a str,
pub version: &'a str,
pub mac: &'a str,
}
/// Live dashboard data at the moment of a render call.
pub struct DashboardCtx<'a> {
pub badge: Badge,
pub config: Option<LoRaConfig>,
pub rx_count: u32,
pub tx_count: u32,
pub last_rssi: Option<i16>,
pub last_snr: Option<i16>,
pub rssi_history: &'a [i16; RSSI_HISTORY_LEN],
pub tx_history: &'a [bool; RSSI_HISTORY_LEN],
pub rssi_count: usize,
pub current_slot_rssi: i16,
pub current_slot_tx: bool,
pub board: &'a BoardInfo<'a>,
}
// ── BoardDisplay trait ────────────────────────────────────────────────
//
// One logical display surface, four content states (splash×2, dashboard,
// blank), plus brightness control and a flush hook. Each board's display
// driver implements this directly — color depth, resolution, dimming
// strategy, and any framebuffer flushing live behind the trait.
/// Single trait every board's display driver implements. The display
/// task talks to the trait; the per-driver impl handles native format.
///
/// `present()` is named distinctly from any inherent `flush()` method on
/// concrete display types so method resolution doesn't get ambiguous.
pub trait BoardDisplay {
fn present(&mut self) -> impl core::future::Future<Output = ()>;
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
fn render_splash_learn_more(&mut self);
fn render_splash_info(&mut self, info: &BoardInfo<'_>);
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>);
fn render_blank(&mut self);
}
// Color renderer module — used by `crate::driver::st7789::St7789Color` on
// the Heltec T114 to draw native 240×135 Rgb565 dashboards/splash screens.
// Gated to the T114 feature so other boards don't pay for the imports.
#[cfg(feature = "heltec_mesh_node_t114")]
pub mod render_color;
// Shared QR-code blit, generic over color. Used by both the mono and
// color splash screens to render the bitmap with proper quiet zones.
mod qr;
// ── Mono-OLED adapters ────────────────────────────────────────────────
//
// SH1106 + SSD1306Async both render `BinaryColor` content into a 128×64
// framebuffer flushed via I2C. Their `BoardDisplay` impls all delegate
// to `mod render` — the existing pixel-perfect mono renderer.
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
impl<I> BoardDisplay for crate::driver::sh1106::Sh1106<I>
where
I: embedded_hal_async::i2c::I2c,
{
async fn present(&mut self) {
let _ = crate::driver::sh1106::Sh1106::flush(self).await;
}
async fn set_bright(&mut self) {
crate::driver::DisplayBrightness::set_bright(self).await;
}
async fn set_dim(&mut self) {
crate::driver::DisplayBrightness::set_dim(self).await;
}
fn render_splash_learn_more(&mut self) {
render::learn_more(self);
}
fn render_splash_info(&mut self, info: &BoardInfo<'_>) {
render::info(self, info);
}
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) {
render::dashboard(self, ctx);
}
fn render_blank(&mut self) {
render::blank(self);
}
}
impl<DI, SIZE> BoardDisplay
for ssd1306::Ssd1306Async<DI, SIZE, ssd1306::mode::BufferedGraphicsModeAsync<SIZE>>
where
DI: display_interface::AsyncWriteOnlyDataCommand,
SIZE: ssd1306::size::DisplaySizeAsync,
{
async fn present(&mut self) {
let _ = ssd1306::Ssd1306Async::flush(self).await;
}
async fn set_bright(&mut self) {
crate::driver::DisplayBrightness::set_bright(self).await;
}
async fn set_dim(&mut self) {
crate::driver::DisplayBrightness::set_dim(self).await;
}
fn render_splash_learn_more(&mut self) {
render::learn_more(self);
}
fn render_splash_info(&mut self, info: &BoardInfo<'_>) {
render::info(self, info);
}
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) {
render::dashboard(self, ctx);
}
fn render_blank(&mut self) {
render::blank(self);
}
}
/// Fine-grained events driving the `Display` statechart.
#[derive(Debug, Clone)]
pub enum DisplayEvent {
@@ -164,14 +355,14 @@ impl DisplayContext {
version: VERSION,
mac: mac_str,
};
render::info(display, &info);
let _ = display.flush().await;
display.render_splash_info(&info);
display.present().await;
}
async fn flush_learn_more(&mut self) {
let display = &mut self.peripherals.display;
render::learn_more(display);
let _ = display.flush().await;
display.render_splash_learn_more();
display.present().await;
}
async fn flush_dashboard(&mut self, badge: Badge) {
@@ -183,27 +374,28 @@ impl DisplayContext {
version: VERSION,
mac: mac_str,
};
render::dashboard(
display,
let ctx = DashboardCtx {
badge,
self.run.config,
self.run.rx_count,
self.run.tx_count,
self.run.last_rssi,
self.run.last_snr,
&self.run.rssi_history,
&self.run.tx_history,
self.run.rssi_count,
self.run.current_slot_rssi,
self.run.current_slot_tx,
&info,
);
let _ = display.flush().await;
config: self.run.config,
rx_count: self.run.rx_count,
tx_count: self.run.tx_count,
last_rssi: self.run.last_rssi,
last_snr: self.run.last_snr,
rssi_history: &self.run.rssi_history,
tx_history: &self.run.tx_history,
rssi_count: self.run.rssi_count,
current_slot_rssi: self.run.current_slot_rssi,
current_slot_tx: self.run.current_slot_tx,
board: &info,
};
display.render_dashboard(&ctx);
display.present().await;
}
async fn flush_blank(&mut self) {
render::blank(&mut self.peripherals.display);
let _ = self.peripherals.display.flush().await;
let display = &mut self.peripherals.display;
display.render_blank();
display.present().await;
}
// Boards without an LED use `LedDriver = ()`; the `RgbLed for ()` impl
@@ -237,6 +429,8 @@ statechart! {
Display {
context: DisplayContext;
events: DisplayEvent;
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
trace;
default(On);
state Off {
@@ -435,7 +629,7 @@ pub async fn display_task(
),
);
let Some(display) = board::create_display(parts.i2c).await else {
let Some(display) = board::create_display(parts).await else {
defmt::error!("display init failed, running LED-only loop");
loop {
match display_commands.receive().await {
@@ -489,7 +683,7 @@ pub async fn display_task(
mod render {
use core::fmt::Write;
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD};
use embedded_graphics::mono_font::ascii::{FONT_5X8, FONT_6X10, FONT_9X15_BOLD};
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
@@ -498,10 +692,8 @@ mod render {
use embedded_graphics::text::{Alignment, Text};
use heapless::String;
use super::Badge;
use crate::protocol::{LoRaBandwidth, LoRaConfig};
pub const RSSI_HISTORY_LEN: usize = 128;
use super::{Badge, BoardInfo, DashboardCtx, QR_WIDTH_PX, RSSI_HISTORY_LEN};
use crate::protocol::LoRaBandwidth;
const CHAR_W: i32 = 6;
const FONT_H: i32 = 10;
@@ -510,72 +702,20 @@ mod render {
const RSSI_MIN: i16 = -120;
const RSSI_MAX: i16 = 0;
/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash
/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is
/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so
/// when drawn with `BinaryColor::On` = lit, the QR appears with the
/// traditional dark-on-light look including a proper quiet zone.
const QR_WIDTH_PX: u32 = 64;
const QR_BITMAP: [u8; 512] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F,
0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6,
0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60,
0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE,
0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F,
0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06,
0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67,
0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66,
0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x00, 0x60,
0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, 0x01,
0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF,
0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F,
0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60,
0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79,
0x99, 0xFF, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80,
0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E,
0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06,
0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF,
0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE,
0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F,
0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E,
0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00,
0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01,
0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x60, 0x66, 0x67,
0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x7F, 0xE6,
0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, 0x00,
0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF,
];
pub struct BoardInfo<'a> {
pub name: &'a str,
pub version: &'a str,
pub mac: &'a str,
}
#[allow(clippy::too_many_arguments)]
pub fn dashboard(
target: &mut impl DrawTarget<Color = BinaryColor>,
badge: Badge,
config: Option<LoRaConfig>,
rx_count: u32,
tx_count: u32,
last_rssi: Option<i16>,
last_snr: Option<i16>,
rssi_history: &[i16; RSSI_HISTORY_LEN],
tx_history: &[bool; RSSI_HISTORY_LEN],
rssi_count: usize,
current_slot_rssi: i16,
current_slot_tx: bool,
board: &BoardInfo,
) {
pub fn dashboard(target: &mut impl DrawTarget<Color = BinaryColor>, ctx: &DashboardCtx<'_>) {
let badge = ctx.badge;
let config = ctx.config;
let rx_count = ctx.rx_count;
let tx_count = ctx.tx_count;
let last_rssi = ctx.last_rssi;
let last_snr = ctx.last_snr;
let rssi_history = ctx.rssi_history;
let tx_history = ctx.tx_history;
let rssi_count = ctx.rssi_count;
let current_slot_rssi = ctx.current_slot_rssi;
let current_slot_tx = ctx.current_slot_tx;
let board = ctx.board;
let bb = target.bounding_box();
let w = bb.size.width as i32;
let h = bb.size.height as i32;
@@ -769,14 +909,31 @@ mod render {
.draw(target)
.ok();
Text::with_alignment(
board.name,
Point::new(center_x, 28),
style,
Alignment::Center,
)
.draw(target)
.ok();
// Board-name font picked at compile time by
// BOARD_NAME_FITS_PRIMARY_FONT (see top of display.rs). If the
// name fits FONT_6X10 we use it; otherwise we fall back to
// FONT_5X8. The const_assert at the BOARD_NAME definition
// rejects names that overflow even the fallback.
if super::BOARD_NAME_FITS_PRIMARY_FONT {
Text::with_alignment(
board.name,
Point::new(center_x, 28),
style,
Alignment::Center,
)
.draw(target)
.ok();
} else {
let small_style = MonoTextStyle::new(&FONT_5X8, BinaryColor::On);
Text::with_alignment(
board.name,
Point::new(center_x, 28),
small_style,
Alignment::Center,
)
.draw(target)
.ok();
}
Text::with_alignment(
board.mac,
@@ -798,28 +955,35 @@ mod render {
}
/// Alternate splash screen: 64 × 64 pixel-perfect QR bitmap on the
/// right (with its own quiet zone baked in), centered text column on
/// the left inviting the user to visit the URL.
/// right (centered within its quiet zone via `super::qr::draw`),
/// centered text column on the left inviting the user to visit the URL.
pub fn learn_more(target: &mut impl DrawTarget<Color = BinaryColor>) {
use embedded_graphics::image::{Image, ImageRaw};
use embedded_graphics::mono_font::ascii::{FONT_5X8, FONT_7X14_BOLD};
use embedded_graphics::mono_font::ascii::{FONT_4X6, FONT_7X14_BOLD};
let bb = target.bounding_box();
let w = bb.size.width as i32;
let _ = target.clear(BinaryColor::Off);
// ── QR code: flush to the right edge, 1:1 pixel rendering ───
// ── QR code: rightmost 64 × 64 region, centered cells ───────
let qr_x = w - QR_WIDTH_PX as i32;
let qr_raw: ImageRaw<BinaryColor> = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX);
Image::new(&qr_raw, Point::new(qr_x, 0)).draw(target).ok();
super::qr::draw(
target,
Point::new(qr_x, 0),
BinaryColor::On,
BinaryColor::Off,
);
// ── Text column (everything left of the QR) ─────────────────
let col_w = qr_x;
let col_center = col_w / 2;
let title_style = MonoTextStyle::new(&FONT_7X14_BOLD, BinaryColor::On);
let text_style = MonoTextStyle::new(&FONT_5X8, BinaryColor::On);
// FONT_4X6 (4 px advance) on the URL line so "donglora.com"
// (12 chars × 4 = 48 px) fits the 64 px left column with ~8 px
// breathing room either side, instead of FONT_5X8's 60 px which
// visually touches both edges.
let text_style = MonoTextStyle::new(&FONT_4X6, BinaryColor::On);
// Layout on a 64 px display:
// blank
+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);
}
}
+9 -3
View File
@@ -1,17 +1,22 @@
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
pub mod sh1106;
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
pub mod axp;
#[cfg(feature = "lilygo_tbeam")]
#[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",
@@ -67,7 +72,8 @@ where
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
feature = "lilygo_tbeam_supreme",
feature = "rak_wisblock_4631"
))]
impl<I> DisplayBrightness for sh1106::Sh1106<I>
where
+18
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,6 +79,8 @@ 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
@@ -81,6 +91,12 @@ impl<I: I2c> Sh1106<I> {
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,6 +104,8 @@ 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
}
}
+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));
}
}
+5 -1
View File
@@ -15,7 +15,11 @@ pub mod esp32s3;
#[cfg(feature = "lilygo_tbeam")]
pub mod esp32;
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
#[cfg(any(
feature = "heltec_mesh_node_t114",
feature = "rak_wisblock_4631",
feature = "wio_tracker_l1"
))]
pub mod nrf52840;
#[cfg(feature = "waveshare_rp2040_lora")]
+84
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>;
@@ -37,6 +49,8 @@ pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRaw
///
/// 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])
@@ -56,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.
+28 -3
View File
@@ -54,7 +54,16 @@ pub async fn dispatch_frame(
}
/// 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),
@@ -106,15 +115,31 @@ pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) ->
) {
Ok(n) => Some(n),
Err(FrameEncodeError::BufferTooSmall) => {
warn!("encode_frame: output buffer too small");
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");
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");
warn!(
"encode_frame: COBS encode refused (type={=u8}, tag={=u16}, payload={=usize}B)",
frame.msg.type_id(),
frame.tag,
payload_len,
);
None
}
}
+83 -35
View File
@@ -4,7 +4,7 @@
//! 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;
@@ -34,25 +34,32 @@ pub async fn host_task(
display_commands.send(DisplayCommand::On).await;
}
let mut read_buf = [0u8; READ_BUF];
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
let mut connected = true;
// 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::{select3, Either3};
loop {
use embassy_futures::select::{select, Either};
match select3(
embedded_io_async::Read::read(&mut rx, &mut read_buf),
outbound.receive(),
embassy_time::Timer::after(Duration::from_millis(100)),
)
.await
{
Either3::First(result) => match result {
Ok(0) => continue,
Ok(n) => {
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 {
@@ -62,31 +69,72 @@ pub async fn host_task(
}
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
}
Err(_) => {
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;
}
},
Either3::Second(frame) => {
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
if tx.write_all(&write_buf[..n]).await.is_err() {
warn!("UART write failed, frame dropped");
Either::Second(()) => {}
}
// 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);
}
}
Either3::Third(()) => {}
}
};
// Inactivity watchdog.
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);
}
connected = false;
}
}
embassy_futures::join::join(read_loop, write_loop).await;
}
async fn dispatch_bytes(
+201 -41
View File
@@ -2,24 +2,35 @@
//!
//! ```text
//! Host
//! ├── Disconnected (default) no DTR, or inactivity timer fired
//! └── Connected host attached, liveness OK
//! entry: wake_display, start_liveness_timer
//! exit: reset_radio, reset_display, stop_liveness_timer
//! ├── 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.
//! ```
//!
//! The statechart tracks one state bit: "is a host present?". DTR drives
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
//! the disconnect edge when DTR is held high but no bytes arrive.
//! 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` for the same reason the radio's
//! `rx_once` stayed out of its chart: `read_packet` blocks.
//! imperative in `protocol_loop` because `read_packet` blocks.
use defmt::warn;
use embassy_executor::task;
use embassy_futures::join::join;
use embassy_time::{Duration, Instant};
use embassy_time::Duration;
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
use embassy_usb::Builder;
use hsmc::{statechart, Duration as HsmcDuration};
@@ -35,17 +46,24 @@ 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 / inactivity check.
/// 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,
/// Host went silent for >1 s; kick the radio back to Unconfigured.
InactivityTimeout,
/// 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 {
@@ -68,8 +86,22 @@ impl HostContext {
}
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() {
warn!("radio Reset inject: command queue full");
// 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);
}
}
}
@@ -78,6 +110,8 @@ statechart! {
Host {
context: HostContext;
events: HostInput;
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
trace;
default(Disconnected);
state Disconnected {
@@ -88,7 +122,28 @@ statechart! {
entry: wake_display;
exit: reset_radio, reset_display;
on(DtrDropped) => Disconnected;
on(InactivityTimeout) => 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;
}
}
}
}
@@ -133,20 +188,34 @@ 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);
// 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();
@@ -186,8 +255,6 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
loop {
match select3(
receiver.read_packet(&mut read_buf),
@@ -196,52 +263,145 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
)
.await
{
Either3::First(Ok(0)) => {}
Either3::First(Ok(0)) => {
defmt::info!("host_task: read_packet returned 0 bytes");
}
Either3::First(Ok(n)) => {
last_frame_at = Instant::now();
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(_)) => {
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) {
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
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, frame 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(()) => {}
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.
}
}
// DTR edge detection — drives Connected / Disconnected transitions.
let connected = receiver.dtr();
let was_connected = matches!(machine.current_state(), HostState::Connected);
match (was_connected, connected) {
// 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;
last_frame_at = Instant::now();
}
(true, false) => {
defmt::info!("host_task: DTR dropped");
decoder.reset();
let _ = machine.dispatch(HostInput::DtrDropped).await;
}
_ => {}
}
// Inactivity watchdog (spec §3.4): while Connected, if no frame
// arrived in the last INACTIVITY_MS, drive the session back to
// Disconnected.
if matches!(machine.current_state(), HostState::Connected)
&& last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS)
{
warn!("host inactivity timeout fired");
let _ = machine.dispatch(HostInput::InactivityTimeout).await;
}
}
}
+14 -2
View File
@@ -46,12 +46,15 @@ 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", feature = "waveshare_rp2040_lora"))] {
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", 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!();
}
}
@@ -68,7 +71,7 @@ static mut INFO: Option<Info> = None;
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
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;
@@ -84,6 +87,15 @@ 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();
// Build the GET_INFO struct once at boot. This is the authoritative
+1680 -328
View File
File diff suppressed because it is too large. Load diff