Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c611841b
|
||
|
|
a1b92356b3
|
||
|
|
7fca5fcb50
|
||
|
|
6867439044
|
||
|
|
bcfe736b30
|
||
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
No files matched your search
+209
@@ -7,6 +7,215 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.5.0] - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+36
-4
@@ -486,7 +486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.5.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cortex-m",
|
"cortex-m",
|
||||||
@@ -527,7 +527,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donglora-protocol"
|
name = "donglora-protocol"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt 1.0.1",
|
"defmt 1.0.1",
|
||||||
"heapless 0.9.2",
|
"heapless 0.9.2",
|
||||||
@@ -1058,6 +1060,8 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
"riscv",
|
"riscv",
|
||||||
|
"sha1",
|
||||||
|
"sha2",
|
||||||
"strum",
|
"strum",
|
||||||
"ufmt-write",
|
"ufmt-write",
|
||||||
"xtensa-lx",
|
"xtensa-lx",
|
||||||
@@ -1114,6 +1118,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"document-features",
|
"document-features",
|
||||||
"esp-metadata-generated",
|
"esp-metadata-generated",
|
||||||
|
"esp32",
|
||||||
"esp32s3",
|
"esp32s3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1389,6 +1394,7 @@ version = "0.9.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"defmt 1.0.1",
|
||||||
"hash32",
|
"hash32",
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
@@ -1401,7 +1407,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc"
|
name = "hsmc"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"embassy-futures",
|
"embassy-futures",
|
||||||
"embassy-sync 0.8.0",
|
"embassy-sync 0.8.0",
|
||||||
@@ -1412,7 +1420,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc-macros"
|
name = "hsmc-macros"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2197,6 +2207,28 @@ dependencies = [
|
|||||||
"unsafe-libyaml",
|
"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]]
|
[[package]]
|
||||||
name = "sha2-const-stable"
|
name = "sha2-const-stable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+46
-9
@@ -3,13 +3,14 @@ members = ["."]
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.5.0"
|
version = "1.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
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"]
|
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||||
categories = ["embedded", "hardware-support", "no-std"]
|
categories = ["embedded", "hardware-support", "no-std"]
|
||||||
|
|
||||||
@@ -29,8 +30,8 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
|
|||||||
embassy-usb-driver = "0.2"
|
embassy-usb-driver = "0.2"
|
||||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||||
heapless = "0.9"
|
heapless = "0.9"
|
||||||
donglora-protocol = { path = "../protocol" }
|
donglora-protocol = "1"
|
||||||
hsmc = { path = "../../hsmc/hsmc", default-features = false, features = ["embassy"] }
|
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
|
||||||
lora-phy = "3.0"
|
lora-phy = "3.0"
|
||||||
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||||
static_cell = "2.1"
|
static_cell = "2.1"
|
||||||
@@ -44,22 +45,20 @@ ssd1306 = { version = "0.10", features = ["async"] }
|
|||||||
embedded-io-async = "0.7"
|
embedded-io-async = "0.7"
|
||||||
embassy-futures = "0.1"
|
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 = [
|
esp-backtrace = { version = "0.18", optional = true, features = [
|
||||||
"esp32s3",
|
|
||||||
"panic-handler",
|
"panic-handler",
|
||||||
"defmt",
|
"defmt",
|
||||||
] }
|
] }
|
||||||
esp-hal = { version = "1.0", optional = true, features = [
|
esp-hal = { version = "1.0", optional = true, features = [
|
||||||
"esp32s3",
|
|
||||||
"unstable",
|
"unstable",
|
||||||
] }
|
] }
|
||||||
esp-println = { version = "0.16", optional = true, features = [
|
esp-println = { version = "0.16", optional = true, features = [
|
||||||
"esp32s3",
|
|
||||||
"defmt-espflash",
|
"defmt-espflash",
|
||||||
] }
|
] }
|
||||||
esp-rtos = { version = "0.2", optional = true, features = [
|
esp-rtos = { version = "0.2", optional = true, features = [
|
||||||
"esp32s3",
|
|
||||||
"embassy",
|
"embassy",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
@@ -109,6 +108,10 @@ heltec_v3 = [
|
|||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
|
"esp-backtrace/esp32s3",
|
||||||
|
"esp-hal/esp32s3",
|
||||||
|
"esp-println/esp32s3",
|
||||||
|
"esp-rtos/esp32s3",
|
||||||
]
|
]
|
||||||
heltec_v3_uart = [
|
heltec_v3_uart = [
|
||||||
"embassy",
|
"embassy",
|
||||||
@@ -116,6 +119,10 @@ heltec_v3_uart = [
|
|||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
|
"esp-backtrace/esp32s3",
|
||||||
|
"esp-hal/esp32s3",
|
||||||
|
"esp-println/esp32s3",
|
||||||
|
"esp-rtos/esp32s3",
|
||||||
]
|
]
|
||||||
heltec_v4 = [
|
heltec_v4 = [
|
||||||
"embassy",
|
"embassy",
|
||||||
@@ -123,6 +130,10 @@ heltec_v4 = [
|
|||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
|
"esp-backtrace/esp32s3",
|
||||||
|
"esp-hal/esp32s3",
|
||||||
|
"esp-println/esp32s3",
|
||||||
|
"esp-rtos/esp32s3",
|
||||||
]
|
]
|
||||||
elecrow_thinknode_m2 = [
|
elecrow_thinknode_m2 = [
|
||||||
"embassy",
|
"embassy",
|
||||||
@@ -130,6 +141,32 @@ elecrow_thinknode_m2 = [
|
|||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"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 = [
|
rak_wisblock_4631 = [
|
||||||
"embassy",
|
"embassy",
|
||||||
|
|||||||
+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 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||||
|
|||||||
@@ -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_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
|
||||||
heltec_v4 := "heltec_v4 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"
|
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"
|
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||||
wio_tracker_l1 := "wio_tracker_l1 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"
|
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`
|
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
||||||
|
|
||||||
# All known boards
|
# 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
|
# Install all required tools and toolchains
|
||||||
setup:
|
setup:
|
||||||
@@ -77,7 +79,7 @@ flash board:
|
|||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||||
case "$target" in \
|
case "$target" in \
|
||||||
xtensa-*) \
|
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 \
|
if [ -n "$port" ]; then \
|
||||||
echo "Using $port"; \
|
echo "Using $port"; \
|
||||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
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_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
|
||||||
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
||||||
elif [ "{{name}}" == "elecrow_thinknode_m2" ]; then echo "{{elecrow_thinknode_m2}}"; \
|
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}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
|
||||||
elif [ "{{name}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \
|
elif [ "{{name}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \
|
||||||
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
|
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||||
//!
|
//!
|
||||||
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
//! 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;
|
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||||
//! this firmware turns it into a DongLoRa radio bridge.
|
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||||
//!
|
//!
|
||||||
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||||
//! and PR meshtastic/firmware#6354):
|
//! and PR meshtastic/firmware#6354):
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||||
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||||
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
//! 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)
|
//! LED: GPIO6 (active HIGH indicator)
|
||||||
//!
|
//!
|
||||||
//! Notable differences from the Heltec ESP32-S3 boards:
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
pub use traits::*;
|
||||||
|
|
||||||
{% for helper, helper_features in helpers|items -%}
|
{% 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 %}))]
|
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
|
||||||
|
{% endif -%}
|
||||||
mod {{ helper }};
|
mod {{ helper }};
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
|
|||||||
+35
-2
@@ -9,6 +9,8 @@
|
|||||||
use embedded_graphics::draw_target::DrawTarget;
|
use embedded_graphics::draw_target::DrawTarget;
|
||||||
use embedded_graphics::pixelcolor::BinaryColor;
|
use embedded_graphics::pixelcolor::BinaryColor;
|
||||||
|
|
||||||
|
use crate::driver::DisplayBrightness;
|
||||||
|
|
||||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||||
pub trait RgbLed {
|
pub trait RgbLed {
|
||||||
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
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).
|
/// Display peripheral bundle (I2C bus for display init).
|
||||||
type DisplayParts;
|
type DisplayParts;
|
||||||
|
|
||||||
/// Concrete display driver type (must implement DrawTarget for rendering).
|
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||||
type DisplayDriver: DrawTarget<Color = BinaryColor>;
|
/// and DisplayBrightness for the two-level dim control).
|
||||||
|
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||||
|
|
||||||
/// RGB LED driver. Boards without an LED use `()`.
|
/// RGB LED driver. Boards without an LED use `()`.
|
||||||
type LedDriver: RgbLed;
|
type LedDriver: RgbLed;
|
||||||
@@ -47,6 +50,36 @@ pub trait LoRaBoard: Sized {
|
|||||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||||
fn mac_address() -> [u8; 6];
|
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.
|
/// Decompose initialized board into peripheral bundles for each task.
|
||||||
fn into_parts(
|
fn into_parts(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+62
-32
@@ -1,54 +1,84 @@
|
|||||||
//! Inter-task communication channels.
|
//! Inter-task communication channels for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
|
||||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
|
||||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
//! and tag-0 async RX/ERR events in one queue)
|
||||||
//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio)
|
//! - [`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::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_sync::channel::Channel;
|
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.
|
// ── Host → Radio: tag-correlated commands ───────────────────────────
|
||||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
|
||||||
pub enum DisplayCommand {
|
/// An event flowing from the host task into the radio task.
|
||||||
On,
|
///
|
||||||
Off,
|
/// `Command` carries a non-zero correlation tag (the host framing layer
|
||||||
#[allow(dead_code)] // only used by USB host task, not UART
|
/// 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,
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
/// Host-to-radio event channel (depth 8).
|
||||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
|
||||||
|
|
||||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
|
||||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
|
||||||
|
|
||||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
/// One outbound D→H frame carrying its tag. Tag-correlated messages
|
||||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
/// (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
|
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
|
||||||
/// every mode transition and every RF packet. Mode events and data events
|
/// RX events + queued command responses without stalling the radio ISR).
|
||||||
/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio
|
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
|
||||||
/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just
|
|
||||||
/// happened* without implying a mode change.
|
// ── 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)]
|
#[derive(Debug, Clone, defmt::Format)]
|
||||||
pub enum RadioEvent {
|
pub enum RadioEvent {
|
||||||
/// Radio is now in RX mode.
|
|
||||||
EnteredRx,
|
EnteredRx,
|
||||||
/// Radio is now in TX mode.
|
|
||||||
EnteredTx,
|
EnteredTx,
|
||||||
/// Radio is now idle.
|
|
||||||
Idle,
|
Idle,
|
||||||
/// A packet was received with these link metrics.
|
|
||||||
PacketRx { rssi: i16, snr: Option<i16> },
|
PacketRx { rssi: i16, snr: Option<i16> },
|
||||||
/// A packet was transmitted successfully.
|
|
||||||
PacketTx,
|
PacketTx,
|
||||||
/// `SetConfig` applied a new validated config.
|
ConfigChanged(LoRaConfig),
|
||||||
ConfigChanged(RadioConfig),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Radio → Display event stream (depth 32: buffer RX bursts while the
|
|
||||||
/// display drains slowly).
|
|
||||||
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||||
+96
-25
@@ -37,8 +37,9 @@ use embassy_time::Timer;
|
|||||||
use hsmc::statechart;
|
use hsmc::statechart;
|
||||||
|
|
||||||
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||||
|
use crate::driver::DisplayBrightness;
|
||||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
||||||
use crate::protocol::RadioConfig;
|
use crate::protocol::LoRaConfig;
|
||||||
|
|
||||||
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
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).
|
/// A packet was transmitted (data only — does not imply a mode change).
|
||||||
PacketTx,
|
PacketTx,
|
||||||
ConfigChanged(RadioConfig),
|
ConfigChanged(LoRaConfig),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RadioEvent> for DisplayEvent {
|
impl From<RadioEvent> for DisplayEvent {
|
||||||
@@ -109,7 +110,7 @@ struct RunState {
|
|||||||
rssi_count: usize,
|
rssi_count: usize,
|
||||||
current_slot_rssi: i16,
|
current_slot_rssi: i16,
|
||||||
current_slot_tx: bool,
|
current_slot_tx: bool,
|
||||||
config: Option<RadioConfig>,
|
config: Option<LoRaConfig>,
|
||||||
rx_count: u32,
|
rx_count: u32,
|
||||||
tx_count: u32,
|
tx_count: u32,
|
||||||
last_rssi: Option<i16>,
|
last_rssi: Option<i16>,
|
||||||
@@ -220,6 +221,14 @@ impl DisplayContext {
|
|||||||
Timer::after_millis(50).await;
|
Timer::after_millis(50).await;
|
||||||
self.peripherals.led.set_rgb(0, 0, 0).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 ─────────────────────────────────────────────────────
|
// ── Statechart ─────────────────────────────────────────────────────
|
||||||
@@ -243,16 +252,26 @@ statechart! {
|
|||||||
on(CmdOff) => Off;
|
on(CmdOff) => Off;
|
||||||
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
|
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
|
||||||
// makes either ordering land in the same place. Reset the run
|
// makes either ordering land in the same place. Reset the run
|
||||||
// state, turn off any blink-in-progress, and re-enter Splash —
|
// state, turn off any blink-in-progress, and land in LearnMore
|
||||||
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
|
// with a fresh 5 s timer. We target `LearnMore` (not `Splash`)
|
||||||
on(CmdReset) => reset_run_state, turn_led_off, 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.
|
// 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 {
|
state Splash {
|
||||||
|
entry: set_bright;
|
||||||
default(LearnMore);
|
default(LearnMore);
|
||||||
on(EnteredRx) => Listening;
|
on(EnteredRx) => Listening;
|
||||||
on(EnteredTx) => Transmitting;
|
on(EnteredTx) => Transmitting;
|
||||||
|
on(after Duration::from_secs(10)) => set_dim;
|
||||||
|
|
||||||
state LearnMore {
|
state LearnMore {
|
||||||
entry: paint_learn_more;
|
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 {
|
state Dashboard {
|
||||||
default(Listening);
|
default(Listening);
|
||||||
on(RadioIdle) => Splash;
|
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 {
|
state Listening {
|
||||||
entry: paint_listening;
|
entry: paint_listening;
|
||||||
|
default(ListeningBright);
|
||||||
on(EnteredTx) => Transmitting;
|
on(EnteredTx) => Transmitting;
|
||||||
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
|
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 {
|
state Transmitting {
|
||||||
entry: paint_transmitting;
|
entry: paint_transmitting;
|
||||||
|
default(TransmittingBright);
|
||||||
on(EnteredRx) => Listening;
|
on(EnteredRx) => Listening;
|
||||||
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
|
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;
|
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);
|
self.run.config = Some(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn advance_sparkline(&mut self) {
|
async fn advance_sparkline(&mut self) {
|
||||||
self.run.advance_slot();
|
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 ───────────────────────────────────────────────
|
// ── Task entry point ───────────────────────────────────────────────
|
||||||
@@ -440,7 +499,7 @@ mod render {
|
|||||||
use heapless::String;
|
use heapless::String;
|
||||||
|
|
||||||
use super::Badge;
|
use super::Badge;
|
||||||
use crate::protocol::{Bandwidth, RadioConfig};
|
use crate::protocol::{LoRaBandwidth, LoRaConfig};
|
||||||
|
|
||||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||||
|
|
||||||
@@ -505,7 +564,7 @@ mod render {
|
|||||||
pub fn dashboard(
|
pub fn dashboard(
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||||
badge: Badge,
|
badge: Badge,
|
||||||
config: Option<RadioConfig>,
|
config: Option<LoRaConfig>,
|
||||||
rx_count: u32,
|
rx_count: u32,
|
||||||
tx_count: u32,
|
tx_count: u32,
|
||||||
last_rssi: Option<i16>,
|
last_rssi: Option<i16>,
|
||||||
@@ -588,21 +647,33 @@ mod render {
|
|||||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||||
let bw_str = match cfg.bw {
|
let bw_str = match cfg.bw {
|
||||||
Bandwidth::Khz7 => "7.8",
|
LoRaBandwidth::Khz7 => "7.8",
|
||||||
Bandwidth::Khz10 => "10.4",
|
LoRaBandwidth::Khz10 => "10.4",
|
||||||
Bandwidth::Khz15 => "15.6",
|
LoRaBandwidth::Khz15 => "15.6",
|
||||||
Bandwidth::Khz20 => "20.8",
|
LoRaBandwidth::Khz20 => "20.8",
|
||||||
Bandwidth::Khz31 => "31.2",
|
LoRaBandwidth::Khz31 => "31.2",
|
||||||
Bandwidth::Khz41 => "41.7",
|
LoRaBandwidth::Khz41 => "41.7",
|
||||||
Bandwidth::Khz62 => "62.5",
|
LoRaBandwidth::Khz62 => "62.5",
|
||||||
Bandwidth::Khz125 => "125",
|
LoRaBandwidth::Khz125 => "125",
|
||||||
Bandwidth::Khz250 => "250",
|
LoRaBandwidth::Khz250 => "250",
|
||||||
Bandwidth::Khz500 => "500",
|
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!(
|
let _ = write!(
|
||||||
buf,
|
buf,
|
||||||
"{}.{:03}/{}/{}/{}",
|
"{}.{:03}/{}/{}/{}",
|
||||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
|
||||||
);
|
);
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
&buf,
|
&buf,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
pub mod sh1106;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
|
||||||
|
pub mod axp;
|
||||||
|
|
||||||
#[cfg(any(
|
#[cfg(any(
|
||||||
feature = "heltec_v3",
|
feature = "heltec_v3",
|
||||||
feature = "heltec_v3_uart",
|
feature = "heltec_v3_uart",
|
||||||
feature = "heltec_v4",
|
feature = "heltec_v4",
|
||||||
feature = "elecrow_thinknode_m2",
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam_supreme",
|
||||||
|
feature = "lilygo_tbeam",
|
||||||
feature = "wio_tracker_l1",
|
feature = "wio_tracker_l1",
|
||||||
feature = "waveshare_rp2040_lora"
|
feature = "waveshare_rp2040_lora"
|
||||||
))]
|
))]
|
||||||
pub mod simple_led;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -7,10 +7,14 @@
|
|||||||
feature = "heltec_v3",
|
feature = "heltec_v3",
|
||||||
feature = "heltec_v3_uart",
|
feature = "heltec_v3_uart",
|
||||||
feature = "heltec_v4",
|
feature = "heltec_v4",
|
||||||
feature = "elecrow_thinknode_m2"
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam_supreme"
|
||||||
))]
|
))]
|
||||||
pub mod esp32s3;
|
pub mod esp32s3;
|
||||||
|
|
||||||
|
#[cfg(feature = "lilygo_tbeam")]
|
||||||
|
pub mod esp32;
|
||||||
|
|
||||||
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
||||||
pub mod nrf52840;
|
pub mod nrf52840;
|
||||||
|
|
||||||
|
|||||||
+108
-40
@@ -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
|
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
|
||||||
//! accumulation, response encoding, and command routing.
|
//! → `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
|
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
|
||||||
//! reuse it; this module re-exports the codec and owns the
|
//! → bytes ready for transport-level write.
|
||||||
//! firmware-only [`route_command`] (which touches embassy channels).
|
|
||||||
|
|
||||||
pub use donglora_protocol::framing::{cobs_encode_response, CobsDecoder, MAX_FRAME};
|
use defmt::warn;
|
||||||
|
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
|
||||||
use crate::protocol::{Command, ErrorCode, Response};
|
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).
|
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
|
||||||
/// All other commands are forwarded to the radio task (response sent later).
|
/// - Known commands are forwarded to the radio task via `commands`.
|
||||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
|
||||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
/// `outbound`, so the host sees a concrete answer instead of timing out.
|
||||||
pub async fn route_command(
|
/// - The caller is responsible for rejecting `tag == 0` before calling
|
||||||
cmd: Command,
|
/// 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,
|
commands: &CommandChannel,
|
||||||
responses: &ResponseChannel,
|
outbound: &OutboundChannel,
|
||||||
display_commands: &DisplayCommandChannel,
|
|
||||||
has_display: bool,
|
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
) {
|
||||||
match cmd {
|
if tag == 0 {
|
||||||
Command::DisplayOn => {
|
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
|
||||||
if has_display {
|
// framing error; do not route to radio. (Spec §2.2)
|
||||||
display_commands.send(DisplayCommand::On).await;
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
responses.send(Response::Ok).await;
|
return;
|
||||||
} else {
|
}
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
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 => {
|
Err(e) => {
|
||||||
if has_display {
|
let code = parse_err_to_wire_code(e);
|
||||||
display_commands.send(DisplayCommand::Off).await;
|
emit_sync_err(outbound, tag, code).await;
|
||||||
responses.send(Response::Ok).await;
|
}
|
||||||
} else {
|
}
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
}
|
||||||
}
|
|
||||||
}
|
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
|
||||||
Command::GetMac => {
|
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
|
||||||
responses.send(Response::MacAddress(mac)).await;
|
let frame = OutboundFrame {
|
||||||
}
|
tag: 0,
|
||||||
other => {
|
msg: DeviceMessage::Err(code),
|
||||||
commands.send(other).await;
|
};
|
||||||
|
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
@@ -6,7 +6,11 @@
|
|||||||
pub mod framing;
|
pub mod framing;
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
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;
|
mod uart;
|
||||||
pub use uart::host_task;
|
pub use uart::host_task;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+107
-51
@@ -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 defmt::warn;
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
|
use embassy_time::{Duration, Instant};
|
||||||
use embedded_io_async::Write;
|
use embedded_io_async::Write;
|
||||||
|
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
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]
|
#[task]
|
||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UartParts,
|
parts: crate::board::UartParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
_mac: [u8; 6],
|
||||||
) {
|
) {
|
||||||
let (mut rx, mut tx) = parts.driver.split();
|
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 {
|
if has_display {
|
||||||
display_commands.send(DisplayCommand::On).await;
|
display_commands.send(DisplayCommand::On).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut read_buf = [0u8; 64];
|
let mut read_buf = [0u8; READ_BUF];
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
let mut decoder = FrameDecoder::new();
|
||||||
let mut decoder = CobsDecoder::new();
|
let mut last_frame_at = Instant::now();
|
||||||
|
let mut connected = true;
|
||||||
|
|
||||||
loop {
|
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),
|
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||||
responses.receive(),
|
outbound.receive(),
|
||||||
|
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either::First(result) => {
|
Either3::First(result) => match result {
|
||||||
let n = match result {
|
Ok(0) => continue,
|
||||||
Ok(0) => continue,
|
Ok(n) => {
|
||||||
Ok(n) => n,
|
last_frame_at = Instant::now();
|
||||||
Err(_) => {
|
if !connected {
|
||||||
decoder.reset();
|
if has_display {
|
||||||
embassy_time::Timer::after_millis(100).await;
|
let _ = display_commands.try_send(DisplayCommand::On);
|
||||||
continue;
|
}
|
||||||
|
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) => {
|
Either3::Third(()) => {}
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
}
|
||||||
crate::debug_blink::set(13);
|
|
||||||
if let Some(frame) =
|
// Inactivity watchdog.
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||||
{
|
warn!("UART host inactivity timeout fired");
|
||||||
if tx.write_all(frame).await.is_err() {
|
let _ = commands.try_send(InboundEvent::Reset);
|
||||||
warn!("UART write failed, response dropped");
|
if has_display {
|
||||||
} else {
|
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
}
|
||||||
crate::debug_blink::set(14);
|
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
@@ -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
|
//! ```text
|
||||||
//! Host
|
//! Host
|
||||||
//! ├── Disconnected (default) no DTR
|
//! ├── Disconnected (default) no DTR, or inactivity timer fired
|
||||||
//! └── Connected host attached
|
//! └── Connected host attached, liveness OK
|
||||||
//! entry: wake_display
|
//! entry: wake_display, start_liveness_timer
|
||||||
//! exit: stop_radio, reset_display
|
//! exit: reset_radio, reset_display, stop_liveness_timer
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The statechart tracks exactly one thing: DTR. Entry/exit actions
|
//! The statechart tracks one state bit: "is a host present?". DTR drives
|
||||||
//! replace the old `was_connected` edge-detector and emit the display
|
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
|
||||||
//! wake / radio-stop / display-reset side-effects that used to be open
|
//! the disconnect edge when DTR is held high but no bytes arrive.
|
||||||
//! coded around the `select3` loop.
|
|
||||||
//!
|
//!
|
||||||
//! The data plane (COBS decoding, command routing, response encoding)
|
//! The data plane (frame decode, command dispatch, frame encode) stays
|
||||||
//! stays imperative in `protocol_loop` for the same reason the radio's
|
//! imperative in `protocol_loop` for the same reason the radio's
|
||||||
//! `rx_once` stayed out of its chart: `read_packet` blocks and can't
|
//! `rx_once` stayed out of its chart: `read_packet` blocks.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use defmt::warn;
|
use defmt::warn;
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
use embassy_futures::join::join;
|
use embassy_futures::join::join;
|
||||||
|
use embassy_time::{Duration, Instant};
|
||||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||||
use embassy_usb::Builder;
|
use embassy_usb::Builder;
|
||||||
use hsmc::{statechart, Duration};
|
use hsmc::{statechart, Duration as HsmcDuration};
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use crate::channel::{
|
||||||
use crate::protocol::Command;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HostInput {
|
pub enum HostInput {
|
||||||
/// DTR rose: host opened the serial port.
|
|
||||||
DtrRaised,
|
DtrRaised,
|
||||||
/// DTR fell: host closed or disconnected.
|
|
||||||
DtrDropped,
|
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 {
|
pub struct HostContext {
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
@@ -68,9 +67,9 @@ impl HostContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_radio_best_effort(&self) {
|
fn send_radio_reset(&self) {
|
||||||
if self.commands.try_send(Command::StopRx).is_err() {
|
if self.commands.try_send(InboundEvent::Reset).is_err() {
|
||||||
warn!("host DTR-drop: StopRx dropped (command channel full)");
|
warn!("radio Reset inject: command queue full");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,8 +86,9 @@ statechart! {
|
|||||||
|
|
||||||
state Connected {
|
state Connected {
|
||||||
entry: wake_display;
|
entry: wake_display;
|
||||||
exit: stop_radio, reset_display;
|
exit: reset_radio, reset_display;
|
||||||
on(DtrDropped) => Disconnected;
|
on(DtrDropped) => Disconnected;
|
||||||
|
on(InactivityTimeout) => Disconnected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,8 @@ impl HostActions for HostActionContext<'_> {
|
|||||||
self.wake_display_if_present().await;
|
self.wake_display_if_present().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_radio(&mut self) {
|
async fn reset_radio(&mut self) {
|
||||||
self.stop_radio_best_effort();
|
self.send_radio_reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reset_display(&mut self) {
|
async fn reset_display(&mut self) {
|
||||||
@@ -111,7 +111,7 @@ impl HostActions for HostActionContext<'_> {
|
|||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UsbParts,
|
parts: crate::board::UsbParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
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 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 mut usb_dev = builder.build();
|
||||||
let (sender, receiver) = class.split();
|
let (sender, receiver) = class.split();
|
||||||
|
|
||||||
@@ -157,10 +157,9 @@ pub async fn host_task(
|
|||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
commands,
|
commands,
|
||||||
responses,
|
outbound,
|
||||||
display_commands,
|
display_commands,
|
||||||
has_display,
|
has_display,
|
||||||
mac,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -170,10 +169,9 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
mut sender: Sender<'d, D>,
|
mut sender: Sender<'d, D>,
|
||||||
mut receiver: Receiver<'d, D>,
|
mut receiver: Receiver<'d, D>,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
) {
|
||||||
use embassy_futures::select::{select3, Either3};
|
use embassy_futures::select::{select3, Either3};
|
||||||
|
|
||||||
@@ -182,55 +180,36 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
display_commands,
|
display_commands,
|
||||||
has_display,
|
has_display,
|
||||||
});
|
});
|
||||||
// Enter the default: Disconnected.
|
machine.step(HsmcDuration::ZERO).await;
|
||||||
machine.step(Duration::ZERO).await;
|
|
||||||
|
|
||||||
let mut read_buf = [0u8; MAX_PACKET];
|
let mut read_buf = [0u8; MAX_USB_PACKET];
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
let mut decoder = FrameDecoder::new();
|
||||||
let mut decoder = CobsDecoder::new();
|
|
||||||
|
let mut last_frame_at = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match select3(
|
match select3(
|
||||||
receiver.read_packet(&mut read_buf),
|
receiver.read_packet(&mut read_buf),
|
||||||
responses.receive(),
|
outbound.receive(),
|
||||||
embassy_time::Timer::after_millis(250),
|
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either3::First(Ok(0)) => {}
|
Either3::First(Ok(0)) => {}
|
||||||
Either3::First(Ok(n)) => {
|
Either3::First(Ok(n)) => {
|
||||||
let mut cmds = heapless::Vec::<_, 4>::new();
|
last_frame_at = Instant::now();
|
||||||
decoder.feed(&read_buf[..n], |cmd| {
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||||
let _ = cmds.push(cmd);
|
|
||||||
});
|
|
||||||
for cmd in cmds {
|
|
||||||
framing::route_command(
|
|
||||||
cmd,
|
|
||||||
commands,
|
|
||||||
responses,
|
|
||||||
display_commands,
|
|
||||||
has_display,
|
|
||||||
mac,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Either3::First(Err(_)) => {
|
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();
|
decoder.reset();
|
||||||
embassy_time::Timer::after_millis(100).await;
|
embassy_time::Timer::after_millis(100).await;
|
||||||
}
|
}
|
||||||
Either3::Second(response) => {
|
Either3::Second(frame) => {
|
||||||
if let Some(frame) =
|
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
|
||||||
{
|
|
||||||
for chunk in frame.chunks(MAX_PACKET) {
|
|
||||||
if sender.write_packet(chunk).await.is_err() {
|
if sender.write_packet(chunk).await.is_err() {
|
||||||
warn!("USB write failed, response dropped");
|
warn!("USB write failed, frame dropped");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,27 +218,90 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
Either3::Third(()) => {}
|
Either3::Third(()) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge-detect DTR against the machine's current state. The
|
// DTR edge detection — drives Connected / Disconnected transitions.
|
||||||
// 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.
|
|
||||||
let connected = receiver.dtr();
|
let connected = receiver.dtr();
|
||||||
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
||||||
match (was_connected, connected) {
|
match (was_connected, connected) {
|
||||||
(false, true) => {
|
(false, true) => {
|
||||||
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
||||||
|
last_frame_at = Instant::now();
|
||||||
}
|
}
|
||||||
(true, false) => {
|
(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();
|
decoder.reset();
|
||||||
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
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
@@ -39,14 +39,16 @@ use embassy_executor::Spawner;
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::board::LoRaBoard;
|
use crate::board::LoRaBoard;
|
||||||
#[cfg(not(test))]
|
#[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(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||||
use defmt_rtt as _;
|
use defmt_rtt as _;
|
||||||
use panic_probe 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_backtrace as _;
|
||||||
use esp_println as _;
|
use esp_println as _;
|
||||||
}
|
}
|
||||||
@@ -55,11 +57,13 @@ cfg_if::cfg_if! {
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||||
|
#[cfg(not(test))]
|
||||||
|
static mut INFO: Option<Info> = None;
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
@@ -68,7 +72,7 @@ cfg_if::cfg_if! {
|
|||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
run(spawner).await;
|
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]
|
#[esp_rtos::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
run(spawner).await;
|
run(spawner).await;
|
||||||
@@ -81,8 +85,18 @@ async fn run(spawner: Spawner) {
|
|||||||
let board = <board::Board as LoRaBoard>::init();
|
let board = <board::Board as LoRaBoard>::init();
|
||||||
let parts = board.into_parts();
|
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(
|
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"),
|
.expect("spawn radio_task"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,7 +104,7 @@ async fn run(spawner: Spawner) {
|
|||||||
host::host_task(
|
host::host_task(
|
||||||
parts.host,
|
parts.host,
|
||||||
&COMMANDS,
|
&COMMANDS,
|
||||||
&RESPONSES,
|
&OUTBOUND,
|
||||||
&DISPLAY_COMMANDS,
|
&DISPLAY_COMMANDS,
|
||||||
parts.display.is_some(),
|
parts.display.is_some(),
|
||||||
parts.mac,
|
parts.mac,
|
||||||
@@ -98,6 +112,9 @@ async fn run(spawner: Spawner) {
|
|||||||
.expect("spawn host_task"),
|
.expect("spawn host_task"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Suppress "assigned but never read" warning on non-USB/UART path.
|
||||||
|
let _ = info_ref;
|
||||||
|
|
||||||
if let Some(dp) = parts.display {
|
if let Some(dp) = parts.display {
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
#[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
@@ -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
|
//! Wire definitions live in the `donglora-protocol` crate so host-side
|
||||||
//! Rust projects (client-rs, mux-rs, bridge) can share them with the
|
//! Rust projects can share them with the firmware without duplicating
|
||||||
//! firmware without duplicating wire layouts. This module re-exports
|
//! wire layouts. This module re-exports them unchanged so firmware
|
||||||
//! them unchanged for existing `crate::protocol::*` call sites.
|
//! modules can `use crate::protocol::*;`.
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use donglora_protocol::{
|
pub use donglora_protocol::{
|
||||||
Bandwidth, Command, ErrorCode, RadioConfig, Response, MAX_PAYLOAD, PREAMBLE_DEFAULT,
|
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
|
||||||
RADIO_CONFIG_SIZE, TX_POWER_MAX,
|
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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user