feat: USB OTG CDC-ACM with custom VID:PID, disconnect UX
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) <noreply@anthropic.com>
This commit is contained in:
9 files changed
+113
-46
No files matched your search
+74
-31
@@ -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<State> = 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<MAX_FRAME> = 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::<Command>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user