12 Commits
Author SHA1 Message Date
swaits a2c611841b chore: release 1.3.0 2026-04-23 12:50:03 -06:00
swaits a1b92356b3 feat(boards): LilyGo T-Beam (classic) support
First classic-ESP32 (non-S3) board in the firmware, and first SX127x
radio. ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC, CP2102 USB-UART
bridge. Pin map verified from MeshCore variants/lilygo_tbeam_SX1276.

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

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

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

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

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

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

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

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

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

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

Implementation notes:

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

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

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

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

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


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


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

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

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

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

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

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

* Minor display and main-loop cleanups needed for the new radio event shape.
2026-04-22 09:32:06 -06:00
25 changed files with 4081 additions and 993 deletions

No files matched your search

+209
View File
@@ -7,6 +7,215 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
Generated
+36 -4
View File
@@ -486,7 +486,7 @@ dependencies = [
[[package]]
name = "donglora"
version = "0.5.0"
version = "1.3.0"
dependencies = [
"cfg-if",
"cortex-m",
@@ -527,7 +527,9 @@ dependencies = [
[[package]]
name = "donglora-protocol"
version = "0.1.0"
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",
@@ -1058,6 +1060,8 @@ dependencies = [
"rand_core 0.6.4",
"rand_core 0.9.5",
"riscv",
"sha1",
"sha2",
"strum",
"ufmt-write",
"xtensa-lx",
@@ -1114,6 +1118,7 @@ dependencies = [
"cfg-if",
"document-features",
"esp-metadata-generated",
"esp32",
"esp32s3",
]
@@ -1389,6 +1394,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",
]
@@ -1401,7 +1407,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
dependencies = [
"embassy-futures",
"embassy-sync 0.8.0",
@@ -1412,7 +1420,9 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
dependencies = [
"proc-macro2",
"quote",
@@ -2197,6 +2207,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",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2-const-stable"
version = "0.1.0"
+46 -9
View File
@@ -3,13 +3,14 @@ members = ["."]
[package]
name = "donglora"
version = "0.5.0"
version = "1.3.0"
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"]
@@ -29,8 +30,8 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
donglora-protocol = { path = "../protocol" }
hsmc = { path = "../../hsmc/hsmc", default-features = false, features = ["embassy"] }
donglora-protocol = "1"
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
lora-phy = "3.0"
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
static_cell = "2.1"
@@ -44,22 +45,20 @@ ssd1306 = { version = "0.10", features = ["async"] }
embedded-io-async = "0.7"
embassy-futures = "0.1"
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
# Xtensa ESP crates — requires esp toolchain (espup). The chip feature
# (esp32 / esp32s3) is selected per board via the board-level feature flag
# below, so the same dep lines support both classic ESP32 and ESP32-S3.
esp-backtrace = { version = "0.18", optional = true, features = [
"esp32s3",
"panic-handler",
"defmt",
] }
esp-hal = { version = "1.0", optional = true, features = [
"esp32s3",
"unstable",
] }
esp-println = { version = "0.16", optional = true, features = [
"esp32s3",
"defmt-espflash",
] }
esp-rtos = { version = "0.2", optional = true, features = [
"esp32s3",
"embassy",
] }
@@ -109,6 +108,10 @@ heltec_v3 = [
"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",
@@ -116,6 +119,10 @@ heltec_v3_uart = [
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
"esp-backtrace/esp32s3",
"esp-hal/esp32s3",
"esp-println/esp32s3",
"esp-rtos/esp32s3",
]
heltec_v4 = [
"embassy",
@@ -123,6 +130,10 @@ heltec_v4 = [
"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",
@@ -130,6 +141,32 @@ elecrow_thinknode_m2 = [
"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",
"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",
"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",
+1901 -269
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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 |
+6 -2
View File
@@ -10,6 +10,8 @@ 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"
lilygo_tbeam := "lilygo_tbeam xtensa-esp32-none-elf esp32"
lilygo_tbeam_supreme := "lilygo_tbeam_supreme 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"
@@ -18,7 +20,7 @@ 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"
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:
@@ -77,7 +79,7 @@ flash board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
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"; \
@@ -214,6 +216,8 @@ _info name:
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}}" == "lilygo_tbeam" ]; then echo "{{lilygo_tbeam}}"; \
elif [ "{{name}}" == "lilygo_tbeam_supreme" ]; then echo "{{lilygo_tbeam_supreme}}"; \
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}}"; \
+3 -3
View File
@@ -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:
+103
View File
@@ -0,0 +1,103 @@
//! Shared types and peripheral wiring for classic-ESP32 SX1276 boards.
//!
//! Counterpart to `board::esp32s3` for the SX126x line. Builds an
//! `Sx127x<Sx1276>` driver from an already-initialized SPI bus plus the
//! board's reset and DIO0 pins. SX127x has no `BUSY` line — DIO0 carries
//! every IRQ (TX done, RX done, header valid, …) and lora-phy's
//! `GenericSx127xInterfaceVariant` waits on it directly.
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
use lora_phy::iv::GenericSx127xInterfaceVariant;
use lora_phy::sx127x::{self, Sx127x};
use crate::hal::esp32 as mcu;
// ── Re-export MCU types ─────────────────────────────────────────────
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx127xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx127x<RadioSpiDevice, Iv, sx127x::Sx1276>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display driver ──────────────────────────────────────────────────
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
/// Construct and initialize an SSD1306 display from raw I²C.
pub async fn create_display(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,
}
}
+137
View File
@@ -0,0 +1,137 @@
//! 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 HIGH — reuses MeshCore's P_LORA_TX_LED)
//!
//! 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::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<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 HIGH).
let led_pin = Output::new(p.GPIO4, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
+153
View File
@@ -0,0 +1,153 @@
//! LilyGo T-Beam S3 Supreme — ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC.
//!
//! Big-brother of the classic T-Beam: ESP32-S3-WROOM-1 module, SX1262 with a
//! TCXO, 128×64 SH1106 OLED, AXP2101 PMIC on a dedicated I²C1 bus. Native USB
//! (no UART bridge chip).
//!
//! The AXP2101 gates power to the SX1262 and OLED. Without its init sequence
//! the LoRa chip never responds on SPI and `LoRa::new` fails.
//!
//! Pin map (verified from MeshCore variants/lilygo_tbeam_supreme_SX1262/):
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
//! LoRa ctl: RESET=5, DIO1=1, BUSY=4 (TCXO @ 1.6 V via DIO3)
//! OLED I²C: SDA=17, SCL=18, SH1106 @ 0x3C (I²C0)
//! PMIC I²C: SDA=42, SCL=41, AXP2101 @ 0x34 (I²C1, blocking, PMIC-only)
//! USB: native USB-C on GPIO19/20 (fixed ESP32-S3 D+/D-)
//! LED: GPIO6 (active HIGH indicator, reuses MeshCore's P_LORA_TX_LED)
//!
//! GPS / battery / IMU / SD / BME280 are unused — DongLoRa is a transparent
//! LoRa-over-USB bridge. The PMIC init only enables ALDO1 (OLED) and ALDO3
//! (LoRa); other rails are left at their AXP2101 power-on defaults.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::axp;
use crate::driver::sh1106::Sh1106;
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
// ── Concrete peripheral types ───────────────────────────────────────
pub type DisplayDriver = Sh1106<DisplayI2c>;
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(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(),
}
}
}
+4
View File
@@ -3,7 +3,11 @@ pub mod traits;
pub use traits::*;
{% for helper, helper_features in helpers|items -%}
{% if helper_features|length == 1 -%}
#[cfg(feature = "{{ helper_features[0] }}")]
{% else -%}
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
{% endif -%}
mod {{ helper }};
{% endfor %}
cfg_if::cfg_if! {
+35 -2
View File
@@ -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,6 +50,36 @@ 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,
+62 -32
View File
@@ -1,54 +1,84 @@
//! Inter-task communication channels.
//! Inter-task communication channels for DongLoRa Protocol v2.
//!
//! - [`CommandChannel`]: USB → Radio (host commands)
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio)
//! - [`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 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
// ── 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).
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
/// Host-to-radio event channel (depth 8).
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
/// 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 → downstream typed events. The radio task emits one of these at
/// every mode transition and every RF packet. Mode events and data events
/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio
/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just
/// happened* without implying a mode change.
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
/// RX events + queued command responses without stalling the radio ISR).
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
// ── 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>;
// ── Radio → Display: typed packet/mode events ──────────────────────
/// 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 enum RadioEvent {
/// Radio is now in RX mode.
EnteredRx,
/// Radio is now in TX mode.
EnteredTx,
/// Radio is now idle.
Idle,
/// A packet was received with these link metrics.
PacketRx { rssi: i16, snr: Option<i16> },
/// A packet was transmitted successfully.
PacketTx,
/// `SetConfig` applied a new validated config.
ConfigChanged(RadioConfig),
ConfigChanged(LoRaConfig),
}
/// Radio → Display event stream (depth 32: buffer RX bursts while the
/// display drains slowly).
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
+96 -25
View File
@@ -37,8 +37,9 @@ use embassy_time::Timer;
use hsmc::statechart;
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
use crate::driver::DisplayBrightness;
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
use crate::protocol::RadioConfig;
use crate::protocol::LoRaConfig;
use render::{BoardInfo, RSSI_HISTORY_LEN};
@@ -75,7 +76,7 @@ pub enum DisplayEvent {
},
/// A packet was transmitted (data only — does not imply a mode change).
PacketTx,
ConfigChanged(RadioConfig),
ConfigChanged(LoRaConfig),
}
impl From<RadioEvent> for DisplayEvent {
@@ -109,7 +110,7 @@ struct RunState {
rssi_count: usize,
current_slot_rssi: i16,
current_slot_tx: bool,
config: Option<RadioConfig>,
config: Option<LoRaConfig>,
rx_count: u32,
tx_count: u32,
last_rssi: Option<i16>,
@@ -220,6 +221,14 @@ impl DisplayContext {
Timer::after_millis(50).await;
self.peripherals.led.set_rgb(0, 0, 0).await;
}
async fn oled_bright(&mut self) {
self.peripherals.display.set_bright().await;
}
async fn oled_dim(&mut self) {
self.peripherals.display.set_dim().await;
}
}
// ── Statechart ─────────────────────────────────────────────────────
@@ -243,16 +252,26 @@ statechart! {
on(CmdOff) => Off;
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
// makes either ordering land in the same place. Reset the run
// state, turn off any blink-in-progress, and re-enter Splash —
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
on(CmdReset) => reset_run_state, turn_led_off, Splash;
// state, turn off any blink-in-progress, and land in LearnMore
// with a fresh 5 s timer. We target `LearnMore` (not `Splash`)
// because under hsmc 0.2 up-transition semantics, targeting a
// state that's already on the active path would only unwind
// below it — we'd park at `Splash` with no active child and
// the alternation timer would never restart.
on(CmdReset) => reset_run_state, turn_led_off, LearnMore;
// Config can arrive before the dashboard is up; cache it anywhere.
on(ConfigChanged(cfg: RadioConfig)) => save_new_config;
on(ConfigChanged(cfg: LoRaConfig)) => save_new_config;
// Splash is bright for the first 10 s of (re-)entry, then the
// action-only `on(after 10s)` handler drops the OLED to dim.
// LearnMore ↔ Info keep alternating underneath — their own
// timers are independent of the 10 s window.
state Splash {
entry: set_bright;
default(LearnMore);
on(EnteredRx) => Listening;
on(EnteredTx) => Transmitting;
on(after Duration::from_secs(10)) => set_dim;
state LearnMore {
entry: paint_learn_more;
@@ -265,24 +284,56 @@ statechart! {
}
}
// Dashboard brightness pattern: each mode-leaf holds a `Bright`
// substate. `entry: set_bright` owns the backlight going on;
// `set_dim` is fired **as an action** by the 3 s timeout
// handler, immediately before the up-transition that parks
// the machine at the leaf. The symmetric `exit: set_dim`
// would also fire on mode-change leaf-crossings (e.g.
// ListeningBright → TransmittingBright via EnteredTx), and
// the subsequent default descent would fire `set_bright`
// again — producing a visible dim/bright flash on every
// TX↔RX swap. Anchoring dim to the *timer* handler instead
// means leaf-crossings go bright→bright with no intermediate
// dim action. CmdOff/CmdReset exit paths don't fire set_dim
// either, which is fine — the display is blanked or walked
// back to Splash, which re-asserts its own brightness.
state Dashboard {
default(Listening);
on(RadioIdle) => Splash;
// Packet events are pure data + one-shot LED feedback.
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketTx) => record_tx_packet, blink_tx_led;
state Listening {
entry: paint_listening;
default(ListeningBright);
on(EnteredTx) => Transmitting;
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
// Split from the transition below because hsmc rejects
// payload bindings on transition-bearing handlers.
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketRx) => ListeningBright;
on(PacketTx) => record_tx_packet, blink_tx_led;
on(PacketTx) => ListeningBright;
state ListeningBright {
entry: set_bright;
on(after Duration::from_secs(3)) => set_dim, Listening;
}
}
state Transmitting {
entry: paint_transmitting;
default(TransmittingBright);
on(EnteredRx) => Listening;
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketRx) => TransmittingBright;
on(PacketTx) => record_tx_packet, blink_tx_led;
on(PacketTx) => TransmittingBright;
state TransmittingBright {
entry: set_bright;
on(after Duration::from_secs(3)) => set_dim, Transmitting;
}
}
}
}
@@ -348,13 +399,21 @@ impl DisplayActions for DisplayActionContext<'_> {
self.led_blip(32, 0, 0).await;
}
async fn save_new_config(&mut self, cfg: RadioConfig) {
async fn save_new_config(&mut self, cfg: LoRaConfig) {
self.run.config = Some(cfg);
}
async fn advance_sparkline(&mut self) {
self.run.advance_slot();
}
async fn set_bright(&mut self) {
self.oled_bright().await;
}
async fn set_dim(&mut self) {
self.oled_dim().await;
}
}
// ── Task entry point ───────────────────────────────────────────────
@@ -440,7 +499,7 @@ mod render {
use heapless::String;
use super::Badge;
use crate::protocol::{Bandwidth, RadioConfig};
use crate::protocol::{LoRaBandwidth, LoRaConfig};
pub const RSSI_HISTORY_LEN: usize = 128;
@@ -505,7 +564,7 @@ mod render {
pub fn dashboard(
target: &mut impl DrawTarget<Color = BinaryColor>,
badge: Badge,
config: Option<RadioConfig>,
config: Option<LoRaConfig>,
rx_count: u32,
tx_count: u32,
last_rssi: Option<i16>,
@@ -588,21 +647,33 @@ mod render {
let freq_mhz = cfg.freq_hz / 1_000_000;
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
let bw_str = match cfg.bw {
Bandwidth::Khz7 => "7.8",
Bandwidth::Khz10 => "10.4",
Bandwidth::Khz15 => "15.6",
Bandwidth::Khz20 => "20.8",
Bandwidth::Khz31 => "31.2",
Bandwidth::Khz41 => "41.7",
Bandwidth::Khz62 => "62.5",
Bandwidth::Khz125 => "125",
Bandwidth::Khz250 => "250",
Bandwidth::Khz500 => "500",
LoRaBandwidth::Khz7 => "7.8",
LoRaBandwidth::Khz10 => "10.4",
LoRaBandwidth::Khz15 => "15.6",
LoRaBandwidth::Khz20 => "20.8",
LoRaBandwidth::Khz31 => "31.2",
LoRaBandwidth::Khz41 => "41.7",
LoRaBandwidth::Khz62 => "62.5",
LoRaBandwidth::Khz125 => "125",
LoRaBandwidth::Khz250 => "250",
LoRaBandwidth::Khz500 => "500",
LoRaBandwidth::Khz200 => "200",
LoRaBandwidth::Khz400 => "400",
LoRaBandwidth::Khz800 => "800",
LoRaBandwidth::Khz1600 => "1600",
};
// Show only the CR denominator; the "4/" numerator is implied for
// every LoRa coding rate and just clutters the line.
let cr_str = match cfg.cr {
crate::protocol::LoRaCodingRate::Cr4_5 => "5",
crate::protocol::LoRaCodingRate::Cr4_6 => "6",
crate::protocol::LoRaCodingRate::Cr4_7 => "7",
crate::protocol::LoRaCodingRate::Cr4_8 => "8",
};
let _ = write!(
buf,
"{}.{:03}/{}/{}/{}",
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
);
Text::with_alignment(
&buf,
+121
View File
@@ -0,0 +1,121 @@
//! AXP PMIC init — turn on the rails that feed LoRa and the OLED.
//!
//! T-Beam–class boards gate LoRa, OLED (and GPS/sensors, which this firmware
//! does not use) through an X-Powers PMIC on I²C. Until the PMIC is told to
//! enable the right LDOs/DCDCs, the SX126x/SX127x chip sits unpowered and
//! `LoRa::new` fails. This module's only job is to flip those switches.
//!
//! Two PMICs are covered:
//!
//! * [`axp2101_init_lora_oled`] — LilyGo T-Beam S3 Supreme. ALDO3 = 3.3 V
//! feeds the SX1262; ALDO1 = 3.3 V feeds the SH1106 OLED. Both live on a
//! dedicated I²C1 bus (separate from the display).
//! * [`axp192_init_lora_oled`] — LilyGo T-Beam (classic). LDO2 = 3.3 V feeds
//! the SX1276; DCDC1 = 3.3 V feeds the SSD1306 OLED. PMIC shares the
//! display I²C bus.
//!
//! Everything unrelated to LoRa + OLED (GPS, sensors, SD, batt ADC) is left
//! alone. Out-of-scope for a transparent LoRa-over-USB bridge.
//!
//! Init is blocking on purpose. It runs once during `into_parts`, which is a
//! synchronous function called before task spawning. Using the blocking
//! `embedded_hal` trait keeps the PMIC wiring out of the async machinery and
//! lets the caller drop the I²C bus immediately after init (no bus handoff).
use embedded_hal::i2c::I2c;
/// Both chips respond at this 7-bit I²C address.
const PMIC_ADDR: u8 = 0x34;
// ── AXP2101 (T-Beam S3 Supreme) ──────────────────────────────────────
#[cfg(feature = "lilygo_tbeam_supreme")]
mod axp2101 {
use super::{I2c, PMIC_ADDR};
/// LDO on/off control register. Bits:
/// 0 = ALDO1, 1 = ALDO2, 2 = ALDO3, 3 = ALDO4, 4 = BLDO1, 5 = BLDO2.
const REG_LDO_ONOFF: u8 = 0x90;
/// ALDO1 voltage register (OLED rail on T-Beam Supreme).
const REG_ALDO1_V: u8 = 0x92;
/// ALDO3 voltage register (LoRa rail on T-Beam Supreme).
const REG_ALDO3_V: u8 = 0x94;
/// Voltage code for 3.3 V on ALDOx: 0.5 V + 28 × 100 mV.
const V_3V3: u8 = 28;
/// Mask enabling ALDO1 + ALDO3 in [`REG_LDO_ONOFF`].
const ALDO1_ALDO3: u8 = 0b0000_0101;
/// Bring up LoRa (ALDO3) and OLED (ALDO1) rails at 3.3 V.
///
/// Voltage registers are set before the enable bits to avoid glitching
/// the rails through the reset-default voltage. Other rails are left
/// untouched (read-modify-write on the enable register) so any
/// board-specific OTP defaults stay intact.
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
i2c.write(PMIC_ADDR, &[REG_ALDO3_V, V_3V3])?;
i2c.write(PMIC_ADDR, &[REG_ALDO1_V, V_3V3])?;
let mut onoff = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[REG_LDO_ONOFF], &mut onoff)?;
i2c.write(PMIC_ADDR, &[REG_LDO_ONOFF, onoff[0] | ALDO1_ALDO3])?;
Ok(())
}
}
#[cfg(feature = "lilygo_tbeam_supreme")]
pub use axp2101::init_lora_oled as axp2101_init_lora_oled;
// ── AXP192 (T-Beam classic) ──────────────────────────────────────────
#[cfg(feature = "lilygo_tbeam")]
mod axp192 {
use super::{I2c, PMIC_ADDR};
/// DCDC/LDO enable register. Bit layout:
/// 0 = DCDC1, 1 = DCDC3, 2 = LDO2, 3 = LDO3, 4 = DCDC2, 6 = EXTEN.
const REG_ONOFF: u8 = 0x12;
/// DCDC1 output voltage register (OLED rail on T-Beam classic).
/// 7-bit code, 25 mV step, 0.7 V offset → 3.3 V = 104 = `0x68`.
const REG_DCDC1_V: u8 = 0x26;
/// LDO2/LDO3 voltage register. Upper nibble = LDO2 (LoRa), lower nibble
/// = LDO3 (GPS). 100 mV step, 1.8 V offset → 3.3 V = 15 = `0xF`.
const REG_LDO23_V: u8 = 0x28;
/// DCDC1 code for 3.3 V.
const DCDC1_3V3: u8 = 0x68;
/// LDO2 nibble for 3.3 V in the upper half of [`REG_LDO23_V`].
const LDO2_3V3_UPPER: u8 = 0xF0;
/// Mask enabling DCDC1 + LDO2 in [`REG_ONOFF`].
const DCDC1_LDO2: u8 = 0b0000_0101;
/// Bring up LoRa (LDO2) and OLED (DCDC1) rails at 3.3 V on the
/// classic T-Beam's AXP192.
///
/// GPS (LDO3) and EXTEN are not touched — this firmware has no GPS,
/// and any board-default for LDO3 voltage is preserved by
/// read-modify-write. Voltage is programmed before the enable bit
/// flips so the rail doesn't glitch through its reset default.
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
let mut ldo23 = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[REG_LDO23_V], &mut ldo23)?;
i2c.write(
PMIC_ADDR,
&[REG_LDO23_V, (ldo23[0] & 0x0F) | LDO2_3V3_UPPER],
)?;
i2c.write(PMIC_ADDR, &[REG_DCDC1_V, DCDC1_3V3])?;
let mut onoff = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[REG_ONOFF], &mut onoff)?;
i2c.write(PMIC_ADDR, &[REG_ONOFF, onoff[0] | DCDC1_LDO2])?;
Ok(())
}
}
#[cfg(feature = "lilygo_tbeam")]
pub use axp192::init_lora_oled as axp192_init_lora_oled;
+67 -1
View File
@@ -1,12 +1,78 @@
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
#[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
))]
pub mod sh1106;
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
pub mod axp;
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme",
feature = "lilygo_tbeam",
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"
))]
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;
}
}
+80
View File
@@ -0,0 +1,80 @@
//! ESP32 (classic / non-S3) MCU initialization primitives.
//!
//! Mirror of `hal::esp32s3` with a single delta: the classic ESP32 uses a
//! peripheral-bound DMA (`DMA_SPI2`) rather than the S3's channel-addressed
//! GDMA (`DMA_CH0`). Timer, I²C, and eFuse access are otherwise identical
//! between the two Xtensa parts in this esp-hal vintage.
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
use esp_hal::i2c::master::I2c;
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
use esp_hal::spi::Mode as SpiMode;
use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup;
use static_cell::StaticCell;
// ── MCU-level types ─────────────────────────────────────────────────
pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
pub type I2cBus = I2c<'static, esp_hal::Async>;
// ── Timer ───────────────────────────────────────────────────────────
/// Start the ESP-RTOS scheduler and Embassy time driver.
pub fn start_timer(
timg0: esp_hal::peripherals::TIMG0<'static>,
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
) {
let timg0 = TimerGroup::new(timg0);
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
}
// ── SPI bus ─────────────────────────────────────────────────────────
/// Initialize SPI2 with peripheral-bound DMA and wrap in a shared bus
/// (`StaticCell` + `Mutex`).
///
/// Generic over pin type. The classic ESP32's matrix of GPIO → peripheral
/// routes lets any GPIO serve as any SPI signal — same as the S3.
pub fn init_spi(
spi2: esp_hal::peripherals::SPI2<'static>,
dma_spi2: esp_hal::peripherals::DMA_SPI2<'static>,
sck: impl PeripheralOutput<'static>,
mosi: impl PeripheralOutput<'static>,
miso: impl PeripheralInput<'static>,
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
let spi = Spi::new(
spi2,
SpiConfig::default()
.with_frequency(Rate::from_mhz(1))
.with_mode(SpiMode::_0),
)
.expect("SPI init")
.with_sck(sck)
.with_mosi(mosi)
.with_miso(miso)
.with_dma(dma_spi2);
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
}
// ── MAC address ─────────────────────────────────────────────────────
/// Read the factory-programmed MAC address from eFuse.
pub fn mac_address() -> [u8; 6] {
esp_hal::efuse::base_mac_address()
.as_bytes()
.try_into()
.expect("MAC is 6 bytes")
}
+5 -1
View File
@@ -7,10 +7,14 @@
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4",
feature = "elecrow_thinknode_m2"
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;
+108 -40
View File
@@ -1,53 +1,121 @@
//! 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`).
//!
//! The actual codec lives in `donglora-protocol` so host crates can
//! reuse it; this module re-exports the codec and owns the
//! firmware-only [`route_command`] (which touches embassy channels).
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
//! → bytes ready for transport-level write.
pub use donglora_protocol::framing::{cobs_encode_response, CobsDecoder, MAX_FRAME};
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,
};
/// Route a parsed command to the appropriate handler.
/// Dispatch a frame-level decode into the appropriate downstream queue.
///
/// 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.
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
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");
None
}
Err(FrameEncodeError::PayloadTooLarge) => {
warn!("encode_frame: payload exceeds MAX_PAYLOAD_FIELD");
None
}
Err(FrameEncodeError::CobsEncode) => {
warn!("encode_frame: COBS encode refused");
None
}
}
}
+5 -1
View File
@@ -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 {
+107 -51
View File
@@ -1,85 +1,141 @@
//! 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 embassy_executor::task;
use embassy_time::{Duration, Instant};
use embedded_io_async::Write;
use super::framing::{self, CobsDecoder, MAX_FRAME};
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
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();
let mut read_buf = [0u8; READ_BUF];
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
let mut connected = true;
loop {
use embassy_futures::select::{select, Either};
use embassy_futures::select::{select3, Either3};
match select(
match select3(
embedded_io_async::Read::read(&mut rx, &mut read_buf),
responses.receive(),
outbound.receive(),
embassy_time::Timer::after(Duration::from_millis(100)),
)
.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;
Either3::First(result) => match result {
Ok(0) => continue,
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;
}
Err(_) => {
decoder.reset();
embassy_time::Timer::after_millis(100).await;
}
},
Either3::Second(frame) => {
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
if tx.write_all(&write_buf[..n]).await.is_err() {
warn!("UART write failed, frame dropped");
}
};
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;
}
}
Either::Second(response) => {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(13);
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");
} else {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(14);
}
}
Either3::Third(()) => {}
}
// Inactivity watchdog.
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
warn!("UART host inactivity timeout fired");
let _ = commands.try_send(InboundEvent::Reset);
if has_display {
let _ = display_commands.try_send(DisplayCommand::Reset);
}
connected = false;
}
}
}
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,
}
+126 -84
View File
@@ -1,54 +1,53 @@
//! 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
//! entry: wake_display
//! exit: stop_radio, reset_display
//! ├── Disconnected (default) no DTR, or inactivity timer fired
//! └── Connected host attached, liveness OK
//! entry: wake_display, start_liveness_timer
//! exit: reset_radio, reset_display, stop_liveness_timer
//! ```
//!
//! The statechart tracks exactly one thing: DTR. Entry/exit actions
//! replace the old `was_connected` edge-detector and emit the display
//! wake / radio-stop / display-reset side-effects that used to be open
//! coded around the `select3` loop.
//! The statechart tracks one state bit: "is a host present?". DTR drives
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
//! the disconnect edge when DTR is held high but no bytes arrive.
//!
//! The data plane (COBS decoding, command routing, response encoding)
//! stays imperative in `protocol_loop` for the same reason the radio's
//! `rx_once` stayed out of its chart: `read_packet` blocks and can't
//! live inside a statechart action. Host-contract lifecycle (one
//! outstanding command, unsolicited `RxPacket`, synchronous local
//! responses) is deliberately *not* modeled — any finer chart here
//! would desynchronise from the concurrent `select3` reality and lie
//! about what the firmware is actually doing.
//! The data plane (frame decode, command dispatch, frame encode) stays
//! imperative in `protocol_loop` for the same reason the radio's
//! `rx_once` stayed out of its chart: `read_packet` blocks.
use defmt::warn;
use embassy_executor::task;
use embassy_futures::join::join;
use embassy_time::{Duration, Instant};
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
use embassy_usb::Builder;
use hsmc::{statechart, Duration};
use hsmc::{statechart, Duration as HsmcDuration};
use static_cell::StaticCell;
use super::framing::{self, CobsDecoder, MAX_FRAME};
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::Command;
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.
const INACTIVITY_MS: u64 = 1000;
/// Poll interval for the DTR edge detector / inactivity check.
const POLL_INTERVAL_MS: u64 = 100;
/// Internal events driving `Host` state transitions.
#[derive(Debug, Clone)]
pub enum HostInput {
/// DTR rose: host opened the serial port.
DtrRaised,
/// DTR fell: host closed or disconnected.
DtrDropped,
/// Host went silent for >1 s; kick the radio back to Unconfigured.
InactivityTimeout,
}
/// Machine context — channel handles the actions need to broadcast
/// DTR-edge side-effects. The COBS decoder, read/write scratch, and
/// USB endpoints stay local to `protocol_loop` because their async
/// operations can't live inside statechart actions.
pub struct HostContext {
commands: &'static CommandChannel,
display_commands: &'static DisplayCommandChannel,
@@ -68,9 +67,9 @@ impl HostContext {
}
}
fn stop_radio_best_effort(&self) {
if self.commands.try_send(Command::StopRx).is_err() {
warn!("host DTR-drop: StopRx dropped (command channel full)");
fn send_radio_reset(&self) {
if self.commands.try_send(InboundEvent::Reset).is_err() {
warn!("radio Reset inject: command queue full");
}
}
}
@@ -87,8 +86,9 @@ statechart! {
state Connected {
entry: wake_display;
exit: stop_radio, reset_display;
exit: reset_radio, reset_display;
on(DtrDropped) => Disconnected;
on(InactivityTimeout) => Disconnected;
}
}
}
@@ -98,8 +98,8 @@ impl HostActions for HostActionContext<'_> {
self.wake_display_if_present().await;
}
async fn stop_radio(&mut self) {
self.stop_radio_best_effort();
async fn reset_radio(&mut self) {
self.send_radio_reset();
}
async fn reset_display(&mut self) {
@@ -111,7 +111,7 @@ impl HostActions for HostActionContext<'_> {
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],
@@ -147,7 +147,7 @@ pub async fn host_task(
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);
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
let mut usb_dev = builder.build();
let (sender, receiver) = class.split();
@@ -157,10 +157,9 @@ pub async fn host_task(
sender,
receiver,
commands,
responses,
outbound,
display_commands,
has_display,
mac,
),
)
.await;
@@ -170,10 +169,9 @@ 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};
@@ -182,55 +180,36 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
display_commands,
has_display,
});
// Enter the default: Disconnected.
machine.step(Duration::ZERO).await;
machine.step(HsmcDuration::ZERO).await;
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 read_buf = [0u8; MAX_USB_PACKET];
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
loop {
match select3(
receiver.read_packet(&mut read_buf),
responses.receive(),
embassy_time::Timer::after_millis(250),
outbound.receive(),
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
)
.await
{
Either3::First(Ok(0)) => {}
Either3::First(Ok(n)) => {
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;
}
last_frame_at = Instant::now();
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
}
Either3::First(Err(_)) => {
// Read errored — usually a USB disconnect. Drop decoder
// scratch and back off briefly so we don't spin at I/O
// rate. Do NOT `continue`: the DTR check below must
// still run so the machine's exit action fires.
decoder.reset();
embassy_time::Timer::after_millis(100).await;
}
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::Second(frame) => {
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
if sender.write_packet(chunk).await.is_err() {
warn!("USB write failed, response dropped");
warn!("USB write failed, frame dropped");
break;
}
}
@@ -239,27 +218,90 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
Either3::Third(()) => {}
}
// Edge-detect DTR against the machine's current state. The
// machine's entry/exit actions own the side-effects. dispatch()
// pushes the event AND drains the internal queue, so
// current_state() reflects the post-dispatch state before we
// loop back around.
// DTR edge detection — drives Connected / Disconnected transitions.
let connected = receiver.dtr();
let was_connected = matches!(machine.current_state(), HostState::Connected);
match (was_connected, connected) {
(false, true) => {
let _ = machine.dispatch(HostInput::DtrRaised).await;
last_frame_at = Instant::now();
}
(true, false) => {
// Belt-and-braces: reset decoder here too. The error
// arm above typically fires on disconnect and already
// resets it, but this makes intent explicit and
// survives embassy-usb behavior changes where a
// disconnect doesn't surface as a read error.
decoder.reset();
let _ = machine.dispatch(HostInput::DtrDropped).await;
}
_ => {}
}
// Inactivity watchdog (spec §3.4): while Connected, if no frame
// arrived in the last INACTIVITY_MS, drive the session back to
// Disconnected.
if matches!(machine.current_state(), HostState::Connected)
&& last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS)
{
warn!("host inactivity timeout fired");
let _ = machine.dispatch(HostInput::InactivityTimeout).await;
}
}
}
/// 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,
}
+81 -6
View File
@@ -39,14 +39,16 @@ use embassy_executor::Spawner;
#[cfg(not(test))]
use crate::board::LoRaBoard;
#[cfg(not(test))]
use crate::channel::{CommandChannel, DisplayCommandChannel, RadioEventChannel, ResponseChannel};
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 _;
}
@@ -55,11 +57,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 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! {
@@ -68,7 +72,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;
@@ -81,8 +85,18 @@ async fn run(spawner: Spawner) {
let board = <board::Board as LoRaBoard>::init();
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, &RADIO_EVENTS)
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
.expect("spawn radio_task"),
);
@@ -90,7 +104,7 @@ async fn run(spawner: Spawner) {
host::host_task(
parts.host,
&COMMANDS,
&RESPONSES,
&OUTBOUND,
&DISPLAY_COMMANDS,
parts.display.is_some(),
parts.mac,
@@ -98,6 +112,9 @@ 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 {
#[cfg(feature = "debug-checkpoint")]
{
@@ -117,3 +134,61 @@ async fn run(spawner: Spawner) {
}
}
}
#[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
}
+11 -7
View File
@@ -1,12 +1,16 @@
//! Wire protocol types.
//! Wire protocol types for DongLoRa Protocol v2.
//!
//! The definitions live in the `donglora-protocol` crate so host-side
//! Rust projects (client-rs, mux-rs, bridge) can share them with the
//! firmware without duplicating wire layouts. This module re-exports
//! them unchanged for existing `crate::protocol::*` call sites.
//! 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::{
Bandwidth, Command, ErrorCode, RadioConfig, Response, MAX_PAYLOAD, PREAMBLE_DEFAULT,
RADIO_CONFIG_SIZE, TX_POWER_MAX,
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,
};
+577 -456
View File
File diff suppressed because it is too large. Load diff