From b6fd85495ac1bbb7f83b52c8e5598880f30e3127 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 27 Mar 2026 00:25:21 -0600 Subject: [PATCH] feat: implement USB serial with COBS-framed postcard protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up UsbSerialJtag on ESP32-S3 boards for host communication: - Read COBS-framed postcard commands from host via serial - Route DisplayOn/Off to display task, everything else to radio - Serialize responses back to host as COBS frames - Use postcard::CobsAccumulator for chunked deserialization - Concurrent read/write via embassy select RAK nRF52840 USB (embassy-usb CDC-ACM) is still a stub — different init path, will be implemented separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 +- src/board/heltec_v3.rs | 10 ++-- src/board/heltec_v4.rs | 9 +-- src/usb/task.rs | 129 +++++++++++++++++++++++++++++++---------- 5 files changed, 113 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd1a8886..677a6663 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ dependencies = [ "embassy-time", "embassy-usb", "embedded-graphics", + "embedded-io-async", "esp-backtrace", "esp-hal", "esp-hal-embassy", diff --git a/Cargo.toml b/Cargo.toml index 184a18bf..e40b81b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,8 @@ static_cell = "2.1" embedded-graphics = "0.8" ssd1306 = { version = "0.10", features = ["async"] } -# Async combinators (select) +# Async IO traits and combinators +embedded-io-async = "0.6" embassy-futures = "0.1" # ESP32-S3 (Heltec V3) — requires esp toolchain (espup) diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs index 7c51ec0c..1cab540f 100644 --- a/src/board/heltec_v3.rs +++ b/src/board/heltec_v3.rs @@ -21,8 +21,8 @@ type Iv = GenericSx126xInterfaceVariant, Input<'static>>; type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; pub type RadioDriver = Sx126x; -// TODO: V3 has CP2102 on UART0 — USB OTG on GPIO19/20 goes nowhere -pub type UsbDriver = (); +// V3 has CP2102 on UART0 but also has USB-Serial-JTAG on the ESP32-S3 +pub type UsbDriver = esp_hal::usb_serial_jtag::UsbSerialJtag<'static, esp_hal::Async>; pub type DisplayI2c = I2c<'static, esp_hal::Async>; @@ -111,8 +111,10 @@ impl Board { delay: Delay, }; - // ── USB (placeholder — V3 uses CP2102 on UART0) ─────────── - let usb = UsbParts { driver: () }; + // ── USB Serial (via USB-Serial-JTAG) ─────────────────────── + let usb_serial = esp_hal::usb_serial_jtag::UsbSerialJtag::new(p.USB_DEVICE) + .into_async(); + let usb = UsbParts { driver: usb_serial }; // ── Display (SSD1306 OLED on I2C, 0x3C) ─────────────────── let i2c = I2c::new(p.I2C0, I2cConfig::default()) diff --git a/src/board/heltec_v4.rs b/src/board/heltec_v4.rs index adf3a659..d111beae 100644 --- a/src/board/heltec_v4.rs +++ b/src/board/heltec_v4.rs @@ -21,8 +21,7 @@ type Iv = GenericSx126xInterfaceVariant, Input<'static>>; type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; pub type RadioDriver = Sx126x; -// TODO: USB CDC-ACM (UsbSerialJtag for dev, or full OTG stack for production) -pub type UsbDriver = (); +pub type UsbDriver = esp_hal::usb_serial_jtag::UsbSerialJtag<'static, esp_hal::Async>; pub type DisplayI2c = I2c<'static, esp_hal::Async>; @@ -112,8 +111,10 @@ impl Board { delay: Delay, }; - // ── USB (placeholder) ────────────────────────────────────── - let usb = UsbParts { driver: () }; + // ── USB Serial (via USB-Serial-JTAG, shares PHY safely) ─── + let usb_serial = esp_hal::usb_serial_jtag::UsbSerialJtag::new(p.USB_DEVICE) + .into_async(); + let usb = UsbParts { driver: usb_serial }; // ── Display (SSD1315 OLED on I2C, 0x3C) ─────────────────── // Reset display controller diff --git a/src/usb/task.rs b/src/usb/task.rs index f56cdb55..74131d8e 100644 --- a/src/usb/task.rs +++ b/src/usb/task.rs @@ -1,7 +1,12 @@ use embassy_executor::task; use crate::board::UsbParts; -use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel}; +use crate::channel::{ + CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel, +}; +use crate::protocol::{Command, ErrorCode, Response}; + +const MAX_FRAME: usize = 512; #[task] pub async fn usb_task( @@ -11,37 +16,101 @@ pub async fn usb_task( display_commands: &'static DisplayCommandChannel, has_display: bool, ) { - let _driver = parts.driver; - let _ = commands; - let _ = responses; - let _ = display_commands; - let _ = has_display; + cfg_if::cfg_if! { + if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] { + run_serial(parts.driver, commands, responses, display_commands, has_display).await; + } else { + // TODO: nRF52840 uses embassy-usb CdcAcmClass — different init path + let _ = (parts, commands, responses, display_commands, has_display); + loop { embassy_time::Timer::after_secs(3600).await; } + } + } +} - // TODO: build embassy_usb CDC-ACM class from driver - // TODO: COBS-framed postcard ser/de loop: - // select! { - // bytes = cdc_read() => { - // let cmd: Command = deserialize(bytes); - // match cmd { - // Command::DisplayOn => route_display(DisplayCommand::On), - // Command::DisplayOff => route_display(DisplayCommand::Off), - // other => commands.send(other).await, - // } - // } - // resp = responses.receive() => serialize Response → cdc_write() - // } - // - // Display command routing: - // async fn route_display(cmd, display_commands, has_display, responses) { - // if has_display { - // display_commands.send(cmd).await; - // responses.send(Response::Ok).await; - // } else { - // responses.send(Response::Error(ErrorCode::NoDisplay)).await; - // } - // } +#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] +async fn run_serial( + serial: crate::board::UsbDriver, + commands: &'static CommandChannel, + responses: &'static ResponseChannel, + display_commands: &'static DisplayCommandChannel, + has_display: bool, +) { + use embassy_futures::select::{select, Either}; + use embedded_io_async::{Read, Write}; + use postcard::accumulator::{CobsAccumulator, FeedResult}; + let (mut rx, mut tx) = serial.split(); + let mut cobs_buf: CobsAccumulator = CobsAccumulator::new(); + let mut read_buf = [0u8; 64]; + let mut write_buf = [0u8; MAX_FRAME]; + loop { - embassy_time::Timer::after_secs(1).await; + match select(rx.read(&mut read_buf), responses.receive()).await { + // ── Bytes from host ──────────────────────────────────── + Either::First(result) => { + let n = match result { + Ok(0) | Err(_) => continue, + Ok(n) => n, + }; + + let mut window = &read_buf[..n]; + 'cobs: while !window.is_empty() { + window = match cobs_buf.feed::(window) { + FeedResult::Consumed => break 'cobs, + FeedResult::OverFull(remaining) => { + defmt::warn!("COBS frame too large"); + remaining + } + FeedResult::DeserError(remaining) => { + defmt::warn!("COBS deserialize error"); + remaining + } + FeedResult::Success { data: cmd, remaining } => { + route_command( + cmd, commands, responses, display_commands, has_display, + ) + .await; + remaining + } + }; + } + } + // ── Response to host ─────────────────────────────────── + Either::Second(response) => { + if let Ok(buf) = postcard::to_slice_cobs(&response, &mut write_buf) { + let _ = tx.write_all(buf).await; + } + } + } + } +} + +async fn route_command( + cmd: Command, + commands: &CommandChannel, + responses: &ResponseChannel, + display_commands: &DisplayCommandChannel, + has_display: bool, +) { + match cmd { + Command::DisplayOn => { + if has_display { + display_commands.send(DisplayCommand::On).await; + responses.send(Response::Ok).await; + } else { + responses.send(Response::Error(ErrorCode::NoDisplay)).await; + } + } + Command::DisplayOff => { + if has_display { + display_commands.send(DisplayCommand::Off).await; + responses.send(Response::Ok).await; + } else { + responses.send(Response::Error(ErrorCode::NoDisplay)).await; + } + } + other => { + commands.send(other).await; + } } }