Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c6e74f26f
|
||
|
|
732bd18ac3
|
||
|
|
f7bac5903c
|
||
|
|
b56c044d57
|
||
|
|
f68e5d9be5
|
||
|
|
eb8202058f
|
||
|
|
1c98786ce6
|
||
|
|
16374ca241
|
||
|
|
e5aeb3896a
|
||
|
|
276f941fbb
|
||
|
|
481712930c
|
||
|
|
d262abe853
|
||
|
|
23ddc15557
|
||
|
|
4d723a85ce
|
||
|
|
6380bcbf6a
|
||
|
|
7a045edc1a
|
||
|
|
b53570ce66
|
||
|
|
647929ff8d
|
||
|
|
0fbd8412c2
|
||
|
|
ebd576b4e2
|
||
|
|
a2c611841b
|
||
|
|
a1b92356b3
|
||
|
|
7fca5fcb50
|
||
|
|
6867439044
|
||
|
|
bcfe736b30
|
||
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
||
|
|
a0b15ddcaa
|
||
|
|
388d07e90f
|
||
|
|
c6ef9559ec
|
||
|
|
596bc3ed96
|
||
|
|
272abceedb
|
||
|
|
f9582c0b94
|
||
|
|
649c29218c
|
No files matched your search
File diff suppressed because it is too large.
Load diff
+444
@@ -7,6 +7,450 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.1] - 2026-04-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Major LoRa reliability boost across every radio family.** Switched
|
||||
`lora-phy` from crates.io to the
|
||||
[`swaits/lora-rs#fixed`](https://github.com/swaits/lora-rs/tree/fixed)
|
||||
fork branch — a merge of three fixes awaiting upstream PR acceptance,
|
||||
pinned at `e8a52ebd` via `Cargo.lock`:
|
||||
- [#428](https://github.com/lora-rs/lora-rs/pull/428) — sx126x:
|
||||
pre-command BUSY check + standby/IRQ-clear hygiene around CAD.
|
||||
- [#431](https://github.com/lora-rs/lora-rs/pull/431) — sx127x:
|
||||
clear stale IRQ flags in `do_rx` before mode change.
|
||||
- [#432](https://github.com/lora-rs/lora-rs/pull/432) — sx127x:
|
||||
clear stale IRQ flags before reconfiguring DIO mapping in
|
||||
`set_irq_params`.
|
||||
|
||||
Stability gain across the fleet: the sx126x BUSY-race fix lands on
|
||||
every nRF / RP2040 board, and the sx127x IRQ-clear fixes unblock the
|
||||
LilyGo T-Beam (classic) in particular.
|
||||
- **UART host RX delivery on UART-only boards.** `RADIO_THROTTLED` was
|
||||
never cleared on UART (`heltec_v3_uart`, `elecrow_thinknode_m2`,
|
||||
`lilygo_tbeam`) — the throttle gate added in v1.3.0's chart-first
|
||||
restructure silently dropped every async RX from boot. Mirrored the
|
||||
USB pattern: clear the throttle on successful `tx.write_all`, re-arm
|
||||
on failure.
|
||||
- **UART host watchdog spurious-trip under saturated bidirectional
|
||||
load.** The `select3`-based loop serialised read and write;
|
||||
`tx.write_all` (~25 ms per max frame at 115200 baud) blocked the read
|
||||
poll long enough for `last_frame_at` to age past 1000 ms, cascading a
|
||||
Reset to radio (`Unconfigured`) and display (splash) and leaving
|
||||
subsequent host TX commands timing out. Restructured `host_task` into
|
||||
two sub-loops joined in the same task: `read_loop` owns rx +
|
||||
watchdog, `write_loop` owns tx + throttle. Same executor, disjoint
|
||||
borrows on the split rx/tx halves, no Mutex needed. Also reset the
|
||||
`FrameDecoder` on watchdog fire so a host that resumes mid-frame
|
||||
resyncs cleanly on the next `0x00` sentinel (USB gets this for free
|
||||
via DTR-drop reset; UART has no DTR).
|
||||
- **Splash board-name overflow on long names.** Names that don't fit
|
||||
`FONT_6X10` (21 ASCII chars on a 128 px-wide OLED) now fall back to
|
||||
`FONT_5X8` (25 chars). A `const_assert` at the `BOARD_NAME`
|
||||
definition rejects names that overflow even the fallback, so the
|
||||
board porter shortens `NAME` at compile time rather than getting
|
||||
silent overflow at runtime. Also dropped the URL-line font on the
|
||||
"learn more" screen from `FONT_5X8` to `FONT_4X6` so `donglora.com`
|
||||
sits inside the 64 px column with breathing room instead of touching
|
||||
both edges.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`hsmc` 0.4 → 0.5.** `default(...)` is optional on every state in
|
||||
0.5; collapsed the `WaitingForFirstFrame` substate that existed only
|
||||
because 0.4 required `Connected` to declare a default child.
|
||||
`Connected` itself is now the resting "DTR up, no traffic yet" state,
|
||||
with `on(FrameReceived) => Active` descending into the
|
||||
inactivity-timer state on the first byte. Behavior is identical
|
||||
(entry/exit chain math unchanged); the host/usb chart has one fewer
|
||||
state and one fewer `default()`. Display and Radio charts left as-is
|
||||
— their defaults serve real grouping/lifecycle purposes, not 0.4
|
||||
artifacts.
|
||||
- **ESP cross-toolchain pinned in `mise.toml`.** `[env]` now owns the
|
||||
PATH (`xtensa-esp-elf-gcc` bin) and `LIBCLANG_PATH`, so any `mise
|
||||
exec` resolves the linker without sourcing espup's stray
|
||||
`~/export-esp.sh`. New `[tasks.esp-install]` invokes espup with the
|
||||
matching crosstool version pin. `cargo:espup` pinned to `0.17.1` to
|
||||
match the `[env]` paths. Triggered by a real failure where
|
||||
`~/export-esp.sh` was moved to Trash and the ESP build broke with
|
||||
`linker xtensa-esp32s3-elf-gcc not found`. Bumping ESP versions
|
||||
remains a two-touch change: edit version dirs in BOTH
|
||||
`[tasks.esp-install]` and `[env]`.
|
||||
- **ESP dependency requirements bumped** to track the upstream
|
||||
`esp-rs/esp-hal` main branch: `esp-hal 1.0 → 1.1`, `esp-rtos 0.2 →
|
||||
0.3`, `esp-backtrace 0.18 → 0.19`, `esp-println 0.16 → 0.17`. `cargo
|
||||
update` had silently invalidated the `[patch.crates-io]` entries (the
|
||||
patched versions were no longer semver-compatible with the old
|
||||
top-level requirements), dropping the patches and falling back to
|
||||
crates.io releases that pin `embassy-executor 0.9` — mismatched with
|
||||
our `0.10`. Catching this keeps the patch suite consistent until the
|
||||
planned `[patch.crates-io]`-removal pass.
|
||||
|
||||
## [1.4.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- **RAK4631 WisBlock board support** (nRF52840 + SX1262, RAK19007 base
|
||||
+ RAK4631 Core + RAK1921 OLED). The second nRF52840 + SX1262 board
|
||||
alongside the Wio Tracker L1.
|
||||
- `SX126X_POWER_EN` (P1.05) driven HIGH at init — the radio is
|
||||
power-gated on this module; without this the chip has no power
|
||||
and SPI hangs.
|
||||
- HFXO + LFXO external crystals selected in `embassy_nrf::Config`.
|
||||
USB CDC-ACM needs ±500 ppm which the internal RC oscillator
|
||||
can't provide.
|
||||
- `SoftwareVbusDetect(true, true)` so USB enumerates without a
|
||||
`CLOCK_POWER` interrupt handler.
|
||||
- Green LED on P1.03 (RAK19007 Base LED1, active-HIGH) via
|
||||
`SimpleLed`.
|
||||
- Hardware-validated end-to-end: ADVERT round-trip with an external
|
||||
receiver on US915.
|
||||
- Added `debug-checkpoint` blink-map coverage at every TX-phase
|
||||
boundary in `radio.rs`, including a new checkpoint between
|
||||
`prepare_for_cad` and `cad()` so future bring-ups can distinguish
|
||||
a modem-setup hang from a DIO1-wait hang.
|
||||
|
||||
- **Adaptive listen-before-talk.** The CAD pre-TX retry loop scales to
|
||||
the active LoRa config instead of running a fixed 10 retries × 10 ms
|
||||
(which was roughly one max-length packet at SF7/BW500 and a no-op at
|
||||
SF12/BW125).
|
||||
- Per-retry sleep is sized to the time-on-air of an assumed-peer
|
||||
80-byte packet under the current SF/BW/CR — cached on
|
||||
`RadioContext`, recomputed on every `SET_CONFIG`.
|
||||
- Retry count = `clamp(floor(BUDGET/T)+1, 1, 10)` with `BUDGET = 2.5
|
||||
s`. Fast configs still get up to 10 retries; SF11/12 degrade to
|
||||
single-CAD-and-bail (logged at config-apply).
|
||||
- Sleeps jittered ±20% per iteration to decorrelate
|
||||
simultaneously-backing-off contenders.
|
||||
- ToA math, CR→{1..4} mapping, retry-count derivation, and jitter
|
||||
helper live in a pure `src/lbt.rs` module — host-testable via
|
||||
`DONGLORA_HOST_TEST=1 cargo test` (13 tests).
|
||||
- Validated on-device: 20 iterations of the AI Bot test flow that
|
||||
previously saw ~2 `ChannelBusy` errors now sees zero at the same
|
||||
SF/BW.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **First-TX-after-boot reliability on SX1262 boards.** Picked up the
|
||||
upstream `lora-phy` fix that moves the BUSY-handshake check from
|
||||
*after* to *before* each SPI op, per Semtech SX1261/2 §8.3.1, LR1121
|
||||
UM §3, and LR1110 DS §1.2.5. The existing post-op wait is preserved
|
||||
as the belt that closes the BUSY-assertion latency window
|
||||
(T_SW ≤ 600 ns) that a single level-poll can miss. Consumed via
|
||||
`[patch.crates-io]` against `swaits/lora-rs#fix/sx126x-busy-race`
|
||||
until the upstream PR merges.
|
||||
- Hardware-verified on Seeed Wio Tracker L1: 20/20 clean-boot TX
|
||||
(vs ~50% before).
|
||||
- Renames `InterfaceVariant::wait_on_busy` → `wait_until_ready` to
|
||||
match the new semantics.
|
||||
- **BUSY pin pull-down → none on nRF SX1262 boards.** SX1262's BUSY
|
||||
pin has an internal 20 kΩ pull-up (datasheet p. 53); our
|
||||
`Input::new(..., Pull::Down)` was contending with it, leaving BUSY
|
||||
at an indeterminate voltage and intermittently mis-reading the
|
||||
chip's ready state. Per `lora-rs/lora-rs#260`, switched to
|
||||
`Pull::None` on both Wio Tracker L1 and RAK4631. The RAK4631
|
||||
appeared to work before, but was affected by the same class of bug
|
||||
per the upstream issue — now deterministically correct rather than
|
||||
"happens to work".
|
||||
|
||||
### Changed
|
||||
|
||||
- **Build-system DX.** `just flash`, `just flash-probe`, and
|
||||
`just build` accept an optional comma-separated `extra_features`
|
||||
argument that is appended to the board feature, e.g. `just flash
|
||||
wio_tracker_l1 debug-checkpoint`. No more dropping to raw
|
||||
`cargo + rust-objcopy + uf2 cp` for iteration loops.
|
||||
- **`just` recipes now resolve all tools through `mise`** via
|
||||
`set shell := ["mise", "exec", "--", "sh", "-c"]`. `cargo`,
|
||||
`espflash`, `rust-objcopy`, `cargo-hex-to-uf2` etc. always pick up
|
||||
the versions pinned in `mise.toml` regardless of `PATH` ordering.
|
||||
- **`mise.toml` rust pins now carry ARM targets + clippy**, so a fresh
|
||||
`mise install` is sufficient bootstrapping — no manual
|
||||
`rustup target add` for `thumbv6m-none-eabi` /
|
||||
`thumbv7em-none-eabihf` and no separate clippy install.
|
||||
- **New `just flash-dfu` recipe** for the Adafruit nRF52 bootloader
|
||||
(VID:PID `239a:0029`) via `adafruit-nrfutil` under `uvx`, with port
|
||||
autodetect.
|
||||
- **Per-board `_info` chain in the justfile** collapsed from a long
|
||||
per-board `if/elif` ladder + per-board `:=` variables into a single
|
||||
`case` statement — one place per board, two lists (names +
|
||||
metadata).
|
||||
|
||||
## [1.3.1] - 2026-04-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **LilyGo T-Beam (classic) red LED now flashes on TX/RX** instead of
|
||||
dimming off. The red LED under the OLED is wired active-LOW on this
|
||||
board (anode to V+, cathode to GPIO4); v1.3.0 drove it as active-HIGH,
|
||||
so the LED sat lit at idle and briefly dimmed during activity.
|
||||
- Empirically verified on hardware against both T-Beam variants.
|
||||
- Introduces `src/driver/inverted_pin.rs::InvertedPin<P>`, a generic
|
||||
`OutputPin` wrapper that swaps `set_high`/`set_low`. Polarity is
|
||||
expressed at the pin (where the physical wiring lives), so
|
||||
`SimpleLed` stays polarity-agnostic and every other board's code
|
||||
path is byte-for-byte unchanged.
|
||||
- Classic T-Beam now constructs its LED as
|
||||
`SimpleLed(InvertedPin(led_pin))` with pin init at `Level::High`
|
||||
(dark at boot).
|
||||
|
||||
## [1.3.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- LilyGo T-Beam S3 Supreme board support (ESP32-S3 + SX1262 + SH1106
|
||||
OLED + AXP2101 PMIC, native USB-C).
|
||||
- Pin map verified against MeshCore's
|
||||
`variants/lilygo_tbeam_supreme_SX1262`.
|
||||
- New `src/driver/axp.rs` module with an AXP2101 init sequence
|
||||
(ALDO3 = 3.3 V LoRa, ALDO1 = 3.3 V OLED). The PMIC gates LoRa and
|
||||
display power — without it the SX1262 never responds on SPI.
|
||||
- PMIC lives on a dedicated I²C1 bus (SDA=42, SCL=41), separate
|
||||
from the SH1106 on I²C0 (SDA=17, SCL=18). I²C1 is used blocking
|
||||
for a one-shot init and then dropped.
|
||||
- TCXO driven at 1.6 V via DIO3 (MeshCore uses the same).
|
||||
- LilyGo T-Beam (classic) board support (ESP32 + SX1276 + SSD1306
|
||||
OLED + AXP192 PMIC, CP2102 USB-UART bridge). This is the first
|
||||
board in the firmware on classic ESP32 (non-S3) and the first on
|
||||
the SX127x radio family.
|
||||
- Pin map verified against MeshCore's `variants/lilygo_tbeam_SX1276`.
|
||||
- New `src/hal/esp32.rs` (classic-ESP32 MCU primitives, peripheral
|
||||
DMA via `DMA_SPI2`) and `src/board/esp32.rs` (SX1276 radio helper
|
||||
+ SSD1306 display pipe).
|
||||
- AXP192 init added to `src/driver/axp.rs`: DCDC1 = 3.3 V for the
|
||||
OLED, LDO2 = 3.3 V for the SX1276 PA_BOOST rail. Shares the
|
||||
OLED I²C bus (SDA=21, SCL=22); the I²C instance is built in
|
||||
blocking mode for PMIC init and converted to async for the
|
||||
display task.
|
||||
- SX127x support via lora-phy 3's `Sx127x<Sx1276>` +
|
||||
`GenericSx127xInterfaceVariant` (DIO0 as the sole IRQ; no
|
||||
BUSY line on SX127x). XTAL clocked (no TCXO), `tx_boost = true`
|
||||
for the PA_BOOST path.
|
||||
- `LoRaBoard` trait gains `supported_sf_bitmap()` and
|
||||
`supported_bw_bitmap()` methods with SX126x defaults
|
||||
(`0x1FE0`, `0x03FF`). T-Beam classic overrides SF to `0x1FC0`
|
||||
(SF6–SF12) — the SX1276 has no SF5.
|
||||
- `radio_chip_id()` reports `0x0011` (`RadioChipId::Sx1276`) and
|
||||
`freq_range_hz()` widens to 137 MHz–1020 MHz per the SX1276
|
||||
datasheet.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Cargo.toml`: the `esp32s3` chip feature has been moved out of
|
||||
the `esp-hal` / `esp-rtos` / `esp-backtrace` / `esp-println`
|
||||
dependency lines and into each ESP board's feature list (as
|
||||
`esp-hal/esp32s3`, etc.). This lets `lilygo_tbeam` select the
|
||||
classic `esp32` chip flavour from the same dependency set.
|
||||
|
||||
## [1.2.1] - 2026-04-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Radio no longer drops to Idle mid-RX-session on transient failures.**
|
||||
Extends the v1.1.0 invariant ("`SET_CONFIG` during RX auto-re-arms")
|
||||
to all failure paths that can occur while a client has RX active:
|
||||
- `Receiving.on(RxFailed) => Idle` now transitions to `ResumingRx`
|
||||
instead of `Idle`. A transient `next_packet` error re-arms the chip
|
||||
rather than ending the RX session.
|
||||
- `TransmittingFromRx.on(TransmitFailed) => Idle` now transitions to
|
||||
`ResumingRx`. TX failure during an active RX session returns
|
||||
`TX_FAILED` to the caller and resumes RX, matching the shape of the
|
||||
adjacent `ChannelBusy` handler.
|
||||
- `ResumingRx.on(StartRxFailed) => Idle` is now a bounded retry
|
||||
(5 × 25 ms backoff via a new `bump_resume_retries` action and a
|
||||
`ResumeRxRetry` / `ResumeRxExhausted` pair of internal events).
|
||||
The chip only lands in `Idle` after exhausting the retry budget —
|
||||
which means it's genuinely wedged and needs reconfiguring.
|
||||
|
||||
Visible effect: the OLED dashboard no longer bounces to Splash when
|
||||
`ai-bot` (or any other client that TXes while RX is running) sends a
|
||||
packet. Previously any of the three transitions above would fire
|
||||
`RadioEvent::Idle`, which the display uses as its `Dashboard → Splash`
|
||||
trigger; the radio would land in `Idle` with no client aware enough
|
||||
to re-send `RX_START`, stranding the dashboard until the session was
|
||||
manually torn down and restarted.
|
||||
|
||||
- Removed `announce_rx_failed` helper action: its only purpose was
|
||||
emitting `RadioEvent::Idle` on the now-deleted `Receiving.on(RxFailed)
|
||||
=> Idle` edge. No replacement needed — `ResumingRx` handles recovery
|
||||
silently.
|
||||
|
||||
## [1.2.0] - 2026-04-22
|
||||
|
||||
### Added
|
||||
|
||||
- **OLED auto-dim after inactivity.** The display is bright on any
|
||||
activity — splash entry, mode change into a dashboard leaf, packet
|
||||
RX/TX — and dims after a short idle window. Splash holds bright for
|
||||
10 s, then drops to dim while the LearnMore / Info carousel keeps
|
||||
alternating. The dashboard stays bright for 3 s after the last
|
||||
packet or mode change, then dims in place without changing
|
||||
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
|
||||
bright→bright with no intermediate dim — `set_dim` is fired as an
|
||||
action by the 3 s timer handler rather than as a sibling `exit:`
|
||||
action, so mode-change leaf-crossings never see a flash.
|
||||
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
|
||||
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
|
||||
and the in-tree `Sh1106` driver. Contrast values centralized as
|
||||
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
|
||||
the two levels can be tuned in one place.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/display.rs` statechart restructured:
|
||||
- `Splash` gains `entry: set_bright` plus an action-only
|
||||
`on(after 10s) => set_dim` handler so the splash bright window
|
||||
is time-scoped without disturbing the LM↔Info alternation.
|
||||
- `Dashboard` `Listening` and `Transmitting` each gain a nested
|
||||
`*Bright` substate owning the `entry: set_bright` action. Packet
|
||||
handlers moved into the leaves and target the leaf's `*Bright` —
|
||||
sibling-to-sibling within the leaf restarts the 3 s timer without
|
||||
re-entering the leaf itself (paint / 1 s sparkline timer
|
||||
preserved). The 3 s timeout handler fires `set_dim` as an action
|
||||
and up-transitions to the leaf, parking the machine at the leaf
|
||||
with no active child.
|
||||
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
|
||||
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
|
||||
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
|
||||
would only unwind below `Splash` and park with no active child,
|
||||
leaving the LM↔Info alternation timer dead and the QR frozen on
|
||||
screen.
|
||||
- `LoRaBoard::DisplayDriver` trait bound extended with
|
||||
`+ DisplayBrightness` so brightness is part of the board contract
|
||||
alongside `DrawTarget<Color = BinaryColor>`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
|
||||
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
|
||||
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
|
||||
the CH340K / CH340 VID:PIDs directly instead of falling through
|
||||
to espflash auto-detect.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
|
||||
to an already-active ancestor unwinds only the subtree strictly
|
||||
below the target; no exit/entry on the target; no default descent).
|
||||
|
||||
## [1.1.0] - 2026-04-22
|
||||
|
||||
Small but high-value behavior change: `SET_CONFIG` issued while the
|
||||
radio is in continuous RX no longer drops the radio (and the OLED)
|
||||
back to idle. The radio now auto-re-arms RX with the new modulation
|
||||
once reconfiguration completes, matching DongLoRa Protocol 1.1's
|
||||
§3.5 / §6.3 amendments.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/radio.rs`: inside `state Receiving`, add `on(ConfigApplied)
|
||||
=> ResumingRx` to shadow the parent `Configured` state's
|
||||
`on(ConfigApplied) => Idle` handler. The reconfigure path now
|
||||
runs `apply_set_config` → `ConfigApplied` → `ResumingRx.entry:
|
||||
try_start_rx_hw` → `StartRxOk` → `Receiving` without ever
|
||||
entering `Idle`.
|
||||
- The OLED's `Dashboard → Splash` transition was previously driven
|
||||
by `RadioEvent::Idle` firing on `Configured.Idle.entry`. That
|
||||
event no longer fires during a SET_CONFIG-from-Receiving
|
||||
sequence, so the display stays on Dashboard.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `donglora-protocol` bumped to `1.1.0` (spec-only change; matches
|
||||
the new normative text the firmware now implements).
|
||||
|
||||
### Use-case unlock
|
||||
|
||||
- Scanner / time-division / dynamic-modulation workflows that
|
||||
rotate LoRa parameters mid-RX no longer need to re-issue
|
||||
`RX_START` after every `SET_CONFIG`. One call, radio continues
|
||||
to receive.
|
||||
|
||||
## [1.0.0] - 2026-04-22
|
||||
|
||||
The 1.0 release. Firmware now speaks DongLoRa Protocol v2 end to end,
|
||||
depends on the freshly-published `donglora-protocol` 1.0.0 and `hsmc`
|
||||
0.1.0 from crates.io, and ships GitHub Release binaries for every
|
||||
supported board.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **DongLoRa Protocol v2 wire format** (wire-incompatible with 0.x
|
||||
clients and firmware). Every `type_id`, tag-correlation rule, and
|
||||
field layout follows `PROTOCOL.md` v1.0.
|
||||
- **Host framing replaced.** `src/host/framing.rs` now drives a
|
||||
streaming `FrameDecoder` from `donglora-protocol` and emits
|
||||
`(type_id, tag, payload)` frames; the 0.x COBS-per-response shim is
|
||||
gone.
|
||||
- **Radio task reshape.** `src/radio.rs` emits v2 `TX_DONE` /
|
||||
`SetConfigResult` / `RxPayload` / `RxOrigin` shapes directly; no 0.x
|
||||
translation layer remains.
|
||||
|
||||
### Changed
|
||||
|
||||
- `hsmc` switched from path-dep on the local workspace to
|
||||
`version = "0.1.0"` from crates.io.
|
||||
- `donglora-protocol` switched from path-dep on `../protocol` to
|
||||
`version = "1.0.0"` from crates.io; firmware builds are now
|
||||
reproducible from a clean checkout with no sibling repo required.
|
||||
- `PROTOCOL.md` updated to the DongLoRa Protocol v2 normative spec,
|
||||
kept byte-for-byte in sync with the `donglora-protocol` crate's
|
||||
copy.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Cargo.toml `repository` + `homepage` now point at the canonical
|
||||
`https://github.com/donglora/firmware` (the `swaits/donglora`
|
||||
repo is archived).
|
||||
|
||||
## [0.5.0] - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- BLE soft-mesh multiplexer design document (`BLE_MUX_DESIGN_DOC.md`) —
|
||||
specification for turning a dongle from a 1:1 USB device into a shared
|
||||
near-field radio gateway for multiple BLE-attached phones. Three-phase
|
||||
rollout (v1 MVP, v2 hardening, v3 multi-dongle mesh) with regulatory
|
||||
duty-cycle and TX-access-control enforcement from v1. Targets nRF52840
|
||||
first; ESP32-S3 reserved for v2.
|
||||
- `debug-checkpoint` cargo feature with a dedicated `debug_blink` task and
|
||||
a latching atomic checkpoint — LED-pattern diagnostic for UART-only
|
||||
boards where defmt output would corrupt the host protocol. Zero
|
||||
footprint when the feature is off.
|
||||
|
||||
### Changed
|
||||
|
||||
- Radio task rewritten as an `hsmc` statechart (`Unconfigured` /
|
||||
`Configured.{Idle, StartingRx, Receiving, ResumingRx,
|
||||
Transmitting.{FromIdle, FromRx}}`). The main loop is now a single
|
||||
`machine.run().await`; command reading, packet RX, and TX are
|
||||
state-scoped `during:` activities. Post-TX RX re-arm is modeled as
|
||||
state identity (`TransmittingFromRx` → `ResumingRx`) instead of a
|
||||
`was_receiving` flag.
|
||||
- Display task rewritten as an `hsmc` statechart (`Off` /
|
||||
`On.{Splash.{LearnMore, Info}, Dashboard.{Listening, Transmitting}}`).
|
||||
The 5 s splash alternation and 1 s sparkline tick use `on(after …)` /
|
||||
`on(every …)` transitions — no external timer task.
|
||||
- USB host task now carries a small `hsmc` statechart (`Disconnected` /
|
||||
`Connected`) whose entry/exit actions handle display wake, radio
|
||||
`StopRx`, and display reset — replacing the open-coded `was_connected`
|
||||
edge detector.
|
||||
- Radio → Display channel switched from a `RadioStatus` watch to a typed
|
||||
`RadioEvent` queue. The radio emits discrete `EnteredRx` / `EnteredTx`
|
||||
/ `Idle` / `PacketRx` / `PacketTx` / `ConfigChanged` events; downstream
|
||||
consumers observe transitions directly rather than diffing snapshots.
|
||||
- Wire protocol types and COBS framing moved out of firmware into the
|
||||
shared `donglora-protocol` crate (path dep on `../protocol`). Firmware
|
||||
`src/protocol.rs` is now a re-export shim preserving every call site;
|
||||
`src/host/framing.rs` keeps only the embassy-specific `route_command`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- USB disconnect recovery: on a read error the DTR-edge-detect branch
|
||||
now runs unconditionally (was early-continuing in some paths, starving
|
||||
the display-reset / radio-stop handoff).
|
||||
|
||||
## [0.4.0] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+359
-94
@@ -2,6 +2,17 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -114,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"
|
||||
@@ -127,6 +138,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte-slice-cast"
|
||||
version = "1.2.3"
|
||||
@@ -147,9 +167,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -172,6 +192,33 @@ dependencies = [
|
||||
"pure-rust-locales",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.1",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmac"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"dbl",
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.11.1"
|
||||
@@ -225,6 +272,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -234,6 +287,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-any"
|
||||
version = "2.5.0"
|
||||
@@ -265,6 +327,24 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -368,6 +448,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbl"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d7a944e61df464668c5f51f56cc667396a8821434273112948ea0b66e405d7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debug-helper"
|
||||
version = "0.3.13"
|
||||
@@ -417,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]]
|
||||
@@ -442,8 +531,19 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"crypto-common 0.2.1",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -486,15 +586,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "0.4.0"
|
||||
version = "1.4.1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"defmt 0.3.100",
|
||||
"defmt-rtt",
|
||||
"display-interface",
|
||||
"donglora-protocol",
|
||||
"embassy-embedded-hal",
|
||||
"embassy-executor",
|
||||
"embassy-futures",
|
||||
@@ -513,6 +614,7 @@ dependencies = [
|
||||
"esp-println",
|
||||
"esp-rtos",
|
||||
"heapless 0.9.2",
|
||||
"hsmc",
|
||||
"lora-phy",
|
||||
"minijinja",
|
||||
"owo-colors",
|
||||
@@ -523,6 +625,17 @@ dependencies = [
|
||||
"ucobs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "donglora-protocol"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"heapless 0.9.2",
|
||||
"ucobs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -630,7 +743,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",
|
||||
@@ -767,12 +880,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.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -781,7 +894,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",
|
||||
@@ -971,8 +1084,8 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"defmt 1.0.1",
|
||||
@@ -988,8 +1101,8 @@ dependencies = [
|
||||
|
||||
[[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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"esp-metadata-generated",
|
||||
@@ -1000,16 +1113,16 @@ 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.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"bitfield 0.19.4",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"critical-section",
|
||||
"delegate",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"document-features",
|
||||
"embassy-embedded-hal",
|
||||
"embassy-futures",
|
||||
@@ -1043,10 +1156,12 @@ 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",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"strum",
|
||||
"ufmt-write",
|
||||
"xtensa-lx",
|
||||
@@ -1055,8 +1170,8 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"object",
|
||||
@@ -1069,13 +1184,13 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
|
||||
[[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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"document-features",
|
||||
@@ -1087,8 +1202,8 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"riscv",
|
||||
@@ -1097,19 +1212,20 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"document-features",
|
||||
"esp-metadata-generated",
|
||||
"esp32",
|
||||
"esp32s3",
|
||||
]
|
||||
|
||||
[[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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"document-features",
|
||||
@@ -1130,8 +1246,8 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"document-features",
|
||||
@@ -1158,67 +1274,73 @@ 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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1267,12 +1389,42 @@ dependencies = [
|
||||
"gcd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@@ -1292,6 +1444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1358,9 +1511,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
@@ -1378,6 +1531,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
@@ -1388,6 +1542,46 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3951a3170ce7aa0f0bf4627ac4cc650857f15449c98e97647fa29fcd19c6d33"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"embassy-futures",
|
||||
"embassy-sync 0.8.0",
|
||||
"embassy-time",
|
||||
"heapless 0.8.0",
|
||||
"hsmc-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b6d97dd1d31fba9e2f96c65c0ce38d7bc1ff59abe775a0ae18e14dfae01d1d1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -1396,12 +1590,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.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1413,6 +1607,15 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
@@ -1447,7 +1650,7 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1490,9 +1693,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -1531,22 +1734,62 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lora-modulation"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c96c4a41a1f1ad04de765306e1829c7007a37898fe4033204fa82cf2e055624"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lora-phy"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61471c3b2909789e3332083577f6cf6c41a4fcf37674ef15156bcbb20504ac65"
|
||||
version = "3.0.2-alpha"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"document-features",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"lora-modulation",
|
||||
"lorawan-device",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lorawan"
|
||||
version = "0.9.1"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cmac",
|
||||
"defmt 0.3.100",
|
||||
"hex",
|
||||
"lorawan-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lorawan-device"
|
||||
version = "0.12.2"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"document-features",
|
||||
"fastrand",
|
||||
"futures",
|
||||
"heapless 0.9.2",
|
||||
"lora-modulation",
|
||||
"lorawan",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lorawan-macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#e8a52ebd35352ce0267148fd392a0b1956d7735f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -1589,9 +1832,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",
|
||||
@@ -1891,7 +2134,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",
|
||||
]
|
||||
@@ -1934,9 +2177,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"
|
||||
@@ -1944,7 +2187,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]]
|
||||
@@ -2166,6 +2409,28 @@ dependencies = [
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2-const-stable"
|
||||
version = "0.1.0"
|
||||
@@ -2174,11 +2439,11 @@ 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",
|
||||
"digest 0.10.7",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
@@ -2401,9 +2666,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",
|
||||
@@ -2413,9 +2678,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",
|
||||
]
|
||||
@@ -2483,9 +2748,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"
|
||||
@@ -2652,9 +2917,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",
|
||||
]
|
||||
@@ -2662,15 +2927,15 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "xtensa-lx"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"xtensa-lx",
|
||||
@@ -2680,7 +2945,7 @@ 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 = "git+https://github.com/esp-rs/esp-hal.git?branch=main#54a62c57f1f005020777a4e0843ce03c68312aba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+147
-23
@@ -3,22 +3,25 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "0.4.0"
|
||||
version = "1.4.1"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
||||
repository = "https://github.com/swaits/donglora"
|
||||
repository = "https://github.com/donglora/firmware"
|
||||
homepage = "https://github.com/donglora/firmware"
|
||||
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||
categories = ["embedded", "hardware-support", "no-std"]
|
||||
|
||||
# NOTE: esp-rtos patched from GitHub main (see [patch.crates-io] below).
|
||||
# Remove patches once esp-rtos publishes with embassy-executor 0.10 support.
|
||||
# NOTE: esp-{hal,rtos,backtrace,println} patched from GitHub main (see
|
||||
# [patch.crates-io] below). The patch versions must stay semver-compatible
|
||||
# with the version requirements above, or Cargo silently drops the patch
|
||||
# and falls back to the (older) crates.io release.
|
||||
[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"
|
||||
@@ -29,8 +32,12 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
|
||||
embassy-usb-driver = "0.2"
|
||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||
heapless = "0.9"
|
||||
lora-phy = "3.0"
|
||||
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||
donglora-protocol = "1"
|
||||
hsmc = { version = "0.5", 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
|
||||
@@ -42,22 +49,20 @@ ssd1306 = { version = "0.10", features = ["async"] }
|
||||
embedded-io-async = "0.7"
|
||||
embassy-futures = "0.1"
|
||||
|
||||
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
|
||||
esp-backtrace = { version = "0.18", optional = true, features = [
|
||||
"esp32s3",
|
||||
# Xtensa ESP crates — requires esp toolchain (espup). The chip feature
|
||||
# (esp32 / esp32s3) is selected per board via the board-level feature flag
|
||||
# below, so the same dep lines support both classic ESP32 and ESP32-S3.
|
||||
esp-backtrace = { version = "0.19", optional = true, features = [
|
||||
"panic-handler",
|
||||
"defmt",
|
||||
] }
|
||||
esp-hal = { version = "1.0", optional = true, features = [
|
||||
"esp32s3",
|
||||
esp-hal = { version = "1.1", optional = true, features = [
|
||||
"unstable",
|
||||
] }
|
||||
esp-println = { version = "0.16", optional = true, features = [
|
||||
"esp32s3",
|
||||
esp-println = { version = "0.17", optional = true, features = [
|
||||
"defmt-espflash",
|
||||
] }
|
||||
esp-rtos = { version = "0.2", optional = true, features = [
|
||||
"esp32s3",
|
||||
esp-rtos = { version = "0.3", optional = true, features = [
|
||||
"embassy",
|
||||
] }
|
||||
|
||||
@@ -66,12 +71,22 @@ 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"] }
|
||||
|
||||
@@ -87,31 +102,121 @@ minijinja = "2.18"
|
||||
owo-colors = "4.3"
|
||||
|
||||
[features]
|
||||
# Bridge features for hsmc's macro-expanded `#[cfg(feature = "...")]`
|
||||
# attributes. Macros expand into the *call-site* crate, so any cfg the
|
||||
# macro emits is evaluated against THIS crate's feature set, not hsmc's.
|
||||
# That means enabling `hsmc/embassy` or `hsmc/trace-defmt` via the dep
|
||||
# only activates them inside hsmc's own compilation — for the cfg gates
|
||||
# inside expanded macro bodies to fire here, we need same-named features
|
||||
# in this Cargo.toml. `tokio` is declared just to silence the macro's
|
||||
# `#[cfg(feature = "tokio")]` checks.
|
||||
#
|
||||
# `trace-defmt` must be in every board feature so chart trace lines
|
||||
# (entries, exits, transitions, etc.) actually compile in. Without it,
|
||||
# hsmc 0.4's `__chart_observe!` macro expands to nothing for the defmt
|
||||
# arm and you get silent statecharts.
|
||||
embassy = []
|
||||
tokio = []
|
||||
trace-defmt = []
|
||||
# When enabled, replaces the display task with a LED-based diagnostic
|
||||
# blinker driven by `crate::debug_blink::CHECKPOINT`. Used for diagnosing
|
||||
# the firmware on UART-only boards where defmt output would corrupt the
|
||||
# host protocol on the same UART. The LED flashes N times then pauses —
|
||||
# a human watches the pattern and reports N to determine where execution
|
||||
# reached / stopped.
|
||||
debug-checkpoint = []
|
||||
# NOTE: chart auto-tracing in hsmc 0.4 emits `__chart_observe!` calls
|
||||
# whose defmt arm is gated on `#[cfg(feature = "trace-defmt")]`. That
|
||||
# cfg is evaluated against THIS crate (proc-macro cfg quirk above), so
|
||||
# every board feature includes `trace-defmt` to keep the arm live. The
|
||||
# `trace;` keyword in `statechart!` blocks is deprecated in 0.4 and
|
||||
# no longer gates emission. Whether trace lines actually emit at
|
||||
# runtime is still controlled by defmt's standard compile-time level
|
||||
# filter (`DEFMT_LOG` env var) -- release builds with `DEFMT_LOG`
|
||||
# unset compile the calls out to nothing.
|
||||
# When enabled, the SH1106 OLED driver emits a defmt::info! line for
|
||||
# every I2C transaction it issues — bytes, lengths, addresses. Used to
|
||||
# debug the embassy_nrf::twim assertion panic at twim.rs:541 (cannot
|
||||
# happen via the documented call path; instrumenting the SH1106 path
|
||||
# captures the actual transaction sequence preceding the panic so we
|
||||
# can root-cause). Off by default; opt in via
|
||||
# `just flash-attach rak_wisblock_4631 debug-twim`.
|
||||
debug-twim = []
|
||||
heltec_v3 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
heltec_v3_uart = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
heltec_v4 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
elecrow_thinknode_m2 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
lilygo_tbeam_supreme = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
lilygo_tbeam = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/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",
|
||||
@@ -122,6 +227,8 @@ rak_wisblock_4631 = [
|
||||
"embassy-executor/executor-thread",
|
||||
]
|
||||
wio_tracker_l1 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"cortex-m-rt/set-vtor",
|
||||
@@ -132,26 +239,39 @@ wio_tracker_l1 = [
|
||||
"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
|
||||
# REVISIT: pulling esp-{hal,rtos,backtrace,println} from esp-rs/esp-hal main
|
||||
# to ride ahead of crates.io releases (notably for embassy-executor 0.10
|
||||
# support in esp-rtos). When bumping the upstream rev, also bump the version
|
||||
# requirements above to stay semver-compatible — Cargo silently ignores a
|
||||
# patch whose version falls outside the requested range.
|
||||
[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"
|
||||
|
||||
@@ -160,4 +280,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
|
||||
+1901
-269
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,8 @@ All commands are run from the `firmware/` directory.
|
||||
| Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
|
||||
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||
| ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
|
||||
| LilyGo T-Beam | `lilygo_tbeam` | ESP32 | SX1276 | CP2102 bridge | xtensa-esp32-none-elf |
|
||||
| LilyGo T-Beam S3 Supreme | `lilygo_tbeam_supreme` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||
| Waveshare RP2040-LoRa | `waveshare_rp2040_lora` | RP2040 | SX1262 | Native CDC-ACM | thumbv6m-none-eabi |
|
||||
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||
|
||||
@@ -1,4 +1,174 @@
|
||||
- LED blinking on TX/RX? colored?
|
||||
- publish new client, mux, bridge (updates in client for UART crap)
|
||||
- split out mux and client repos
|
||||
- move to GH organization
|
||||
# Firmware TODO List
|
||||
|
||||
## `lora-phy` fix: drop fork pin once PRs #428 / #431 / #432 merge + release
|
||||
|
||||
Upstream PRs (all open):
|
||||
|
||||
- [#428 — *phy: add pre-command BUSY checks per Semtech
|
||||
datasheets*](https://github.com/lora-rs/lora-rs/pull/428) (sx126x):
|
||||
pre-command BUSY check + standby/IRQ-clear hygiene around CAD.
|
||||
- [#431 — *phy: sx127x: clear stale IRQ flags in do_rx before mode
|
||||
change*](https://github.com/lora-rs/lora-rs/pull/431).
|
||||
- [#432 — *phy: sx127x: clear stale IRQ flags before reconfiguring DIO
|
||||
mapping in set_irq_params*](https://github.com/lora-rs/lora-rs/pull/432).
|
||||
|
||||
Fork branch we ship from:
|
||||
[`swaits/lora-rs @ fixed`](https://github.com/swaits/lora-rs/tree/fixed)
|
||||
— a merge of all three fix branches kept up-to-date with upstream
|
||||
`main`.
|
||||
|
||||
Firmware consumes the fixes via a direct `git` dependency in
|
||||
`Cargo.toml`:
|
||||
|
||||
```toml
|
||||
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
|
||||
```
|
||||
|
||||
When all three PRs merge and a `lora-phy` release ships on crates.io
|
||||
that contains them, switch this line back to a normal
|
||||
`lora-phy = "X.Y"` crates.io version and drop the explanatory comment
|
||||
block above it.
|
||||
|
||||
Hardware impact: large stability gain on every board — the sx126x
|
||||
BUSY-race fix lands on every nRF/RP2040 board, and the sx127x IRQ-clear
|
||||
fixes unblock TBeam classic in particular. Hardware-verified on Wio
|
||||
Tracker L1 (sx126x): 20/20 clean-boot TX after the fix (vs ~50%
|
||||
before).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -108,20 +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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
workers = ["app.routes"]
|
||||
|
||||
[settings]
|
||||
cache = ".script"
|
||||
address = "127.0.0.1"
|
||||
port = 3500
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,41 +1,30 @@
|
||||
set shell := ["bash", "-c"]
|
||||
|
||||
# Board definitions: feature target chip
|
||||
# Xtensa targets use the self-contained esp toolchain (espup-installed) with
|
||||
# `-Zbuild-std=core,alloc`. Using `+esp` rather than `+nightly` keeps the
|
||||
# compiler and rust-src versions aligned — the public nightly drifts ahead of
|
||||
# the esp fork and breaks core-stdlib compilation otherwise.
|
||||
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
|
||||
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
|
||||
heltec_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
|
||||
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
|
||||
elecrow_thinknode_m2 := "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3"
|
||||
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||
waveshare_rp2040_lora := "waveshare_rp2040_lora thumbv6m-none-eabi rp2040"
|
||||
# Run every recipe with mise's tool versions active — so `just` invocations
|
||||
# of cargo, espflash, rust-objcopy, etc. always resolve to the pinned
|
||||
# versions in mise.toml, even if the user has a different version elsewhere
|
||||
# on PATH (e.g. a stray `cargo install espflash`).
|
||||
set shell := ["mise", "exec", "--", "sh", "-c"]
|
||||
|
||||
builds_dir := "builds"
|
||||
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
||||
|
||||
# All known boards
|
||||
boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
|
||||
# All known boards. ADD A NEW BOARD HERE and in the `_info` case below.
|
||||
#
|
||||
# Board metadata ("feature target chip") lives in the `_info` recipe —
|
||||
# Xtensa targets are built with `+esp` + `-Zbuild-std=core,alloc` (the
|
||||
# self-contained esp toolchain ships its own rust-src; the public
|
||||
# nightly drifts ahead and breaks core-stdlib compilation).
|
||||
boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
|
||||
|
||||
# Install all required tools and toolchains
|
||||
setup:
|
||||
mise trust --yes
|
||||
mise install
|
||||
@# ESP Xtensa toolchain (not managed by mise)
|
||||
@# ESP Xtensa toolchain — installed via the `esp-install` mise task
|
||||
@# (which pins versions to match mise.toml's [env] paths).
|
||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||
echo "ESP toolchain not found, installing via espup (this may take a while)..."; \
|
||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
||||
echo "ESP toolchain not found, installing via mise task (this may take a while)..."; \
|
||||
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
|
||||
fi
|
||||
@# nightly rust-src for -Zbuild-std
|
||||
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
||||
rustup component add rust-src --toolchain nightly; \
|
||||
fi
|
||||
@# ARM targets
|
||||
@rustup target list --installed | grep -q "^thumbv6m-none-eabi$" || rustup target add thumbv6m-none-eabi
|
||||
@rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf
|
||||
|
||||
# Build release firmware for all boards, installing toolchains as needed
|
||||
build-all:
|
||||
@@ -65,19 +54,23 @@ check-all:
|
||||
check board:
|
||||
@just _cargo {{board}} "clippy --release" "-- -D warnings"
|
||||
|
||||
# Build release firmware and copy to builds/ with a readable name
|
||||
build board profile="release":
|
||||
@just _cargo {{board}} "build --{{profile}}"
|
||||
# Build release firmware and copy to builds/ with a readable name.
|
||||
# `extra_features` (comma-separated) is appended to the board feature,
|
||||
# e.g. `just build wio_tracker_l1 release debug-checkpoint`.
|
||||
build board profile="release" extra_features="":
|
||||
@just _cargo {{board}} "build --{{profile}}" "" "{{extra_features}}"
|
||||
@just _copy_firmware {{board}} {{profile}}
|
||||
|
||||
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM)
|
||||
flash board:
|
||||
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM).
|
||||
# `extra_features` is passed through to the build, e.g.
|
||||
# `just flash wio_tracker_l1 debug-checkpoint`.
|
||||
flash board extra_features="":
|
||||
@just _ensure_tools
|
||||
@just build {{board}} release
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
@just build {{board}} release "{{extra_features}}"
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
case "$target" in \
|
||||
xtensa-*) \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
|
||||
if [ -n "$port" ]; then \
|
||||
echo "Using $port"; \
|
||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||
@@ -88,13 +81,50 @@ flash board:
|
||||
*) just _flash_uf2 {{board}} ;; \
|
||||
esac
|
||||
|
||||
# Flash ARM board via debug probe (requires J-Link or similar)
|
||||
flash-probe board:
|
||||
# Flash ARM board via debug probe (requires J-Link or similar).
|
||||
# `extra_features` is passed through to the build.
|
||||
# Sets DEFMT_LOG at build time so defmt::info!/warn!/error! are actually
|
||||
# compiled in (default filter is ERROR-only — strips info logs).
|
||||
# `embassy_usb=error` silences the benign Linux modem-manager
|
||||
# `SET_INTERFACE: alt setting out of range` WARN that fires once per
|
||||
# enumeration; it's a normal STALL response (correct USB behavior),
|
||||
# the WARN is just embassy_usb noise. Other crates inherit the `info`
|
||||
# default so chart traces, our own info!/warn!/error! all still fire.
|
||||
flash-probe board extra_features="":
|
||||
@just _ensure_tools
|
||||
@just build {{board}} release
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
|
||||
|
||||
# UF2-flash via the bootloader, then probe-rs attach for RTT log streaming.
|
||||
# Required on boards (e.g. RAK4631) where the bootloader gates the app
|
||||
# behind a "valid app flag" in its settings page that only the bootloader
|
||||
# itself can set — `probe-rs run`'s direct SWD writes bypass that flag and
|
||||
# leave the bootloader stuck in DFU mode. UF2 path goes through the
|
||||
# bootloader, sets the flag, then we attach for RTT (no reset, no reflash).
|
||||
flash-attach board extra_features="":
|
||||
@just _ensure_tools
|
||||
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
|
||||
@just _flash_uf2 {{board}}
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
sleep 1; \
|
||||
probe-rs attach --chip $chip --no-catch-reset \
|
||||
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
|
||||
|
||||
# Attach probe-rs for live RTT/defmt streaming WITHOUT flashing or
|
||||
# resetting the chip. Logs are tee'd to a UTC-timestamped file in /tmp
|
||||
# so soak captures don't clobber each other across runs. Use this with
|
||||
# the dongle already running the desired firmware (flash via `just flash`
|
||||
# or `just flash-attach` first).
|
||||
attach board:
|
||||
@just _ensure_tools
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
log="/tmp/donglora-soak-$(date -u +%Y%m%dT%H%M%SZ).log"; \
|
||||
echo "Logging to $log"; \
|
||||
probe-rs attach --chip $chip --no-catch-reset \
|
||||
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf" 2>&1 \
|
||||
| tee "$log"
|
||||
|
||||
# Show binary size for a release build
|
||||
size board:
|
||||
@just _cargo {{board}} "size --release"
|
||||
@@ -114,16 +144,17 @@ outdated:
|
||||
_ensure_tools:
|
||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||
|
||||
# Run a cargo command for a board.
|
||||
# Run a cargo command for a board, optionally with extra Cargo features
|
||||
# (comma-separated, e.g. "debug-checkpoint").
|
||||
# Xtensa: esp toolchain (cargo + rustc + rust-src together) + -Zbuild-std=core
|
||||
[private]
|
||||
_cargo board cmd extra_args="":
|
||||
_cargo board cmd extra_args="" extra_features="":
|
||||
@just _ensure_tools
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
[ -n "{{extra_features}}" ] && feat="$feat,{{extra_features}}"; \
|
||||
extra=""; buildstd=""; \
|
||||
case "$target" in xtensa-*) \
|
||||
just _require_esp_toolchain; \
|
||||
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
|
||||
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
|
||||
esac; \
|
||||
cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
||||
@@ -131,21 +162,23 @@ _cargo board cmd extra_args="":
|
||||
# Ensure a board's toolchain is available, auto-installing if needed
|
||||
[private]
|
||||
_can_build board:
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
case "$target" in \
|
||||
xtensa-*) just _require_esp_toolchain ;; \
|
||||
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
||||
esac
|
||||
|
||||
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise).
|
||||
# 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)
|
||||
@@ -167,25 +200,25 @@ _find_port +vid_pids:
|
||||
[private]
|
||||
_copy_firmware board profile:
|
||||
@mkdir -p {{builds_dir}}
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
src="target/$target/{{profile}}/donglora"; \
|
||||
dst="{{builds_dir}}/donglora-{{board}}-v{{version}}"; \
|
||||
if [ -f "$src" ]; then \
|
||||
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
|
||||
case "$target" in \
|
||||
xtensa-*) \
|
||||
mise exec -- espflash save-image --chip "$chip" --merge --skip-padding "$src" "$dst.bin"; \
|
||||
espflash save-image --chip "$chip" --merge --skip-padding "$src" "$dst.bin"; \
|
||||
echo "→ $dst.bin"; \
|
||||
;; \
|
||||
thumbv6m-*) \
|
||||
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
|
||||
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family rp2040; \
|
||||
rust-objcopy -O ihex "$src" "$dst.hex"; \
|
||||
cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family rp2040; \
|
||||
rm "$dst.hex"; \
|
||||
echo "→ $dst.uf2"; \
|
||||
;; \
|
||||
thumbv7em-*) \
|
||||
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
|
||||
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
|
||||
rust-objcopy -O ihex "$src" "$dst.hex"; \
|
||||
cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
|
||||
rm "$dst.hex"; \
|
||||
echo "→ $dst.uf2"; \
|
||||
;; \
|
||||
@@ -208,13 +241,19 @@ _flash_uf2 board:
|
||||
sync; \
|
||||
echo "Done — board will reboot automatically"
|
||||
|
||||
# Single source of truth for per-board metadata: "feature target chip".
|
||||
# ADD A NEW BOARD HERE and in the `boards` list near the top of this file.
|
||||
[private]
|
||||
_info name:
|
||||
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
|
||||
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
|
||||
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
||||
elif [ "{{name}}" == "elecrow_thinknode_m2" ]; then echo "{{elecrow_thinknode_m2}}"; \
|
||||
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
|
||||
elif [ "{{name}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \
|
||||
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
|
||||
else echo "Unknown board: {{name}}" >&2; exit 1; fi
|
||||
@case "{{name}}" in \
|
||||
heltec_v3) echo "heltec_v3 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
heltec_v3_uart) echo "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
heltec_v4) echo "heltec_v4 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
elecrow_thinknode_m2) echo "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
lilygo_tbeam) echo "lilygo_tbeam xtensa-esp32-none-elf esp32" ;; \
|
||||
lilygo_tbeam_supreme) echo "lilygo_tbeam_supreme xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
rak_wisblock_4631) echo "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
|
||||
waveshare_rp2040_lora) echo "waveshare_rp2040_lora thumbv6m-none-eabi rp2040" ;; \
|
||||
wio_tracker_l1) echo "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
|
||||
*) echo "Unknown board: {{name}}" >&2; exit 1 ;; \
|
||||
esac
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,9 +1,46 @@
|
||||
[tools]
|
||||
rust = "1.94"
|
||||
just = "latest"
|
||||
|
||||
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
||||
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 via Intel HEX for nRF52840 DFU
|
||||
"cargo:espup" = "0.16"
|
||||
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
|
||||
"cargo:probe-rs-tools" = "0.31"
|
||||
# Cargo-installed tooling via mise's `cargo:` backend.
|
||||
"cargo:cargo-binutils" = "latest" # rust-objcopy, rust-size
|
||||
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 for nRF52 / RP2040 DFU drives
|
||||
"cargo:espup" = "0.17.1" # ESP Xtensa toolchain installer (pinned: [env] paths below match this version's defaults)
|
||||
"cargo:espflash" = "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:probe-rs-tools" = "latest" # debug-probe flash/run for ARM targets
|
||||
|
||||
# Stable Rust + cross-compile targets. Targets land under $RUSTUP_HOME for
|
||||
# the pinned toolchain, so they're always available under `mise exec` — no
|
||||
# extra `rustup target add` step at setup time.
|
||||
#
|
||||
# Xtensa ESP targets are NOT listed here — they use the self-contained `esp`
|
||||
# toolchain populated by `mise run esp-install` (see [tasks] below). That
|
||||
# toolchain ships its own rust-src for `-Zbuild-std`.
|
||||
#
|
||||
# profile = "minimal" (just rustc, rust-std, cargo), "default" (adds rust-docs, rustfmt, clippy)
|
||||
#
|
||||
[tools.rust]
|
||||
version = "1.94"
|
||||
profile = "default"
|
||||
targets = "thumbv6m-none-eabi,thumbv7em-none-eabihf"
|
||||
|
||||
# ESP Xtensa toolchain. `cargo:espup` above only installs the *installer*;
|
||||
# the actual rustc-esp + xtensa-esp-elf-gcc + esp-clang toolchains are
|
||||
# populated by `mise run esp-install` (or `just setup`).
|
||||
#
|
||||
# espup hardcodes its install root to ~/.rustup/toolchains/esp/ — there's
|
||||
# no --prefix or --install-dir flag — so the binaries necessarily live
|
||||
# there. The [env] block below pins the exact PATH and LIBCLANG_PATH so
|
||||
# every `mise exec` (which the justfile uses for `set shell`) resolves
|
||||
# the cross-toolchain without depending on espup's stray ~/export-esp.sh.
|
||||
#
|
||||
# Bumping versions: edit the version dirs in BOTH [tasks.esp-install] (so
|
||||
# espup installs the matching crosstool) AND [env] (so the path resolves).
|
||||
[env]
|
||||
_.path = [
|
||||
"{{env.HOME}}/.rustup/toolchains/esp/xtensa-esp-elf/esp-15.2.0_20250920/xtensa-esp-elf/bin",
|
||||
]
|
||||
LIBCLANG_PATH = "{{env.HOME}}/.rustup/toolchains/esp/xtensa-esp32-elf-clang/esp-20.1.1_20250829/esp-clang/lib"
|
||||
|
||||
[tasks.esp-install]
|
||||
description = "Install the ESP Xtensa toolchain (rustc-esp + GCC + clang) at versions matching [env]"
|
||||
run = "espup install --crosstool-toolchain-version esp-15.2.0_20250920"
|
||||
@@ -1,8 +1,8 @@
|
||||
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||
//!
|
||||
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
||||
//! OLED + CP2102 UART bridge + 1000 mAh Li-Po. Sold pre-loaded with Meshtastic;
|
||||
//! this firmware turns it into a DongLoRa radio bridge.
|
||||
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||
//!
|
||||
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||
//! and PR meshtastic/firmware#6354):
|
||||
@@ -11,7 +11,7 @@
|
||||
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
||||
//! UART: TX=43, RX=44 via CP2102 bridge
|
||||
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
|
||||
//! LED: GPIO6 (active HIGH indicator)
|
||||
//!
|
||||
//! Notable differences from the Heltec ESP32-S3 boards:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Shared types and peripheral wiring for classic-ESP32 SX1276 boards.
|
||||
//!
|
||||
//! Counterpart to `board::esp32s3` for the SX126x line. Builds an
|
||||
//! `Sx127x<Sx1276>` driver from an already-initialized SPI bus plus the
|
||||
//! board's reset and DIO0 pins. SX127x has no `BUSY` line — DIO0 carries
|
||||
//! every IRQ (TX done, RX done, header valid, …) and lora-phy's
|
||||
//! `GenericSx127xInterfaceVariant` waits on it directly.
|
||||
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::Delay;
|
||||
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
|
||||
|
||||
use lora_phy::iv::GenericSx127xInterfaceVariant;
|
||||
use lora_phy::sx127x::{self, Sx127x};
|
||||
|
||||
use crate::hal::esp32 as mcu;
|
||||
|
||||
// ── Re-export MCU types ─────────────────────────────────────────────
|
||||
|
||||
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
|
||||
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx127xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx127x<RadioSpiDevice, Iv, sx127x::Sx1276>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: Delay,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
}
|
||||
|
||||
// ── Display driver ──────────────────────────────────────────────────
|
||||
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||
>;
|
||||
|
||||
/// Construct and initialize an SSD1306 display from raw I²C.
|
||||
pub async fn create_display(i2c: DisplayI2c) -> 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 mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 display init failed");
|
||||
return None;
|
||||
}
|
||||
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||
Some(display)
|
||||
}
|
||||
|
||||
// ── Shared peripheral init helpers ──────────────────────────────────
|
||||
|
||||
/// Construct an SX1276 radio from an initialized SPI bus.
|
||||
///
|
||||
/// `dio0` is the primary IRQ (TX/RX done). The SX1276 module on the T-Beam
|
||||
/// runs from a crystal oscillator, not a TCXO, and power comes from the
|
||||
/// AXP192's PA_BOOST rail — `tx_boost = true` routes TX through the PA
|
||||
/// output for +20 dBm.
|
||||
pub fn init_radio(
|
||||
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
|
||||
nss_pin: impl OutputPin + 'static,
|
||||
reset_pin: impl OutputPin + 'static,
|
||||
dio0_pin: impl InputPin + 'static,
|
||||
) -> RadioParts {
|
||||
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
let reset = Output::new(reset_pin, Level::High, OutputConfig::default());
|
||||
let dio0 = Input::new(dio0_pin, InputConfig::default().with_pull(Pull::Down));
|
||||
|
||||
let iv =
|
||||
GenericSx127xInterfaceVariant::new(reset, dio0, None, None).expect("SX1276 interface init");
|
||||
|
||||
let sx_config = sx127x::Config {
|
||||
chip: sx127x::Sx1276,
|
||||
tcxo_used: false,
|
||||
tx_boost: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
|
||||
RadioParts {
|
||||
driver: Sx127x::new(spi_device, iv, sx_config),
|
||||
delay: Delay,
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -35,14 +35,22 @@ pub struct RadioParts {
|
||||
// SSD1306 display support is Heltec-specific. ESP32-S3 boards that use a
|
||||
// different controller (e.g. ThinkNode-M2 uses SH1106) provide their own
|
||||
// DisplayParts / DisplayDriver / create_display directly.
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
|
||||
#[cfg(any(
|
||||
feature = "heltec_v3",
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4"
|
||||
))]
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
}
|
||||
|
||||
// ── Display driver ──────────────────────────────────────────────────
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
|
||||
#[cfg(any(
|
||||
feature = "heltec_v3",
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4"
|
||||
))]
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
@@ -50,7 +58,11 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
>;
|
||||
|
||||
/// Construct and initialize an SSD1306 display from raw I2C.
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
|
||||
#[cfg(any(
|
||||
feature = "heltec_v3",
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4"
|
||||
))]
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||
|
||||
@@ -14,9 +14,7 @@ use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
pub use super::esp32s3::{
|
||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
||||
};
|
||||
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||
|
||||
@@ -13,9 +13,7 @@ use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
pub use super::esp32s3::{
|
||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
||||
};
|
||||
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||
|
||||
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||
|
||||
@@ -6,9 +6,7 @@ use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
pub use super::esp32s3::{
|
||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
||||
};
|
||||
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! LilyGo T-Beam (classic) — ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC.
|
||||
//!
|
||||
//! First-generation T-Beam. ESP32 (dual-core Xtensa LX6, no native USB),
|
||||
//! SX1276 module with XTAL (no TCXO), 128×64 SSD1306 OLED, AXP192 PMIC
|
||||
//! gating the LoRa and OLED rails, CP2102 USB-UART bridge for host I/O.
|
||||
//!
|
||||
//! The AXP192 shares an I²C bus with the OLED (SDA=21, SCL=22). PMIC init
|
||||
//! runs blocking on that bus first, then the same pins are re-initialized
|
||||
//! in async mode for the display task.
|
||||
//!
|
||||
//! Pin map (verified from MeshCore variants/lilygo_tbeam_SX1276/):
|
||||
//! LoRa SPI: SCK=5, MOSI=27, MISO=19, CS=18
|
||||
//! LoRa ctl: RESET=23, DIO0=26 (primary IRQ — SX127x has no BUSY)
|
||||
//! OLED I²C: SDA=21, SCL=22, SSD1306 @ 0x3C (shared with AXP192 @ 0x34)
|
||||
//! UART host: TX=1, RX=3 via CP2102 bridge (USB 10c4:ea60)
|
||||
//! LED: GPIO4 (active LOW — red LED under the OLED, wrapped in
|
||||
//! InvertedPin so SimpleLed sees normal polarity)
|
||||
//!
|
||||
//! GPS / battery / IMU / PCF8563 RTC are populated on the board but unused
|
||||
//! by this firmware.
|
||||
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||
|
||||
use super::esp32;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::axp;
|
||||
use crate::driver::inverted_pin::InvertedPin;
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32 as mcu;
|
||||
use crate::protocol::RadioChipId;
|
||||
|
||||
pub use super::esp32::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||
pub type LedDriver = SimpleLed<InvertedPin<Output<'static>>>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct UartParts {
|
||||
pub driver: UartDriver,
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "LilyGo T-Beam";
|
||||
// SX1276 PA_BOOST range per the datasheet: +2 dBm min, +20 dBm max.
|
||||
const TX_POWER_RANGE: (i8, i8) = (2, 20);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UartParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
mcu::mac_address()
|
||||
}
|
||||
|
||||
fn radio_chip_id() -> u16 {
|
||||
RadioChipId::Sx1276.as_u16()
|
||||
}
|
||||
|
||||
fn freq_range_hz() -> (u32, u32) {
|
||||
// SX1276 tuning window per datasheet (LF + HF bands combined).
|
||||
(137_000_000, 1_020_000_000)
|
||||
}
|
||||
|
||||
fn supported_sf_bitmap() -> u16 {
|
||||
// SX127x supports SF6–SF12 (no SF5 like the SX126x).
|
||||
0x1FC0
|
||||
}
|
||||
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||
|
||||
// AXP192 and SSD1306 share I²C0 (SDA=21, SCL=22). Build the bus
|
||||
// once as blocking, drive the PMIC init, then convert to async for
|
||||
// the display task — avoids re-consuming the I²C0 peripheral.
|
||||
// PMIC failure isn't fatal to boot: log and proceed so the rest of
|
||||
// the firmware can still emit diagnostics.
|
||||
let mut i2c_blocking = I2c::new(p.I2C0, I2cConfig::default())
|
||||
.expect("I2C0 init")
|
||||
.with_sda(p.GPIO21)
|
||||
.with_scl(p.GPIO22);
|
||||
if let Err(_e) = axp::axp192_init_lora_oled(&mut i2c_blocking) {
|
||||
defmt::error!("AXP192 init failed — LoRa rail may be dead");
|
||||
}
|
||||
// Let DCDC1 / LDO2 settle before SPI or display traffic.
|
||||
esp_hal::delay::Delay::new().delay_millis(20);
|
||||
|
||||
// SPI2 for SX1276: SCK=5, MOSI=27, MISO=19
|
||||
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_SPI2, p.GPIO5, p.GPIO27, p.GPIO19);
|
||||
|
||||
// SX1276: CS=18, RESET=23, DIO0=26 (primary IRQ)
|
||||
let radio = esp32::init_radio(spi_bus, p.GPIO18, p.GPIO23, p.GPIO26);
|
||||
|
||||
// UART0 via CP2102 bridge: TX=1, RX=3 (ESP32 default console)
|
||||
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||
let host = UartParts {
|
||||
driver: Uart::new(p.UART0, UartConfig::default())
|
||||
.expect("UART init")
|
||||
.with_tx(p.GPIO1)
|
||||
.with_rx(p.GPIO3)
|
||||
.into_async(),
|
||||
};
|
||||
|
||||
// Hand the (now async) I²C bus to the SSD1306 display task.
|
||||
let display = Some(DisplayParts {
|
||||
i2c: i2c_blocking.into_async(),
|
||||
});
|
||||
|
||||
// TX activity LED on GPIO4 (active LOW — pin idles HIGH = LED off).
|
||||
// InvertedPin flips set_high/set_low so SimpleLed stays polarity-agnostic.
|
||||
let led_pin = Output::new(p.GPIO4, Level::High, OutputConfig::default());
|
||||
let led = SimpleLed(InvertedPin(led_pin));
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! LilyGo T-Beam S3 Supreme — ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC.
|
||||
//!
|
||||
//! Big-brother of the classic T-Beam: ESP32-S3-WROOM-1 module, SX1262 with a
|
||||
//! TCXO, 128×64 SH1106 OLED, AXP2101 PMIC on a dedicated I²C1 bus. Native USB
|
||||
//! (no UART bridge chip).
|
||||
//!
|
||||
//! The AXP2101 gates power to the SX1262 and OLED. Without its init sequence
|
||||
//! the LoRa chip never responds on SPI and `LoRa::new` fails.
|
||||
//!
|
||||
//! Pin map (verified from MeshCore variants/lilygo_tbeam_supreme_SX1262/):
|
||||
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
|
||||
//! LoRa ctl: RESET=5, DIO1=1, BUSY=4 (TCXO @ 1.6 V via DIO3)
|
||||
//! OLED I²C: SDA=17, SCL=18, SH1106 @ 0x3C (I²C0)
|
||||
//! PMIC I²C: SDA=42, SCL=41, AXP2101 @ 0x34 (I²C1, blocking, PMIC-only)
|
||||
//! USB: native USB-C on GPIO19/20 (fixed ESP32-S3 D+/D-)
|
||||
//! LED: GPIO6 (active HIGH indicator, reuses MeshCore's P_LORA_TX_LED)
|
||||
//!
|
||||
//! GPS / battery / IMU / SD / BME280 are unused — DongLoRa is a transparent
|
||||
//! LoRa-over-USB bridge. The PMIC init only enables ALDO1 (OLED) and ALDO3
|
||||
//! (LoRa); other rails are left at their AXP2101 power-on defaults.
|
||||
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use super::esp32s3;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::axp;
|
||||
use crate::driver::sh1106::Sh1106;
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
|
||||
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
pub type DisplayDriver = Sh1106<DisplayI2c>;
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
}
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
let mut display = Sh1106::new(i2c, 0x3C);
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SH1106 display init failed");
|
||||
return None;
|
||||
}
|
||||
let _ = display.set_brightness(0xFF).await;
|
||||
Some(display)
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "LilyGo T-Beam S3 Supreme";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
mcu::mac_address()
|
||||
}
|
||||
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||
|
||||
// AXP2101 PMIC on I²C1 (SDA=42, SCL=41). Blocking, one-shot: we only
|
||||
// need to flip the LoRa/OLED rails on, then the bus can be dropped.
|
||||
// If this fails the LoRa chip stays unpowered — log and keep going so
|
||||
// the rest of the boot path can still produce diagnostics.
|
||||
{
|
||||
let mut pmic = I2c::new(p.I2C1, I2cConfig::default())
|
||||
.expect("I2C1 init")
|
||||
.with_sda(p.GPIO42)
|
||||
.with_scl(p.GPIO41);
|
||||
if let Err(_e) = axp::axp2101_init_lora_oled(&mut pmic) {
|
||||
defmt::error!("AXP2101 init failed — LoRa rail may be dead");
|
||||
}
|
||||
}
|
||||
// Let ALDO1/ALDO3 settle before touching the LoRa SPI or OLED I²C.
|
||||
esp_hal::delay::Delay::new().delay_millis(20);
|
||||
|
||||
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
|
||||
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
|
||||
|
||||
// SX1262: CS=10, RESET=5, DIO1=1, BUSY=4, TCXO @ 1.6 V via DIO3.
|
||||
let radio = esp32s3::init_radio(
|
||||
spi_bus,
|
||||
p.GPIO10,
|
||||
p.GPIO5,
|
||||
p.GPIO1,
|
||||
p.GPIO4,
|
||||
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V6,
|
||||
);
|
||||
|
||||
// Native USB-C on GPIO19/20. Switches the internal USB PHY from
|
||||
// Serial-JTAG to OTG.
|
||||
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||
let host = UsbParts {
|
||||
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||
usb_inst,
|
||||
ep_out_buf,
|
||||
esp_hal::otg_fs::asynch::Config::default(),
|
||||
),
|
||||
};
|
||||
|
||||
// I2C0 for SH1106 OLED: SDA=17, SCL=18 (async, handed to display task).
|
||||
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||
let display = Some(DisplayParts { i2c });
|
||||
|
||||
// Activity LED on GPIO6 (active HIGH).
|
||||
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
|
||||
let led = SimpleLed(led_pin);
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ pub mod traits;
|
||||
pub use traits::*;
|
||||
|
||||
{% for helper, helper_features in helpers|items -%}
|
||||
{% if helper_features|length == 1 -%}
|
||||
#[cfg(feature = "{{ helper_features[0] }}")]
|
||||
{% else -%}
|
||||
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
|
||||
{% endif -%}
|
||||
mod {{ helper }};
|
||||
{% endfor %}
|
||||
cfg_if::cfg_if! {
|
||||
|
||||
@@ -20,13 +20,20 @@ type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
pub type UsbDriver = mcu::UsbNrfDriver;
|
||||
pub type DisplayI2c = mcu::I2cBus;
|
||||
pub type LedDriver = ();
|
||||
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||
>;
|
||||
// SH1106 hand-rolled driver instead of the ssd1306 crate. The RAK1921
|
||||
// OLED ships under various Chinese-OEM packaging (e.g. JMD0.96C) that
|
||||
// can be either SSD1306 or SH1106 silicon — physically and pin
|
||||
// identical, but use different addressing modes. The bigger reason
|
||||
// though: ssd1306 0.10's display-interface-i2c emits I2C transaction
|
||||
// patterns (consecutive writes, 3+ ops) that embassy_nrf::twim's
|
||||
// `unreachable!()` rejects, so even on real SSD1306 silicon the panic
|
||||
// would fire. Our hand-rolled SH1106 driver issues only single writes
|
||||
// per logical op and is twim-safe. SH1106 init opcodes are similar
|
||||
// enough that real SSD1306 silicon mostly works (worst case: mirrored
|
||||
// display until proper runtime detection lands).
|
||||
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
@@ -46,22 +53,29 @@ 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};
|
||||
let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3C);
|
||||
|
||||
let interface = I2CDisplayInterface::new(i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 display init failed");
|
||||
return None;
|
||||
// Retry 3× with 100ms backoff. The SH1106's first transaction
|
||||
// after power-on can return Err if the chip's internal voltage
|
||||
// converter hasn't fully settled — observed empirically on
|
||||
// RAK4631/RAK1921. Each attempt is independent (full init
|
||||
// sequence). Display works visibly even if the FIRST attempt
|
||||
// failed, because subsequent attempts succeed.
|
||||
for attempt in 0..3u8 {
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_ok() {
|
||||
let _ = display.set_brightness(0xFF).await;
|
||||
return Some(display);
|
||||
}
|
||||
if attempt < 2 {
|
||||
defmt::warn!(
|
||||
"SH1106 display init attempt {=u8}/3 failed; retrying",
|
||||
attempt + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||
Some(display)
|
||||
defmt::error!("SH1106 display init failed after 3 attempts");
|
||||
None
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
@@ -78,10 +92,14 @@ impl LoRaBoard for Board {
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = ();
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let p = embassy_nrf::init(Default::default());
|
||||
// USB requires the 32 MHz HFXO (±50 ppm); the RC oscillator is out of spec.
|
||||
let mut config = embassy_nrf::config::Config::default();
|
||||
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
|
||||
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
|
||||
let p = embassy_nrf::init(config);
|
||||
Self { p }
|
||||
}
|
||||
|
||||
@@ -89,12 +107,18 @@ impl LoRaBoard for Board {
|
||||
mcu::mac_address()
|
||||
}
|
||||
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, ()> {
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
// SX1262 is power-gated on P1.05 — must be HIGH before any SPI access.
|
||||
let sx_power_en = Output::new(p.P1_05, Level::High, OutputDrive::Standard);
|
||||
core::mem::forget(sx_power_en);
|
||||
|
||||
// ── SPI bus for SX1262 ──────────────────────────────────
|
||||
let mut spi_cfg = spim::Config::default();
|
||||
spi_cfg.frequency = spim::Frequency::M1;
|
||||
// embassy_nrf::spim::Spim::new args: (spim, irq, sck, miso, mosi, config).
|
||||
// Per RAK4631 BSP: SCK=P1.11, MISO=P1.13, MOSI=P1.12.
|
||||
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
|
||||
let spi_bus = mcu::share_spi_bus(spi);
|
||||
|
||||
@@ -103,7 +127,11 @@ impl LoRaBoard for Board {
|
||||
|
||||
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
|
||||
let dio1 = Input::new(p.P1_15, Pull::Down);
|
||||
let busy = Input::new(p.P1_14, Pull::Down);
|
||||
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
|
||||
// p. 53); an MCU-side Pull::Down fights it and leaves the line at
|
||||
// an indeterminate voltage, making wait_on_busy unreliable. See
|
||||
// lora-rs/lora-rs#260.
|
||||
let busy = Input::new(p.P1_14, Pull::None);
|
||||
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
|
||||
.expect("SX1262 interface init");
|
||||
@@ -121,7 +149,9 @@ impl LoRaBoard for Board {
|
||||
};
|
||||
|
||||
// ── USB ─────────────────────────────────────────────────
|
||||
let vbus = mcu::alloc_vbus_detect(true, false);
|
||||
// SoftwareVbusDetect(vbus_detected, power_ready). Mark both true so the
|
||||
// driver doesn't block waiting for a power-ready signal we never raise.
|
||||
let vbus = mcu::alloc_vbus_detect(true, true);
|
||||
let host = UsbParts {
|
||||
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
|
||||
};
|
||||
@@ -129,15 +159,24 @@ impl LoRaBoard for Board {
|
||||
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
|
||||
let twim_buf = mcu::alloc_i2c_buffer();
|
||||
let i2c = embassy_nrf::twim::Twim::new(
|
||||
p.TWISPI0, mcu::Irqs, p.P0_13, p.P0_14, twim::Config::default(), twim_buf,
|
||||
p.TWISPI0,
|
||||
mcu::Irqs,
|
||||
p.P0_13,
|
||||
p.P0_14,
|
||||
twim::Config::default(),
|
||||
twim_buf,
|
||||
);
|
||||
let display = Some(DisplayParts { i2c });
|
||||
|
||||
// Green LED on P1.03 (RAK19007 Base, active HIGH).
|
||||
let led_pin = Output::new(p.P1_03, Level::Low, OutputDrive::Standard);
|
||||
let led = crate::driver::simple_led::SimpleLed(led_pin);
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led: (),
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
|
||||
+38
-3
@@ -9,6 +9,8 @@
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
|
||||
use crate::driver::DisplayBrightness;
|
||||
|
||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||
pub trait RgbLed {
|
||||
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
||||
@@ -35,8 +37,9 @@ pub trait LoRaBoard: Sized {
|
||||
/// Display peripheral bundle (I2C bus for display init).
|
||||
type DisplayParts;
|
||||
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor>;
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||
/// and DisplayBrightness for the two-level dim control).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||
|
||||
/// RGB LED driver. Boards without an LED use `()`.
|
||||
type LedDriver: RgbLed;
|
||||
@@ -47,8 +50,40 @@ pub trait LoRaBoard: Sized {
|
||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||
fn mac_address() -> [u8; 6];
|
||||
|
||||
/// Radio chip identifier reported in `GET_INFO`. Defaults to SX1262
|
||||
/// (`0x0002`), matching every currently-supported board. Override for
|
||||
/// boards that carry a different chip (LLCC68, SX1261, LR11xx, …).
|
||||
fn radio_chip_id() -> u16 {
|
||||
// SX1262 — see PROTOCOL.md §8.
|
||||
0x0002
|
||||
}
|
||||
|
||||
/// (freq_min_hz, freq_max_hz) — the RF front-end's effective tuning
|
||||
/// window on this specific board. Reported in `GET_INFO`. Defaults
|
||||
/// to the SX1262's nominal 150–960 MHz range.
|
||||
fn freq_range_hz() -> (u32, u32) {
|
||||
(150_000_000, 960_000_000)
|
||||
}
|
||||
|
||||
/// Bitmap of supported LoRa spreading factors — bit N = SF N. Defaults
|
||||
/// to SX126x's SF5–SF12 (`0x1FE0`). SX127x boards must override to
|
||||
/// `0x1FC0` (SF6–SF12); LLCC68 boards to `0x0FE0` (SF5–SF11).
|
||||
fn supported_sf_bitmap() -> u16 {
|
||||
0x1FE0
|
||||
}
|
||||
|
||||
/// Bitmap of supported LoRa bandwidths. Bit positions match
|
||||
/// `LoRaBandwidth::as_u8()` (sub-GHz BW enum values 0..9). Defaults
|
||||
/// to all sub-GHz BWs (`0x03FF`). SX128x boards flip the 2.4 GHz
|
||||
/// bits when we add them.
|
||||
fn supported_bw_bitmap() -> u16 {
|
||||
0x03FF
|
||||
}
|
||||
|
||||
/// Decompose initialized board into peripheral bundles for each task.
|
||||
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
|
||||
fn into_parts(
|
||||
self,
|
||||
) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
|
||||
}
|
||||
|
||||
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
|
||||
|
||||
@@ -21,7 +21,8 @@ pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||
|
||||
// Display types — never instantiated (no OLED on this board), but the
|
||||
// trait requires concrete associated types that satisfy the bounds.
|
||||
pub type DisplayI2c = embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
|
||||
pub type DisplayI2c =
|
||||
embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
@@ -78,7 +79,8 @@ impl LoRaBoard for Board {
|
||||
let p = self.p;
|
||||
|
||||
// ── MAC address from flash unique ID ───────────────────
|
||||
let mut flash = embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
|
||||
let mut flash =
|
||||
embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
|
||||
mcu::init_mac(&mut flash);
|
||||
|
||||
// ── SPI1 bus for SX1262 ────────────────────────────────
|
||||
@@ -92,7 +94,14 @@ impl LoRaBoard for Board {
|
||||
let mut spi_cfg = spi::Config::default();
|
||||
spi_cfg.frequency = 1_000_000;
|
||||
let spi = spi::Spi::new(
|
||||
p.SPI1, p.PIN_14, p.PIN_15, p.PIN_24, p.DMA_CH0, p.DMA_CH1, mcu::Irqs, spi_cfg,
|
||||
p.SPI1,
|
||||
p.PIN_14,
|
||||
p.PIN_15,
|
||||
p.PIN_24,
|
||||
p.DMA_CH0,
|
||||
p.DMA_CH1,
|
||||
mcu::Irqs,
|
||||
spi_cfg,
|
||||
);
|
||||
let spi_bus = mcu::share_spi_bus(spi);
|
||||
|
||||
|
||||
@@ -96,7 +96,11 @@ impl LoRaBoard for Board {
|
||||
|
||||
let reset = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
|
||||
let dio1 = Input::new(p.P0_07, Pull::Down);
|
||||
let busy = Input::new(p.P1_10, Pull::Down);
|
||||
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
|
||||
// p. 53); an MCU-side Pull::Down fights it and leaves the line at
|
||||
// an indeterminate voltage, making wait_on_busy unreliable. See
|
||||
// lora-rs/lora-rs#260.
|
||||
let busy = Input::new(p.P1_10, Pull::None);
|
||||
|
||||
// RF switch: RXEN on P1.08 (GPIO, toggled by lora_phy), TX via DIO2 (SX1262 internal).
|
||||
let rx_enable = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
|
||||
@@ -128,7 +132,12 @@ impl LoRaBoard for Board {
|
||||
// ── Display (SH1106 OLED on I2C) ────────────────────────
|
||||
let twim_buf = mcu::alloc_i2c_buffer();
|
||||
let i2c = embassy_nrf::twim::Twim::new(
|
||||
p.TWISPI0, mcu::Irqs, p.P0_06, p.P0_05, twim::Config::default(), twim_buf,
|
||||
p.TWISPI0,
|
||||
mcu::Irqs,
|
||||
p.P0_06,
|
||||
p.P0_05,
|
||||
twim::Config::default(),
|
||||
twim_buf,
|
||||
);
|
||||
let display = Some(DisplayParts { i2c });
|
||||
|
||||
|
||||
+132
-52
@@ -1,68 +1,148 @@
|
||||
//! Inter-task communication channels.
|
||||
//! Inter-task communication channels for DongLoRa Protocol v2.
|
||||
//!
|
||||
//! Each channel connects exactly two tasks:
|
||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
||||
//! - [`StatusWatch`]: Radio → Display (observable radio state)
|
||||
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
|
||||
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
|
||||
//! and tag-0 async RX/ERR events in one queue)
|
||||
//! - [`DisplayCommandChannel`]: host_task → display_task (internal only —
|
||||
//! `On` on connect, `Reset` on disconnect; no longer driven from the wire)
|
||||
//! - [`RadioEventChannel`]: radio_task → display_task (typed mode/packet events)
|
||||
|
||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||
use embassy_sync::channel::Channel;
|
||||
use embassy_sync::watch::Watch;
|
||||
use portable_atomic::AtomicBool;
|
||||
|
||||
use crate::protocol::{Command, RadioConfig, Response};
|
||||
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
|
||||
|
||||
/// Display commands routed from host_task to display_task.
|
||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||
pub enum DisplayCommand {
|
||||
On,
|
||||
Off,
|
||||
#[allow(dead_code)] // only used by USB host task, not UART
|
||||
/// True while a Reset has been queued in `CommandChannel` but not yet
|
||||
/// consumed by the radio task. Dedupes injections from any host chart
|
||||
/// (USB DTR-driven OR UART inactivity-driven) that fires `reset_radio`
|
||||
/// repeatedly during quiet/inactive periods. Without this dedup, a slow
|
||||
/// or wedged radio causes the host chart's exit action to inject Reset
|
||||
/// every cycle, filling the commands channel and producing a "queue
|
||||
/// full" warn flood. Cleared by the radio chart's Reset handler
|
||||
/// (`reset_to_unconfigured`).
|
||||
///
|
||||
/// Uses `portable_atomic::AtomicBool` (not `core::sync::atomic`) because
|
||||
/// Cortex-M0+ targets (RP2040) lack native CAS for `swap`. portable-atomic
|
||||
/// transparently uses native atomics on M3+/M4 and critical-section
|
||||
/// emulation on M0/M0+.
|
||||
pub static RESET_PENDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// True while the host transport's USB IN endpoint is observed-dead
|
||||
/// (write_packet returned Err). Set by `host_task::protocol_loop` on the
|
||||
/// first failed write; cleared on the next successful write. Producers of
|
||||
/// **async** D→H events — `radio_task::record_packet` (RX) and
|
||||
/// `host::framing::emit_async_err` — check this flag before pushing into
|
||||
/// `OutboundChannel` and silently skip when set, so the queue does not
|
||||
/// fill while the host is gone.
|
||||
///
|
||||
/// Tag-correlated responses (OK / sync ERR / TX_DONE) are NOT gated by
|
||||
/// this flag: they're a small, bounded volume and the protocol contract
|
||||
/// requires them to be delivered if we ever reach the host again.
|
||||
///
|
||||
/// Same `portable_atomic::AtomicBool` rationale as `RESET_PENDING`.
|
||||
///
|
||||
/// **Initial value `true`**: at boot we have no proof the host can
|
||||
/// drain the outbound queue. Until host_task observes its first
|
||||
/// successful `write_packet` (and clears this), async-RX producers
|
||||
/// silently skip pushing. Otherwise — at boot, with no client
|
||||
/// attached or with mux-rs idle — RX events would fill the queue
|
||||
/// faster than host_task could drain it (which it can't: `write_packet`
|
||||
/// blocks until the host actually polls), and the depth-64 ring would
|
||||
/// flood `[WARN] RX dropped: outbound queue full` hundreds of times
|
||||
/// per second. Pre-throttle is the structural fix.
|
||||
pub static RADIO_THROTTLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Set by `radio::lora_setup` / `lora_setup_with` when a SHORT-DURATION
|
||||
/// lora-phy op blew through its wall-clock budget (almost always BUSY
|
||||
/// stuck high — chip is wedged). Read by chart actions and `during:`
|
||||
/// activities that called the helper: when they observe a downstream
|
||||
/// `Err(_)` AND see this flag set, they emit `WedgeDetected` (which
|
||||
/// drives the radio chart into `Recovering` for an in-place NRESET +
|
||||
/// `lora.init()` retry) instead of the per-action failure event. The
|
||||
/// flag is consumed (`swap(false)`) by whoever reads it so the next
|
||||
/// independent op gets a fresh signal.
|
||||
///
|
||||
/// This is the cleanest plumbing path for "wedge timeout vs ordinary
|
||||
/// driver error" given the helper sits below the chart-action layer
|
||||
/// (free function, no chart-emit access). One atomic, one concern,
|
||||
/// matching the established `RESET_PENDING` / `RADIO_THROTTLED` pattern.
|
||||
pub static LORA_WEDGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ── Host → Radio: tag-correlated commands ───────────────────────────
|
||||
|
||||
/// An event flowing from the host task into the radio task.
|
||||
///
|
||||
/// `Command` carries a non-zero correlation tag (the host framing layer
|
||||
/// rejects tag=0 before it reaches here). `Reset` is a lifecycle signal
|
||||
/// from the host transport layer (DTR drop, UART teardown, or spec §3.4
|
||||
/// inactivity timeout) telling the radio to park back to Unconfigured
|
||||
/// and drain queues silently.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InboundEvent {
|
||||
Command { tag: u16, cmd: Command },
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
||||
/// Host-to-radio event channel (depth 8).
|
||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
|
||||
|
||||
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
|
||||
|
||||
/// One outbound D→H frame carrying its tag. Tag-correlated messages
|
||||
/// (OK, ERR in response, TX_DONE) carry the originating command's tag;
|
||||
/// async events (RX, async ERR) carry `tag = 0x0000`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboundFrame {
|
||||
pub tag: u16,
|
||||
pub msg: DeviceMessage,
|
||||
}
|
||||
|
||||
/// Radio-to-host outbound channel.
|
||||
///
|
||||
/// Depth 64: absorbs bursts of RX events plus queued command responses
|
||||
/// plus async ERRs while the host transport drains. Each slot is ~280 B
|
||||
/// (max RxPayload + envelope), so 64 slots is ~18 KB — well within RAM
|
||||
/// budget on every supported MCU. The previous depth-32 was provoking
|
||||
/// "outbound queue full" floods during host stalls; the larger queue
|
||||
/// plus `RADIO_THROTTLED` backpressure flag (set by host_task on a
|
||||
/// failed write_packet) together stop the cascade.
|
||||
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 64>;
|
||||
|
||||
// ── Internal: display commands (no longer wire-visible) ────────────
|
||||
|
||||
/// Internal display command routed from host_task to display_task. The
|
||||
/// wire protocol no longer carries DisplayOn/Off (`PROTOCOL.md §15.2`);
|
||||
/// these values drive the display statechart from local events only.
|
||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||
#[allow(dead_code)] // `Off` is reserved for future diagnostic paths.
|
||||
pub enum DisplayCommand {
|
||||
/// Host connected (DTR rose on USB, or UART came up). Wake display.
|
||||
On,
|
||||
/// Host disconnected. Park display.
|
||||
Off,
|
||||
/// Reset cumulative counters (e.g., on config change).
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Host-to-display command channel.
|
||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
||||
|
||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
||||
// ── Radio → Display: typed packet/mode events ──────────────────────
|
||||
|
||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
||||
|
||||
/// Observable radio status for the display task (2 receivers max).
|
||||
/// Display always sees the latest state — intermediate updates may be
|
||||
/// skipped, which is fine for a status display.
|
||||
pub type StatusWatch = Watch<CriticalSectionRawMutex, RadioStatus, 2>;
|
||||
|
||||
/// Current radio state exposed to observers (e.g. display).
|
||||
/// Radio → downstream typed events. These drive the display; they are
|
||||
/// NOT the same thing as `OutboundFrame` (which goes out the host
|
||||
/// transport). Mode events describe what the radio is doing now; packet
|
||||
/// events describe what just happened.
|
||||
#[derive(Debug, Clone, defmt::Format)]
|
||||
pub struct RadioStatus {
|
||||
pub state: RadioState,
|
||||
pub config: Option<RadioConfig>,
|
||||
pub rx_count: u32,
|
||||
pub tx_count: u32,
|
||||
pub last_rssi: Option<i16>,
|
||||
pub last_snr: Option<i16>,
|
||||
}
|
||||
|
||||
/// Radio state machine states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
|
||||
pub enum RadioState {
|
||||
pub enum RadioEvent {
|
||||
EnteredRx,
|
||||
EnteredTx,
|
||||
Idle,
|
||||
Receiving,
|
||||
Transmitting,
|
||||
PacketRx { rssi: i16, snr: Option<i16> },
|
||||
PacketTx,
|
||||
ConfigChanged(LoRaConfig),
|
||||
}
|
||||
|
||||
impl Default for RadioStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RadioState::Idle,
|
||||
config: None,
|
||||
rx_count: 0,
|
||||
tx_count: 0,
|
||||
last_rssi: None,
|
||||
last_snr: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||
@@ -0,0 +1,230 @@
|
||||
//! LED-based diagnostic blinker (opt-in via the `debug-checkpoint` cargo
|
||||
//! feature).
|
||||
//!
|
||||
//! On boards whose UART is also the host protocol (heltec_v3_uart, any
|
||||
//! UART-only variant), defmt output would corrupt the COBS-framed host
|
||||
//! protocol. We can't use `defmt::info!` for live debugging on those
|
||||
//! boards while the host is connected. Instead we expose a single global
|
||||
//! atomic checkpoint; code sets it at interesting points in the boot /
|
||||
//! dispatch flow, and a dedicated task continuously flashes the board
|
||||
//! LED N times, pauses, and repeats — so a human can count the flashes
|
||||
//! and report "it got to N."
|
||||
//!
|
||||
//! Blink pattern:
|
||||
//! * Each flash: 400 ms ON, 400 ms OFF.
|
||||
//! * After N flashes: 2000 ms pause (LED held OFF).
|
||||
//! * Repeat indefinitely with the current `CHECKPOINT` value.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```ignore
|
||||
//! #[cfg(feature = "debug-checkpoint")]
|
||||
//! crate::debug_blink::set(3); // "we reached checkpoint 3"
|
||||
//! ```
|
||||
//!
|
||||
//! The task replaces `display_task` when the `debug-checkpoint` feature is
|
||||
//! enabled — it takes the board LED and drives it exclusively.
|
||||
//!
|
||||
//! # Current checkpoint map
|
||||
//!
|
||||
//! Defined by the `crate::radio` module. Updated here any time the
|
||||
//! numbering changes there.
|
||||
//!
|
||||
//! | Flashes | Meaning |
|
||||
//! |--------:|--------------------------------------------------------|
|
||||
//! | 1 | `radio_task` entered (pre-`LoRa::new`) |
|
||||
//! | 2 | `LoRa::new` succeeded |
|
||||
//! | 3 | Machine constructed; about to enter `run()` |
|
||||
//! | 4 | In `Idle` state |
|
||||
//! | 5 | In `Receiving` state |
|
||||
//! | 6 | TX phase 1: entered `perform_tx` (engages latch) |
|
||||
//! | 7 | TX phase 2: in CAD |
|
||||
//! | 8 | TX phase 3: in `prepare_for_tx` |
|
||||
//! | 9 | TX phase 4: awaiting `tx()` |
|
||||
//! | 10 | TX phase 5: `do_tx` returned OK |
|
||||
//! | 11 | TX phase 6: `respond_tx_done` handler entered |
|
||||
//! | 12 | TX phase 7: `responses.send(TxDone)` returned |
|
||||
//! | 13 | TX phase 8: `host_task` received the response from chan |
|
||||
//! | 14 | TX phase 9: `host_task` wrote response bytes to UART — TERMINAL SUCCESS |
|
||||
//! | 15 | TX errored somewhere — TERMINAL ERROR |
|
||||
//! | 16 | Boot error: `LoRa::new` failed |
|
||||
//! | 17 | Boot error: `start_rx` failed inside `start_rx_hw` |
|
||||
//!
|
||||
//! # Latching
|
||||
//!
|
||||
//! * Latch engages **only** on `set(6)` (entry to `perform_tx`).
|
||||
//! * While latched, only TX-phase values (6–15) can update `CHECKPOINT`,
|
||||
//! and only if strictly deeper than the current value.
|
||||
//! * Terminals (14 success, 15 error) freeze the latch until reboot.
|
||||
//! * `set(13)` and `set(14)` called from `host_task` are **no-ops before
|
||||
//! the latch engages** — this prevents unrelated SetConfig/Ok responses
|
||||
//! from polluting the display before any TX has happened.
|
||||
//!
|
||||
//! # Diagnostic logic
|
||||
//!
|
||||
//! If ai-bot reports a timeout but the LED shows:
|
||||
//! * **10** → `do_tx` ok, but `TransmitDone` event never dispatched.
|
||||
//! * **11** → handler entered, but `responses.send(...)` hung.
|
||||
//! * **12** → send pushed to channel, but `host_task` never picked it up.
|
||||
//! * **13** → `host_task` picked it up, but `tx.write_all` to UART hung.
|
||||
//! * **14** → full end-to-end firmware success. Hang is on the host side
|
||||
//! of the UART (mux or wire).
|
||||
//! * **15** → TX errored somewhere along the way.
|
||||
//!
|
||||
//! # Building with the feature
|
||||
//!
|
||||
//! The `just` build/flash recipes pass a fixed feature list and don't
|
||||
//! surface an `extra-features` argument. To build a debug image:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cd firmware
|
||||
//! . ~/export-esp.sh # if building for xtensa (esp32s3)
|
||||
//! cargo +esp build --release \
|
||||
//! --target xtensa-esp32s3-none-elf \
|
||||
//! --features heltec_v3_uart,debug-checkpoint \
|
||||
//! -Zbuild-std=core,alloc
|
||||
//! cp target/xtensa-esp32s3-none-elf/release/donglora \
|
||||
//! builds/donglora-heltec_v3_uart-v$(sed -n 's/^version = "\(.*\)"/\1/p' \
|
||||
//! Cargo.toml).elf
|
||||
//! espflash flash -p /dev/ttyUSB0 builds/donglora-heltec_v3_uart-v0.4.0.elf
|
||||
//! ```
|
||||
//!
|
||||
//! The normal `just flash heltec_v3_uart` command builds without the
|
||||
//! feature, so the debug code has zero footprint on release firmware.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
|
||||
use embassy_executor::task;
|
||||
use embassy_time::{Duration, Timer};
|
||||
|
||||
use crate::board::{LedDriver, RgbLed};
|
||||
|
||||
/// Current diagnostic checkpoint — read by the blinker task. Zero means
|
||||
/// "no checkpoint set yet."
|
||||
///
|
||||
/// Write policy (see [`set`]): once a TX-phase value (6, 11, 12, 13) is
|
||||
/// stored, the latch engages and:
|
||||
/// * subsequent TX-phase writes update the value only if strictly deeper,
|
||||
/// * non-TX-phase writes (including the 7/10 terminal markers and the
|
||||
/// steady-state 4/5 post-TX writes) are **ignored** so the deepest phase
|
||||
/// reached on the FIRST TX attempt persists.
|
||||
///
|
||||
/// Rationale: a stuck-then-recovered TX attempt would normally flicker
|
||||
/// through the display too quickly to count. Latching preserves the
|
||||
/// evidence until reboot.
|
||||
pub static CHECKPOINT: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Has the TX-phase latch engaged yet? Written by [`set`], checked by
|
||||
/// [`set`]. Not intended for external inspection.
|
||||
static HAS_LATCHED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[inline]
|
||||
fn is_host_phase(n: u8) -> bool {
|
||||
// Values set only from `host_task` — meaningless before a TX has
|
||||
// started, so we refuse to overwrite with them when the latch is
|
||||
// not yet engaged.
|
||||
n == 13 || n == 14
|
||||
}
|
||||
|
||||
/// Ordered depth of each TX-related checkpoint. Returns `0` for values
|
||||
/// that are NOT TX phases (e.g. boot / steady-state / error indicators).
|
||||
///
|
||||
/// Value 18 ("next_command received CmdTransmit") is intentionally the
|
||||
/// SHALLOWEST TX phase — it fires before `perform_tx` entry (value 6)
|
||||
/// but is numerically higher because it was added last. The latch uses
|
||||
/// `phase_depth` rather than raw numeric comparison so 18 can engage
|
||||
/// the latch at depth 1 and later TX phases (6..=15) deepen over it.
|
||||
#[inline]
|
||||
fn phase_depth(n: u8) -> u8 {
|
||||
match n {
|
||||
18 => 1, // pre-TX: CmdTransmit just read from commands channel
|
||||
6 => 2, // perform_tx entered
|
||||
7 => 3, // in CAD (before prepare_for_cad)
|
||||
19 => 4, // after prepare_for_cad (awaiting lora.cad IRQ)
|
||||
8 => 5, // in prepare_for_tx
|
||||
9 => 6, // in tx().await
|
||||
10 => 7, // do_tx returned Ok
|
||||
11 => 8, // respond_tx_done entered
|
||||
12 => 9, // respond_tx_done: responses.send returned
|
||||
13 => 10, // host_task received response from channel
|
||||
14 => 11, // host_task wrote response to UART (terminal success)
|
||||
15 => 12, // TX errored (terminal error)
|
||||
_ => 0, // not a TX phase
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_tx_phase(n: u8) -> bool {
|
||||
phase_depth(n) > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_tx_terminal(n: u8) -> bool {
|
||||
n == 14 || n == 15
|
||||
}
|
||||
|
||||
/// Record that we reached checkpoint `n`. A no-op in terms of visible
|
||||
/// behavior unless the blinker task is spawned; the store itself is very
|
||||
/// cheap so leaving these calls in non-debug builds is harmless.
|
||||
///
|
||||
/// Latch semantics:
|
||||
/// * `set(18)` (CmdTransmit received by `next_command`) or `set(6)`
|
||||
/// (entry to `perform_tx`) engage the latch.
|
||||
/// * While latched, only strictly-deeper TX phases (by `phase_depth`)
|
||||
/// update the checkpoint.
|
||||
/// * Terminal values (14 success, 15 error) freeze the latch until
|
||||
/// reboot.
|
||||
/// * Host-phase values (13, 14) called from `host_task` are ignored
|
||||
/// before the latch engages, so unrelated `Response::Ok` writes from
|
||||
/// SetConfig don't pollute the LED reading before any TX.
|
||||
#[inline]
|
||||
pub fn set(n: u8) {
|
||||
if HAS_LATCHED.load(Ordering::Relaxed) {
|
||||
let cur = CHECKPOINT.load(Ordering::Relaxed);
|
||||
if is_tx_terminal(cur) {
|
||||
return;
|
||||
}
|
||||
if phase_depth(n) > phase_depth(cur) {
|
||||
CHECKPOINT.store(n, Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Latch not yet engaged.
|
||||
if is_host_phase(n) {
|
||||
// `host_task` is draining the response channel before any TX —
|
||||
// these values would be misleading. Skip them.
|
||||
return;
|
||||
}
|
||||
|
||||
CHECKPOINT.store(n, Ordering::Relaxed);
|
||||
if is_tx_phase(n) {
|
||||
HAS_LATCHED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dedicated task that continuously flashes the LED to show the current
|
||||
/// checkpoint. Owns the board's LED driver exclusively — on boards where
|
||||
/// the display task would normally own the LED for RX/TX blips, spawn
|
||||
/// *either* this task *or* `display_task`, not both.
|
||||
#[task]
|
||||
pub async fn debug_blink_task(mut led: LedDriver) {
|
||||
// Start with the LED off so the first flash is unambiguous.
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
|
||||
loop {
|
||||
let n = CHECKPOINT.load(Ordering::Relaxed);
|
||||
if n == 0 {
|
||||
// No checkpoint yet — wait and re-check.
|
||||
Timer::after(Duration::from_millis(200)).await;
|
||||
continue;
|
||||
}
|
||||
for _ in 0..n {
|
||||
led.set_rgb(255, 255, 255).await;
|
||||
Timer::after(Duration::from_millis(400)).await;
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
Timer::after(Duration::from_millis(400)).await;
|
||||
}
|
||||
// Long pause to separate cycles so the human can count.
|
||||
Timer::after(Duration::from_millis(2000)).await;
|
||||
}
|
||||
}
|
||||
+633
-256
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
||||
//! AXP PMIC init — turn on the rails that feed LoRa and the OLED.
|
||||
//!
|
||||
//! T-Beam–class boards gate LoRa, OLED (and GPS/sensors, which this firmware
|
||||
//! does not use) through an X-Powers PMIC on I²C. Until the PMIC is told to
|
||||
//! enable the right LDOs/DCDCs, the SX126x/SX127x chip sits unpowered and
|
||||
//! `LoRa::new` fails. This module's only job is to flip those switches.
|
||||
//!
|
||||
//! Two PMICs are covered:
|
||||
//!
|
||||
//! * [`axp2101_init_lora_oled`] — LilyGo T-Beam S3 Supreme. ALDO3 = 3.3 V
|
||||
//! feeds the SX1262; ALDO1 = 3.3 V feeds the SH1106 OLED. Both live on a
|
||||
//! dedicated I²C1 bus (separate from the display).
|
||||
//! * [`axp192_init_lora_oled`] — LilyGo T-Beam (classic). LDO2 = 3.3 V feeds
|
||||
//! the SX1276; DCDC1 = 3.3 V feeds the SSD1306 OLED. PMIC shares the
|
||||
//! display I²C bus.
|
||||
//!
|
||||
//! Everything unrelated to LoRa + OLED (GPS, sensors, SD, batt ADC) is left
|
||||
//! alone. Out-of-scope for a transparent LoRa-over-USB bridge.
|
||||
//!
|
||||
//! Init is blocking on purpose. It runs once during `into_parts`, which is a
|
||||
//! synchronous function called before task spawning. Using the blocking
|
||||
//! `embedded_hal` trait keeps the PMIC wiring out of the async machinery and
|
||||
//! lets the caller drop the I²C bus immediately after init (no bus handoff).
|
||||
|
||||
use embedded_hal::i2c::I2c;
|
||||
|
||||
/// Both chips respond at this 7-bit I²C address.
|
||||
const PMIC_ADDR: u8 = 0x34;
|
||||
|
||||
// ── AXP2101 (T-Beam S3 Supreme) ──────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam_supreme")]
|
||||
mod axp2101 {
|
||||
use super::{I2c, PMIC_ADDR};
|
||||
|
||||
/// LDO on/off control register. Bits:
|
||||
/// 0 = ALDO1, 1 = ALDO2, 2 = ALDO3, 3 = ALDO4, 4 = BLDO1, 5 = BLDO2.
|
||||
const REG_LDO_ONOFF: u8 = 0x90;
|
||||
/// ALDO1 voltage register (OLED rail on T-Beam Supreme).
|
||||
const REG_ALDO1_V: u8 = 0x92;
|
||||
/// ALDO3 voltage register (LoRa rail on T-Beam Supreme).
|
||||
const REG_ALDO3_V: u8 = 0x94;
|
||||
|
||||
/// Voltage code for 3.3 V on ALDOx: 0.5 V + 28 × 100 mV.
|
||||
const V_3V3: u8 = 28;
|
||||
|
||||
/// Mask enabling ALDO1 + ALDO3 in [`REG_LDO_ONOFF`].
|
||||
const ALDO1_ALDO3: u8 = 0b0000_0101;
|
||||
|
||||
/// Bring up LoRa (ALDO3) and OLED (ALDO1) rails at 3.3 V.
|
||||
///
|
||||
/// Voltage registers are set before the enable bits to avoid glitching
|
||||
/// the rails through the reset-default voltage. Other rails are left
|
||||
/// untouched (read-modify-write on the enable register) so any
|
||||
/// board-specific OTP defaults stay intact.
|
||||
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
|
||||
i2c.write(PMIC_ADDR, &[REG_ALDO3_V, V_3V3])?;
|
||||
i2c.write(PMIC_ADDR, &[REG_ALDO1_V, V_3V3])?;
|
||||
|
||||
let mut onoff = [0u8; 1];
|
||||
i2c.write_read(PMIC_ADDR, &[REG_LDO_ONOFF], &mut onoff)?;
|
||||
i2c.write(PMIC_ADDR, &[REG_LDO_ONOFF, onoff[0] | ALDO1_ALDO3])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam_supreme")]
|
||||
pub use axp2101::init_lora_oled as axp2101_init_lora_oled;
|
||||
|
||||
// ── AXP192 (T-Beam classic) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam")]
|
||||
mod axp192 {
|
||||
use super::{I2c, PMIC_ADDR};
|
||||
|
||||
/// DCDC/LDO enable register. Bit layout:
|
||||
/// 0 = DCDC1, 1 = DCDC3, 2 = LDO2, 3 = LDO3, 4 = DCDC2, 6 = EXTEN.
|
||||
const REG_ONOFF: u8 = 0x12;
|
||||
/// DCDC1 output voltage register (OLED rail on T-Beam classic).
|
||||
/// 7-bit code, 25 mV step, 0.7 V offset → 3.3 V = 104 = `0x68`.
|
||||
const REG_DCDC1_V: u8 = 0x26;
|
||||
/// LDO2/LDO3 voltage register. Upper nibble = LDO2 (LoRa), lower nibble
|
||||
/// = LDO3 (GPS). 100 mV step, 1.8 V offset → 3.3 V = 15 = `0xF`.
|
||||
const REG_LDO23_V: u8 = 0x28;
|
||||
|
||||
/// DCDC1 code for 3.3 V.
|
||||
const DCDC1_3V3: u8 = 0x68;
|
||||
/// LDO2 nibble for 3.3 V in the upper half of [`REG_LDO23_V`].
|
||||
const LDO2_3V3_UPPER: u8 = 0xF0;
|
||||
|
||||
/// Mask enabling DCDC1 + LDO2 in [`REG_ONOFF`].
|
||||
const DCDC1_LDO2: u8 = 0b0000_0101;
|
||||
|
||||
/// Bring up LoRa (LDO2) and OLED (DCDC1) rails at 3.3 V on the
|
||||
/// classic T-Beam's AXP192.
|
||||
///
|
||||
/// GPS (LDO3) and EXTEN are not touched — this firmware has no GPS,
|
||||
/// and any board-default for LDO3 voltage is preserved by
|
||||
/// read-modify-write. Voltage is programmed before the enable bit
|
||||
/// flips so the rail doesn't glitch through its reset default.
|
||||
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
|
||||
let mut ldo23 = [0u8; 1];
|
||||
i2c.write_read(PMIC_ADDR, &[REG_LDO23_V], &mut ldo23)?;
|
||||
i2c.write(
|
||||
PMIC_ADDR,
|
||||
&[REG_LDO23_V, (ldo23[0] & 0x0F) | LDO2_3V3_UPPER],
|
||||
)?;
|
||||
|
||||
i2c.write(PMIC_ADDR, &[REG_DCDC1_V, DCDC1_3V3])?;
|
||||
|
||||
let mut onoff = [0u8; 1];
|
||||
i2c.write_read(PMIC_ADDR, &[REG_ONOFF], &mut onoff)?;
|
||||
i2c.write(PMIC_ADDR, &[REG_ONOFF, onoff[0] | DCDC1_LDO2])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam")]
|
||||
pub use axp192::init_lora_oled as axp192_init_lora_oled;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Generic `OutputPin` wrapper that inverts logic levels.
|
||||
//!
|
||||
//! When a peripheral is wired active-low (LED anode to V+, cathode to GPIO;
|
||||
//! reset line idle-high, etc.) the physical polarity is a property of the
|
||||
//! board, not of the driver. Wrapping the pin in `InvertedPin` lets drivers
|
||||
//! keep "set_high = assert" semantics while the board file expresses the
|
||||
//! wiring reality at construction time.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let pin = Output::new(p.GPIO4, Level::High, OutputConfig::default()); // off
|
||||
//! let led = SimpleLed(InvertedPin(pin)); // driver sees normal polarity
|
||||
//! ```
|
||||
|
||||
use embedded_hal::digital::{ErrorType, OutputPin};
|
||||
|
||||
pub struct InvertedPin<P>(pub P);
|
||||
|
||||
impl<P: ErrorType> ErrorType for InvertedPin<P> {
|
||||
type Error = P::Error;
|
||||
}
|
||||
|
||||
impl<P: OutputPin> OutputPin for InvertedPin<P> {
|
||||
fn set_low(&mut self) -> Result<(), Self::Error> {
|
||||
self.0.set_high()
|
||||
}
|
||||
|
||||
fn set_high(&mut self) -> Result<(), Self::Error> {
|
||||
self.0.set_low()
|
||||
}
|
||||
}
|
||||
+81
-2
@@ -1,5 +1,84 @@
|
||||
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
|
||||
#[cfg(any(
|
||||
feature = "wio_tracker_l1",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme",
|
||||
feature = "rak_wisblock_4631"
|
||||
))]
|
||||
pub mod sh1106;
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))]
|
||||
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
|
||||
pub mod axp;
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam")]
|
||||
pub mod inverted_pin;
|
||||
|
||||
#[cfg(any(
|
||||
feature = "heltec_v3",
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme",
|
||||
feature = "lilygo_tbeam",
|
||||
feature = "rak_wisblock_4631",
|
||||
feature = "wio_tracker_l1",
|
||||
feature = "waveshare_rp2040_lora"
|
||||
))]
|
||||
pub mod simple_led;
|
||||
|
||||
// ── Display brightness ────────────────────────────────────────────────
|
||||
//
|
||||
// The display statechart dims the OLED a few seconds after the last
|
||||
// activity. Our two driver families (`ssd1306` crate and our in-tree
|
||||
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
|
||||
// single async trait that the state-machine actions can call uniformly.
|
||||
|
||||
/// Two-level backlight control used by the display dashboard.
|
||||
pub trait DisplayBrightness {
|
||||
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
}
|
||||
|
||||
// Contrast values for the two brightness levels, picked to be common across
|
||||
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
|
||||
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
|
||||
const CONTRAST_BRIGHT: u8 = 0xFF;
|
||||
const CONTRAST_DIM: u8 = 0x08;
|
||||
|
||||
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
|
||||
where
|
||||
DI: display_interface::AsyncWriteOnlyDataCommand,
|
||||
SIZE: ssd1306::size::DisplaySizeAsync,
|
||||
{
|
||||
async fn set_bright(&mut self) {
|
||||
// `Brightness::custom(precharge, contrast)`. Matches the shape of
|
||||
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
|
||||
let _ = self
|
||||
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
|
||||
.await;
|
||||
}
|
||||
async fn set_dim(&mut self) {
|
||||
// Low precharge pairs with low contrast — same combination the
|
||||
// crate's DIMMEST preset uses.
|
||||
let _ = self
|
||||
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(
|
||||
feature = "wio_tracker_l1",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme",
|
||||
feature = "rak_wisblock_4631"
|
||||
))]
|
||||
impl<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||
where
|
||||
I: embedded_hal_async::i2c::I2c,
|
||||
{
|
||||
async fn set_bright(&mut self) {
|
||||
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
|
||||
}
|
||||
async fn set_dim(&mut self) {
|
||||
let _ = self.set_brightness(CONTRAST_DIM).await;
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -37,6 +37,12 @@ impl<I: I2c> Sh1106<I> {
|
||||
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
|
||||
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
|
||||
for &c in commands {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!(
|
||||
"[sh1106] cmd write: addr=0x{=u8:02X} bytes=[0x00, 0x{=u8:02X}]",
|
||||
self.addr,
|
||||
c,
|
||||
);
|
||||
self.i2c.write(self.addr, &[0x00, c]).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -44,6 +50,8 @@ impl<I: I2c> Sh1106<I> {
|
||||
|
||||
/// Initialize the display with sensible defaults.
|
||||
pub async fn init(&mut self) -> Result<(), I::Error> {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!("[sh1106] init: starting");
|
||||
self.cmd(&[
|
||||
0xAE, // display off
|
||||
0xD5, 0x80, // clock div: default
|
||||
@@ -71,16 +79,24 @@ impl<I: I2c> Sh1106<I> {
|
||||
page_buf[0] = 0x40;
|
||||
|
||||
for page in 0..PAGES {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!("[sh1106] flush page {=usize}: addr cmds (3 bytes)", page);
|
||||
// Set page address and column start (with SH1106 2-column offset).
|
||||
self.cmd(&[
|
||||
0xB0 | page as u8, // page address
|
||||
COL_OFFSET & 0x0F, // column low nibble
|
||||
0x10 | (COL_OFFSET >> 4), // column high nibble
|
||||
0xB0 | page as u8, // page address
|
||||
COL_OFFSET & 0x0F, // column low nibble
|
||||
0x10 | (COL_OFFSET >> 4), // column high nibble
|
||||
])
|
||||
.await?;
|
||||
|
||||
let start = page * WIDTH;
|
||||
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!(
|
||||
"[sh1106] flush page {=usize}: data write {=usize} bytes (0x40 prefix + 128 pixels)",
|
||||
page,
|
||||
page_buf.len(),
|
||||
);
|
||||
self.i2c.write(self.addr, &page_buf).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -88,9 +104,10 @@ impl<I: I2c> Sh1106<I> {
|
||||
|
||||
/// Set display brightness (contrast).
|
||||
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!("[sh1106] set_brightness: 0x{=u8:02X}", value);
|
||||
self.cmd(&[0x81, value]).await
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<I: I2c> DrawTarget for Sh1106<I> {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! ESP32 (classic / non-S3) MCU initialization primitives.
|
||||
//!
|
||||
//! Mirror of `hal::esp32s3` with a single delta: the classic ESP32 uses a
|
||||
//! peripheral-bound DMA (`DMA_SPI2`) rather than the S3's channel-addressed
|
||||
//! GDMA (`DMA_CH0`). Timer, I²C, and eFuse access are otherwise identical
|
||||
//! between the two Xtensa parts in this esp-hal vintage.
|
||||
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
|
||||
use esp_hal::i2c::master::I2c;
|
||||
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||
use esp_hal::spi::Mode as SpiMode;
|
||||
use esp_hal::time::Rate;
|
||||
use esp_hal::timer::timg::TimerGroup;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
// ── MCU-level types ─────────────────────────────────────────────────
|
||||
|
||||
pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
||||
pub type I2cBus = I2c<'static, esp_hal::Async>;
|
||||
|
||||
// ── Timer ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||
pub fn start_timer(
|
||||
timg0: esp_hal::peripherals::TIMG0<'static>,
|
||||
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
|
||||
) {
|
||||
let timg0 = TimerGroup::new(timg0);
|
||||
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
|
||||
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
|
||||
}
|
||||
|
||||
// ── SPI bus ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Initialize SPI2 with peripheral-bound DMA and wrap in a shared bus
|
||||
/// (`StaticCell` + `Mutex`).
|
||||
///
|
||||
/// Generic over pin type. The classic ESP32's matrix of GPIO → peripheral
|
||||
/// routes lets any GPIO serve as any SPI signal — same as the S3.
|
||||
pub fn init_spi(
|
||||
spi2: esp_hal::peripherals::SPI2<'static>,
|
||||
dma_spi2: esp_hal::peripherals::DMA_SPI2<'static>,
|
||||
sck: impl PeripheralOutput<'static>,
|
||||
mosi: impl PeripheralOutput<'static>,
|
||||
miso: impl PeripheralInput<'static>,
|
||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
let spi = Spi::new(
|
||||
spi2,
|
||||
SpiConfig::default()
|
||||
.with_frequency(Rate::from_mhz(1))
|
||||
.with_mode(SpiMode::_0),
|
||||
)
|
||||
.expect("SPI init")
|
||||
.with_sck(sck)
|
||||
.with_mosi(mosi)
|
||||
.with_miso(miso)
|
||||
.with_dma(dma_spi2);
|
||||
|
||||
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
||||
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
||||
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
||||
|
||||
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
||||
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||
}
|
||||
|
||||
// ── MAC address ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the factory-programmed MAC address from eFuse.
|
||||
pub fn mac_address() -> [u8; 6] {
|
||||
esp_hal::efuse::base_mac_address()
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("MAC is 6 bytes")
|
||||
}
|
||||
+10
-1
@@ -3,9 +3,18 @@
|
||||
//! Each submodule provides low-level bus and peripheral init for one MCU family.
|
||||
//! Board files call these with specific pins and construct higher-level drivers.
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))]
|
||||
#[cfg(any(
|
||||
feature = "heltec_v3",
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme"
|
||||
))]
|
||||
pub mod esp32s3;
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam")]
|
||||
pub mod esp32;
|
||||
|
||||
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
||||
pub mod nrf52840;
|
||||
|
||||
|
||||
+71
-3
@@ -25,9 +25,7 @@ pub type UsbNrfDriver =
|
||||
// ── SPI bus sharing ─────────────────────────────────────────────────
|
||||
|
||||
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||
pub fn share_spi_bus(
|
||||
spi: SpiBus,
|
||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||
@@ -58,6 +56,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.
|
||||
|
||||
+10
-8
@@ -17,15 +17,14 @@ bind_interrupts!(pub struct Irqs {
|
||||
|
||||
// ── MCU-level types ────────────────────────────────────────────────
|
||||
|
||||
pub type SpiBus = embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
|
||||
pub type SpiBus =
|
||||
embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
|
||||
pub type UsbRpDriver = embassy_rp::usb::Driver<'static, embassy_rp::peripherals::USB>;
|
||||
|
||||
// ── SPI bus sharing ────────────────────────────────────────────────
|
||||
|
||||
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||
pub fn share_spi_bus(
|
||||
spi: SpiBus,
|
||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||
@@ -42,7 +41,12 @@ static mut MAC_REF: Option<&'static [u8; 6]> = None;
|
||||
///
|
||||
/// Must be called once during board init before `mac_address()`.
|
||||
pub fn init_mac<const FLASH_SIZE: usize>(
|
||||
flash: &mut embassy_rp::flash::Flash<'_, embassy_rp::peripherals::FLASH, embassy_rp::flash::Blocking, FLASH_SIZE>,
|
||||
flash: &mut embassy_rp::flash::Flash<
|
||||
'_,
|
||||
embassy_rp::peripherals::FLASH,
|
||||
embassy_rp::flash::Blocking,
|
||||
FLASH_SIZE,
|
||||
>,
|
||||
) {
|
||||
let mut uid = [0u8; 8];
|
||||
flash.blocking_unique_id(&mut uid).expect("flash unique ID");
|
||||
@@ -72,7 +76,5 @@ pub fn init_mac<const FLASH_SIZE: usize>(
|
||||
pub fn mac_address() -> [u8; 6] {
|
||||
// SAFETY: MAC_REF is set once during init before the executor starts,
|
||||
// and only read (never written) after that.
|
||||
unsafe {
|
||||
*MAC_REF.expect("init_mac() must be called before mac_address()")
|
||||
}
|
||||
unsafe { *MAC_REF.expect("init_mac() must be called before mac_address()") }
|
||||
}
|
||||
+133
-111
@@ -1,124 +1,146 @@
|
||||
//! Shared COBS protocol helpers for USB and UART transports.
|
||||
//! Shared framing helpers for USB and UART host transports.
|
||||
//!
|
||||
//! Both `usb_task` and `uart_task` use these helpers for frame
|
||||
//! accumulation, response encoding, and command routing.
|
||||
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
|
||||
//! → `Command::parse` → forwarded to radio task along with the tag. On
|
||||
//! parse failure we echo `ERR(ELENGTH/EPARAM/EMODULATION/EUNKNOWN_CMD)`
|
||||
//! with the received tag. On CRC/COBS failure we emit async
|
||||
//! `ERR(EFRAME)` with tag `0x0000` (`PROTOCOL.md §2.5`).
|
||||
//!
|
||||
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
|
||||
//! → bytes ready for transport-level write.
|
||||
|
||||
use defmt::warn;
|
||||
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use crate::protocol::{Command, ErrorCode, Response};
|
||||
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
|
||||
use crate::protocol::{
|
||||
encode_frame, Command, CommandParseError, DeviceMessage, ErrorCode, FrameEncodeError,
|
||||
MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME,
|
||||
};
|
||||
|
||||
/// Maximum COBS frame size (bytes).
|
||||
pub const MAX_FRAME: usize = 512;
|
||||
|
||||
// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes.
|
||||
// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room.
|
||||
const _: () = assert!(
|
||||
MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64,
|
||||
"MAX_FRAME too small for max payload + COBS overhead"
|
||||
);
|
||||
|
||||
/// Decodes COBS-framed commands from a byte stream.
|
||||
pub struct CobsDecoder {
|
||||
buf: [u8; MAX_FRAME],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl CobsDecoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
buf: [0u8; MAX_FRAME],
|
||||
len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the accumulator (discard partial frame).
|
||||
pub fn reset(&mut self) {
|
||||
self.len = 0;
|
||||
}
|
||||
|
||||
/// Feed a chunk of bytes, calling `on_command` for each decoded command.
|
||||
pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) {
|
||||
for &byte in data {
|
||||
if byte == 0x00 {
|
||||
// End of COBS frame — decode and dispatch
|
||||
if self.len > 0 {
|
||||
let mut decode_buf = [0u8; MAX_FRAME];
|
||||
if let Some(decoded_len) =
|
||||
ucobs::decode(&self.buf[..self.len], &mut decode_buf)
|
||||
{
|
||||
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
|
||||
on_command(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.len = 0;
|
||||
} else if self.len < MAX_FRAME {
|
||||
self.buf[self.len] = byte;
|
||||
self.len += 1;
|
||||
} else {
|
||||
// Frame too large — discard
|
||||
self.len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel.
|
||||
/// Dispatch a frame-level decode into the appropriate downstream queue.
|
||||
///
|
||||
/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow.
|
||||
pub fn cobs_encode_response<'a>(
|
||||
response: Response,
|
||||
write_buf: &mut [u8; MAX_FRAME],
|
||||
encode_buf: &'a mut [u8; MAX_FRAME],
|
||||
) -> Option<&'a [u8]> {
|
||||
let raw_len = response.write_to(write_buf);
|
||||
let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0);
|
||||
if encoded_len < encode_buf.len() {
|
||||
encode_buf[encoded_len] = 0x00;
|
||||
Some(&encode_buf[..encoded_len + 1])
|
||||
} else {
|
||||
warn!("COBS encode buffer overflow");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a parsed command to the appropriate handler.
|
||||
///
|
||||
/// Display and MAC commands are handled locally (response sent immediately).
|
||||
/// All other commands are forwarded to the radio task (response sent later).
|
||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
||||
pub async fn route_command(
|
||||
cmd: Command,
|
||||
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
|
||||
/// - Known commands are forwarded to the radio task via `commands`.
|
||||
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
|
||||
/// `outbound`, so the host sees a concrete answer instead of timing out.
|
||||
/// - The caller is responsible for rejecting `tag == 0` before calling
|
||||
/// this; the spec says hosts MUST NOT send tag 0, and we classify such
|
||||
/// frames as framing errors upstream.
|
||||
pub async fn dispatch_frame(
|
||||
type_id: u8,
|
||||
tag: u16,
|
||||
payload: &[u8],
|
||||
commands: &CommandChannel,
|
||||
responses: &ResponseChannel,
|
||||
display_commands: &DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
outbound: &OutboundChannel,
|
||||
) {
|
||||
match cmd {
|
||||
Command::DisplayOn => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
if tag == 0 {
|
||||
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
|
||||
// framing error; do not route to radio. (Spec §2.2)
|
||||
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||
return;
|
||||
}
|
||||
match Command::parse(type_id, payload) {
|
||||
Ok(cmd) => {
|
||||
let envelope = InboundEvent::Command { tag, cmd };
|
||||
if commands.try_send(envelope).is_err() {
|
||||
emit_sync_err(outbound, tag, ErrorCode::EBusy).await;
|
||||
}
|
||||
}
|
||||
Command::DisplayOff => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Off).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::GetMac => {
|
||||
responses.send(Response::MacAddress(mac)).await;
|
||||
}
|
||||
other => {
|
||||
commands.send(other).await;
|
||||
Err(e) => {
|
||||
let code = parse_err_to_wire_code(e);
|
||||
emit_sync_err(outbound, tag, code).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
|
||||
///
|
||||
/// Backpressure: if the host transport's IN endpoint is observed-dead
|
||||
/// (RADIO_THROTTLED set by host_task), drop silently. Without this gate,
|
||||
/// a bad-frame burst from a wedged host fills `OutboundChannel` faster
|
||||
/// than host_task can drain it (because it can't drain it at all when
|
||||
/// write_packet keeps failing), producing the warn flood seen in soak.
|
||||
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
|
||||
if crate::channel::RADIO_THROTTLED.load(portable_atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let frame = OutboundFrame {
|
||||
tag: 0,
|
||||
msg: DeviceMessage::Err(code),
|
||||
};
|
||||
if outbound.try_send(frame).is_err() {
|
||||
warn!("async ERR dropped: outbound queue full");
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a synchronous error response echoing the originating tag.
|
||||
async fn emit_sync_err(outbound: &OutboundChannel, tag: u16, code: ErrorCode) {
|
||||
let frame = OutboundFrame {
|
||||
tag,
|
||||
msg: DeviceMessage::Err(code),
|
||||
};
|
||||
if outbound.try_send(frame).is_err() {
|
||||
warn!("sync ERR dropped: outbound queue full");
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_err_to_wire_code(e: CommandParseError) -> ErrorCode {
|
||||
match e {
|
||||
CommandParseError::UnknownType => ErrorCode::EUnknownCmd,
|
||||
CommandParseError::WrongLength => ErrorCode::ELength,
|
||||
CommandParseError::InvalidField => ErrorCode::EParam,
|
||||
CommandParseError::ReservedBitSet => ErrorCode::EParam,
|
||||
CommandParseError::UnknownModulation => ErrorCode::EModulation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an outbound frame into a single wire-ready byte buffer.
|
||||
///
|
||||
/// Returns the number of bytes written to `out`, or `None` on buffer
|
||||
/// overflow / encode failure (caller may log and drop).
|
||||
pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) -> Option<usize> {
|
||||
let mut payload_buf = [0u8; MAX_PAYLOAD_FIELD];
|
||||
let payload_len = match frame.msg.encode_payload(&mut payload_buf) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("encode_payload failed: {:?}", defmt::Debug2Format(&e));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match encode_frame(
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
&payload_buf[..payload_len],
|
||||
out.as_mut_slice(),
|
||||
) {
|
||||
Ok(n) => Some(n),
|
||||
Err(FrameEncodeError::BufferTooSmall) => {
|
||||
warn!(
|
||||
"encode_frame: output buffer too small (type={=u8}, tag={=u16}, payload={=usize}B, out_buf={=usize}B)",
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
payload_len,
|
||||
out.len(),
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(FrameEncodeError::PayloadTooLarge) => {
|
||||
warn!(
|
||||
"encode_frame: payload exceeds MAX_PAYLOAD_FIELD (type={=u8}, tag={=u16}, payload={=usize}B)",
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
payload_len,
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(FrameEncodeError::CobsEncode) => {
|
||||
warn!(
|
||||
"encode_frame: COBS encode refused (type={=u8}, tag={=u16}, payload={=usize}B)",
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
payload_len,
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -6,7 +6,11 @@
|
||||
pub mod framing;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(feature = "heltec_v3_uart", feature = "elecrow_thinknode_m2"))] {
|
||||
if #[cfg(any(
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam"
|
||||
))] {
|
||||
mod uart;
|
||||
pub use uart::host_task;
|
||||
} else {
|
||||
|
||||
+160
-46
@@ -1,75 +1,189 @@
|
||||
//! UART host task: COBS-framed fixed-size LE command/response protocol.
|
||||
//! UART host task for DongLoRa Protocol v2.
|
||||
//!
|
||||
//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3).
|
||||
//! UART has no DTR line, so we assume a host is always present and
|
||||
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
|
||||
//! → Disconnected → drop radio to Unconfigured).
|
||||
|
||||
use defmt::warn;
|
||||
use defmt::{info, warn};
|
||||
use embassy_executor::task;
|
||||
use embassy_time::{Duration, Instant};
|
||||
use embedded_io_async::Write;
|
||||
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
||||
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||
use crate::channel::{
|
||||
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||
};
|
||||
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME};
|
||||
|
||||
const READ_BUF: usize = 64;
|
||||
const INACTIVITY_MS: u64 = 1000;
|
||||
|
||||
#[task]
|
||||
pub async fn host_task(
|
||||
parts: crate::board::UartParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
_mac: [u8; 6],
|
||||
) {
|
||||
let (mut rx, mut tx) = parts.driver.split();
|
||||
|
||||
// UART has no DTR — assume a host is always present.
|
||||
// UART has no DTR — wake display immediately and assume a host.
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
|
||||
let mut read_buf = [0u8; 64];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
let mut decoder = CobsDecoder::new();
|
||||
// Two sub-loops joined in the same task. Splitting read and write
|
||||
// is what stops a long-running tx.write_all (~25 ms for a max-size
|
||||
// frame at 115_200 baud, multiplied across a saturated outbound
|
||||
// queue) from starving the inbound poll. While write_all sits in
|
||||
// its FIFO-drain await, join re-polls the read future on the next
|
||||
// wake, so last_frame_at keeps refreshing and the §3.4 watchdog
|
||||
// does not spuriously fire under sustained bidirectional load.
|
||||
// Same task = single executor + disjoint borrows on the split
|
||||
// rx/tx halves, so no Mutex is needed.
|
||||
let read_loop = async {
|
||||
let mut read_buf = [0u8; READ_BUF];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut last_frame_at = Instant::now();
|
||||
let mut connected = true;
|
||||
|
||||
loop {
|
||||
use embassy_futures::select::{select, Either};
|
||||
loop {
|
||||
use embassy_futures::select::{select, Either};
|
||||
|
||||
match select(
|
||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||
responses.receive(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::First(result) => {
|
||||
let n = match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
continue;
|
||||
match select(
|
||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::First(Ok(0)) => {}
|
||||
Either::First(Ok(n)) => {
|
||||
last_frame_at = Instant::now();
|
||||
if !connected {
|
||||
if has_display {
|
||||
let _ = display_commands.try_send(DisplayCommand::On);
|
||||
}
|
||||
connected = true;
|
||||
}
|
||||
};
|
||||
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||
}
|
||||
Either::First(Err(_)) => {
|
||||
// Byte-level error (FIFO overrun, UART framing,
|
||||
// parity). Per PROTOCOL.md §3.4: only "byte stream
|
||||
// activity that results in a complete frame
|
||||
// arriving" resets the inactivity timer, so we do
|
||||
// NOT touch last_frame_at here. The 100 ms sleep
|
||||
// is anti-spin: a stuck driver pumping continuous
|
||||
// Errs would otherwise burn the executor.
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
}
|
||||
Either::Second(()) => {}
|
||||
}
|
||||
|
||||
let mut cmds = heapless::Vec::<_, 4>::new();
|
||||
decoder.feed(&read_buf[..n], |cmd| {
|
||||
let _ = cmds.push(cmd);
|
||||
});
|
||||
for cmd in cmds {
|
||||
framing::route_command(
|
||||
cmd, commands, responses, display_commands, has_display, mac,
|
||||
)
|
||||
.await;
|
||||
// Inactivity watchdog (PROTOCOL.md §3.4, 1000 ms).
|
||||
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||
warn!("UART host inactivity timeout fired");
|
||||
let _ = commands.try_send(InboundEvent::Reset);
|
||||
if has_display {
|
||||
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||
}
|
||||
// Drop in-flight frame state. When the host resumes it
|
||||
// may pick up mid-frame; without resetting the decoder
|
||||
// we'd chew through garbage until the next 0x00
|
||||
// sentinel. USB gets the equivalent for free via
|
||||
// DTR-drop reset (host/usb.rs Disconnected entry); UART
|
||||
// has no DTR.
|
||||
decoder.reset();
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let write_loop = async {
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
|
||||
loop {
|
||||
let frame = outbound.receive().await;
|
||||
let Some(n) = encode_outbound(&frame, &mut write_buf) else {
|
||||
continue;
|
||||
};
|
||||
// Mirror src/host/usb.rs:349-374. The throttle gate at
|
||||
// src/radio.rs:2017 silently drops async RX into the
|
||||
// outbound queue while RADIO_THROTTLED is true; clear it
|
||||
// on every successful write and re-arm it on failure,
|
||||
// same contract as USB.
|
||||
match tx.write_all(&write_buf[..n]).await {
|
||||
Ok(()) => {
|
||||
if crate::channel::RADIO_THROTTLED
|
||||
.load(portable_atomic::Ordering::Acquire)
|
||||
{
|
||||
info!("UART write recovered; clearing radio throttle");
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(false, portable_atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("UART write failed, frame dropped, throttling radio");
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(true, portable_atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
Either::Second(response) => {
|
||||
if let Some(frame) =
|
||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
||||
{
|
||||
if tx.write_all(frame).await.is_err() {
|
||||
warn!("UART write failed, response dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
embassy_futures::join::join(read_loop, write_loop).await;
|
||||
}
|
||||
|
||||
async fn dispatch_bytes(
|
||||
decoder: &mut FrameDecoder,
|
||||
bytes: &[u8],
|
||||
commands: &'static CommandChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
) {
|
||||
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||
decoder.feed(bytes, |res| match res {
|
||||
FrameResult::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
let mut p: heapless::Vec<u8, MAX_PAYLOAD_FIELD> = heapless::Vec::new();
|
||||
let _ = p.extend_from_slice(payload);
|
||||
let _ = work.push(FrameWork::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload: p,
|
||||
});
|
||||
}
|
||||
FrameResult::Err(_) => {
|
||||
let _ = work.push(FrameWork::Frame);
|
||||
}
|
||||
});
|
||||
for w in work {
|
||||
match w {
|
||||
FrameWork::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||
}
|
||||
FrameWork::Frame => {
|
||||
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum FrameWork {
|
||||
Ok {
|
||||
type_id: u8,
|
||||
tag: u16,
|
||||
payload: heapless::Vec<u8, MAX_PAYLOAD_FIELD>,
|
||||
},
|
||||
Frame,
|
||||
}
|
||||
+384
-58
@@ -1,23 +1,172 @@
|
||||
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
|
||||
//! USB CDC-ACM host task for DongLoRa Protocol v2.
|
||||
//!
|
||||
//! ```text
|
||||
//! Host
|
||||
//! ├── Disconnected (default) no DTR
|
||||
//! └── Connected host attached, DTR up
|
||||
//! │ resting here means no traffic yet —
|
||||
//! │ NO inactivity timer (boot quiet is
|
||||
//! │ normal; chart structurally blocks the
|
||||
//! │ spurious 1s fire).
|
||||
//! └── Active ≥1 frame seen since connect
|
||||
//! inactivity timer (1s) armed and
|
||||
//! restarted by every frame via
|
||||
//! self-transition.
|
||||
//! ```
|
||||
//!
|
||||
//! Two chart-level invariants made structurally impossible by the shape:
|
||||
//! 1. **No spurious inactivity at boot.** The 1-second timer only exists
|
||||
//! in `Active`, which requires a `FrameReceived` event to enter.
|
||||
//! A freshly-connected host that holds DTR high but sends nothing
|
||||
//! rests at `Connected` indefinitely (hsmc 0.5 optional-default
|
||||
//! "microwave Standby" pattern) — no timer, no warn, no Reset cascade.
|
||||
//! 2. **Chart-owned timer scope.** Per hsmc README rule §7 (timers
|
||||
//! armed by state lifecycle), the inactivity timer is cancelled on
|
||||
//! every exit of `Active`, restarted on every entry. No global
|
||||
//! `last_frame_at` instant, no manual elapsed check in the loop.
|
||||
//!
|
||||
//! The data plane (frame decode, command dispatch, frame encode) stays
|
||||
//! imperative in `protocol_loop` because `read_packet` blocks.
|
||||
|
||||
use defmt::warn;
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::join::join;
|
||||
use embassy_time::Duration;
|
||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||
use embassy_usb::Builder;
|
||||
use hsmc::{statechart, Duration as HsmcDuration};
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use crate::protocol::Command;
|
||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
||||
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||
use crate::channel::{
|
||||
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||
};
|
||||
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_WIRE_FRAME};
|
||||
|
||||
const MAX_PACKET: usize = 64;
|
||||
const MAX_USB_PACKET: usize = 64;
|
||||
|
||||
/// Spec §3.4: inactivity timer period. The device must see at least one
|
||||
/// frame (good or bad) every 1000 ms or the session is considered lost.
|
||||
/// Declared in the chart on `Active` — chart owns timer lifecycle.
|
||||
const INACTIVITY_MS: u64 = 1000;
|
||||
|
||||
/// Poll interval for the DTR edge detector. Chart owns the inactivity
|
||||
/// timer; this is only the cadence at which protocol_loop wakes when
|
||||
/// no other event is pending, to re-check DTR.
|
||||
const POLL_INTERVAL_MS: u64 = 100;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HostInput {
|
||||
DtrRaised,
|
||||
DtrDropped,
|
||||
/// A USB read returned ≥1 byte. Drives `Connected → Active` on
|
||||
/// the first frame after DTR up, and the `Active → Active`
|
||||
/// self-transition that restarts the inactivity timer per
|
||||
/// hsmc README rule §4 (self-transition exits + re-enters target,
|
||||
/// re-arming its timer).
|
||||
FrameReceived,
|
||||
}
|
||||
|
||||
pub struct HostContext {
|
||||
commands: &'static CommandChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
}
|
||||
|
||||
impl HostContext {
|
||||
async fn wake_display_if_present(&self) {
|
||||
if self.has_display {
|
||||
self.display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_display_if_present(&self) {
|
||||
if self.has_display {
|
||||
self.display_commands.send(DisplayCommand::Reset).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn send_radio_reset(&self) {
|
||||
use portable_atomic::Ordering;
|
||||
// Dedupe: if a Reset is already queued (and not yet consumed by
|
||||
// the radio task), don't queue another. Without this, a slow or
|
||||
// wedged radio + repeated chart exits (DTR drop, inactivity,
|
||||
// both transports) fill the commands channel and produce the
|
||||
// "queue full" warn flood. The radio chart's reset_to_unconfigured
|
||||
// clears the flag once it processes the queued Reset.
|
||||
if crate::channel::RESET_PENDING.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
if self.commands.try_send(InboundEvent::Reset).is_err() {
|
||||
// Channel genuinely full of non-Reset commands; this is the
|
||||
// edge case worth knowing about. Clear the flag so future
|
||||
// Reset attempts can re-try once the channel drains.
|
||||
defmt::warn!("radio Reset inject: command queue full");
|
||||
crate::channel::RESET_PENDING.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statechart! {
|
||||
Host {
|
||||
context: HostContext;
|
||||
events: HostInput;
|
||||
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
|
||||
trace;
|
||||
default(Disconnected);
|
||||
|
||||
state Disconnected {
|
||||
on(DtrRaised) => Connected;
|
||||
}
|
||||
|
||||
state Connected {
|
||||
entry: wake_display;
|
||||
exit: reset_radio, reset_display;
|
||||
on(DtrDropped) => Disconnected;
|
||||
// hsmc 0.5 microwave-Standby pattern: Connected itself is the
|
||||
// resting "DTR up, no traffic yet" state. No inactivity timer
|
||||
// here — boot quiet is normal (mux-rs holds DTR high before
|
||||
// any client attaches), so the chart structurally cannot fire
|
||||
// a 1 s warn before the first byte. The first FrameReceived
|
||||
// descends into Active, which owns the inactivity timer.
|
||||
on(FrameReceived) => Active;
|
||||
|
||||
// Inactivity timer is chart-scoped: armed on every entry to
|
||||
// Active (from Connected on the first frame, or from the
|
||||
// self-transition below on each subsequent frame), cancelled
|
||||
// on every exit (per hsmc README rule §7). The self-transition
|
||||
// re-enters Active per rule §4, re-arming the timer —
|
||||
// equivalent to the "reset last_frame_at on each byte" pattern
|
||||
// but expressed in chart shape.
|
||||
//
|
||||
// hsmc's `after` triggers want `hsmc::Duration` (re-export
|
||||
// of `core::time::Duration`), not `embassy_time::Duration`.
|
||||
state Active {
|
||||
on(after HsmcDuration::from_millis(INACTIVITY_MS)) => Disconnected;
|
||||
on(FrameReceived) => Active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostActions for HostActionContext<'_> {
|
||||
async fn wake_display(&mut self) {
|
||||
self.wake_display_if_present().await;
|
||||
}
|
||||
|
||||
async fn reset_radio(&mut self) {
|
||||
self.send_radio_reset();
|
||||
}
|
||||
|
||||
async fn reset_display(&mut self) {
|
||||
self.reset_display_if_present().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[task]
|
||||
pub async fn host_task(
|
||||
parts: crate::board::UsbParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
@@ -39,27 +188,48 @@ pub async fn host_task(
|
||||
}
|
||||
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
|
||||
|
||||
// CTRL_BUF was 64 bytes; bumped to 128 because the previous size
|
||||
// matches the panic site "control buffer too small" in
|
||||
// embassy_usb/src/lib.rs:758. CdcAcmClass only requires >= 7 for
|
||||
// SET_LINE_CODING, but composite descriptor handlers and string
|
||||
// descriptor responses may overrun a 64 B buffer for some host
|
||||
// enumerations. 128 is safely above the largest CDC-ACM control
|
||||
// request and well within RAM budget.
|
||||
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 128]> = StaticCell::new();
|
||||
static CDC_STATE: StaticCell<State> = StaticCell::new();
|
||||
|
||||
let desc_buf = DESC_BUF.init([0; 256]);
|
||||
let conf_buf = CONF_BUF.init([0; 256]);
|
||||
let bos_buf = BOS_BUF.init([0; 256]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 64]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 128]);
|
||||
let cdc_state = CDC_STATE.init(State::new());
|
||||
|
||||
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
|
||||
|
||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
|
||||
// Note: when this device enumerates on Linux, modem-manager's
|
||||
// CDC-ACM probe issues SET_INTERFACE with alt settings beyond what
|
||||
// we declare, triggering a "SET_INTERFACE: alt setting out of
|
||||
// range" WARN from embassy_usb. The probe is normal Linux
|
||||
// behavior; embassy_usb's STALL response is correct USB negotiation.
|
||||
// The WARN is benign noise — do NOT add dummy alt settings just to
|
||||
// silence it (risks breaking Windows/macOS enumeration).
|
||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
|
||||
let mut usb_dev = builder.build();
|
||||
let (sender, receiver) = class.split();
|
||||
|
||||
join(
|
||||
usb_dev.run(),
|
||||
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
|
||||
protocol_loop(
|
||||
sender,
|
||||
receiver,
|
||||
commands,
|
||||
outbound,
|
||||
display_commands,
|
||||
has_display,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -68,74 +238,230 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
mut sender: Sender<'d, D>,
|
||||
mut receiver: Receiver<'d, D>,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
|
||||
let mut read_buf = [0u8; MAX_PACKET];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
let mut decoder = CobsDecoder::new();
|
||||
let mut was_connected = false;
|
||||
let mut machine = Host::new_local(HostContext {
|
||||
commands,
|
||||
display_commands,
|
||||
has_display,
|
||||
});
|
||||
machine.step(HsmcDuration::ZERO).await;
|
||||
|
||||
let mut read_buf = [0u8; MAX_USB_PACKET];
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.read_packet(&mut read_buf),
|
||||
responses.receive(),
|
||||
embassy_time::Timer::after_millis(250),
|
||||
outbound.receive(),
|
||||
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(result) => {
|
||||
let n = match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut cmds = heapless::Vec::<_, 4>::new();
|
||||
decoder.feed(&read_buf[..n], |cmd| {
|
||||
let _ = cmds.push(cmd);
|
||||
});
|
||||
for cmd in cmds {
|
||||
framing::route_command(
|
||||
cmd, commands, responses, display_commands, has_display, mac,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Either3::First(Ok(0)) => {
|
||||
defmt::info!("host_task: read_packet returned 0 bytes");
|
||||
}
|
||||
Either3::Second(response) => {
|
||||
if let Some(frame) =
|
||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
||||
{
|
||||
for chunk in frame.chunks(MAX_PACKET) {
|
||||
Either3::First(Ok(n)) => {
|
||||
defmt::info!(
|
||||
"host_task: read_packet got {=usize}B (DTR={=bool})",
|
||||
n,
|
||||
receiver.dtr(),
|
||||
);
|
||||
// Drives Connected → Active on first byte and
|
||||
// Active → Active (timer-restart) on every byte after.
|
||||
// The chart owns inactivity from here.
|
||||
let _ = machine.dispatch(HostInput::FrameReceived).await;
|
||||
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||
}
|
||||
Either3::First(Err(e)) => {
|
||||
// Per embassy_usb_driver::EndpointError, `read_packet`
|
||||
// returns one of two errors:
|
||||
// - `Disabled`: OUT endpoint not enabled by the host
|
||||
// (USB enumeration in progress, host
|
||||
// suspended, or interface not yet
|
||||
// configured). Completely normal during
|
||||
// bring-up + every host disconnect.
|
||||
// - `BufferOverflow`: incoming packet larger than read_buf.
|
||||
// read_buf is sized to MAX_USB_PACKET
|
||||
// (64) which matches the CDC-ACM EP
|
||||
// max packet size, so this should
|
||||
// never happen in practice.
|
||||
//
|
||||
// `Disabled` floods at boot before USB enumeration finishes
|
||||
// (the OS hasn't sent SET_CONFIGURATION yet) — was previously
|
||||
// logging dozens of "read_packet error" lines per second.
|
||||
// Demote to debug-level since it's expected; reserve WARN
|
||||
// for BufferOverflow which IS a real bug.
|
||||
use embassy_usb_driver::EndpointError;
|
||||
match e {
|
||||
EndpointError::Disabled => {
|
||||
defmt::debug!(
|
||||
"host_task: read_packet returned Disabled (USB endpoint not yet enabled by host — normal during enumeration)"
|
||||
);
|
||||
}
|
||||
EndpointError::BufferOverflow => {
|
||||
defmt::warn!(
|
||||
"host_task: read_packet BufferOverflow — host sent a packet larger than {=usize}B (max EP packet size); decoder reset",
|
||||
MAX_USB_PACKET,
|
||||
);
|
||||
}
|
||||
}
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
}
|
||||
Either3::Second(frame) => {
|
||||
let frame_tag = frame.tag;
|
||||
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||
defmt::info!(
|
||||
"host_task: sending {=usize}B frame tag={=u16}",
|
||||
n,
|
||||
frame_tag,
|
||||
);
|
||||
// Per embassy_usb 0.6: write_packet only returns
|
||||
// EndpointError::Disabled when the IN endpoint is
|
||||
// dead (host gone or USB suspended). If a chunk
|
||||
// mid-frame fails, the partial bytes already sent
|
||||
// are unrecoverable — host's framing layer will see
|
||||
// corruption until a clean frame arrives. Bail on
|
||||
// the rest of THIS frame and set RADIO_THROTTLED so
|
||||
// the radio task stops generating async events
|
||||
// until the host comes back. Without the throttle,
|
||||
// every subsequent frame would also fail mid-write
|
||||
// and produce more wire corruption.
|
||||
let mut frame_failed = false;
|
||||
for (chunk_idx, chunk) in
|
||||
write_buf[..n].chunks(MAX_USB_PACKET).enumerate()
|
||||
{
|
||||
defmt::info!(
|
||||
"host_task: write_packet chunk {=usize} ({=usize}B) tag={=u16}",
|
||||
chunk_idx,
|
||||
chunk.len(),
|
||||
frame_tag,
|
||||
);
|
||||
if sender.write_packet(chunk).await.is_err() {
|
||||
warn!("USB write failed, response dropped");
|
||||
defmt::warn!(
|
||||
"USB write failed (host stalled or gone); frame dropped tag={=u16}, throttling radio",
|
||||
frame_tag,
|
||||
);
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(true, portable_atomic::Ordering::Release);
|
||||
frame_failed = true;
|
||||
break;
|
||||
}
|
||||
defmt::info!(
|
||||
"host_task: write_packet OK chunk {=usize} tag={=u16}",
|
||||
chunk_idx,
|
||||
frame_tag,
|
||||
);
|
||||
}
|
||||
if !frame_failed
|
||||
&& crate::channel::RADIO_THROTTLED
|
||||
.load(portable_atomic::Ordering::Acquire)
|
||||
{
|
||||
defmt::info!(
|
||||
"USB write recovered (tag={=u16}); clearing radio throttle",
|
||||
frame_tag,
|
||||
);
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(false, portable_atomic::Ordering::Release);
|
||||
}
|
||||
defmt::info!("host_task: frame done tag={=u16}", frame_tag);
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {}
|
||||
}
|
||||
|
||||
let connected = receiver.dtr();
|
||||
if was_connected && !connected {
|
||||
let _ = commands.try_send(Command::StopRx);
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Reset).await;
|
||||
Either3::Third(()) => {
|
||||
// Heartbeat tick — drives DTR edge polling + inactivity
|
||||
// watchdog below. No log: it would fire every
|
||||
// POLL_INTERVAL_MS and bury the interesting traffic.
|
||||
}
|
||||
}
|
||||
if !was_connected && connected && has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
|
||||
// DTR edge detection — drives Connected / Disconnected
|
||||
// transitions. The chart's `current_state()` may be
|
||||
// Disconnected, Connected (resting), or Connected/Active.
|
||||
// We're connected if current state is anywhere inside
|
||||
// Connected.
|
||||
let dtr_high = receiver.dtr();
|
||||
let was_connected = !matches!(
|
||||
machine.current_state(),
|
||||
HostState::Disconnected
|
||||
);
|
||||
match (was_connected, dtr_high) {
|
||||
(false, true) => {
|
||||
defmt::info!("host_task: DTR raised");
|
||||
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
||||
}
|
||||
(true, false) => {
|
||||
defmt::info!("host_task: DTR dropped");
|
||||
decoder.reset();
|
||||
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
was_connected = connected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed raw bytes into the frame decoder. For each complete frame:
|
||||
/// - Ok → route into the command channel (or emit sync ERR on parse fail)
|
||||
/// - Err(Crc | Cobs | TooShort) → emit async ERR(EFRAME)
|
||||
///
|
||||
/// Borrow-scoped per the `FrameDecoder::feed` callback contract: the
|
||||
/// payload slice is valid only for the duration of the callback.
|
||||
async fn dispatch_bytes(
|
||||
decoder: &mut FrameDecoder,
|
||||
bytes: &[u8],
|
||||
commands: &'static CommandChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
) {
|
||||
// `FrameDecoder::feed` hands payloads by reference; we must synchronously
|
||||
// copy any data we need before the callback returns. To avoid fighting
|
||||
// lifetimes we accumulate per-frame work as an enum.
|
||||
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||
decoder.feed(bytes, |res| match res {
|
||||
FrameResult::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
let mut p: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }> =
|
||||
heapless::Vec::new();
|
||||
let _ = p.extend_from_slice(payload);
|
||||
let _ = work.push(FrameWork::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload: p,
|
||||
});
|
||||
}
|
||||
FrameResult::Err(_e) => {
|
||||
let _ = work.push(FrameWork::Frame);
|
||||
}
|
||||
});
|
||||
for w in work {
|
||||
match w {
|
||||
FrameWork::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||
}
|
||||
FrameWork::Frame => {
|
||||
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum FrameWork {
|
||||
Ok {
|
||||
type_id: u8,
|
||||
tag: u16,
|
||||
payload: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }>,
|
||||
},
|
||||
Frame,
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
//! Pure-math helpers for Listen-Before-Talk (CAD) retry timing.
|
||||
//!
|
||||
//! The CAD retry loop in `radio::do_tx` scales its backoff to the active
|
||||
//! LoRa configuration: per-retry sleep is one time-on-air of an assumed
|
||||
//! peer packet, total retry budget is capped at `CAD_BUDGET_US`. This
|
||||
//! module owns the ToA math (Semtech AN1200.13) and the retry-count
|
||||
//! derivation, split out so it can be unit-tested host-side without the
|
||||
//! radio module's hardware dependencies.
|
||||
|
||||
use crate::protocol::{LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
|
||||
|
||||
/// Total LBT budget: the maximum time the CAD retry loop is allowed to
|
||||
/// tie up the radio before returning `ChannelBusy`.
|
||||
pub const CAD_BUDGET_US: u32 = 2_500_000;
|
||||
/// Upper bound on retries, independent of budget — caps the work on
|
||||
/// very fast configs (SF7/BW500 would otherwise get 60+ retries).
|
||||
pub const CAD_HARD_CAP: u8 = 10;
|
||||
/// Assumed peer-packet payload (bytes) used to size the per-retry sleep.
|
||||
/// 80 is a representative median for typical mesh traffic. Peers sending
|
||||
/// larger packets may be partially under-waited (accepted trade-off —
|
||||
/// host-level retry handles collisions).
|
||||
#[allow(dead_code)] // consumed by `radio.rs`, which is gated out of test builds
|
||||
pub const CAD_ASSUMED_PEER_PAYLOAD_B: u16 = 80;
|
||||
/// Jitter applied to each sleep, in tenths of a percent (±20 % = 200).
|
||||
pub const CAD_JITTER_TENTHS_PCT: u32 = 200;
|
||||
|
||||
/// ToA formula expects CR ∈ {1..4} for 4/5..4/8 (each coded symbol carries
|
||||
/// `CR+4` bits). The protocol enum starts at 0, so map explicitly — using
|
||||
/// `as u32` alone would underestimate airtime by one code bit everywhere.
|
||||
pub fn cr_numeric(cr: LoRaCodingRate) -> u32 {
|
||||
match cr {
|
||||
LoRaCodingRate::Cr4_5 => 1,
|
||||
LoRaCodingRate::Cr4_6 => 2,
|
||||
LoRaCodingRate::Cr4_7 => 3,
|
||||
LoRaCodingRate::Cr4_8 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Worst-case LoRa time-on-air in microseconds (Semtech AN1200.13).
|
||||
///
|
||||
/// Uses `N_preamble + 4.25` quarter-symbols for preamble duration; SX126x
|
||||
/// uses 6.25 at SF5/SF6, so the result is low by 2·T_sym at those SFs
|
||||
/// (sub-millisecond — negligible at the LBT-budget scale).
|
||||
pub fn toa_us(cfg: &LoRaConfig, payload_len: u16) -> u32 {
|
||||
let sf = cfg.sf as u64;
|
||||
let bw_hz = cfg.bw.as_hz() as u64;
|
||||
// Symbol time in µs: (2^SF / BW_Hz) * 1e6. u64 avoids overflow at
|
||||
// SF12/BW7 where T_sym ≈ 524 ms.
|
||||
let t_sym_us = (1u64 << sf) * 1_000_000 / bw_hz.max(1);
|
||||
let de: i64 = if t_sym_us > 16_000 { 1 } else { 0 };
|
||||
let crc: i64 = if cfg.payload_crc { 1 } else { 0 };
|
||||
let h: i64 = if matches!(cfg.header_mode, LoRaHeaderMode::Implicit) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pl = payload_len as i64;
|
||||
let sf_i = sf as i64;
|
||||
|
||||
// Payload symbol count (signed intermediates — `num` goes negative
|
||||
// for small PL at SF12 with H=1, CRC=0).
|
||||
let num = 8 * pl - 4 * sf_i + 28 + 16 * crc - 20 * h;
|
||||
let den = 4 * (sf_i - 2 * de);
|
||||
let payload_sym_ceil = if num <= 0 || den <= 0 {
|
||||
0i64
|
||||
} else {
|
||||
(num + den - 1) / den
|
||||
};
|
||||
let cr_num = cr_numeric(cfg.cr) as i64;
|
||||
let n_payload = 8 + (payload_sym_ceil * (cr_num + 4)).max(0);
|
||||
let t_payload_us = n_payload as u64 * t_sym_us;
|
||||
|
||||
// Preamble duration: (N + 4.25) * T_sym = (4·N + 17) * T_sym / 4.
|
||||
let t_preamble_us = (cfg.preamble_len as u64 * 4 + 17) * t_sym_us / 4;
|
||||
|
||||
(t_preamble_us + t_payload_us).min(u32::MAX as u64) as u32
|
||||
}
|
||||
|
||||
/// Derive the CAD retry count and per-retry sleep from the cached
|
||||
/// `cad_retry_delay_us`. Retries form: N CADs separated by N-1 sleeps.
|
||||
/// When one assumed-packet wait exceeds the total budget, `retries = 1`
|
||||
/// — a single CAD, bail on busy, no sleep.
|
||||
pub fn cad_retries(cad_retry_delay_us: u32) -> (u8, u32) {
|
||||
if cad_retry_delay_us == 0 {
|
||||
// Unconfigured should never reach this path; fall back to a
|
||||
// single CAD with no sleep rather than divide-by-zero.
|
||||
return (1, 0);
|
||||
}
|
||||
let n_sleeps = CAD_BUDGET_US / cad_retry_delay_us;
|
||||
let retries = (n_sleeps + 1).clamp(1, CAD_HARD_CAP as u32) as u8;
|
||||
(retries, cad_retry_delay_us)
|
||||
}
|
||||
|
||||
/// Apply ±`CAD_JITTER_TENTHS_PCT`/10 % jitter around `base_us`.
|
||||
/// `entropy` is a raw 32-bit source; caller provides it so this stays
|
||||
/// pure (radio task passes `Instant::now().as_ticks() as u32`).
|
||||
pub fn jittered_sleep_us(base_us: u32, entropy: u32) -> u32 {
|
||||
let mixed = entropy.wrapping_mul(0x9E37_79B9);
|
||||
let span = 2 * CAD_JITTER_TENTHS_PCT + 1; // e.g. 401 for ±20 %
|
||||
let r = mixed % span; // 0..=2·CAD_JITTER_TENTHS_PCT
|
||||
// factor_thousandths ∈ [1000 − CAD_JITTER_TENTHS_PCT, 1000 + CAD_JITTER_TENTHS_PCT]
|
||||
let factor_thousandths = 1000 - CAD_JITTER_TENTHS_PCT + r;
|
||||
((base_us as u64 * factor_thousandths as u64) / 1000) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
|
||||
|
||||
fn cfg(sf: u8, bw: LoRaBandwidth, cr: LoRaCodingRate, preamble: u16, implicit: bool, crc: bool) -> LoRaConfig {
|
||||
LoRaConfig {
|
||||
freq_hz: 915_000_000,
|
||||
sf,
|
||||
bw,
|
||||
cr,
|
||||
preamble_len: preamble,
|
||||
sync_word: 0x1424,
|
||||
tx_power_dbm: 0,
|
||||
header_mode: if implicit {
|
||||
LoRaHeaderMode::Implicit
|
||||
} else {
|
||||
LoRaHeaderMode::Explicit
|
||||
},
|
||||
payload_crc: crc,
|
||||
iq_invert: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: assert `actual` is within `tolerance_pct`% of `expected_us`.
|
||||
fn approx(actual_us: u32, expected_us: u32, tolerance_pct: u32) {
|
||||
let diff = actual_us.abs_diff(expected_us);
|
||||
let tol = expected_us * tolerance_pct / 100;
|
||||
assert!(
|
||||
diff <= tol,
|
||||
"actual {} us not within {}% of expected {} us (diff {}, tol {})",
|
||||
actual_us,
|
||||
tolerance_pct,
|
||||
expected_us,
|
||||
diff,
|
||||
tol
|
||||
);
|
||||
}
|
||||
|
||||
// Reference values sanity-checked against Semtech's LoRa calculator
|
||||
// and AN1200.13.
|
||||
|
||||
#[test]
|
||||
fn toa_sf7_bw125_cr45_pl10_explicit_crc() {
|
||||
let c = cfg(7, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
// Hand-computed reference from AN1200.13:
|
||||
// T_sym = 128/125 kHz = 1.024 ms
|
||||
// T_preamble = (8 + 4.25) · 1.024 = 12.544 ms
|
||||
// n_payload = 8 + ceil((80 − 28 + 28 + 16) / 28) · 5 = 8 + 4·5 = 28
|
||||
// T_payload = 28 · 1.024 = 28.672 ms
|
||||
// Total ≈ 41.22 ms
|
||||
approx(toa_us(&c, 10), 41_216, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toa_sf12_bw125_cr45_pl10_explicit_crc() {
|
||||
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
// Reference: ~991 ms for PL=10 at SF12/BW125/CR4/5 (LDRO on).
|
||||
approx(toa_us(&c, 10), 991_000, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toa_sf10_bw125_cr45_pl255_max() {
|
||||
let c = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
// Reference: ~2.34 s for PL=255 at SF10/BW125/CR4/5.
|
||||
approx(toa_us(&c, 255), 2_335_000, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toa_ldro_activates_at_sf11_and_sf12_bw125() {
|
||||
// T_sym > 16 ms → DE=1. SF11/BW125: T_sym = 16.384 ms. SF10/BW125: 8.192 ms.
|
||||
let sf10 = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
let sf11 = cfg(11, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
let sf12 = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
// Crude check: each step roughly doubles airtime (LDRO doesn't
|
||||
// break the doubling trend — it just shifts constants).
|
||||
let t10 = toa_us(&sf10, 80);
|
||||
let t11 = toa_us(&sf11, 80);
|
||||
let t12 = toa_us(&sf12, 80);
|
||||
assert!(t11 > t10 * 15 / 10, "SF11 should be > 1.5x SF10, got {} vs {}", t11, t10);
|
||||
assert!(t12 > t11 * 15 / 10, "SF12 should be > 1.5x SF11, got {} vs {}", t12, t11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toa_assumed_peer_80b_matches_plan_table() {
|
||||
let mk = |sf, bw| cfg(sf, bw, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||
// Plan table values (approximate, within 5%).
|
||||
approx(toa_us(&mk(7, LoRaBandwidth::Khz500), 80), 36_000, 5);
|
||||
approx(toa_us(&mk(7, LoRaBandwidth::Khz250), 80), 72_000, 5);
|
||||
approx(toa_us(&mk(7, LoRaBandwidth::Khz125), 80), 144_000, 5);
|
||||
approx(toa_us(&mk(8, LoRaBandwidth::Khz125), 80), 257_000, 5);
|
||||
approx(toa_us(&mk(9, LoRaBandwidth::Khz125), 80), 452_000, 5);
|
||||
approx(toa_us(&mk(10, LoRaBandwidth::Khz125), 80), 862_000, 5);
|
||||
approx(toa_us(&mk(11, LoRaBandwidth::Khz125), 80), 1_806_000, 5);
|
||||
approx(toa_us(&mk(12, LoRaBandwidth::Khz125), 80), 3_285_000, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toa_no_underflow_at_small_pl_sf12_implicit_no_crc() {
|
||||
// num = 8·PL − 4·SF + 28 + 16·CRC − 20·H goes negative here; the
|
||||
// i64 + max(0) guard must kick in without panicking or wrapping.
|
||||
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, true, false);
|
||||
let t = toa_us(&c, 1);
|
||||
// Only 8 payload symbols survive (the formula floors to 0 extra);
|
||||
// should be preamble + 8·T_sym ≈ 401 + 262 = 663 ms roughly.
|
||||
approx(t, 663_000, 5);
|
||||
}
|
||||
|
||||
// Retry-count derivation table from the plan.
|
||||
|
||||
#[test]
|
||||
fn retries_sf7_bw500_hits_hard_cap() {
|
||||
let (n, s) = cad_retries(36_000);
|
||||
assert_eq!(n, 10);
|
||||
assert_eq!(s, 36_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_sf10_bw125() {
|
||||
// T_assumed ≈ 862 ms; budget 2.5 s ⇒ 2 sleeps ⇒ 3 retries.
|
||||
let (n, _) = cad_retries(862_000);
|
||||
assert_eq!(n, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_sf11_bw125() {
|
||||
// T_assumed ≈ 1.81 s; budget 2.5 s ⇒ 1 sleep ⇒ 2 retries.
|
||||
let (n, _) = cad_retries(1_806_000);
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_sf12_bw125_degraded() {
|
||||
// T_assumed ≈ 3.29 s > 2.5 s budget ⇒ 0 sleeps ⇒ 1 retry (bail on busy).
|
||||
let (n, _) = cad_retries(3_285_000);
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_zero_delay_falls_back_to_single_cad() {
|
||||
// Unconfigured safety: avoid divide-by-zero.
|
||||
let (n, s) = cad_retries(0);
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(s, 0);
|
||||
}
|
||||
|
||||
// Jitter sanity.
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_within_pm_20_pct() {
|
||||
let base = 1_000_000u32;
|
||||
// Exhaustive over the low byte — enough to cover the `% span` range.
|
||||
let mut saw_low = false;
|
||||
let mut saw_high = false;
|
||||
for e in 0u32..=u16::MAX as u32 {
|
||||
let j = jittered_sleep_us(base, e);
|
||||
// ±20% ⇒ [800_000, 1_200_000].
|
||||
assert!(
|
||||
(800_000..=1_200_000).contains(&j),
|
||||
"jitter {} us out of ±20% bounds for entropy {}",
|
||||
j,
|
||||
e
|
||||
);
|
||||
if j <= 850_000 {
|
||||
saw_low = true;
|
||||
}
|
||||
if j >= 1_150_000 {
|
||||
saw_high = true;
|
||||
}
|
||||
}
|
||||
assert!(saw_low && saw_high, "jitter didn't span the expected range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_zero_base_is_zero() {
|
||||
for e in [0u32, 1, 42, 0xDEAD_BEEF] {
|
||||
assert_eq!(jittered_sleep_us(0, e), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
-12
@@ -19,6 +19,8 @@
|
||||
mod board;
|
||||
#[cfg(not(test))]
|
||||
mod channel;
|
||||
#[cfg(all(not(test), feature = "debug-checkpoint"))]
|
||||
mod debug_blink;
|
||||
#[cfg(not(test))]
|
||||
mod display;
|
||||
#[cfg(not(test))]
|
||||
@@ -27,6 +29,7 @@ mod driver;
|
||||
mod hal;
|
||||
#[cfg(not(test))]
|
||||
mod host;
|
||||
mod lbt;
|
||||
mod protocol;
|
||||
#[cfg(not(test))]
|
||||
mod radio;
|
||||
@@ -37,14 +40,16 @@ use embassy_executor::Spawner;
|
||||
#[cfg(not(test))]
|
||||
use crate::board::LoRaBoard;
|
||||
#[cfg(not(test))]
|
||||
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
|
||||
use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel};
|
||||
#[cfg(not(test))]
|
||||
use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", 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"))] {
|
||||
} 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 _;
|
||||
}
|
||||
@@ -53,11 +58,13 @@ cfg_if::cfg_if! {
|
||||
#[cfg(not(test))]
|
||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
||||
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static STATUS: StatusWatch = StatusWatch::new();
|
||||
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static mut INFO: Option<Info> = None;
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
@@ -66,7 +73,7 @@ cfg_if::cfg_if! {
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
}
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] {
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] {
|
||||
#[esp_rtos::main]
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
@@ -77,10 +84,29 @@ 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 = "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
|
||||
// snapshot of device capabilities reported over the wire.
|
||||
let info = build_info(parts.mac);
|
||||
// SAFETY: touched only here at boot, before any task is spawned.
|
||||
let info_ref: &'static Info = unsafe {
|
||||
INFO = Some(info);
|
||||
#[allow(static_mut_refs)]
|
||||
INFO.as_ref().expect("INFO just initialized")
|
||||
};
|
||||
|
||||
spawner.spawn(
|
||||
radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS)
|
||||
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
|
||||
.expect("spawn radio_task"),
|
||||
);
|
||||
|
||||
@@ -88,7 +114,7 @@ async fn run(spawner: Spawner) {
|
||||
host::host_task(
|
||||
parts.host,
|
||||
&COMMANDS,
|
||||
&RESPONSES,
|
||||
&OUTBOUND,
|
||||
&DISPLAY_COMMANDS,
|
||||
parts.display.is_some(),
|
||||
parts.mac,
|
||||
@@ -96,11 +122,83 @@ async fn run(spawner: Spawner) {
|
||||
.expect("spawn host_task"),
|
||||
);
|
||||
|
||||
// Suppress "assigned but never read" warning on non-USB/UART path.
|
||||
let _ = info_ref;
|
||||
|
||||
if let Some(dp) = parts.display {
|
||||
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
||||
spawner.spawn(
|
||||
display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS)
|
||||
.expect("spawn display_task"),
|
||||
);
|
||||
#[cfg(feature = "debug-checkpoint")]
|
||||
{
|
||||
// LED is taken over by the checkpoint blinker; the display
|
||||
// init struct is dropped unused for this debug build.
|
||||
let _ = dp;
|
||||
spawner
|
||||
.spawn(debug_blink::debug_blink_task(parts.led).expect("spawn debug_blink_task"));
|
||||
}
|
||||
#[cfg(not(feature = "debug-checkpoint"))]
|
||||
{
|
||||
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
||||
spawner.spawn(
|
||||
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
|
||||
.expect("spawn display_task"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn build_info(mac: [u8; 6]) -> Info {
|
||||
let (fmin, fmax) = <board::Board as LoRaBoard>::freq_range_hz();
|
||||
let chip_id = <board::Board as LoRaBoard>::radio_chip_id();
|
||||
let (tx_min, tx_max) = board::Board::TX_POWER_RANGE;
|
||||
let supported_sf = <board::Board as LoRaBoard>::supported_sf_bitmap();
|
||||
let supported_bw = <board::Board as LoRaBoard>::supported_bw_bitmap();
|
||||
|
||||
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
|
||||
mcu_uid[..6].copy_from_slice(&mac);
|
||||
|
||||
Info {
|
||||
proto_major: 1,
|
||||
proto_minor: 0,
|
||||
fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0),
|
||||
fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0),
|
||||
fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0),
|
||||
radio_chip_id: chip_id,
|
||||
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
||||
supported_sf_bitmap: supported_sf,
|
||||
supported_bw_bitmap: supported_bw,
|
||||
max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16,
|
||||
// Real channel depths from channel.rs.
|
||||
rx_queue_capacity: 32,
|
||||
tx_queue_capacity: 1,
|
||||
freq_min_hz: fmin,
|
||||
freq_max_hz: fmax,
|
||||
tx_power_min_dbm: tx_min,
|
||||
tx_power_max_dbm: tx_max,
|
||||
mcu_uid_len: 6,
|
||||
mcu_uid,
|
||||
radio_uid_len: 0,
|
||||
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
const fn parse_u8_or(s: &str, default: u8) -> u8 {
|
||||
// CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at
|
||||
// compile time via a tiny constant function instead of pulling in a
|
||||
// dependency.
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut out: u32 = 0;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if b < b'0' || b > b'9' {
|
||||
return default;
|
||||
}
|
||||
out = out * 10 + (b - b'0') as u32;
|
||||
if out > u8::MAX as u32 {
|
||||
return default;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out as u8
|
||||
}
|
||||
+15
-706
@@ -1,707 +1,16 @@
|
||||
//! Wire protocol types and fixed-size little-endian serialization.
|
||||
//! Wire protocol types for DongLoRa Protocol v2.
|
||||
//!
|
||||
//! Every integer is fixed-width LE. No varints, no zigzag.
|
||||
//! See `docs/PROTOCOL.md` for the complete specification.
|
||||
|
||||
use heapless::Vec;
|
||||
|
||||
/// Maximum LoRa payload size in bytes.
|
||||
pub const MAX_PAYLOAD: usize = 256;
|
||||
|
||||
/// RadioConfig wire size (fixed).
|
||||
pub const RADIO_CONFIG_SIZE: usize = 13;
|
||||
|
||||
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
|
||||
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
|
||||
|
||||
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
|
||||
pub const PREAMBLE_DEFAULT: u16 = 0;
|
||||
|
||||
/// LoRa signal bandwidth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum Bandwidth {
|
||||
Khz7 = 0,
|
||||
Khz10 = 1,
|
||||
Khz15 = 2,
|
||||
Khz20 = 3,
|
||||
Khz31 = 4,
|
||||
Khz41 = 5,
|
||||
Khz62 = 6,
|
||||
Khz125 = 7,
|
||||
Khz250 = 8,
|
||||
Khz500 = 9,
|
||||
}
|
||||
|
||||
impl Bandwidth {
|
||||
fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::Khz7),
|
||||
1 => Some(Self::Khz10),
|
||||
2 => Some(Self::Khz15),
|
||||
3 => Some(Self::Khz20),
|
||||
4 => Some(Self::Khz31),
|
||||
5 => Some(Self::Khz41),
|
||||
6 => Some(Self::Khz62),
|
||||
7 => Some(Self::Khz125),
|
||||
8 => Some(Self::Khz250),
|
||||
9 => Some(Self::Khz500),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete LoRa radio configuration.
|
||||
///
|
||||
/// Wire layout (13 bytes, all little-endian):
|
||||
/// ```text
|
||||
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
pub struct RadioConfig {
|
||||
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
|
||||
pub freq_hz: u32,
|
||||
pub bw: Bandwidth,
|
||||
/// Spreading factor (5-12).
|
||||
pub sf: u8,
|
||||
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
|
||||
pub cr: u8,
|
||||
pub sync_word: u16,
|
||||
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
|
||||
/// board's maximum.
|
||||
pub tx_power_dbm: i8,
|
||||
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
|
||||
/// the firmware default (16 symbols). Valid range: 6–65535.
|
||||
pub preamble_len: u16,
|
||||
/// Channel Activity Detection (listen-before-talk) before TX.
|
||||
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
|
||||
pub cad: u8,
|
||||
}
|
||||
|
||||
impl RadioConfig {
|
||||
/// Validate fields against hardware limits.
|
||||
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
|
||||
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
|
||||
return Err("frequency out of range (150-960 MHz)");
|
||||
}
|
||||
if !(5..=12).contains(&self.sf) {
|
||||
return Err("spreading factor out of range (5-12)");
|
||||
}
|
||||
if !(5..=8).contains(&self.cr) {
|
||||
return Err("coding rate out of range (5-8)");
|
||||
}
|
||||
if self.tx_power_dbm != TX_POWER_MAX
|
||||
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
|
||||
{
|
||||
return Err("TX power out of range for this board");
|
||||
}
|
||||
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
|
||||
return Err("preamble length too short (min 6)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve sentinel values to concrete defaults.
|
||||
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
|
||||
if self.tx_power_dbm == TX_POWER_MAX {
|
||||
self.tx_power_dbm = power_range.1;
|
||||
}
|
||||
if self.preamble_len == PREAMBLE_DEFAULT {
|
||||
self.preamble_len = 16;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether CAD (listen-before-talk) is enabled.
|
||||
pub fn cad_enabled(&self) -> bool {
|
||||
self.cad != 0
|
||||
}
|
||||
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.bw as u8;
|
||||
buf[5] = self.sf;
|
||||
buf[6] = self.cr;
|
||||
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
|
||||
buf[9] = self.tx_power_dbm as u8;
|
||||
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
|
||||
buf[12] = self.cad;
|
||||
RADIO_CONFIG_SIZE
|
||||
}
|
||||
|
||||
/// Deserialize from fixed-size LE bytes.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
if buf.len() < RADIO_CONFIG_SIZE {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
|
||||
bw: Bandwidth::from_u8(buf[4])?,
|
||||
sf: buf[5],
|
||||
cr: buf[6],
|
||||
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
|
||||
tx_power_dbm: buf[9] as i8,
|
||||
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
|
||||
cad: buf[12],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Host → firmware commands.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Command {
|
||||
Ping,
|
||||
GetConfig,
|
||||
SetConfig(RadioConfig),
|
||||
StartRx,
|
||||
StopRx,
|
||||
Transmit {
|
||||
config: Option<RadioConfig>,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
DisplayOn,
|
||||
DisplayOff,
|
||||
GetMac,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Deserialize from a COBS-decoded frame.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
let tag = *buf.first()?;
|
||||
let rest = &buf[1..];
|
||||
match tag {
|
||||
0 => Some(Self::Ping),
|
||||
1 => Some(Self::GetConfig),
|
||||
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
|
||||
3 => Some(Self::StartRx),
|
||||
4 => Some(Self::StopRx),
|
||||
5 => {
|
||||
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (config, pos) = if rest[0] == 0 {
|
||||
(None, 1)
|
||||
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
|
||||
(
|
||||
Some(RadioConfig::from_bytes(&rest[1..])?),
|
||||
1 + RADIO_CONFIG_SIZE,
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.len() < pos + 2 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
|
||||
let data_start = pos + 2;
|
||||
if rest.len() < data_start + len {
|
||||
return None;
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
|
||||
Some(Self::Transmit { config, payload })
|
||||
}
|
||||
6 => Some(Self::DisplayOn),
|
||||
7 => Some(Self::DisplayOff),
|
||||
8 => Some(Self::GetMac),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Firmware → host responses.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Config(RadioConfig),
|
||||
RxPacket {
|
||||
rssi: i16,
|
||||
snr: i16,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
TxDone,
|
||||
Ok,
|
||||
Error(ErrorCode),
|
||||
MacAddress([u8; 6]),
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
match self {
|
||||
Self::Pong => {
|
||||
buf[0] = 0;
|
||||
1
|
||||
}
|
||||
Self::Config(cfg) => {
|
||||
buf[0] = 1;
|
||||
1 + cfg.write_to(&mut buf[1..])
|
||||
}
|
||||
Self::RxPacket { rssi, snr, payload } => {
|
||||
buf[0] = 2;
|
||||
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
|
||||
buf[3..5].copy_from_slice(&snr.to_le_bytes());
|
||||
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[7..7 + payload.len()].copy_from_slice(&payload);
|
||||
7 + payload.len()
|
||||
}
|
||||
Self::TxDone => {
|
||||
buf[0] = 3;
|
||||
1
|
||||
}
|
||||
Self::Ok => {
|
||||
buf[0] = 4;
|
||||
1
|
||||
}
|
||||
Self::Error(code) => {
|
||||
buf[0] = 5;
|
||||
buf[1] = code as u8;
|
||||
2
|
||||
}
|
||||
Self::MacAddress(mac) => {
|
||||
buf[0] = 6;
|
||||
buf[1..7].copy_from_slice(&mac);
|
||||
7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error codes reported to the host.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum ErrorCode {
|
||||
InvalidConfig = 0,
|
||||
RadioBusy = 1,
|
||||
TxTimeout = 2,
|
||||
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
|
||||
NotConfigured = 4,
|
||||
NoDisplay = 5,
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config() -> RadioConfig {
|
||||
RadioConfig {
|
||||
freq_hz: 915_000_000,
|
||||
bw: Bandwidth::Khz125,
|
||||
sf: 7,
|
||||
cr: 5,
|
||||
sync_word: 0x3444,
|
||||
tx_power_dbm: 22,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// ── RadioConfig roundtrip ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
let n = cfg.write_to(&mut buf);
|
||||
assert_eq!(n, RADIO_CONFIG_SIZE);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip_all_bandwidths() {
|
||||
for bw_val in 0u8..=9 {
|
||||
let bw = Bandwidth::from_u8(bw_val).unwrap();
|
||||
let cfg = RadioConfig {
|
||||
freq_hz: 433_000_000,
|
||||
bw,
|
||||
sf: 12,
|
||||
cr: 8,
|
||||
sync_word: 0x1234,
|
||||
tx_power_dbm: -9,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip_negative_power() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_from_short_buffer() {
|
||||
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
|
||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_from_empty_buffer() {
|
||||
assert!(RadioConfig::from_bytes(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_invalid_bandwidth() {
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
make_config().write_to(&mut buf);
|
||||
buf[4] = 255; // invalid bandwidth
|
||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
// ── RadioConfig::validate ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_freq_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
let mut cfg = RadioConfig {
|
||||
freq_hz: 150_000_000,
|
||||
..base
|
||||
};
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 960_000_000;
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 149_999_999;
|
||||
assert!(cfg.validate(power_range).is_err());
|
||||
|
||||
cfg.freq_hz = 960_000_001;
|
||||
assert!(cfg.validate(power_range).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_sf_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
for sf in 5..=12 {
|
||||
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
|
||||
}
|
||||
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
|
||||
assert!(RadioConfig { sf: 13, ..base }
|
||||
.validate(power_range)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cr_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
for cr in 5..=8 {
|
||||
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
|
||||
}
|
||||
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
|
||||
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_max_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_out_of_range() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: 23,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_err());
|
||||
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: -10,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_preamble_default_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_preamble_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
assert!(RadioConfig {
|
||||
preamble_len: 6,
|
||||
..base
|
||||
}
|
||||
.validate(power_range)
|
||||
.is_ok());
|
||||
assert!(RadioConfig {
|
||||
preamble_len: 5,
|
||||
..base
|
||||
}
|
||||
.validate(power_range)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// ── RadioConfig::resolve ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_power_max_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_power_explicit_unchanged() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: 10,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preamble_default() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preamble_explicit_unchanged() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: 32,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
|
||||
}
|
||||
|
||||
// ── Command::from_bytes ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn command_ping() {
|
||||
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_get_config() {
|
||||
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_set_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
||||
buf[0] = 2;
|
||||
cfg.write_to(&mut buf[1..]);
|
||||
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_start_stop_rx() {
|
||||
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
|
||||
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_no_config() {
|
||||
let payload = b"hello";
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 0; // has_config = false
|
||||
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[4..9].copy_from_slice(payload);
|
||||
|
||||
match Command::from_bytes(&buf[..9]).unwrap() {
|
||||
Command::Transmit { config, payload: p } => {
|
||||
assert!(config.is_none());
|
||||
assert_eq!(p.as_slice(), b"hello");
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_with_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 1; // has_config = true
|
||||
cfg.write_to(&mut buf[2..]);
|
||||
let payload = b"test";
|
||||
let pos = 2 + RADIO_CONFIG_SIZE;
|
||||
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[pos + 2..pos + 6].copy_from_slice(payload);
|
||||
|
||||
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
|
||||
Command::Transmit { config, payload: p } => {
|
||||
assert_eq!(config, Some(cfg));
|
||||
assert_eq!(p.as_slice(), b"test");
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_empty_payload() {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 0; // has_config = false
|
||||
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
|
||||
|
||||
match Command::from_bytes(&buf).unwrap() {
|
||||
Command::Transmit { config, payload } => {
|
||||
assert!(config.is_none());
|
||||
assert!(payload.is_empty());
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_truncated() {
|
||||
// Tag only — missing has_config byte
|
||||
assert!(Command::from_bytes(&[5]).is_none());
|
||||
|
||||
// has_config=1 but no config bytes
|
||||
assert!(Command::from_bytes(&[5, 1]).is_none());
|
||||
|
||||
// has_config=0 but no length bytes
|
||||
assert!(Command::from_bytes(&[5, 0]).is_none());
|
||||
|
||||
// has_config=0, length says 5 but only 2 bytes of payload
|
||||
let mut buf = [0u8; 6];
|
||||
buf[0] = 5;
|
||||
buf[1] = 0;
|
||||
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
|
||||
buf[4] = 0xAA;
|
||||
buf[5] = 0xBB;
|
||||
assert!(Command::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_display_and_mac() {
|
||||
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
|
||||
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
|
||||
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_invalid_tag() {
|
||||
assert!(Command::from_bytes(&[9]).is_none());
|
||||
assert!(Command::from_bytes(&[255]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_empty_buffer() {
|
||||
assert!(Command::from_bytes(&[]).is_none());
|
||||
}
|
||||
|
||||
// ── Response::write_to ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn response_pong() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::Pong.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
||||
let n = Response::Config(cfg).write_to(&mut buf);
|
||||
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
|
||||
assert_eq!(buf[0], 1);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_rx_packet() {
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(b"data");
|
||||
let mut buf = [0u8; 64];
|
||||
let n = Response::RxPacket {
|
||||
rssi: -80,
|
||||
snr: 10,
|
||||
payload,
|
||||
}
|
||||
.write_to(&mut buf);
|
||||
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
|
||||
assert_eq!(buf[0], 2);
|
||||
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
|
||||
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
|
||||
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
|
||||
assert_eq!(&buf[7..11], b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tx_done() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_ok() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::Ok.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_error_codes() {
|
||||
let mut buf = [0u8; 2];
|
||||
for (code, val) in [
|
||||
(ErrorCode::InvalidConfig, 0),
|
||||
(ErrorCode::RadioBusy, 1),
|
||||
(ErrorCode::TxTimeout, 2),
|
||||
(ErrorCode::NotConfigured, 4),
|
||||
(ErrorCode::NoDisplay, 5),
|
||||
] {
|
||||
let n = Response::Error(code).write_to(&mut buf);
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(buf[0], 5);
|
||||
assert_eq!(buf[1], val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_mac_address() {
|
||||
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
||||
let mut buf = [0u8; 7];
|
||||
let n = Response::MacAddress(mac).write_to(&mut buf);
|
||||
assert_eq!(n, 7);
|
||||
assert_eq!(buf[0], 6);
|
||||
assert_eq!(&buf[1..7], &mac);
|
||||
}
|
||||
}
|
||||
//! Wire definitions live in the `donglora-protocol` crate so host-side
|
||||
//! Rust projects can share them with the firmware without duplicating
|
||||
//! wire layouts. This module re-exports them unchanged so firmware
|
||||
//! modules can `use crate::protocol::*;`.
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use donglora_protocol::{
|
||||
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
|
||||
DeviceMessageParseError, ErrorCode, FlrcConfig, FrameDecoder, FrameEncodeError, FrameResult,
|
||||
FskConfig, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode,
|
||||
LrFhssConfig, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload,
|
||||
SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, MAX_MCU_UID_LEN,
|
||||
MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_WIRE_FRAME,
|
||||
};
|
||||
+2139
-314
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user