fix: resolve display stuck after disconnect and radio task deadlock

- Set disconnected flag on DisplayCommand::Reset to block stale radio
  status updates from re-rendering dashboard over splash screen
- Send StopRx to radio when USB DTR drops so radio stops generating
  packets after host disconnects
- Use try_send for unsolicited responses (RxPacket, error recovery,
  StopRx Ok) to prevent radio task deadlock on full response channel
- Fall back to LED-only event loop when display init fails instead of
  returning early and orphaning the LED

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-07 19:49:15 -06:00
1 parent 0c9094dcbd
commit de38278d7b
6 files changed
+114 -12

No files matched your search

+85
View File
@@ -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<R, C, D, L>` 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
Generated
+1 -1
View File
@@ -372,7 +372,7 @@ dependencies = [
[[package]] [[package]]
name = "donglora" name = "donglora"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cortex-m", "cortex-m",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["."]
[package] [package]
name = "donglora" name = "donglora"
version = "0.2.0" version = "0.2.1"
edition = "2021" edition = "2021"
build = "build.rs" build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
+10 -2
View File
@@ -131,8 +131,15 @@ pub async fn display_task(
}; };
let Some(mut display) = crate::board::create_display(parts.i2c).await else { let Some(mut display) = crate::board::create_display(parts.i2c).await else {
defmt::error!("display init failed, giving up"); defmt::error!("display init failed, running LED-only loop");
return; loop {
match display_commands.receive().await {
DisplayCommand::Reset | DisplayCommand::Off => {
led.set_rgb(0, 0, 0).await;
}
DisplayCommand::On => {}
}
}
}; };
let mut state = DisplayState::new(); let mut state = DisplayState::new();
@@ -207,6 +214,7 @@ pub async fn display_task(
} }
DisplayCommand::Reset => { DisplayCommand::Reset => {
state = DisplayState::new(); state = DisplayState::new();
state.disconnected = true;
render::splash(&mut display, &board_info); render::splash(&mut display, &board_info);
let _ = display.flush().await; let _ = display.flush().await;
led.set_rgb(0, 0, 0).await; led.set_rgb(0, 0, 0).await;
+6 -2
View File
@@ -8,6 +8,7 @@ use embassy_usb::Builder;
use static_cell::StaticCell; use static_cell::StaticCell;
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::Command;
use super::framing::{self, CobsDecoder, MAX_FRAME}; use super::framing::{self, CobsDecoder, MAX_FRAME};
const MAX_PACKET: usize = 64; const MAX_PACKET: usize = 64;
@@ -126,8 +127,11 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
} }
let connected = receiver.dtr(); let connected = receiver.dtr();
if was_connected && !connected && has_display { if was_connected && !connected {
display_commands.send(DisplayCommand::Reset).await; let _ = commands.try_send(Command::StopRx);
if has_display {
display_commands.send(DisplayCommand::Reset).await;
}
} }
if !was_connected && connected && has_display { if !was_connected && connected && has_display {
display_commands.send(DisplayCommand::On).await; display_commands.send(DisplayCommand::On).await;
+11 -6
View File
@@ -113,19 +113,22 @@ pub async fn radio_task(
// copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail. // copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail.
let _ = payload.extend_from_slice(&rx_buf[..copy_len]); let _ = payload.extend_from_slice(&rx_buf[..copy_len]);
responses if responses
.send(Response::RxPacket { .try_send(Response::RxPacket {
rssi: pkt_status.rssi, rssi: pkt_status.rssi,
snr: pkt_status.snr, snr: pkt_status.snr,
payload, payload,
}) })
.await; .is_err()
{
warn!("RX packet dropped: response channel full");
}
if let Err(e) = start_rx(&mut lora, &cfg).await { if let Err(e) = start_rx(&mut lora, &cfg).await {
warn!("restart RX failed: {}", e); warn!("restart RX failed: {}", e);
state.state = RadioState::Idle; state.state = RadioState::Idle;
status.sender().send(state.clone()); status.sender().send(state.clone());
responses.send(Response::Error(ErrorCode::RadioBusy)).await; let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
} }
} }
Err(e) => { Err(e) => {
@@ -133,7 +136,7 @@ pub async fn radio_task(
if start_rx(&mut lora, &cfg).await.is_err() { if start_rx(&mut lora, &cfg).await.is_err() {
state.state = RadioState::Idle; state.state = RadioState::Idle;
status.sender().send(state.clone()); 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; let _ = lora.enter_standby().await;
state.state = RadioState::Idle; state.state = RadioState::Idle;
status.sender().send(state.clone()); 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::DisplayOn | Command::DisplayOff | Command::GetMac => {}
Command::Transmit { config, payload } => { Command::Transmit { config, payload } => {