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:
6 files changed
+114
-12
No files matched your search
@@ -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
@@ -372,7 +372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
|
||||
+10
-2
@@ -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;
|
||||
|
||||
+6
-2
@@ -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;
|
||||
|
||||
+11
-6
@@ -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 } => {
|
||||
|
||||
Reference in new issue
Block a user