From 4e7e6d216759de3e037936968f64f3706e4ea0ce Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 27 Mar 2026 01:19:29 -0600 Subject: [PATCH] feat: USB OTG CDC-ACM with custom VID:PID, disconnect UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch all ESP32-S3 boards from UsbSerialJtag to USB OTG CDC-ACM via embassy-usb. The USB device stack is now shared code — boards only provide the platform-specific embassy-usb Driver. - Custom VID:PID 1209:5741 (open-source VID, "WA" PID) - CDC-ACM class with proper device enumeration - COBS+postcard protocol over CDC-ACM packet read/write - Concurrent USB device loop + protocol loop via join() - On disconnect: sends DisplayCommand::Reset to show "Waiting for host..." - Justfile: espflash no longer uses --monitor (OTG steals PHY) - Test script updated to detect by new VID:PID Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + Cargo.toml | 1 + Justfile | 2 +- src/board/heltec_v3.rs | 18 ++++--- src/board/heltec_v4.rs | 18 +++++-- src/channel.rs | 1 + src/display/task.rs | 7 +++ src/usb/task.rs | 105 +++++++++++++++++++++++++++++------------ tools/test_ping.py | 6 +-- 9 files changed, 113 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7fc8800b..d66b9e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ dependencies = [ "embassy-sync 0.6.2", "embassy-time", "embassy-usb", + "embassy-usb-driver", "embedded-graphics", "embedded-io-async", "esp-backtrace", diff --git a/Cargo.toml b/Cargo.toml index 7062d7b7..84fd8088 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ embassy-executor = { version = "0.6", features = ["task-arena-size-32768"] } embassy-sync = "0.6" embassy-time = "0.3" embassy-usb = { version = "0.3", features = ["defmt"] } +embassy-usb-driver = "0.1" heapless = { version = "0.8", features = ["serde"] } lora-phy = "3.0" postcard = { version = "1.0", default-features = false } diff --git a/Justfile b/Justfile index 9a74bbf6..6e7ca411 100644 --- a/Justfile +++ b/Justfile @@ -62,7 +62,7 @@ flash board: @just build {{board}} release @read -r feat target chip <<< "$(just _info {{board}})"; \ case "$target" in \ - xtensa-*) espflash flash --monitor "{{firmware_dir}}/donglora-{{board}}-release.elf" ;; \ + xtensa-*) espflash flash "{{firmware_dir}}/donglora-{{board}}-release.elf" ;; \ *) probe-rs run --chip $chip "{{firmware_dir}}/donglora-{{board}}-release.elf" ;; \ esac diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs index 1cab540f..6179fb25 100644 --- a/src/board/heltec_v3.rs +++ b/src/board/heltec_v3.rs @@ -21,8 +21,7 @@ type Iv = GenericSx126xInterfaceVariant, Input<'static>>; type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; pub type RadioDriver = Sx126x; -// 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 UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>; pub type DisplayI2c = I2c<'static, esp_hal::Async>; @@ -111,10 +110,17 @@ impl Board { delay: Delay, }; - // ── 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 }; + // ── USB OTG CDC-ACM driver ────────────────────────────── + let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19); + static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); + let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]); + let usb = UsbParts { + driver: esp_hal::otg_fs::asynch::Driver::new( + usb_inst, + ep_out_buf, + esp_hal::otg_fs::asynch::Config::default(), + ), + }; // ── 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 d111beae..b5be79b3 100644 --- a/src/board/heltec_v4.rs +++ b/src/board/heltec_v4.rs @@ -21,7 +21,7 @@ type Iv = GenericSx126xInterfaceVariant, Input<'static>>; type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; pub type RadioDriver = Sx126x; -pub type UsbDriver = esp_hal::usb_serial_jtag::UsbSerialJtag<'static, esp_hal::Async>; +pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>; pub type DisplayI2c = I2c<'static, esp_hal::Async>; @@ -112,9 +112,19 @@ impl Board { }; // ── 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 }; + // ── USB OTG CDC-ACM driver ────────────────────────────── + // Note: this switches the internal USB PHY from Serial-JTAG to OTG. + // espflash --monitor will stop working after this point. + let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19); + static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); + let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]); + let usb = UsbParts { + driver: esp_hal::otg_fs::asynch::Driver::new( + usb_inst, + ep_out_buf, + esp_hal::otg_fs::asynch::Config::default(), + ), + }; // ── Display (SSD1315 OLED on I2C, 0x3C) ─────────────────── // Reset display controller diff --git a/src/channel.rs b/src/channel.rs index 43f82d43..12363608 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -10,6 +10,7 @@ use crate::protocol::{Command, RadioConfig, Response}; pub enum DisplayCommand { On, Off, + Reset, } /// USB-to-display command channel. diff --git a/src/display/task.rs b/src/display/task.rs index fc64a8f6..45c3bfb7 100644 --- a/src/display/task.rs +++ b/src/display/task.rs @@ -120,6 +120,13 @@ pub async fn display_task( state.display_on = true; render_and_flush(&mut display, &state).await; } + DisplayCommand::Reset => { + state.last_status = RadioStatus::default(); + state.rssi_history = [NO_SIGNAL; RSSI_HISTORY_LEN]; + state.rssi_count = 0; + state.current_slot_rssi = NO_SIGNAL; + render_and_flush(&mut display, &state).await; + } }, Either3::Third(()) => { // Timer tick: advance sparkline slot diff --git a/src/usb/task.rs b/src/usb/task.rs index 74131d8e..64235a12 100644 --- a/src/usb/task.rs +++ b/src/usb/task.rs @@ -1,4 +1,10 @@ use embassy_executor::task; +use embassy_futures::join::join; +use embassy_futures::select::{select, Either}; +use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State}; +use embassy_usb::Builder; +use postcard::accumulator::{CobsAccumulator, FeedResult}; +use static_cell::StaticCell; use crate::board::UsbParts; use crate::channel::{ @@ -7,6 +13,7 @@ use crate::channel::{ use crate::protocol::{Command, ErrorCode, Response}; const MAX_FRAME: usize = 512; +const MAX_PACKET: usize = 64; #[task] pub async fn usb_task( @@ -16,55 +23,89 @@ pub async fn usb_task( display_commands: &'static DisplayCommandChannel, has_display: bool, ) { - 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; } - } - } + // ── Build embassy-usb device with CDC-ACM class ──────────────── + let mut config = embassy_usb::Config::new(0x1209, 0x5741); + config.manufacturer = Some("DongLoRa"); + config.product = Some("DongLoRa LoRa Radio"); + config.serial_number = Some("001"); + config.device_class = 0xEF; + config.device_sub_class = 0x02; + config.device_protocol = 0x01; + config.composite_with_iads = true; + + static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new(); + static CDC_STATE: StaticCell = StaticCell::new(); + + let desc_buf = DESC_BUF.init([0; 256]); + let conf_buf = CONF_BUF.init([0; 256]); + let bos_buf = BOS_BUF.init([0; 256]); + let ctrl_buf = CTRL_BUF.init([0; 64]); + let cdc_state = CDC_STATE.init(State::new()); + + let mut builder = Builder::new( + parts.driver, + config, + desc_buf, + conf_buf, + bos_buf, + ctrl_buf, + ); + + let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16); + let mut usb_dev = builder.build(); + let (sender, receiver) = class.split(); + + // ── Run USB device + protocol loop concurrently ──────────────── + join( + usb_dev.run(), + protocol_loop(sender, receiver, commands, responses, display_commands, has_display), + ) + .await; } -#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] -async fn run_serial( - serial: crate::board::UsbDriver, +async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( + mut sender: Sender<'d, D>, + mut receiver: Receiver<'d, D>, 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 read_buf = [0u8; MAX_PACKET]; let mut write_buf = [0u8; MAX_FRAME]; + // Wait for host to open the port (DTR signal) + receiver.wait_connection().await; + if has_display { + // Could send a "connected" signal here in the future + } + loop { - match select(rx.read(&mut read_buf), responses.receive()).await { - // ── Bytes from host ──────────────────────────────────── + match select(receiver.read_packet(&mut read_buf), responses.receive()).await { Either::First(result) => { let n = match result { - Ok(0) | Err(_) => continue, Ok(n) => n, + Err(_) => { + // USB disconnect — reset and wait for reconnection + cobs_buf = CobsAccumulator::new(); + if has_display { + let _ = display_commands.try_send(DisplayCommand::Reset); + } + receiver.wait_connection().await; + continue; + } }; 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::OverFull(remaining) => remaining, + FeedResult::DeserError(remaining) => remaining, FeedResult::Success { data: cmd, remaining } => { route_command( cmd, commands, responses, display_commands, has_display, @@ -75,10 +116,12 @@ async fn run_serial( }; } } - // ── Response to host ─────────────────────────────────── Either::Second(response) => { if let Ok(buf) = postcard::to_slice_cobs(&response, &mut write_buf) { - let _ = tx.write_all(buf).await; + // CDC-ACM has a max packet size — send in chunks + for chunk in buf.chunks(MAX_PACKET) { + let _ = sender.write_packet(chunk).await; + } } } } diff --git a/tools/test_ping.py b/tools/test_ping.py index 8ab96dca..e611d203 100644 --- a/tools/test_ping.py +++ b/tools/test_ping.py @@ -12,10 +12,8 @@ import sys import time from cobs import cobs -# TODO: switch to custom VID:PID 1209:5741 (open-source VID, "WA" PID) -# once USB OTG CDC-ACM is implemented. For now, ESP32-S3's built-in -# USB-Serial-JTAG always presents as the Espressif VID:PID. -USB_VID_PID = "303a:1001" +# Open-source VID 1209, PID "WA" (0x5741) +USB_VID_PID = "1209:5741" def find_serial_port() -> str | None: