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).
190 lines
6.9 KiB
Rust
190 lines
6.9 KiB
Rust
//! UART host task for DongLoRa Protocol v2.
|
|
//!
|
|
//! UART has no DTR line, so we assume a host is always present and
|
|
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
|
|
//! → Disconnected → drop radio to Unconfigured).
|
|
|
|
use defmt::{info, warn};
|
|
use embassy_executor::task;
|
|
use embassy_time::{Duration, Instant};
|
|
use embedded_io_async::Write;
|
|
|
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
|
use crate::channel::{
|
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
|
};
|
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME};
|
|
|
|
const READ_BUF: usize = 64;
|
|
const INACTIVITY_MS: u64 = 1000;
|
|
|
|
#[task]
|
|
pub async fn host_task(
|
|
parts: crate::board::UartParts,
|
|
commands: &'static CommandChannel,
|
|
outbound: &'static OutboundChannel,
|
|
display_commands: &'static DisplayCommandChannel,
|
|
has_display: bool,
|
|
_mac: [u8; 6],
|
|
) {
|
|
let (mut rx, mut tx) = parts.driver.split();
|
|
|
|
// UART has no DTR — wake display immediately and assume a host.
|
|
if has_display {
|
|
display_commands.send(DisplayCommand::On).await;
|
|
}
|
|
|
|
// 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::{select, Either};
|
|
|
|
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 {
|
|
let _ = display_commands.try_send(DisplayCommand::On);
|
|
}
|
|
connected = true;
|
|
}
|
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
|
}
|
|
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;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
embassy_futures::join::join(read_loop, write_loop).await;
|
|
}
|
|
|
|
async fn dispatch_bytes(
|
|
decoder: &mut FrameDecoder,
|
|
bytes: &[u8],
|
|
commands: &'static CommandChannel,
|
|
outbound: &'static OutboundChannel,
|
|
) {
|
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
|
decoder.feed(bytes, |res| match res {
|
|
FrameResult::Ok {
|
|
type_id,
|
|
tag,
|
|
payload,
|
|
} => {
|
|
let mut p: heapless::Vec<u8, MAX_PAYLOAD_FIELD> = heapless::Vec::new();
|
|
let _ = p.extend_from_slice(payload);
|
|
let _ = work.push(FrameWork::Ok {
|
|
type_id,
|
|
tag,
|
|
payload: p,
|
|
});
|
|
}
|
|
FrameResult::Err(_) => {
|
|
let _ = work.push(FrameWork::Frame);
|
|
}
|
|
});
|
|
for w in work {
|
|
match w {
|
|
FrameWork::Ok {
|
|
type_id,
|
|
tag,
|
|
payload,
|
|
} => {
|
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
|
}
|
|
FrameWork::Frame => {
|
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::large_enum_variant)]
|
|
enum FrameWork {
|
|
Ok {
|
|
type_id: u8,
|
|
tag: u16,
|
|
payload: heapless::Vec<u8, MAX_PAYLOAD_FIELD>,
|
|
},
|
|
Frame,
|
|
}
|