diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0f453a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [0.2.1] - 2026-04-07 + +### Fixed + +- Display stuck on status screen after USB disconnect (stale radio status + updates re-rendered dashboard over splash because `disconnected` flag was + not set in the `Reset` handler) +- Radio left running after host disconnect, now sends `StopRx` when DTR drops +- System deadlock when host app closes serial port while radio is receiving + (unsolicited responses in radio task now use `try_send` to avoid blocking + on a full response channel) +- LED orphaned if OLED display initialization fails (display task now falls + back to a LED-only event loop instead of returning early) + +## [0.2.0] - 2026-04-07 + +### Added + +- Wio Tracker L1 board support (nRF52840 + SX1262, SH1106 OLED) +- LED blink on packet activity: brief flash on RX and TX when display is on + - RX brightness scales with SNR (stronger signal = brighter) + - Works on all boards with a user LED (Heltec V3/V4, Wio Tracker) +- Native USB CDC-ACM support for Heltec V3 (requires hardware mod: solder R29/R3) +- `heltec_v3_uart` feature for stock (unmodified) V3 boards via CP2102 bridge +- `RgbLed` trait and `SimpleLed` driver for GPIO-based LEDs +- `LoRaBoard` trait with full associated types (`RadioParts`, `CommParts`, + `DisplayParts`, `DisplayDriver`, `LedDriver`) and `BoardParts` struct + +### Changed + +- Major firmware architecture reorganization: + - `hal/` module for MCU-specific primitives (ESP32-S3, nRF52840) + - `driver/` module for hardware peripheral drivers (SH1106, SimpleLed) + - `host/` module for unified USB/UART communication with internal cfg dispatch + - `board/` contains only board definitions — no MCU helpers or drivers mixed in + - Board files are declarative wiring diagrams calling hal primitives +- `run()` function has zero board-specific conditional compilation +- Display init moved to board layer (`create_display()`) — display task has no cfg +- Flattened `radio/` and `display/` directories (no single-file directories) +- `build.rs` auto-discovers boards by content (implements `LoRaBoard`) and + helper modules by `use super::` imports — no exclusion lists +- `check` and `clippy` commands now use `--release` profile +- Deduplicated USB/UART protocol framing (shared `CobsDecoder` + `route_command`) +- Removed `ErrorCode::CrcError` (SX1262 silently drops bad-CRC packets) + +### Fixed + +- Display stuck on splash screen for UART boards (disconnected flag started true) + +## [0.1.0] - 2026-03-15 + +### Added + +- Transparent LoRa radio over USB CDC-ACM (VID `1209`, PID `5741`) +- COBS-framed fixed-size LE binary protocol (9 commands, 7 responses) +- Radio configuration: frequency, bandwidth, spreading factor, coding rate, TX power +- Continuous RX mode with RSSI/SNR per packet +- Single-shot TX with optional per-packet config override +- SSD1306 OLED display support with status dashboard + - Radio state, frequency, modulation parameters + - Packet counters (RX/TX) + - RSSI sparkline (1-minute history, TX shown as dotted bars) + - Combined splash/waiting screen on disconnect +- Auto-detect USB host connect/disconnect via DTR +- Config validation against per-board hardware limits +- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power +- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631 +- Python host library (`clients/python/`) with `pip install` and `donglora-mux` CLI +- USB multiplexer for sharing one dongle with multiple applications +- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`) + +### Technical + +- Embassy async runtime (no threads, CPU sleeps when idle) +- Zero-config: boots and waits for host commands +- Protocol-agnostic: firmware has no opinion about what you transmit +- Tasks never panic: all errors reported to host or logged +- Board abstraction: one `.rs` file + one Cargo feature per board diff --git a/Cargo.lock b/Cargo.lock index 5dcd879..4122f6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -372,7 +372,7 @@ dependencies = [ [[package]] name = "donglora" -version = "0.2.0" +version = "0.2.1" dependencies = [ "cfg-if", "cortex-m", diff --git a/Cargo.toml b/Cargo.toml index f65fd30..e86497e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["."] [package] name = "donglora" -version = "0.2.0" +version = "0.2.1" edition = "2021" build = "build.rs" authors = ["Stephen Waits "] diff --git a/src/display.rs b/src/display.rs index 0d29ba7..0dbe7d0 100644 --- a/src/display.rs +++ b/src/display.rs @@ -131,8 +131,15 @@ pub async fn display_task( }; let Some(mut display) = crate::board::create_display(parts.i2c).await else { - defmt::error!("display init failed, giving up"); - return; + defmt::error!("display init failed, running LED-only loop"); + loop { + match display_commands.receive().await { + DisplayCommand::Reset | DisplayCommand::Off => { + led.set_rgb(0, 0, 0).await; + } + DisplayCommand::On => {} + } + } }; let mut state = DisplayState::new(); @@ -207,6 +214,7 @@ pub async fn display_task( } DisplayCommand::Reset => { state = DisplayState::new(); + state.disconnected = true; render::splash(&mut display, &board_info); let _ = display.flush().await; led.set_rgb(0, 0, 0).await; diff --git a/src/host/usb.rs b/src/host/usb.rs index 71e089e..5261d34 100644 --- a/src/host/usb.rs +++ b/src/host/usb.rs @@ -8,6 +8,7 @@ use embassy_usb::Builder; use static_cell::StaticCell; use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; +use crate::protocol::Command; use super::framing::{self, CobsDecoder, MAX_FRAME}; const MAX_PACKET: usize = 64; @@ -126,8 +127,11 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( } let connected = receiver.dtr(); - if was_connected && !connected && has_display { - display_commands.send(DisplayCommand::Reset).await; + if was_connected && !connected { + let _ = commands.try_send(Command::StopRx); + if has_display { + display_commands.send(DisplayCommand::Reset).await; + } } if !was_connected && connected && has_display { display_commands.send(DisplayCommand::On).await; diff --git a/src/radio.rs b/src/radio.rs index 99d09e3..d598bdc 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -113,19 +113,22 @@ pub async fn radio_task( // copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail. let _ = payload.extend_from_slice(&rx_buf[..copy_len]); - responses - .send(Response::RxPacket { + if responses + .try_send(Response::RxPacket { rssi: pkt_status.rssi, snr: pkt_status.snr, payload, }) - .await; + .is_err() + { + warn!("RX packet dropped: response channel full"); + } if let Err(e) = start_rx(&mut lora, &cfg).await { warn!("restart RX failed: {}", e); state.state = RadioState::Idle; status.sender().send(state.clone()); - responses.send(Response::Error(ErrorCode::RadioBusy)).await; + let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy)); } } Err(e) => { @@ -133,7 +136,7 @@ pub async fn radio_task( if start_rx(&mut lora, &cfg).await.is_err() { state.state = RadioState::Idle; status.sender().send(state.clone()); - responses.send(Response::Error(ErrorCode::RadioBusy)).await; + let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy)); } } }, @@ -210,7 +213,9 @@ async fn handle_cmd( let _ = lora.enter_standby().await; state.state = RadioState::Idle; status.sender().send(state.clone()); - responses.send(Response::Ok).await; + // try_send: StopRx may be sent internally on USB disconnect + // when the host isn't draining responses. + let _ = responses.try_send(Response::Ok); } Command::DisplayOn | Command::DisplayOff | Command::GetMac => {} Command::Transmit { config, payload } => {