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.
191type LogChannel = Channel<CriticalSectionRawMutex, LogMessage, 4>;
192static LOG_CHANNEL: LogChannel = Channel::new();
193
194static LOG_SETTINGS: CsMutex<RefCell<Option<LogModuleSettings>>> = CsMutex::new(RefCell::new(None));
195
196fn parse_level_filter(token: &str) -> Option<LevelFilter> {
197 let mut chars = token.chars();
198 let ch = chars.next()?.to_ascii_uppercase();
199 if chars.next().is_some() {
200 return None;
201 }
202 match ch {
203 'T' => Some(LevelFilter::Trace),
204 'D' => Some(LevelFilter::Debug),
205 'I' => Some(LevelFilter::Info),
206 'W' => Some(LevelFilter::Warn),
207 'E' => Some(LevelFilter::Error),
208 'O' => Some(LevelFilter::Off),
209 _ => None,
210 }
211}
212
213fn level_allowed(filter: LevelFilter, level: Level) -> bool {
214 match filter.to_level() {
215 Some(max) => level <= max,
216 None => false,
217 }
218}
219
220/// Internal logger implementation that forwards formatted lines to the USB channel.
221///
222/// This logger formats log records and sends them through the internal channel
223/// to be transmitted over USB. If the channel is full, messages are silently
224/// dropped to maintain non-blocking behavior.
225struct USBLogger;
226
227impl Log for USBLogger {
228 fn enabled(&self, _metadata: &Metadata) -> bool {
229 true
230 }
231
232 fn log(&self, record: &Record) {
233 if self.enabled(record.metadata()) {
234 let should_emit = critical_section::with(|cs| {
235 let guard = LOG_SETTINGS.borrow(cs);
236 let settings_ref = guard.borrow();
237 match &*settings_ref {
238 Some(settings) => {
239 let module_name = settings.module_name();
240 let target_filter = match record.module_path() {
241 Some(path) if !module_name.is_empty() && path.contains(module_name) => settings.module_level,
242 _ => settings.other_level,
243 };
244 level_allowed(target_filter, record.level())
245 }
246 None => true,
247 }
248 });
249
250 if !should_emit {
251 return;
252 }
253
254 let mut message = LogMessage::new();
255 let path = if let Some(p) = record.module_path() { p } else { "" };
256 if write!(&mut message, "[{}] {}: {}\r\n", record.level(), path, record.args()).is_ok() {
257 // Non-blocking send. If the channel is full, the message is dropped.
258 let _ = LOG_CHANNEL.try_send(message);
259 }
260 }
261 }
262 fn flush(&self) {}
263}
264
265static LOGGER: USBLogger = USBLogger;
266
267/// USB receive task that handles incoming data from the host.
268///
269/// This task waits for USB connection, then continuously reads incoming packets
270/// and accumulates them into a single buffer until a line terminator (`\r`,
271/// `\n`, or `\r\n`) is received. Completed lines are dispatched to the optional
272/// command channel for application handling, while built-in control commands
273/// (such as `/BS` and `/LM`) are processed internally. If `command_sender` is
274/// `None`, application commands are ignored after internal processing.
275///
276/// The task automatically handles USB disconnection/reconnection cycles.
277#[embassy_executor::task]
278async fn usb_rx_task(
279 mut receiver: Receiver<'static, Driver<'static, USB>>,
280 command_sender: Option<Sender<'static, CriticalSectionRawMutex, [u8; USB_READ_BUFFER_SIZE], 4>>,
281) {
282 let mut buf = [0u8; USB_READ_BUFFER_SIZE];
283 let mut buf_position: usize = 0;
284 loop {
285 receiver.wait_connection().await;
286 let mut read_buf = [0u8; USB_READ_BUFFER_SIZE];
287 loop {
288 match receiver.read_packet(&mut read_buf).await {
289 Ok(len) => {
290 if len + buf_position > USB_READ_BUFFER_SIZE {
291 buf_position = 0;
292 }
293 buf[buf_position..buf_position + len].copy_from_slice(&read_buf[..len]);
294 buf_position += len;
295
296 if !(buf.contains(&b'\n') || buf.contains(&b'\r')) {
297 continue; // Wait for more data
298 }
299
300 let mut processed = false;
301 if let Ok(command_str) = core::str::from_utf8(&buf[0..3]) {
302 match command_str {
303 "/BS" => {
304 reset_to_usb_boot(0, 0);
305 }
306 "/RS" => {
307 rp2040_hal::reset();
308 }
309 "/LT" => {
310 processed = true;
311 unsafe {
312 log::set_max_level_racy(LevelFilter::Trace);
313 }
314 log::info!("Log level set to Trace");
315 }
316 "/LD" => {
317 processed = true;
318 unsafe {
319 log::set_max_level_racy(LevelFilter::Debug);
320 }
321 log::info!("Log level set to Debug");
322 }
323 "/LI" => {
324 processed = true;
325 unsafe {
326 log::set_max_level_racy(LevelFilter::Info);
327 }
328 log::info!("Log level set to Info");
329 }
330 "/LW" => {
331 processed = true;
332 unsafe {
333 log::set_max_level_racy(LevelFilter::Warn);
334 }
335 log::warn!("Log level set to Warn");
336 }
337 "/LE" => {
338 processed = true;
339 unsafe {
340 log::set_max_level_racy(LevelFilter::Error);
341 }
342 log::error!("Log level set to Error");
343 }
344 "/LO" => {
345 processed = true;
346 unsafe {
347 log::set_max_level_racy(LevelFilter::Off);
348 }
349 // Cannot log here since logging is now off
350 }
351 "/LM" => {
352 processed = true;
353 if let Ok(param_string) = core::str::from_utf8(&buf[4..]) {
354 let param_string = param_string.trim_matches(char::from(0)).trim();
355
356 let mut parts = param_string.splitn(2, ',');
357 match (parts.next(), parts.next()) {
358 (Some(module_filter), Some(module_log_level)) => {
359 let module_filter = module_filter.trim();
360 let module_level_str = module_log_level.trim();
361
362 if module_filter.is_empty() {
363 log::error!("Invalid /LM parameters '{}'. Module name cannot be empty", param_string);
364 buf_position = 0; // Reset buffer for next command
365 buf = [0u8; USB_READ_BUFFER_SIZE];
366 continue;
367 }
368
369 if module_level_str == "-" {
370 log::info!("Module logging override cleared for '{}'", module_filter);
371 critical_section::with(|cs| {
372 if let Some(x) = *LOG_SETTINGS.borrow(cs).borrow() {
373 unsafe {
374 log::set_max_level_racy(x.other_level);
375 }
376 }
377 *LOG_SETTINGS.borrow(cs).borrow_mut() = None;
378 });
379 buf_position = 0; // Reset buffer for next command
380 buf = [0u8; USB_READ_BUFFER_SIZE];
381 continue;
382 }
383
384 let Some(module_level) = parse_level_filter(module_level_str) else {
385 log::error!("Invalid /LM module level '{}'. Use one of T,D,I,W,E,O", module_level_str);
386 buf_position = 0; // Reset buffer for next command
387 buf = [0u8; USB_READ_BUFFER_SIZE];
388 continue;
389 };
390 let other_level = log::max_level();
391
392 unsafe {
393 log::set_max_level_racy(module_level);
394 }
395
396 let settings = LogModuleSettings::new(module_filter, module_level, other_level);
397
398 critical_section::with(|cs| {
399 *LOG_SETTINGS.borrow(cs).borrow_mut() = Some(settings);
400 });
401
402 log::info!("Module logging override: module='{}' module_level={:?}", module_filter, module_level);
403 }
404 _ => {
405 log::error!(
406 "Invalid /LM parameters '{}'. Expected format: /LM <module_filter>,<module_log_level[T|D|I|W|E]>",
407 param_string
408 );
409 }
410 }
411 }
412 }
413
414 _ => {}
415 }
416 }
417 if !processed && buf_position > 1 {
418 if let Some(sender) = &command_sender {
419 // Null-terminate the command string
420 if buf_position < USB_READ_BUFFER_SIZE {
421 buf[buf_position] = 0;
422 } else {
423 buf[USB_READ_BUFFER_SIZE - 1] = 0;
424 }
425 let _ = sender.send(buf).await;
426 }
427 }
428 buf_position = 0; // Reset buffer for next command
429 buf = [0u8; USB_READ_BUFFER_SIZE];
430 }
431 Err(_) => break,
432 }
433 }
434 }
435}
436
437/// USB transmit task that sends log messages over USB.
438///
439/// This task drains the internal log channel and transmits each message by
440/// fragmenting it into 64-byte USB packets. Large messages are automatically
441/// split across multiple packets for reliable transmission.
442///
443/// The task handles USB disconnection gracefully and will resume transmission
444/// when the connection is restored.
445#[embassy_executor::task]
446async fn usb_tx_task(mut sender: UsbSender<'static, Driver<'static, USB>>) {
447 loop {
448 sender.wait_connection().await;
449 loop {
450 // Wait for a log message from the channel.
451 let message = LOG_CHANNEL.receive().await;
452 let mut problem = false;
453 for i in 0..message.packet_count() {
454 let packet = message.as_packet_bytes(i);
455
456 // Send the log message over USB. If sending fails, break to wait for reconnection.
457 if sender.write_packet(packet).await.is_err() {
458 problem = true;
459 }
460 }
461 if problem {
462 break;
463 }
464 }
465 }
466}
467
468/// USB device task that runs the USB device state machine.
469///
470/// This task handles the low-level USB device protocol and must be spawned
471/// for USB communication to function. It manages device enumeration,
472/// configuration, and the USB control endpoint.
473#[embassy_executor::task]
474async fn usb_device_task(mut usb: UsbDevice<'static, Driver<'static, USB>>) {
475 usb.run().await;
476}
477
478/// Initialize USB CDC logging and command channel, spawning all necessary tasks.
479///
480/// This function sets up the USB CDC ACM interface for bidirectional communication
481/// and spawns three tasks to handle the USB device, transmission, and reception.
482/// It also initializes the global logger to forward log messages over USB.
483///
484/// # Arguments
485///
486/// * `spawner` - Embassy task spawner for creating the USB tasks
487/// * `level` - Maximum log level to transmit (e.g., `LevelFilter::Info`)
488/// * `usb_peripheral` - RP2040 USB peripheral instance
489/// * `command_sender` - Optional channel sender for receiving line-buffered commands from the host (`None` disables forwarding)
490///
491/// # Panics
492///
493/// Panics if called more than once, as it sets the global logger.
494///
495/// # Examples
496///
497/// ```rust,no_run
498/// use embassy_executor::Spawner;
499/// use embassy_sync::channel::Channel;
500/// use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
501/// use log::LevelFilter;
502/// # use rp_usb_console;
503///
504/// static COMMAND_CHANNEL: Channel<CriticalSectionRawMutex, [u8; rp_usb_console::USB_READ_BUFFER_SIZE], 4> = Channel::new();
505///
506/// # async fn example(spawner: Spawner, usb_peripheral: embassy_rp::peripherals::USB) {
507/// rp_usb_console::start(
508/// spawner,
509/// LevelFilter::Info,
510/// usb_peripheral,
511/// Some(COMMAND_CHANNEL.sender()),
512/// );
513/// # }
514/// ```
515pub fn start(
516 spawner: Spawner,
517 level: LevelFilter,
518 usb_peripheral: Peri<'static, USB>,
519 command_sender: Option<Sender<'static, CriticalSectionRawMutex, [u8; USB_READ_BUFFER_SIZE], 4>>,
520) {
521 // Initialize the logger (use racy variants on targets without atomic ptr support, e.g. thumbv6m/RP2040)
522 unsafe {
523 log::set_logger_racy(&LOGGER).unwrap();
524 log::set_max_level_racy(level);
525 }
526
527 // Simple wrapper to mark our single-threaded statics as Sync. RP2040 + embassy executor: we ensure single-core access.
528 struct StaticCell<T>(UnsafeCell<T>);
529 unsafe impl<T> Sync for StaticCell<T> {}
530
531 static DEVICE_DESC: StaticCell<[u8; 256]> = StaticCell(UnsafeCell::new([0; 256]));
532 static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell(UnsafeCell::new([0; 256]));
533 static BOS_DESC: StaticCell<[u8; 256]> = StaticCell(UnsafeCell::new([0; 256]));
534 static CONTROL_BUF: StaticCell<[u8; 128]> = StaticCell(UnsafeCell::new([0; 128]));
535 static STATE: StaticCell<State> = StaticCell(UnsafeCell::new(State::new()));
536
537 let driver = Driver::new(usb_peripheral, Irqs);
538
539 let mut config = embassy_usb::Config::new(0xc0de, 0xcafe);
540 config.manufacturer = Some("Embassy");
541 config.product = Some("Modular USB-Serial");
542 config.serial_number = Some("12345678");
543 config.max_power = 100;
544
545 let mut builder = Builder::new(
546 driver,
547 config,
548 unsafe { &mut *DEVICE_DESC.0.get() },
549 unsafe { &mut *CONFIG_DESC.0.get() },
550 unsafe { &mut *BOS_DESC.0.get() },
551 unsafe { &mut *CONTROL_BUF.0.get() },
552 );
553
554 let class = CdcAcmClass::new(&mut builder, unsafe { &mut *STATE.0.get() }, 64);
555 let (sender, receiver) = class.split();
556 let usb = builder.build();
557
558 // Spawn all the necessary tasks.
559 spawner.spawn(usb_device_task(usb)).unwrap();
560 spawner.spawn(usb_tx_task(sender)).unwrap();
561 spawner.spawn(usb_rx_task(receiver, command_sender)).unwrap();
562}