Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion lp-fw/fw-esp32-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false }
lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false }
embassy-time = "0.5.0"
embassy-sync = "0.8.0"
# `serial::chunked_write`: the select-with-timeout and the async byte sink. Both
# are `no_std` and chip-free, and both are already on every bin crate's line —
# this is the seam moving down, not a new dependency for the images.
embassy-futures = "0.1"
embedded-io-async = "0.6"
smart-leds = "0.3"
log = { workspace = true, default-features = false }
hashbrown = { workspace = true }
Expand All @@ -39,7 +44,12 @@ lpa-server = { path = "../../lp-app/lpa-server", default-features = false, optio
lpc-model = { path = "../../lp-core/lpc-model", default-features = false, optional = true }
lpc-wire = { path = "../../lp-core/lpc-wire", default-features = false, optional = true, features = ["ser-write-json"] }
lpfs = { path = "../../lp-base/lpfs", default-features = false, optional = true }
# `serial::server_msg`'s `StackJsonWriter` implements this crate's `SerWrite`
# trait, so it must be a direct dependency and not just `lpc-wire`'s. Same
# version/features as the bin crates so all three resolve to one instance —
# two would make the impl invisible to `lpc_wire::ser_write_json_to`.
ser-write-json = { version = "0.3", optional = true, default-features = false, features = ["alloc"] }

[features]
default = []
server = ["dep:littlefs-rust", "dep:lp-recovery", "dep:lpa-server", "dep:lpc-model", "dep:lpc-wire", "dep:lpfs"]
server = ["dep:littlefs-rust", "dep:lp-recovery", "dep:lpa-server", "dep:lpc-model", "dep:lpc-wire", "dep:lpfs", "dep:ser-write-json"]
113 changes: 113 additions & 0 deletions lp-fw/fw-esp32-common/src/serial/chunked_write.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Chunked, timeout-bounded writes to a host link's TX half.
//!
//! Every ESP32 firmware writes to its host link the same way: split the buffer
//! into chunks, bound each chunk with a timeout, and do something small between
//! chunks. Only that last part is a chip fact, so it arrives as a hook rather
//! than a dependency — see [`ChunkedWriter::new`].

use embassy_time::{Duration, Timer};
use embedded_io_async::Write;

/// How a link chunks and bounds its writes.
///
/// A chip fact, passed in rather than hard-coded, because the two numbers mean
/// different things per transport: USB-Serial-JTAG sizes its chunk for syscall
/// overhead, a UART sizes it for *line time* (the RX FIFO overflows if the TX
/// loop does not yield often enough at 115200 baud).
#[derive(Debug, Clone, Copy)]
pub struct WritePolicy {
/// Per-chunk timeout. A chunk that does not complete inside it fails the
/// whole write.
pub timeout: Duration,
/// Bytes per chunk.
pub chunk_size: usize,
/// What to call the link in error text (`"USB"`, `"UART"`). Surfaces to the
/// host inside `TransportError::Other`.
pub link_name: &'static str,
}

impl WritePolicy {
/// The USB-Serial-JTAG policy used by `fw-esp32c6` and `fw-esp32s3`.
///
/// Timeout: if a chunk doesn't complete in this time, the host is not
/// draining. A healthy USB full-speed host drains a chunk in well under a
/// millisecond, so this is still very generous — but short enough that the
/// frame loop's inline sends stall briefly, not for seconds, in the window
/// before the not-draining latch kicks in.
///
/// Chunk size: small enough to avoid timeout on slow USB, large enough to
/// avoid excessive syscalls. Resource snapshots can be 10KB+.
pub const USB_SERIAL_JTAG: Self = Self {
timeout: Duration::from_millis(250),
chunk_size: 256,
link_name: "USB",
};
}

/// A link's TX half plus the policy and per-chunk hook that make writing to it
/// chip-correct.
///
/// Construct one per write; it borrows the transmitter and holds no state of
/// its own, so the cost is nothing and the borrow stays as short as the write.
pub struct ChunkedWriter<'a, W, F> {
pub(crate) tx: &'a mut W,
pub(crate) policy: WritePolicy,
on_chunk: F,
}

impl<'a, W: Write, F: FnMut()> ChunkedWriter<'a, W, F> {
/// Wrap `tx` with a write policy and a per-chunk hook.
///
/// `on_chunk` runs **before every chunk**, including the first, and is the
/// single crate-specific seam in this module. It exists because a bounded
/// in-flight write is healthy, not silence, and each firmware has its own
/// thing to do about that:
///
/// * `fw-esp32c6` / `fw-esp32s3` tick `recovery::watchdog::note_io_alive`,
/// so a slow host cannot starve the watchdog feeder into resetting the
/// device. The watchdog is a chip fact (it is an `esp-hal` `Rwdt`), so
/// this crate may not reach for it — hence the hook.
/// * `fw-esp32v3` drains its UART RX FIFO, which holds only ~1.4 ms of
/// incoming line and would overflow during a multi-second write.
///
/// A `FnMut` rather than a `fn()` precisely so the second kind — which
/// needs the RX half and the line buffer — fits without a second writer.
pub fn new(tx: &'a mut W, policy: WritePolicy, on_chunk: F) -> Self {
Self {
tx,
policy,
on_chunk,
}
}

/// Write all of `data` in chunks with the policy's per-chunk timeout.
///
/// Prevents large messages (e.g. resource snapshots) from timing out
/// mid-write and corrupting the stream by concatenating with the next
/// message. Uses `write_all` per chunk to handle partial writes.
///
/// Returns false on the first chunk that times out or errors.
pub async fn write_all(&mut self, data: &[u8]) -> bool {
self.write_all_with(data, self.policy.timeout).await
}

/// [`write_all`](Self::write_all) with an explicit per-chunk timeout, for
/// callers that want a shorter bound than the policy's (the C6/S3
/// not-draining probe writes a single byte and will not wait long for it).
pub async fn write_all_with(&mut self, data: &[u8], timeout: Duration) -> bool {
use embassy_futures::select::{Either, select};
let mut offset = 0;
while offset < data.len() {
(self.on_chunk)();
let chunk_end = (offset + self.policy.chunk_size).min(data.len());
let chunk = &data[offset..chunk_end];
match select(Timer::after(timeout), self.tx.write_all(chunk)).await {
Either::First(_) => return false,
Either::Second(Err(_)) => return false,
Either::Second(Ok(())) => {}
}
offset = chunk_end;
}
true
}
}
6 changes: 6 additions & 0 deletions lp-fw/fw-esp32-common/src/serial/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
//! Chip-generic serial helpers.

pub mod chunked_write;
pub mod shared_serial;

/// Wire-protocol serialization for the host link — the chip-agnostic half of
/// every firmware's `serial::io_task`.
#[cfg(feature = "server")]
pub mod server_msg;
Loading
Loading