Skip to main content

m5stack_core/io/
console.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Async buffered serial console for both targets — byte-level ring buffer
3//! with **overwrite-on-full** semantics. The producer (`log!()` / [`send_line`])
4//! is O(1) and NEVER blocks the caller: never spins, never awaits, never
5//! creates back-pressure. On full, the **oldest** bytes are overwritten so the
6//! lines fired *just before* a failure survive (those are the informative ones).
7//!
8//! A single [`drain_task`] pulls contiguous slices from the ring and writes
9//! them via the target's async TX sink (`write_all().await` parks on the
10//! TX-done IRQ when the FIFO is full — no busy-spin). Producers signal the
11//! drain after every write; the drain awaits the signal when the ring is empty.
12//!
13//! Per-target seam (hardware): the sink types + [`setup`] (build + split the
14//! peripheral) + [`imp::boot_panic_write`]. fire27 = UART0 @ 1 Mbaud; cores3 =
15//! USB-Serial-JTAG CDC. `setup` does NOT make the fire27 TX async — `into_async()`
16//! binds the IRQ to the *calling* core, so the binary does it from `main` (PRO).
17//!
18//! The firmware's `alternator_regulator::logger::cat_line` calls [`send_line`]
19//! for the `:cat` dump. Unlike `log!()`, [`send_line`] is **back-pressuring**:
20//! it awaits ring space before writing, so a fast read-back self-paces to the TX
21//! drain rate and is lossless (a plain overwrite-on-full write dropped lines and
22//! made `log_interval`'s read-back show false gaps). alternator-regulator depends
23//! on this crate ONLY for that — optional + esp-hal-gated, so host builds never
24//! pull it.
25
26use core::cell::RefCell;
27use core::fmt::Write as _;
28
29use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
30use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
31use embassy_sync::signal::Signal;
32#[cfg(feature = "console-serial")]
33use embedded_io_async::Write as _;
34use esp_hal::ram;
35use heapless::String;
36
37#[cfg(all(feature = "fire27", feature = "console-serial"))]
38mod imp {
39    use esp_hal::{
40        Async, Blocking,
41        gpio::AnyPin,
42        peripherals::UART0,
43        uart::{Config, Uart, UartRx, UartTx},
44    };
45
46    /// RX half → `serial_cmd`; TX (blocking) → made async by the binary at
47    /// drain-spawn (`into_async` binds the IRQ to the calling = PRO core).
48    pub type ConsoleRx<'d> = UartRx<'d, Blocking>;
49    pub type ConsoleTx<'d> = UartTx<'d, Blocking>;
50    /// The drain task's sink — the TX after the binary's `into_async()`.
51    pub type ConsoleTxAsync<'d> = UartTx<'d, Async>;
52
53    /// Build UART0 @ 1 Mbaud and split. Run early (before radio bring-up); HIL-
54    /// confirmed safe with the async console (see memory fire27-uart-async-corrected).
55    pub fn setup(
56        uart: UART0<'static>,
57        tx_pin: AnyPin<'static>,
58        rx_pin: AnyPin<'static>,
59    ) -> (ConsoleRx<'static>, ConsoleTx<'static>) {
60        Uart::new(uart, Config::default().with_baudrate(1_000_000))
61            .expect("UART0 console init")
62            .with_tx(tx_pin)
63            .with_rx(rx_pin)
64            .split()
65    }
66
67    /// The peripheral bundle [`super::install`] needs to bring the console up:
68    /// UART0 + its TX/RX pins (Fire27 console is UART0 @ 1 Mbaud).
69    pub struct SerialResources {
70        pub uart: UART0<'static>,
71        pub tx_pin: AnyPin<'static>,
72        pub rx_pin: AnyPin<'static>,
73    }
74
75    /// Build the console, returning the RX half (for `serial_cmd`) and the async
76    /// TX sink the drain task writes. `into_async()` binds the TX-done IRQ to the
77    /// calling core, so [`super::install`] must run on the core that owns the
78    /// drain task (main / PRO).
79    pub fn install_serial(
80        res: SerialResources,
81    ) -> (ConsoleRx<'static>, ConsoleTxAsync<'static>) {
82        let (rx, tx) = setup(res.uart, res.tx_pin, res.rx_pin);
83        (rx, tx.into_async())
84    }
85
86    // Raw UART0 TX-FIFO writer (panic only). Spins for FIFO room with a
87    // bounded budget per byte: the panic message MUST get out — a dropped
88    // [PANIC] line turns a clean panic into a silent "wedge" (cost a long
89    // stack-overflow hunt on cores3). The bound keeps a dead/unclocked UART
90    // from hanging the panic loop forever. Used by `on_panic` to synchronously
91    // flush the ring after the async drain is gone (or never started). NEVER
92    // call from steady-state code — `log!()` / `send_line` go through the ring.
93    const UART0_FIFO_REG: *mut u32 = 0x3FF4_0000 as *mut u32;
94    const UART0_STATUS_REG: *const u32 = 0x3FF4_001C as *const u32;
95    const TX_FIFO_DEPTH: u32 = 128;
96    /// ~a few ms at CPU speed — plenty for one byte at 1 Mbaud.
97    const PANIC_SPIN_PER_BYTE: u32 = 1_000_000;
98
99    pub fn boot_panic_write(bytes: &[u8]) {
100        for &b in bytes {
101            let mut budget = PANIC_SPIN_PER_BYTE;
102            while unsafe { (UART0_STATUS_REG.read_volatile() >> 16) & 0xFF } >= TX_FIFO_DEPTH - 2 {
103                budget -= 1;
104                if budget == 0 {
105                    return; // UART dead — give up rather than hang the panic
106                }
107                core::hint::spin_loop();
108            }
109            unsafe { UART0_FIFO_REG.write_volatile(b as u32) };
110        }
111    }
112}
113
114#[cfg(all(feature = "cores3", feature = "console-serial"))]
115mod imp {
116    use esp_hal::{
117        Async,
118        peripherals::USB_DEVICE,
119        usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagRx, UsbSerialJtagTx},
120    };
121
122    /// RX half → `serial_cmd` (async poller); TX half → the drain task.
123    pub type ConsoleRx<'d> = UsbSerialJtagRx<'d, Async>;
124    pub type ConsoleTx<'d> = UsbSerialJtagTx<'d, Async>;
125    /// The drain task's sink — the split TX is already async on cores3.
126    pub type ConsoleTxAsync<'d> = UsbSerialJtagTx<'d, Async>;
127
128    /// Build the USB-Serial-JTAG console and split. `into_async()` here binds the
129    /// IRQ to whatever core calls this — the binary calls it from main.
130    pub fn setup(usb: USB_DEVICE<'static>) -> (ConsoleRx<'static>, ConsoleTx<'static>) {
131        UsbSerialJtag::new(usb).into_async().split()
132    }
133
134    /// The peripheral bundle [`super::install`] needs: just the USB-Serial-JTAG
135    /// device (the CoreS3 console is the native CDC port — no probe needed, R7).
136    pub struct SerialResources {
137        pub usb: USB_DEVICE<'static>,
138    }
139
140    /// Build the console, returning the RX half (for `serial_cmd`) and the async
141    /// TX sink the drain task writes. The split TX is already async on CoreS3.
142    pub fn install_serial(
143        res: SerialResources,
144    ) -> (ConsoleRx<'static>, ConsoleTxAsync<'static>) {
145        setup(res.usb)
146    }
147
148    // Raw SERIAL_JTAG EP1 FIFO writer (panic only). Spins for FIFO room with
149    // a bounded budget per fill: the panic message MUST get out — the old
150    // drop-on-full policy lost the [PANIC] line whenever the ring held more
151    // than one 64-byte EP buffer of pre-panic context, turning every panic
152    // into a silent "wedge" (cost a long stack-overflow hunt). The bound
153    // keeps an unplugged USB host from hanging the panic loop forever.
154    const SERIAL_JTAG_FIFO_REG: *mut u32 = 0x6003_8000 as *mut u32;
155    const SERIAL_JTAG_CONF_REG: *mut u32 = 0x6003_8004 as *mut u32;
156    /// ~tens of ms at CPU speed — the host polls the 64-byte EP every USB
157    /// micro-frame, so a live host clears the FIFO well within this.
158    const PANIC_SPIN_PER_FILL: u32 = 5_000_000;
159
160    #[inline]
161    fn fifo_full() -> bool {
162        unsafe { SERIAL_JTAG_CONF_REG.read_volatile() & 0b010 == 0 }
163    }
164
165    pub fn boot_panic_write(bytes: &[u8]) {
166        for &b in bytes {
167            if fifo_full() {
168                // Hand the queued bytes to the host, then wait (bounded) for
169                // room. No host within the budget → give up, don't hang.
170                unsafe { SERIAL_JTAG_CONF_REG.write_volatile(0b001) }; // flush (wr_done)
171                let mut budget = PANIC_SPIN_PER_FILL;
172                while fifo_full() {
173                    budget -= 1;
174                    if budget == 0 {
175                        return;
176                    }
177                    core::hint::spin_loop();
178                }
179            }
180            unsafe { SERIAL_JTAG_FIFO_REG.write_volatile(b as u32) };
181        }
182        unsafe { SERIAL_JTAG_CONF_REG.write_volatile(0b001) }; // flush (wr_done)
183    }
184}
185
186#[cfg(feature = "console-serial")]
187pub use imp::{ConsoleRx, ConsoleTx, ConsoleTxAsync, SerialResources, setup};
188#[cfg(feature = "console-serial")]
189use imp::{boot_panic_write, install_serial};
190
191/// Serial-free build (R9): the transport is compiled out, so this type is
192/// uninhabited and [`Config::serial`] can only ever be `None`.
193#[cfg(not(feature = "console-serial"))]
194pub enum SerialResources {}
195
196// ---- byte-level ring buffer (target-agnostic) ----
197
198/// Ring capacity. ~50 lines × 80 B = ~4 KB — same memory budget as the prior
199/// `Channel<Line, 12>` (~4.2 KB), and large enough to hold a message-only
200/// panic plus the immediate pre-failure context. Bump if `esp-backtrace` is
201/// ever wired up (a trace would add several KB).
202const RING_SIZE: usize = 4096;
203/// Per-line stack-format buffer. Largest record is the `[hil-cat]` CSV dump
204/// (≈ 320 B + prefix + CRLF).
205const LINE_CAP: usize = 352;
206
207/// Byte ring with overwrite-on-full. Single struct held inside the mutex.
208struct Ring {
209    buf: [u8; RING_SIZE],
210    head: usize, // write position (next byte to be written)
211    tail: usize, // read position (next byte to be read)
212    full: bool,  // disambiguates empty vs full when head == tail
213    /// Bytes silently overwritten (oldest discarded) since the last drain read.
214    /// The drain turns this into a VISIBLE `[CONSOLE-DROP …]` marker so a
215    /// ring overrun never looks like a clean gap (which previously made
216    /// `log_interval`'s read-back fail mysteriously). Saturates.
217    dropped: u32,
218}
219
220impl Ring {
221    const fn new() -> Self {
222        Self { buf: [0; RING_SIZE], head: 0, tail: 0, full: false, dropped: 0 }
223    }
224
225    /// Append `bytes`; on full, the **oldest** bytes are overwritten. Always
226    /// succeeds — no return value, no error path, no waiting. Called inside
227    /// the mutex by both the log macros and `send_line`. Each overwritten byte
228    /// bumps `dropped` so the drain can flag the loss.
229    fn write(&mut self, bytes: &[u8]) {
230        for &b in bytes {
231            self.buf[self.head] = b;
232            self.head = (self.head + 1) % RING_SIZE;
233            if self.full {
234                // Overwrote the tail byte — advance tail to track it, and
235                // record the drop so it surfaces downstream.
236                self.tail = (self.tail + 1) % RING_SIZE;
237                self.dropped = self.dropped.saturating_add(1);
238            } else if self.head == self.tail {
239                self.full = true;
240            }
241        }
242    }
243
244    /// Read + reset the overwritten-byte counter (drain-side).
245    #[cfg(feature = "console-serial")]
246    fn take_dropped(&mut self) -> u32 {
247        core::mem::take(&mut self.dropped)
248    }
249
250    #[cfg(feature = "console-serial")]
251    fn is_empty(&self) -> bool {
252        !self.full && self.head == self.tail
253    }
254
255    /// Bytes currently queued (unread).
256    fn used(&self) -> usize {
257        if self.full {
258            RING_SIZE
259        } else if self.head >= self.tail {
260            self.head - self.tail
261        } else {
262            RING_SIZE - self.tail + self.head
263        }
264    }
265
266    /// Free bytes available before overwrite-on-full would discard data.
267    fn free(&self) -> usize {
268        RING_SIZE - self.used()
269    }
270
271    /// Copy up to `dst.len()` readable bytes into `dst` and advance `tail`.
272    /// Bytes are taken from a single contiguous slice — if the ring wraps,
273    /// the caller will get the rest on the next call. Returns bytes copied.
274    #[cfg(feature = "console-serial")]
275    fn read_and_consume(&mut self, dst: &mut [u8]) -> usize {
276        if self.is_empty() {
277            return 0;
278        }
279        // Length of the next contiguous readable slice.
280        let n_contig = if self.head > self.tail {
281            self.head - self.tail
282        } else {
283            RING_SIZE - self.tail
284        };
285        let n = n_contig.min(dst.len());
286        let end = self.tail + n;
287        dst[..n].copy_from_slice(&self.buf[self.tail..end]);
288        self.tail = end % RING_SIZE;
289        if n > 0 {
290            self.full = false;
291        }
292        n
293    }
294}
295
296/// The ring + a CriticalSectionRawMutex so any task on any core can write
297/// safely. Lock scope is per-op (one memcpy + a few indices) — never held
298/// across an `.await`, never spans more than one log line.
299static RING: BlockingMutex<CriticalSectionRawMutex, RefCell<Ring>> =
300    BlockingMutex::new(RefCell::new(Ring::new()));
301
302/// Producer signal — woken after every ring write so the drain can resume
303/// when the ring was empty. Idempotent (multiple signals before a wait =
304/// one wait wakes); the drain reads everything it can per wakeup.
305static DRAIN_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
306
307/// Drain → producer signal — raised after every drain read so a *back-pressuring*
308/// producer ([`send_line`], the HIL `:cat` dump) can wake and retry once the ring
309/// has freed space. The non-blocking hot log path ([`push_line`]) ignores it.
310static SPACE_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
311
312/// Stack-format a line with a `[SSSSS.mmm LEVEL ] msg\r\n` header. Formats
313/// WITHOUT the CRLF first, then guarantees termination so an over-long record is
314/// truncated-but-terminated, never merged into the next (which would corrupt the
315/// `[hil-cat]` CSV dump).
316fn format_line(level: &str, args: core::fmt::Arguments<'_>) -> String<LINE_CAP> {
317    let now = embassy_time::Instant::now();
318    let mut line: String<LINE_CAP> = String::new();
319    let _ = write!(
320        line,
321        "[{:05}.{:03} {:<5}] {}",
322        now.as_secs(),
323        now.as_millis() % 1000,
324        level,
325        args
326    );
327    while line.len() + 2 > LINE_CAP {
328        let _ = line.pop();
329    }
330    let _ = line.push_str("\r\n");
331    line
332}
333
334/// Format + push a line into the ring, signal the drain. O(1) on the caller
335/// side: brief CS for the memcpy + an idempotent signal. **Non-blocking** —
336/// on full the oldest bytes are overwritten (protects time-critical producers).
337fn push_line(level: &str, args: core::fmt::Arguments<'_>) {
338    let line = format_line(level, args);
339    RING.lock(|r| r.borrow_mut().write(line.as_bytes()));
340    DRAIN_SIGNAL.signal(());
341}
342
343struct ConsoleLogger;
344
345impl log::Log for ConsoleLogger {
346    fn enabled(&self, _: &log::Metadata) -> bool {
347        true
348    }
349
350    fn log(&self, record: &log::Record) {
351        // Format level as a string so the column width matches the prior layout
352        // exactly (existing log scrapers depend on it).
353        let lvl = match record.level() {
354            log::Level::Error => "ERROR",
355            log::Level::Warn => "WARN ",
356            log::Level::Info => "INFO ",
357            log::Level::Debug => "DEBUG",
358            log::Level::Trace => "TRACE",
359        };
360        push_line(lvl, *record.args());
361    }
362
363    fn flush(&self) {}
364}
365
366static LOGGER: ConsoleLogger = ConsoleLogger;
367
368/// Register the console as the global `log` backend. Call once, early. The
369/// ring is statically initialised, so producers can write immediately —
370/// pre-[`drain_task`] writes simply accumulate in the ring and are flushed
371/// once the drain runs.
372pub fn init() {
373    init_with_level(log::LevelFilter::Info);
374}
375
376/// Like [`init`] but with an explicit max level (used by [`install`]).
377fn init_with_level(level: log::LevelFilter) {
378    let _ = log::set_logger(&LOGGER);
379    log::set_max_level(level);
380}
381
382/// Stable, greppable log markers that the HIL `detect_crash` contract keys on.
383/// Treat these as part of the public contract — do not reword without updating
384/// the test harness.
385pub mod markers {
386    /// Emitted by [`super::on_panic`] before halt.
387    pub const PANIC: &str = "[PANIC]";
388    /// Prefix of the drain's ring-overrun marker (` <n>B]` follows).
389    pub const CONSOLE_DROP: &str = "[CONSOLE-DROP";
390    /// Logged once at boot when a previous-run panic breadcrumb is read back.
391    pub const PREV_PANIC: &str = "[bc] previous panic";
392    /// Logged once at boot by [`super::install`] (the `app-desc` feature) —
393    /// project name, version (or the `identity`-enforced git mark), and an
394    /// `app_elf_sha256` prefix.
395    pub const IDENTITY: &str = "identity:";
396}
397
398/// Console install configuration. `serial: None` is the production backstop —
399/// the log backend is registered (so `log!` is a cheap no-op into the ring) but
400/// no transport is brought up and no drain task runs, leaving zero serial
401/// surface. `Some(_)` brings up the chip's native transport (UART0 on Fire27,
402/// USB-Serial-JTAG CDC on CoreS3) and spawns the drain.
403pub struct Config {
404    /// The chip's serial peripheral bundle, or `None` for a serial-free build.
405    pub serial: Option<SerialResources>,
406    /// Global `log` max level.
407    pub level: log::LevelFilter,
408}
409
410/// What [`install`] hands back: the console RX half, when a transport was
411/// brought up, to be handed to `serial_cmd` (bidirectional on one port, R7).
412/// In a serial-free build (`console-serial` off, R9) it carries no transport.
413pub struct Console {
414    /// `Some` iff `Config::serial` was `Some`. Hand to the serial-command reader.
415    #[cfg(feature = "console-serial")]
416    pub rx: Option<ConsoleRx<'static>>,
417}
418
419/// One-call console bring-up: register the `log` backend at `cfg.level` and,
420/// when `cfg.serial` is `Some`, build the chip's transport + spawn the single
421/// [`drain_task`]. Replaces the hand-wired `init()` + `setup()` + drain-spawn
422/// sequence in every binary. Call once from main (the core that owns the drain;
423/// Fire27's TX `into_async()` binds the IRQ to the calling core).
424///
425/// Returns a [`Console`] whose `rx` (when present) goes to the serial-command
426/// reader so log TX and command RX share the one port (R7).
427pub fn install(spawner: embassy_executor::Spawner, cfg: Config) -> Console {
428    init_with_level(cfg.level);
429    let console = {
430        #[cfg(feature = "console-serial")]
431        {
432            let rx = cfg.serial.map(|res| {
433                let (rx, tx) = install_serial(res);
434                crate::must_spawn!(spawner, drain_task(tx));
435                rx
436            });
437            Console { rx }
438        }
439        // R9 serial-free: backend registered above; no transport, no drain task.
440        #[cfg(not(feature = "console-serial"))]
441        {
442            let _ = &spawner;
443            match cfg.serial {
444                Some(res) => match res {}, // SerialResources is uninhabited here
445                None => {}
446            }
447            Console {}
448        }
449    };
450    // As early as possible: the first BSP-emitted log line, right after the
451    // backend (and transport, if any) are up. See markers::IDENTITY.
452    #[cfg(feature = "app-desc")]
453    crate::log_boot_identity();
454    console
455}
456
457/// Compatibility no-op. The prior design switched producers from a blocking
458/// writer to an async queue here; with the ring buffer the producer path is
459/// the same in every phase (boot and steady-state both write to the ring),
460/// so there's nothing to switch. Kept so binaries don't have to change in
461/// this commit — can be removed once both binaries stop calling it.
462pub fn enable_async() {}
463
464/// Back-pressuring line sink — the **R10 injection point**. A control crate must
465/// not depend on this BSP directly; instead it defines its own `LineSink`-style
466/// trait and the *binary* injects a ~5-line newtype whose `send_line` forwards
467/// here (the trait stays consumer-side, so neither BSP nor control crate depends
468/// on the other). This is also the bulk-dump path for the HIL `:cat` CSV.
469///
470/// Bulk-dump emit (HIL `:cat` CSV read-back) — **back-pressuring** (lossless).
471/// Unlike the hot log path ([`push_line`], which overwrites-oldest and never
472/// blocks to protect time-critical producers like RWBLE), this AWAITS until the
473/// ring has room for the whole line before writing, then signals the drain. So a
474/// fast `:cat` dump self-paces to the TX drain rate instead of overflowing the
475/// ~4 KB ring and dropping lines (which made `log_interval`'s read-back show
476/// false gaps). Safe because the dump runs on a non-time-critical task and can
477/// tolerate await latency; the `log!()` path does NOT use this.
478pub async fn send_line(args: core::fmt::Arguments<'_>) {
479    let line = format_line("INFO ", args);
480    loop {
481        // Reserve only if the WHOLE line fits — a partial write would still
482        // overwrite-on-full and corrupt the dump. LINE_CAP < RING_SIZE, so it
483        // always fits once the drain has caught up.
484        let wrote = RING.lock(|r| {
485            let mut r = r.borrow_mut();
486            if r.free() >= line.len() {
487                r.write(line.as_bytes());
488                true
489            } else {
490                false
491            }
492        });
493        if wrote {
494            DRAIN_SIGNAL.signal(());
495            return;
496        }
497        // Ring full — wait for the drain to free space, then retry. (No lost
498        // wakeup: SPACE_SIGNAL latches, so a signal between the check above and
499        // this wait still wakes us.)
500        SPACE_SIGNAL.wait().await;
501    }
502}
503
504/// The single console writer: pulls contiguous slices from the ring and
505/// writes them via the target's async TX sink. `write_all().await` parks on
506/// the TX-done IRQ when the FIFO is full — no spin, no interrupts-off, no
507/// cross-core contention. When the ring is empty, awaits [`DRAIN_SIGNAL`]
508/// (woken by every producer write). Spawn once from the binary's main
509/// (fire27: pass `tx.into_async()`; cores3: the split TX is already async).
510#[cfg(feature = "console-serial")]
511#[embassy_executor::task]
512pub async fn drain_task(mut tx: ConsoleTxAsync<'static>) {
513    // Per-iteration scratch. 256 B on the task stack is fine; the loop runs
514    // again immediately to drain whatever didn't fit.
515    let mut scratch = [0u8; 256];
516    // Overflow-marker rate-limit state (see below).
517    let mut drop_accum: u32 = 0;
518    let mut last_drop_report = embassy_time::Instant::now();
519    loop {
520        // Read the next slice AND any overwrite count in one lock.
521        let (n, dropped) = RING.lock(|r| {
522            let mut r = r.borrow_mut();
523            let n = r.read_and_consume(&mut scratch);
524            (n, r.take_dropped())
525        });
526        drop_accum = drop_accum.saturating_add(dropped);
527        // Surface a ring overrun as a LOUD, greppable marker so dropped bytes
528        // never masquerade as a clean gap (the silent loss that made
529        // `log_interval`'s read-back fail; host can grep `[CONSOLE-DROP`).
530        //
531        // RATE-LIMITED + coalesced: the marker is written by the drain straight
532        // to TX (bypassing the ring, so it costs no ring space and can't itself
533        // be overwritten), but it still shares TX bandwidth + drain time with
534        // the data it's preserving. Emitting it every 256-B chunk under a
535        // sustained log storm would steal drain throughput and AMPLIFY the
536        // overrun (more drops → more markers → slower drain). So emit at most
537        // ~4×/s with the accumulated byte count, plus an immediate flush the
538        // moment the ring drains (episode end). Never blocks/back-pressures
539        // producers — it only delays the drain slightly, now bounded.
540        if drop_accum > 0 {
541            let now = embassy_time::Instant::now();
542            if n == 0 || (now - last_drop_report).as_millis() >= 250 {
543                let mut mark: String<48> = String::new();
544                let _ = write!(mark, "\r\n[CONSOLE-DROP {}B]\r\n", drop_accum);
545                let _ = tx.write_all(mark.as_bytes()).await;
546                drop_accum = 0;
547                last_drop_report = now;
548            }
549        }
550        if n == 0 {
551            DRAIN_SIGNAL.wait().await;
552            continue;
553        }
554        // Freed `n` bytes — wake any back-pressuring producer (the :cat dump).
555        SPACE_SIGNAL.signal(());
556        // Write outside the lock — `.await` is NEVER inside the ring CS.
557        let _ = tx.write_all(&scratch[..n]).await;
558    }
559}
560
561// ---- RTC-persistent panic breadcrumb (R8) ----
562
563/// Marks a written crumb, distinguishing a real breadcrumb from uninitialised
564/// RTC RAM after a cold boot / power cycle.
565const CRUMB_MAGIC: u32 = 0x6D35_C0DE;
566
567// Breadcrumb word layout in the RTC-fast persistent array.
568const CRUMB_MAGIC_IDX: usize = 0;
569const CRUMB_REASON_IDX: usize = 1;
570const CRUMB_FILE_PTR_IDX: usize = 2;
571const CRUMB_FILE_LEN_IDX: usize = 3;
572
573/// Breadcrumb in RTC-fast RAM as `[magic, reason, file_ptr, file_len]`.
574/// `#[ram(unstable(rtc_fast, persistent))]` keeps it out of the data-init that
575/// runs on a watchdog/software reset, so it survives the RWDT recovery that
576/// follows a panic — exactly the window R8 needs. A `[u32; 4]` (not a struct)
577/// because esp-hal's persistent section requires `Persistable`, which is
578/// implemented for primitive arrays; `usize` is 32-bit on these chips, so the
579/// `.rodata` file-`&str` pointer + length fit in a `u32` each. The pointer is
580/// valid to reconstruct because the crumb only survives a *warm* reset of the
581/// *same* firmware image.
582#[ram(unstable(rtc_fast, persistent))]
583static mut PANIC_CRUMB: [u32; 4] = [0; 4];
584
585/// A panic breadcrumb recovered from the previous run: `location` is the panic
586/// file, `reason` a 32-bit digest of the panic message.
587pub struct PanicCrumb {
588    pub location: &'static str,
589    pub reason: u32,
590}
591
592/// FNV-1a 32-bit digest of the panic message — the R8 "reason-digest".
593fn reason_digest(s: &str) -> u32 {
594    let mut h: u32 = 0x811c_9dc5;
595    for &b in s.as_bytes() {
596        h ^= b as u32;
597        h = h.wrapping_mul(0x0100_0193);
598    }
599    h
600}
601
602/// Record the panic breadcrumb (file location + message digest) in
603/// RTC-persistent RAM before halting. Called by [`on_panic`].
604fn write_panic_crumb(info: &core::panic::PanicInfo<'_>, msg: &str) {
605    let (file_ptr, file_len) = info
606        .location()
607        .map(|l| (l.file().as_ptr() as usize, l.file().len()))
608        .unwrap_or((0, 0));
609    let mut crumb = [0u32; 4];
610    crumb[CRUMB_MAGIC_IDX] = CRUMB_MAGIC;
611    crumb[CRUMB_REASON_IDX] = reason_digest(msg);
612    crumb[CRUMB_FILE_PTR_IDX] = file_ptr as u32;
613    crumb[CRUMB_FILE_LEN_IDX] = file_len as u32;
614    // SAFETY: panic context is single-threaded and terminal; the only other
615    // access is `take_panic_breadcrumb` at boot before any task runs.
616    unsafe { core::ptr::addr_of_mut!(PANIC_CRUMB).write(crumb) };
617}
618
619/// Read and clear the previous run's panic breadcrumb, if any. Call **once** at
620/// boot, before [`install`], and log the result (the [`markers::PREV_PANIC`]
621/// line) — that read-back is the cross-transport fault contract (R8), identical
622/// on both targets. Returns `None` on a clean boot or once the crumb is taken.
623pub fn take_panic_breadcrumb() -> Option<PanicCrumb> {
624    // SAFETY: called once at boot before any task runs — no concurrent access.
625    unsafe {
626        let ptr = core::ptr::addr_of_mut!(PANIC_CRUMB);
627        let c = ptr.read();
628        if c[CRUMB_MAGIC_IDX] != CRUMB_MAGIC {
629            return None;
630        }
631        (*ptr)[CRUMB_MAGIC_IDX] = 0; // clear in place → reported exactly once
632        let file_ptr = c[CRUMB_FILE_PTR_IDX] as usize;
633        let location = if file_ptr != 0 {
634            core::str::from_utf8_unchecked(core::slice::from_raw_parts(
635                file_ptr as *const u8,
636                c[CRUMB_FILE_LEN_IDX] as usize,
637            ))
638        } else {
639            "?"
640        };
641        Some(PanicCrumb { location, reason: c[CRUMB_REASON_IDX] })
642    }
643}
644
645/// Shared message-only panic handler for both targets. Pushes the panic info
646/// into the ring (alongside the pre-panic context that's already there), then
647/// **synchronously drains the ring** via the raw FIFO poker — the async drain
648/// task is gone (or never started) so it can't service the ring for us. After
649/// the drain, halts so the fault stays visible. No stack walk: message-only
650/// on both targets (deliberate, symmetric), which is why neither `esp-backtrace`
651/// nor `esp-println` is pulled in. The binary's `#[panic_handler]` is a
652/// one-line wrapper around this.
653pub fn on_panic(info: &core::panic::PanicInfo<'_>) -> ! {
654    // Push the panic into the ring. Producer-side is unchanged from any
655    // normal log: brief CS, no await.
656    let mut line: String<256> = String::new();
657    let _ = write!(line, "\r\n[PANIC] {}\r\n", info);
658    // R8: record the breadcrumb (location + message digest) in RTC-persistent
659    // RAM BEFORE any best-effort transport print — the crumb is the contract; a
660    // CDC/UART flush that can't complete post-halt must not gate it.
661    write_panic_crumb(info, line.as_str());
662    RING.lock(|r| r.borrow_mut().write(line.as_bytes()));
663
664    // Best-effort transport print: synchronously drain the ring via
665    // `boot_panic_write` (the async drain task is gone/never-started). Compiled
666    // out in a serial-free build (R9) — the RTC breadcrumb above is the contract;
667    // here we just halt and let the RWDT recover.
668    #[cfg(feature = "console-serial")]
669    loop {
670        let mut chunk = [0u8; 64];
671        let n = RING.lock(|r| r.borrow_mut().read_and_consume(&mut chunk));
672        if n == 0 {
673            break;
674        }
675        boot_panic_write(&chunk[..n]);
676    }
677
678    loop {
679        core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst);
680    }
681}