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:
3 files changed
+212
-141
No files matched your search
+83
-35
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user