rp_usb_console/lib.rs
1#![no_std]
2#![doc = include_str!("../README.md")]
3#![warn(missing_docs)]
4
5//! USB CDC logging & command channel for RP2040 (embassy).
6//!
7//! This crate provides a zero-heap solution for bidirectional USB communication
8//! on RP2040 microcontrollers using the Embassy async framework. It enables both
9//! logging output and line-buffered command input (terminated by `\r`, `\n`, or
10//! `\r\n`) over a standard USB CDC ACM interface.
11//!
12//! # Quick Start
13//!
14//! ```rust,no_run
15//! use embassy_executor::Spawner;
16//! use embassy_sync::channel::Channel;
17//! use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
18//! use log::info;
19//! use rp_usb_console::USB_READ_BUFFER_SIZE;
20//!
21//! // Create a channel for receiving line-buffered commands from USB
22//! static COMMAND_CHANNEL: Channel<CriticalSectionRawMutex, [u8; USB_READ_BUFFER_SIZE], 4> = Channel::new();
23//!
24//! #[embassy_executor::main]
25//! async fn main(spawner: Spawner) {
26//! let p = embassy_rp::init(Default::default());
27//!
28//! // Initialize USB logging with Info level
29//! rp_usb_console::start(
30//! spawner,
31//! log::LevelFilter::Info,
32//! p.USB,
33//! Some(COMMAND_CHANNEL.sender()),
34//! );
35//!
36//! // Now you can use standard log macros
37//! info!("Hello over USB!");
38//!
39//! // Handle incoming commands in a separate task
40//! spawner.spawn(command_handler()).unwrap();
41//! }
42//!
43//! #[embassy_executor::task]
44//! async fn command_handler() {
45//! let receiver = COMMAND_CHANNEL.receiver();
46//! loop {
47//! let command = receiver.receive().await;
48//! // Process received command data (trailing zeros may be present)
49//! info!("Received command: {:?}", command);
50//! }
51//! }
52//! ```
53//!
54//! # Features
55//!
56//! - **Zero-heap operation**: All buffers are fixed-size and statically allocated
57//! - **Non-blocking logging**: Messages are dropped rather than blocking when channels are full
58//! - **USB CDC ACM**: Standard USB serial interface compatible with most terminal programs
59//! - **Packet fragmentation**: Large messages are automatically split for reliable transmission
60//! - **Bidirectional**: Both log output and line-buffered command input over the same USB connection
61//! - **Configurable command sink**: Forward parsed commands to your own channel or disable command forwarding entirely
62//! - **Embassy integration**: Designed for Embassy's async executor on RP2040
63//!
64//! # Design Principles
65//!
66//! This crate prioritizes:
67//! - **Real-time behavior**: Never blocks the caller, even when USB is disconnected
68//! - **Memory efficiency**: Fixed buffers with no dynamic allocation
69//! - **Reliability**: Fragmented transmission reduces host-side latency issues
70//! - **Safety**: Single-core assumptions with proper synchronization primitives
71
72use core::cell::{RefCell, UnsafeCell};
73use core::cmp::min;
74use core::fmt::{Result as FmtResult, Write};
75use critical_section::Mutex as CsMutex;
76use embassy_executor::Spawner;
77use embassy_rp::peripherals::USB;
78use embassy_rp::usb::{Driver, InterruptHandler};
79use embassy_rp::{bind_interrupts, Peri};
80use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
81use embassy_sync::channel::{Channel, Sender};
82use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender as UsbSender, State};
83use embassy_usb::{Builder, UsbDevice};
84use log::{Level, LevelFilter, Log, Metadata, Record};
85use rp2040_hal::rom_data::reset_to_usb_boot;
86
87bind_interrupts!(struct Irqs {
88 USBCTRL_IRQ => InterruptHandler<USB>;
89});
90
91/// Size of each USB packet fragment.
92const PACKET_SIZE: usize = 64;
93const MODULE_FILTER_CAPACITY: usize = 255;
94/// Size (in bytes) of the receive buffer used to accumulate incoming USB command data before processing.
95///
96/// Applications that forward commands should allocate their channels using this
97/// buffer size to avoid truncation.
98pub const USB_READ_BUFFER_SIZE: usize = 255;
99
100#[derive(Copy, Clone)]
101struct LogModuleSettings {
102 module_filter: [u8; MODULE_FILTER_CAPACITY],
103 module_filter_len: usize,
104 module_level: LevelFilter,
105 other_level: LevelFilter,
106}
107
108impl LogModuleSettings {
109 fn new(module_name: &str, module_level: LevelFilter, other_level: LevelFilter) -> Self {
110 let mut buf = [0u8; MODULE_FILTER_CAPACITY];
111 let bytes = module_name.as_bytes();
112 let len = min(bytes.len(), MODULE_FILTER_CAPACITY);
113 buf[..len].copy_from_slice(&bytes[..len]);
114
115 Self {
116 module_filter: buf,
117 module_filter_len: len,
118 module_level,
119 other_level,
120 }
121 }
122
123 fn module_name(&self) -> &str {
124 core::str::from_utf8(&self.module_filter[..self.module_filter_len]).unwrap_or("")
125 }
126}
127
128/// Fixed-size (255 byte) log/command message buffer with USB packet fragmentation support.
129///
130/// This struct provides a fixed-size buffer for log messages that can be efficiently
131/// transmitted over USB by fragmenting into smaller packets. Messages longer than the
132/// buffer capacity are automatically truncated with an ellipsis indicator.
133///
134/// ```
135pub struct LogMessage {
136 len: usize,
137 buf: [u8; 255],
138}
139
140impl LogMessage {
141 /// Create an empty message buffer.
142 pub fn new() -> Self {
143 Self { len: 0, buf: [0; 255] }
144 }
145
146 /// Append a string (UTF-8 bytes) truncating if capacity exceeded.
147 ///
148 /// If the message would exceed the 255-byte capacity, it is truncated
149 /// and the last three bytes are replaced with dots to indicate truncation.
150 fn push_str(&mut self, s: &str) {
151 for &b in s.as_bytes() {
152 if self.len >= self.buf.len() {
153 self.buf[self.len - 1] = b'.'; // Indicate truncation with ellipsis
154 self.buf[self.len - 2] = b'.';
155 self.buf[self.len - 3] = b'.';
156 break;
157 }
158 self.buf[self.len] = b;
159 self.len += 1;
160 }
161 }
162
163 /// Number of 64-byte USB packets required to send this message.
164 ///
165 /// Returns the minimum number of USB packets needed to transmit the
166 /// entire message content.
167 pub fn packet_count(&self) -> usize {
168 self.len / PACKET_SIZE + if self.len % PACKET_SIZE == 0 { 0 } else { 1 }
169 }
170
171 /// Slice for a specific packet index (0-based) containing that chunk.
172 ///
173 /// Returns the bytes for the specified packet index. The last packet
174 /// may be shorter than `PACKET_SIZE` if the message doesn't divide evenly.
175 ///
176 pub fn as_packet_bytes(&self, packet_index: usize) -> &[u8] {
177 let start = core::cmp::min(packet_index * PACKET_SIZE, self.len);
178 let end = core::cmp::min(start + PACKET_SIZE, self.len);
179 &self.buf[start..end]
180 }
181}
182
183impl Write for LogMessage {
184 fn write_str(&mut self, s: &str) -> FmtResult {
185 self.push_str(s);
186 Ok(())
187 }
188}
189
190/// Channel type for sending log messages from the application to the USB sender task.
191///
192/// Each `LogMessage` contains a 255-byte buffer, so the total memory usage of this channel is
193/// approximately `CAPACITY × 255` bytes plus channel bookkeeping. A capacity of 32 was chosen
194/// as a balance between buffering bursty logs and conserving RAM on RP2040-class MCUs with
195/// limited memory.
196///
197/// Earlier revisions of this module experimented with higher capacities (for example, 100
198/// messages), but this significantly increased static RAM usage without a proportional benefit
199/// on typical RP2040 workloads. The current capacity of 32 is therefore an intentional
200/// compromise. Increase this value only if profiling shows frequent log drops and your
201/// application can afford the additional static RAM usage; conversely, you may reduce it further
202/// on extremely memory-constrained systems at the cost of more aggressive log dropping.
203type LogChannel = Channel<CriticalSectionRawMutex, LogMessage, 32>;
204static LOG_CHANNEL: LogChannel = Channel::new();
205
206// Log settings protected by critical section for dual-core safety.
207// RP2040's critical sections use hardware spinlocks to synchronize between cores.
208static LOG_SETTINGS: CsMutex<RefCell<Option<LogModuleSettings>>> = CsMutex::new(RefCell::new(None));
209
210/// Read current log settings (inside critical section).
211#[inline]
212fn get_log_settings() -> Option<LogModuleSettings> {
213 critical_section::with(|cs| *LOG_SETTINGS.borrow(cs).borrow())
214}
215
216/// Update log settings (inside critical section).
217#[inline]
218fn set_log_settings(settings: Option<LogModuleSettings>) {
219 critical_section::with(|cs| {
220 *LOG_SETTINGS.borrow(cs).borrow_mut() = settings;
221 });
222}
223
224fn parse_level_filter(token: &str) -> Option<LevelFilter> {
225 let mut chars = token.chars();
226 let ch = chars.next()?.to_ascii_uppercase();
227 if chars.next().is_some() {
228 return None;
229 }
230 match ch {
231 'T' => Some(LevelFilter::Trace),
232 'D' => Some(LevelFilter::Debug),
233 'I' => Some(LevelFilter::Info),
234 'W' => Some(LevelFilter::Warn),
235 'E' => Some(LevelFilter::Error),
236 'O' => Some(LevelFilter::Off),
237 _ => None,
238 }
239}
240
241fn level_allowed(filter: LevelFilter, level: Level) -> bool {
242 match filter.to_level() {
243 Some(max) => level <= max,
244 None => false,
245 }
246}
247
248/// Internal logger implementation that forwards formatted lines to the USB channel.
249///
250/// This logger formats log records and sends them through the internal channel
251/// to be transmitted over USB. If the channel is full, messages are silently
252/// dropped to maintain non-blocking behavior.
253struct USBLogger;
254
255impl Log for USBLogger {
256 fn enabled(&self, _metadata: &Metadata) -> bool {
257 true
258 }
259
260 fn log(&self, record: &Record) {
261 if self.enabled(record.metadata()) {
262 let should_emit = match get_log_settings() {
263 Some(settings) => {
264 let module_name = settings.module_name();
265 let target_filter = match record.module_path() {
266 Some(path) if !module_name.is_empty() && path.contains(module_name) => settings.module_level,
267 _ => settings.other_level,
268 };
269 level_allowed(target_filter, record.level())
270 }
271 None => true,
272 };
273
274 if !should_emit {
275 return;
276 }
277
278 let mut message = LogMessage::new();
279 let path = if let Some(p) = record.module_path() { p } else { "" };
280 if write!(&mut message, "[{}] {}: {}\r\n", record.level(), path, record.args()).is_ok() {
281 // Non-blocking send. If the channel is full, the message is dropped.
282 let _ = LOG_CHANNEL.try_send(message);
283 }
284 }
285 }
286 fn flush(&self) {}
287}
288
289static LOGGER: USBLogger = USBLogger;
290
291/// USB receive task that handles incoming data from the host.
292///
293/// This task waits for USB connection, then continuously reads incoming packets
294/// and accumulates them into a single buffer until a line terminator (`\r`,
295/// `\n`, or `\r\n`) is received. Completed lines are dispatched to the optional
296/// command channel for application handling, while built-in control commands
297/// (such as `/BS` and `/LM`) are processed internally. If `command_sender` is
298/// `None`, application commands are ignored after internal processing.
299///
300/// The task automatically handles USB disconnection/reconnection cycles.
301#[embassy_executor::task]
302async fn usb_rx_task(
303 mut receiver: Receiver<'static, Driver<'static, USB>>,
304 command_sender: Option<Sender<'static, CriticalSectionRawMutex, [u8; USB_READ_BUFFER_SIZE], 4>>,
305) {
306 let mut buf = [0u8; USB_READ_BUFFER_SIZE];
307 let mut buf_position: usize = 0;
308 loop {
309 receiver.wait_connection().await;
310 let mut read_buf = [0u8; USB_READ_BUFFER_SIZE];
311 loop {
312 match receiver.read_packet(&mut read_buf).await {
313 Ok(len) => {
314 if len + buf_position > USB_READ_BUFFER_SIZE {
315 buf_position = 0;
316 }
317 buf[buf_position..buf_position + len].copy_from_slice(&read_buf[..len]);
318 buf_position += len;
319
320 if !(buf.contains(&b'\n') || buf.contains(&b'\r')) {
321 continue; // Wait for more data
322 }
323
324 let mut processed = false;
325 if let Ok(command_str) = core::str::from_utf8(&buf[0..3]) {
326 match command_str {
327 "/BS" => {
328 reset_to_usb_boot(0, 0);
329 }
330 "/RS" => {
331 rp2040_hal::reset();
332 }
333 "/LT" => {
334 processed = true;
335 unsafe {
336 log::set_max_level_racy(LevelFilter::Trace);
337 }
338 log::info!("Log level set to Trace");
339 }
340 "/LD" => {
341 processed = true;
342 unsafe {
343 log::set_max_level_racy(LevelFilter::Debug);
344 }
345 log::info!("Log level set to Debug");
346 }
347 "/LI" => {
348 processed = true;
349 unsafe {
350 log::set_max_level_racy(LevelFilter::Info);
351 }
352 log::info!("Log level set to Info");
353 }
354 "/LW" => {
355 processed = true;
356 unsafe {
357 log::set_max_level_racy(LevelFilter::Warn);
358 }
359 log::warn!("Log level set to Warn");
360 }
361 "/LE" => {
362 processed = true;
363 unsafe {
364 log::set_max_level_racy(LevelFilter::Error);
365 }
366 log::error!("Log level set to Error");
367 }
368 "/LO" => {
369 processed = true;
370 unsafe {
371 log::set_max_level_racy(LevelFilter::Off);
372 }
373 // Cannot log here since logging is now off
374 }
375 "/LM" => {
376 processed = true;
377 if let Ok(param_string) = core::str::from_utf8(&buf[4..]) {
378 let param_string = param_string.trim_matches(char::from(0)).trim();
379
380 let mut parts = param_string.splitn(2, ',');
381 match (parts.next(), parts.next()) {
382 (Some(module_filter), Some(module_log_level)) => {
383 let module_filter = module_filter.trim();
384 let module_level_str = module_log_level.trim();
385
386 if module_filter.is_empty() {
387 log::error!("Invalid /LM parameters '{}'. Module name cannot be empty", param_string);
388 buf_position = 0; // Reset buffer for next command
389 buf = [0u8; USB_READ_BUFFER_SIZE];
390 continue;
391 }
392
393 if module_level_str == "-" {
394 log::info!("Module logging override cleared for '{}'", module_filter);
395 if let Some(settings) = get_log_settings() {
396 unsafe {
397 log::set_max_level_racy(settings.other_level);
398 }
399 }
400 set_log_settings(None);
401 buf_position = 0; // Reset buffer for next command
402 buf = [0u8; USB_READ_BUFFER_SIZE];
403 continue;
404 }
405
406 let Some(module_level) = parse_level_filter(module_level_str) else {
407 log::error!("Invalid /LM module level '{}'. Use one of T,D,I,W,E,O", module_level_str);
408 buf_position = 0; // Reset buffer for next command
409 buf = [0u8; USB_READ_BUFFER_SIZE];
410 continue;
411 };
412 let other_level = log::max_level();
413
414 unsafe {
415 log::set_max_level_racy(module_level);
416 }
417
418 let settings = LogModuleSettings::new(module_filter, module_level, other_level);
419 set_log_settings(Some(settings));
420
421 log::info!("Module logging override: module='{}' module_level={:?}", module_filter, module_level);
422 }
423 _ => {
424 log::error!(
425 "Invalid /LM parameters '{}'. Expected format: /LM <module_filter>,<module_log_level[T|D|I|W|E]>",
426 param_string
427 );
428 }
429 }
430 }
431 }
432
433 _ => {}
434 }
435 }
436 if !processed && buf_position > 1 {
437 if let Some(sender) = &command_sender {
438 // Null-terminate the command string
439 if buf_position < USB_READ_BUFFER_SIZE {
440 buf[buf_position] = 0;
441 } else {
442 buf[USB_READ_BUFFER_SIZE - 1] = 0;
443 }
444 // Use try_send to avoid blocking if channel is full
445 let _ = sender.try_send(buf);
446 }
447 }
448 buf_position = 0; // Reset buffer for next command
449 buf = [0u8; USB_READ_BUFFER_SIZE];
450 }
451 Err(_) => break,
452 }
453 }
454 }
455}
456
457/// USB transmit task that sends log messages over USB.
458///
459/// This task drains the internal log channel and transmits each message by
460/// fragmenting it into 64-byte USB packets. Large messages are automatically
461/// split across multiple packets for reliable transmission.
462///
463/// The task handles USB disconnection gracefully and will resume transmission
464/// when the connection is restored.
465#[embassy_executor::task]
466async fn usb_tx_task(mut sender: UsbSender<'static, Driver<'static, USB>>) {
467 loop {
468 sender.wait_connection().await;
469 loop {
470 // Wait for a log message from the channel.
471 let message = LOG_CHANNEL.receive().await;
472 let mut problem = false;
473 for i in 0..message.packet_count() {
474 let packet = message.as_packet_bytes(i);
475
476 // Send the log message over USB. If sending fails, break to wait for reconnection.
477 if sender.write_packet(packet).await.is_err() {
478 problem = true;
479 }
480 }
481 if problem {
482 break;
483 }
484 }
485 }
486}
487
488/// USB device task that runs the USB device state machine.
489///
490/// This task handles the low-level USB device protocol and must be spawned
491/// for USB communication to function. It manages device enumeration,
492/// configuration, and the USB control endpoint.
493#[embassy_executor::task]
494async fn usb_device_task(mut usb: UsbDevice<'static, Driver<'static, USB>>) {
495 usb.run().await;
496}
497
498/// Initialize USB CDC logging and command channel, spawning all necessary tasks.
499///
500/// This function sets up the USB CDC ACM interface for bidirectional communication
501/// and spawns three tasks to handle the USB device, transmission, and reception.
502/// It also initializes the global logger to forward log messages over USB.
503///
504/// # Arguments
505///
506/// * `spawner` - Embassy task spawner for creating the USB tasks
507/// * `level` - Maximum log level to transmit (e.g., `LevelFilter::Info`)
508/// * `usb_peripheral` - RP2040 USB peripheral instance
509/// * `command_sender` - Optional channel sender for receiving line-buffered commands from the host (`None` disables forwarding)
510///
511/// # Panics
512///
513/// Panics if called more than once, as it sets the global logger.
514///
515/// # Examples
516///
517/// ```rust,no_run
518/// use embassy_executor::Spawner;
519/// use embassy_sync::channel::Channel;
520/// use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
521/// use log::LevelFilter;
522/// # use rp_usb_console;
523///
524/// static COMMAND_CHANNEL: Channel<CriticalSectionRawMutex, [u8; rp_usb_console::USB_READ_BUFFER_SIZE], 4> = Channel::new();
525///
526/// # async fn example(spawner: Spawner, usb_peripheral: embassy_rp::peripherals::USB) {
527/// rp_usb_console::start(
528/// spawner,
529/// LevelFilter::Info,
530/// usb_peripheral,
531/// Some(COMMAND_CHANNEL.sender()),
532/// );
533/// # }
534/// ```
535pub fn start(
536 spawner: Spawner,
537 level: LevelFilter,
538 usb_peripheral: Peri<'static, USB>,
539 command_sender: Option<Sender<'static, CriticalSectionRawMutex, [u8; USB_READ_BUFFER_SIZE], 4>>,
540) {
541 // Initialize the logger (use racy variants on targets without atomic ptr support, e.g. thumbv6m/RP2040)
542 unsafe {
543 log::set_logger_racy(&LOGGER).unwrap();
544 log::set_max_level_racy(level);
545 }
546
547 // Simple wrapper to mark our single-threaded statics as Sync. RP2040 + embassy executor: we ensure single-core access.
548 struct StaticCell<T>(UnsafeCell<T>);
549 unsafe impl<T> Sync for StaticCell<T> {}
550
551 static DEVICE_DESC: StaticCell<[u8; 256]> = StaticCell(UnsafeCell::new([0; 256]));
552 static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell(UnsafeCell::new([0; 256]));
553 static BOS_DESC: StaticCell<[u8; 256]> = StaticCell(UnsafeCell::new([0; 256]));
554 static CONTROL_BUF: StaticCell<[u8; 128]> = StaticCell(UnsafeCell::new([0; 128]));
555 static STATE: StaticCell<State> = StaticCell(UnsafeCell::new(State::new()));
556
557 let driver = Driver::new(usb_peripheral, Irqs);
558
559 let mut config = embassy_usb::Config::new(0xc0de, 0xcafe);
560 config.manufacturer = Some("Embassy");
561 config.product = Some("Modular USB-Serial");
562 config.serial_number = Some("12345678");
563 config.max_power = 100;
564
565 let mut builder = Builder::new(
566 driver,
567 config,
568 unsafe { &mut *DEVICE_DESC.0.get() },
569 unsafe { &mut *CONFIG_DESC.0.get() },
570 unsafe { &mut *BOS_DESC.0.get() },
571 unsafe { &mut *CONTROL_BUF.0.get() },
572 );
573
574 let class = CdcAcmClass::new(&mut builder, unsafe { &mut *STATE.0.get() }, 64);
575 let (sender, receiver) = class.split();
576 let usb = builder.build();
577
578 // Spawn all the necessary tasks.
579 spawner.spawn(usb_device_task(usb)).unwrap();
580 spawner.spawn(usb_tx_task(sender)).unwrap();
581 spawner.spawn(usb_rx_task(receiver, command_sender)).unwrap();
582}