fix(host/uart): deliver RX to host and survive sustained bidirectional load

Two regressions on UART boards (heltec_v3_uart, etc.):

1. RADIO_THROTTLED was never cleared on UART. The throttle gate at
   src/radio.rs:2017 (added in 276f941, "chart-first restructure ...
   soak hardening") silently drops async RX while the flag is true.
   USB host_task clears it on first successful write_packet
   (src/host/usb.rs:354/373), but the UART path was never updated --
   every RX event was dropped from boot. The OLED bar lit up
   regardless because record_packet announces PacketRx before the
   gate. Mirror the USB pattern: clear on successful tx.write_all,
   re-arm on failure.

2. Once RX flowed, sustained bidirectional spam tripped the §3.4
   inactivity watchdog spuriously. The select3-based loop serialised
   read and write; tx.write_all (~25 ms per max frame at 115_200
   baud) blocked the read poll long enough for last_frame_at to age
   past 1000 ms under a saturated outbound queue. The Reset cascaded
   to radio (Unconfigured) and display (splash), leaving subsequent
   host TX commands timing out waiting for TX_DONE. Restructure
   host_task into two sub-loops joined in the same task: read_loop
   owns rx + watchdog, write_loop owns tx + throttle. The read future
   re-polls on every executor wake regardless of write progress.
   Same executor, disjoint borrows on the split rx/tx halves, no
   Mutex needed.

Also reset the FrameDecoder when the watchdog fires so a host that
resumes mid-frame resyncs cleanly on the next 0x00 sentinel (USB
gets this for free via DTR-drop reset; UART has no DTR).

Build: bump esp-hal/esp-rtos/esp-backtrace/esp-println version
requirements (1.0->1.1, 0.2->0.3, 0.18->0.19, 0.16->0.17) to match
what the upstream esp-rs/esp-hal main branch now publishes. cargo
update had invalidated the [patch.crates-io] entries since the
patched versions were no longer semver-compatible with the old
top-level requirements, which silently dropped the patches and fell
back to crates.io releases that pin embassy-executor 0.9 (mismatched
with our 0.10).
This commit is contained in:
swaits committed 2026-04-29 22:26:16 -06:00
1 parent eb8202058f
commit f68e5d9be5
3 files changed
+212 -141

No files matched your search

+83 -35
View File
@@ -4,7 +4,7 @@
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
//! → Disconnected → drop radio to Unconfigured).
use defmt::warn;
use defmt::{info, warn};
use embassy_executor::task;
use embassy_time::{Duration, Instant};
use embedded_io_async::Write;
@@ -34,25 +34,32 @@ pub async fn host_task(
display_commands.send(DisplayCommand::On).await;
}
let mut read_buf = [0u8; READ_BUF];
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
let mut connected = true;
// Two sub-loops joined in the same task. Splitting read and write
// is what stops a long-running tx.write_all (~25 ms for a max-size
// frame at 115_200 baud, multiplied across a saturated outbound
// queue) from starving the inbound poll. While write_all sits in
// its FIFO-drain await, join re-polls the read future on the next
// wake, so last_frame_at keeps refreshing and the §3.4 watchdog
// does not spuriously fire under sustained bidirectional load.
// Same task = single executor + disjoint borrows on the split
// rx/tx halves, so no Mutex is needed.
let read_loop = async {
let mut read_buf = [0u8; READ_BUF];
let mut decoder = FrameDecoder::new();
let mut last_frame_at = Instant::now();
let mut connected = true;
loop {
use embassy_futures::select::{select3, Either3};
loop {
use embassy_futures::select::{select, Either};
match select3(
embedded_io_async::Read::read(&mut rx, &mut read_buf),
outbound.receive(),
embassy_time::Timer::after(Duration::from_millis(100)),
)
.await
{
Either3::First(result) => match result {
Ok(0) => continue,
Ok(n) => {
match select(
embedded_io_async::Read::read(&mut rx, &mut read_buf),
embassy_time::Timer::after(Duration::from_millis(100)),
)
.await
{
Either::First(Ok(0)) => {}
Either::First(Ok(n)) => {
last_frame_at = Instant::now();
if !connected {
if has_display {
@@ -62,31 +69,72 @@ pub async fn host_task(
}
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
}
Err(_) => {
Either::First(Err(_)) => {
// Byte-level error (FIFO overrun, UART framing,
// parity). Per PROTOCOL.md §3.4: only "byte stream
// activity that results in a complete frame
// arriving" resets the inactivity timer, so we do
// NOT touch last_frame_at here. The 100 ms sleep
// is anti-spin: a stuck driver pumping continuous
// Errs would otherwise burn the executor.
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");
Either::Second(()) => {}
}
// Inactivity watchdog (PROTOCOL.md §3.4, 1000 ms).
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
warn!("UART host inactivity timeout fired");
let _ = commands.try_send(InboundEvent::Reset);
if has_display {
let _ = display_commands.try_send(DisplayCommand::Reset);
}
// Drop in-flight frame state. When the host resumes it
// may pick up mid-frame; without resetting the decoder
// we'd chew through garbage until the next 0x00
// sentinel. USB gets the equivalent for free via
// DTR-drop reset (host/usb.rs Disconnected entry); UART
// has no DTR.
decoder.reset();
connected = false;
}
}
};
let write_loop = async {
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
loop {
let frame = outbound.receive().await;
let Some(n) = encode_outbound(&frame, &mut write_buf) else {
continue;
};
// Mirror src/host/usb.rs:349-374. The throttle gate at
// src/radio.rs:2017 silently drops async RX into the
// outbound queue while RADIO_THROTTLED is true; clear it
// on every successful write and re-arm it on failure,
// same contract as USB.
match tx.write_all(&write_buf[..n]).await {
Ok(()) => {
if crate::channel::RADIO_THROTTLED
.load(portable_atomic::Ordering::Acquire)
{
info!("UART write recovered; clearing radio throttle");
crate::channel::RADIO_THROTTLED
.store(false, portable_atomic::Ordering::Release);
}
}
Err(_) => {
warn!("UART write failed, frame dropped, throttling radio");
crate::channel::RADIO_THROTTLED
.store(true, portable_atomic::Ordering::Release);
}
}
Either3::Third(()) => {}
}
};
// Inactivity watchdog.
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
warn!("UART host inactivity timeout fired");
let _ = commands.try_send(InboundEvent::Reset);
if has_display {
let _ = display_commands.try_send(DisplayCommand::Reset);
}
connected = false;
}
}
embassy_futures::join::join(read_loop, write_loop).await;
}
async fn dispatch_bytes(