Skip to main content

dmx_interface/
logging.rs

1//! Logging backend: routes the `log-04` facade into a static ring buffer
2//! which is drained over the USB-CDC connection.
3//!
4//! Architecture:
5//!
6//! ```text
7//! log::info!(...) ─► Logger ─► push_line() ─► CDC ring (4 KiB, drop-oldest)
8//!                                    ▲
9//!                        panic_report (raw, bypasses level filter)
10//!
11//! usb.rs class_task: cdc_peek ─► write_packet ─► cdc_commit ─► inv monitor
12//! ```
13//!
14//! The ring is a static, allocation-free byte buffer. Producers append
15//! formatted lines (oldest bytes are overwritten when full), the single
16//! consumer in the USB class task copies chunks out with peek/commit so a
17//! cancelled write never loses data.
18
19use core::cell::UnsafeCell;
20use core::fmt::Write as _;
21
22use embassy_time::Instant;
23
24/// Total CDC ring capacity in bytes (power of two).
25const RING_SIZE: usize = 4096;
26/// Maximum formatted length of one log line, prefix and newline included.
27const LINE_MAX: usize = 256;
28
29/// Set once [`init`] ran; gates embassy-time access (the time driver only
30/// starts with `esp_rtos::start()`, before that `Instant::now()` would panic).
31static READY: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
32
33/// Global CDC ring: shared by every producer and the USB drain task.
34static CDC_RING: LogRing<RING_SIZE> = LogRing::new();
35
36/// `log::Log` implementation that formats records into [`CDC_RING`].
37struct Logger;
38
39/// Static logger instance registered by [`init`].
40static LOGGER: Logger = Logger;
41
42/// Initialize the global logger.
43///
44/// Must be called after `esp_rtos::start()` (uptime timestamps need the
45/// embassy time driver). The max level comes from the `LOG_LEVEL` env var
46/// baked in by `build.rs` (debug profile: debug, release profile: info).
47pub fn init() {
48    // The racy variants are required: xtensa has no CAS, so the plain
49    // `set_logger`/`set_max_level` functions do not exist on this target.
50    critical_section::with(|_| {
51        // SAFETY: invoked exactly once at startup with interrupts masked by
52        // the critical section, so no other `set_logger` call can race (the
53        // safety contract of the racy function).
54        unsafe {
55            log::set_logger_racy(&LOGGER).expect("logger already installed");
56        }
57        // SAFETY: same single, interrupt-masked startup call as above, no
58        // concurrent `set_max_level` possible.
59        unsafe {
60            log::set_max_level_racy(configured_level());
61        }
62    });
63    READY.store(true, core::sync::atomic::Ordering::Relaxed);
64}
65
66/// Map the `LOG_LEVEL` build env to a max level; unknown values fall back to Info.
67fn configured_level() -> log::LevelFilter {
68    match env!("LOG_LEVEL") {
69        "error" => log::LevelFilter::Error,
70        "warn" => log::LevelFilter::Warn,
71        "debug" => log::LevelFilter::Debug,
72        "trace" => log::LevelFilter::Trace,
73        _ => log::LevelFilter::Info,
74    }
75}
76
77/// One-character level marker used in the line prefix.
78const fn level_marker(level: log::Level) -> char {
79    match level {
80        log::Level::Error => 'E',
81        log::Level::Warn => 'W',
82        log::Level::Info => 'I',
83        log::Level::Debug => 'D',
84        log::Level::Trace => 'T',
85    }
86}
87
88/// Milliseconds since boot; `0` before the embassy time driver runs.
89fn uptime_ms() -> u64 {
90    if READY.load(core::sync::atomic::Ordering::Relaxed) {
91        Instant::now().as_millis()
92    } else {
93        0
94    }
95}
96
97/// Format one prefixed line (`[<uptime>ms][<marker>] <args>\n`) into the CDC
98/// ring. Used by the [`log::Log`] implementation and by `panic_report`
99/// (which bypasses the log level filter).
100///
101/// Long lines are truncated but always keep a trailing newline, so the next
102/// line starts clean. Never allocates and never panics.
103pub(crate) fn push_line(marker: char, args: core::fmt::Arguments<'_>) {
104    let mut buf = heapless::String::<LINE_MAX>::new();
105    let ms = uptime_ms();
106    let _ = write!(buf, "[{ms:>7}ms][{marker}] ");
107    let _ = core::fmt::write(&mut buf, args);
108    if buf.push('\n').is_err() {
109        buf.pop();
110        let _ = buf.push('\n');
111    }
112    CDC_RING.push(buf.as_bytes());
113}
114
115/// Copy as much pending data as fits into `out` without consuming it.
116///
117/// Returns the number of bytes copied (0 when the ring is empty).
118pub(crate) fn cdc_peek(out: &mut [u8]) -> usize {
119    CDC_RING.peek(out)
120}
121
122/// Mark the `n` bytes previously returned by [`cdc_peek`] as consumed.
123pub(crate) fn cdc_commit(n: usize) {
124    CDC_RING.commit(n);
125}
126
127/// Move the ring contents since [`mark_snapshot_floor`] into `out`,
128/// dropping the oldest bytes if needed so the newest data always fits.
129/// Consumes everything.
130///
131/// Used by the panic handler to snapshot the panic banner plus the log
132/// lines that followed the boot-time replay for persistence.
133pub(crate) fn drain_snapshot(out: &mut [u8]) -> usize {
134    CDC_RING.drain_snapshot(out)
135}
136
137/// Append bytes verbatim to the CDC ring (no prefix, no newline handling).
138///
139/// Used by the boot-time replay of a persisted panic report, whose lines
140/// already carry their own timestamp/marker.
141pub(crate) fn push_raw(bytes: &[u8]) {
142    CDC_RING.push(bytes);
143}
144
145/// Mark the current write position as the earliest byte a panic snapshot
146/// may contain, called after the boot-time replay.
147///
148/// Without this, [`drain_snapshot`] would capture the replayed report of
149/// the previous crash too, and every stored report would swallow its
150/// predecessor until the snapshot buffer only held repeats.
151pub(crate) fn mark_snapshot_floor() {
152    CDC_RING.set_floor();
153}
154
155impl log::Log for Logger {
156    /// Gate records against the globally configured max level and the
157    /// crate's own target namespace.
158    ///
159    /// The `log-04` features of `esp-hal`/`esp-rtos` route their messages
160    /// into the same facade — without filtering, the esp-rtos scheduler
161    /// would flood the 4 KiB ring with task-switch traces on every `debug`
162    /// build. Only records originating from this crate's module tree are
163    /// accepted; third-party targets can be added here deliberately.
164    fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
165        metadata.level() <= log::max_level() && metadata.target().starts_with("dmx_interface")
166    }
167
168    /// Format matching records as prefixed lines into the ring.
169    fn log(&self, record: &log::Record<'_>) {
170        if self.enabled(record.metadata()) {
171            push_line(level_marker(record.level()), *record.args());
172        }
173    }
174
175    /// No-op — the ring is drained asynchronously by the USB class task.
176    fn flush(&self) {}
177}
178
179/// Raw ring contents guarded by [`LogRing`]'s critical sections.
180struct State<const N: usize> {
181    buf: [u8; N],
182    /// Read position, monotonic logical byte cursor (wrapping).
183    head: usize,
184    /// Write position, monotonic logical byte cursor (wrapping).
185    tail: usize,
186    /// Earliest logical position a panic snapshot may start at — set once
187    /// after the boot-time replay so snapshots never re-capture it.
188    floor: usize,
189}
190
191/// Single-producer/single-consumer byte ring with overwrite-oldest policy.
192///
193/// Every operation runs inside one critical section. Positions are logical
194/// `usize` cursors masked into the buffer (size must be a power of two), so
195/// wrap-around is just masking and dropping the oldest bytes never moves
196/// data.
197struct LogRing<const N: usize> {
198    state: UnsafeCell<State<N>>,
199}
200
201// SAFETY: every access to the `UnsafeCell` happens inside a critical section
202// (see the method bodies), so the state is never aliased mutably.
203unsafe impl<const N: usize> Sync for LogRing<N> {}
204
205impl<const N: usize> LogRing<N> {
206    /// Bitmask mapping a logical cursor to a buffer offset (`N - 1`).
207    const MASK: usize = {
208        assert!(N.is_power_of_two(), "ring size must be a power of two");
209        N.wrapping_sub(1)
210    };
211
212    /// Create an empty ring; `const fn` so a `static` can build it.
213    const fn new() -> Self {
214        Self {
215            state: UnsafeCell::new(State {
216                buf: [0; N],
217                head: 0,
218                tail: 0,
219                floor: 0,
220            }),
221        }
222    }
223
224    /// Number of pending bytes (`tail - head` with wrapping arithmetic).
225    fn used(s: &State<N>) -> usize {
226        s.tail.wrapping_sub(s.head)
227    }
228
229    /// Append `data`, overwriting the oldest bytes when there is not enough
230    /// room. Never panics, never allocates.
231    fn push(&self, data: &[u8]) {
232        if data.is_empty() {
233            return;
234        }
235        critical_section::with(|_| {
236            // SAFETY: exclusive access inside the critical section.
237            let s = unsafe { &mut *self.state.get() };
238
239            // Keep only the newest N bytes of an oversized input.
240            let data = if data.len() >= N {
241                data.split_at(data.len().wrapping_sub(N)).1
242            } else {
243                data
244            };
245
246            // Make room by dropping the oldest bytes.
247            let free = N.wrapping_sub(Self::used(s));
248            if data.len() > free {
249                s.head = s.head.wrapping_add(data.len().wrapping_sub(free));
250            }
251
252            // Copy in at most two segments (the tail may wrap).
253            let off = s.tail & Self::MASK;
254            let first = data.len().min(N.wrapping_sub(off));
255            let (seg1, seg2) = data.split_at(first);
256            let seg2_len = seg2.len();
257
258            let (prefix, rest) = s.buf.split_at_mut(off);
259            let (dst1, _) = rest.split_at_mut(seg1.len());
260            dst1.copy_from_slice(seg1);
261            if seg2_len > 0 {
262                let (dst2, _) = prefix.split_at_mut(seg2_len);
263                dst2.copy_from_slice(seg2);
264            }
265
266            s.tail = s.tail.wrapping_add(data.len());
267        });
268    }
269
270    /// Copy up to `out.len()` pending bytes into `out` without consuming
271    /// them. Returns how many were copied.
272    fn peek(&self, out: &mut [u8]) -> usize {
273        if out.is_empty() {
274            return 0;
275        }
276        critical_section::with(|_| {
277            // SAFETY: shared access inside the critical section.
278            let s = unsafe { &*self.state.get() };
279            let n = Self::used(s).min(out.len());
280            let off = s.head & Self::MASK;
281            let first = n.min(N.wrapping_sub(off));
282            let remaining = n.wrapping_sub(first);
283
284            let (prefix, rest) = s.buf.split_at(off);
285            let (src1, _) = rest.split_at(first);
286            let (dst1, dst2) = out.split_at_mut(first);
287            dst1.copy_from_slice(src1);
288            if remaining > 0 {
289                let (src2, _) = prefix.split_at(remaining);
290                let (dst2, _) = dst2.split_at_mut(remaining);
291                dst2.copy_from_slice(src2);
292            }
293            n
294        })
295    }
296
297    /// Mark the `n` most recently peeked bytes as consumed. Values larger
298    /// than the pending amount consume everything.
299    fn commit(&self, n: usize) {
300        critical_section::with(|_| {
301            // SAFETY: exclusive access inside the critical section.
302            let s = unsafe { &mut *self.state.get() };
303            let used = Self::used(s);
304            s.head = if n >= used {
305                s.tail
306            } else {
307                s.head.wrapping_add(n)
308            };
309        });
310    }
311
312    /// Copy the newest bytes into `out`, then empty the ring. Returns how
313    /// many bytes were written (oldest data is dropped first when `out` is
314    /// too small).
315    ///
316    /// Bytes before the [`floor`](Self::set_floor) marker are skipped: a
317    /// snapshot must contain the panic banner plus the log lines that
318    /// followed the boot-time replay, never the replayed report itself.
319    fn drain_snapshot(&self, out: &mut [u8]) -> usize {
320        if out.is_empty() {
321            return 0;
322        }
323        critical_section::with(|_| {
324            // SAFETY: exclusive access inside the critical section.
325            let s = unsafe { &mut *self.state.get() };
326            let start = s.head.max(s.floor);
327            let avail = s.tail.wrapping_sub(start);
328            let skip = avail.saturating_sub(out.len());
329            let n = avail.wrapping_sub(skip);
330            let begin = start.wrapping_add(skip);
331            let off = begin & Self::MASK;
332            let first = n.min(N.wrapping_sub(off));
333            let remaining = n.wrapping_sub(first);
334
335            let (prefix, rest) = s.buf.split_at(off);
336            let (src1, _) = rest.split_at(first);
337            let (dst1, dst2) = out.split_at_mut(first);
338            dst1.copy_from_slice(src1);
339            if remaining > 0 {
340                let (src2, _) = prefix.split_at(remaining);
341                let (dst2, _) = dst2.split_at_mut(remaining);
342                dst2.copy_from_slice(src2);
343            }
344
345            s.head = s.tail;
346            n
347        })
348    }
349
350    /// Mark the current write position as the earliest byte
351    /// [`drain_snapshot`](Self::drain_snapshot) may capture.
352    fn set_floor(&self) {
353        critical_section::with(|_| {
354            // SAFETY: exclusive access inside the critical section.
355            let s = unsafe { &mut *self.state.get() };
356            s.floor = s.tail;
357        });
358    }
359}