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
Generated
+1
@@ -347,6 +347,7 @@ dependencies = [
|
|||||||
"embassy-sync 0.6.2",
|
"embassy-sync 0.6.2",
|
||||||
"embassy-time",
|
"embassy-time",
|
||||||
"embassy-usb",
|
"embassy-usb",
|
||||||
|
"embassy-usb-driver",
|
||||||
"embedded-graphics",
|
"embedded-graphics",
|
||||||
"embedded-io-async",
|
"embedded-io-async",
|
||||||
"esp-backtrace",
|
"esp-backtrace",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ embassy-executor = { version = "0.6", features = ["task-arena-size-32768"] }
|
|||||||
embassy-sync = "0.6"
|
embassy-sync = "0.6"
|
||||||
embassy-time = "0.3"
|
embassy-time = "0.3"
|
||||||
embassy-usb = { version = "0.3", features = ["defmt"] }
|
embassy-usb = { version = "0.3", features = ["defmt"] }
|
||||||
|
embassy-usb-driver = "0.1"
|
||||||
heapless = { version = "0.8", features = ["serde"] }
|
heapless = { version = "0.8", features = ["serde"] }
|
||||||
lora-phy = "3.0"
|
lora-phy = "3.0"
|
||||||
postcard = { version = "1.0", default-features = false }
|
postcard = { version = "1.0", default-features = false }
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ flash board:
|
|||||||
@just build {{board}} release
|
@just build {{board}} release
|
||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||||
case "$target" in \
|
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" ;; \
|
*) probe-rs run --chip $chip "{{firmware_dir}}/donglora-{{board}}-release.elf" ;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -21,8 +21,7 @@ type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
|||||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||||
|
|
||||||
// V3 has CP2102 on UART0 but also has USB-Serial-JTAG on the ESP32-S3
|
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||||
pub type UsbDriver = esp_hal::usb_serial_jtag::UsbSerialJtag<'static, esp_hal::Async>;
|
|
||||||
|
|
||||||
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
||||||
|
|
||||||
@@ -111,10 +110,17 @@ impl Board {
|
|||||||
delay: Delay,
|
delay: Delay,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── USB Serial (via USB-Serial-JTAG) ───────────────────────
|
// ── USB OTG CDC-ACM driver ──────────────────────────────
|
||||||
let usb_serial = esp_hal::usb_serial_jtag::UsbSerialJtag::new(p.USB_DEVICE)
|
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||||
.into_async();
|
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||||
let usb = UsbParts { driver: usb_serial };
|
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) ───────────────────
|
// ── Display (SSD1306 OLED on I2C, 0x3C) ───────────────────
|
||||||
let i2c = I2c::new(p.I2C0, I2cConfig::default())
|
let i2c = I2c::new(p.I2C0, I2cConfig::default())
|
||||||
|
|||||||
+14
-4
@@ -21,7 +21,7 @@ type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
|||||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||||
|
|
||||||
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>;
|
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
||||||
|
|
||||||
@@ -112,9 +112,19 @@ impl Board {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── USB Serial (via USB-Serial-JTAG, shares PHY safely) ───
|
// ── USB Serial (via USB-Serial-JTAG, shares PHY safely) ───
|
||||||
let usb_serial = esp_hal::usb_serial_jtag::UsbSerialJtag::new(p.USB_DEVICE)
|
// ── USB OTG CDC-ACM driver ──────────────────────────────
|
||||||
.into_async();
|
// Note: this switches the internal USB PHY from Serial-JTAG to OTG.
|
||||||
let usb = UsbParts { driver: usb_serial };
|
// 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) ───────────────────
|
// ── Display (SSD1315 OLED on I2C, 0x3C) ───────────────────
|
||||||
// Reset display controller
|
// Reset display controller
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::protocol::{Command, RadioConfig, Response};
|
|||||||
pub enum DisplayCommand {
|
pub enum DisplayCommand {
|
||||||
On,
|
On,
|
||||||
Off,
|
Off,
|
||||||
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USB-to-display command channel.
|
/// USB-to-display command channel.
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ pub async fn display_task(
|
|||||||
state.display_on = true;
|
state.display_on = true;
|
||||||
render_and_flush(&mut display, &state).await;
|
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(()) => {
|
Either3::Third(()) => {
|
||||||
// Timer tick: advance sparkline slot
|
// Timer tick: advance sparkline slot
|
||||||
|
|||||||
+74
-31
@@ -1,4 +1,10 @@
|
|||||||
use embassy_executor::task;
|
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::board::UsbParts;
|
||||||
use crate::channel::{
|
use crate::channel::{
|
||||||
@@ -7,6 +13,7 @@ use crate::channel::{
|
|||||||
use crate::protocol::{Command, ErrorCode, Response};
|
use crate::protocol::{Command, ErrorCode, Response};
|
||||||
|
|
||||||
const MAX_FRAME: usize = 512;
|
const MAX_FRAME: usize = 512;
|
||||||
|
const MAX_PACKET: usize = 64;
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn usb_task(
|
pub async fn usb_task(
|
||||||
@@ -16,55 +23,89 @@ pub async fn usb_task(
|
|||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
) {
|
) {
|
||||||
cfg_if::cfg_if! {
|
// ── Build embassy-usb device with CDC-ACM class ────────────────
|
||||||
if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
let mut config = embassy_usb::Config::new(0x1209, 0x5741);
|
||||||
run_serial(parts.driver, commands, responses, display_commands, has_display).await;
|
config.manufacturer = Some("DongLoRa");
|
||||||
} else {
|
config.product = Some("DongLoRa LoRa Radio");
|
||||||
// TODO: nRF52840 uses embassy-usb CdcAcmClass — different init path
|
config.serial_number = Some("001");
|
||||||
let _ = (parts, commands, responses, display_commands, has_display);
|
config.device_class = 0xEF;
|
||||||
loop { embassy_time::Timer::after_secs(3600).await; }
|
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 protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||||
async fn run_serial(
|
mut sender: Sender<'d, D>,
|
||||||
serial: crate::board::UsbDriver,
|
mut receiver: Receiver<'d, D>,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
responses: &'static ResponseChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
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 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];
|
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 {
|
loop {
|
||||||
match select(rx.read(&mut read_buf), responses.receive()).await {
|
match select(receiver.read_packet(&mut read_buf), responses.receive()).await {
|
||||||
// ── Bytes from host ────────────────────────────────────
|
|
||||||
Either::First(result) => {
|
Either::First(result) => {
|
||||||
let n = match result {
|
let n = match result {
|
||||||
Ok(0) | Err(_) => continue,
|
|
||||||
Ok(n) => n,
|
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];
|
let mut window = &read_buf[..n];
|
||||||
'cobs: while !window.is_empty() {
|
'cobs: while !window.is_empty() {
|
||||||
window = match cobs_buf.feed::<Command>(window) {
|
window = match cobs_buf.feed::<Command>(window) {
|
||||||
FeedResult::Consumed => break 'cobs,
|
FeedResult::Consumed => break 'cobs,
|
||||||
FeedResult::OverFull(remaining) => {
|
FeedResult::OverFull(remaining) => remaining,
|
||||||
defmt::warn!("COBS frame too large");
|
FeedResult::DeserError(remaining) => remaining,
|
||||||
remaining
|
|
||||||
}
|
|
||||||
FeedResult::DeserError(remaining) => {
|
|
||||||
defmt::warn!("COBS deserialize error");
|
|
||||||
remaining
|
|
||||||
}
|
|
||||||
FeedResult::Success { data: cmd, remaining } => {
|
FeedResult::Success { data: cmd, remaining } => {
|
||||||
route_command(
|
route_command(
|
||||||
cmd, commands, responses, display_commands, has_display,
|
cmd, commands, responses, display_commands, has_display,
|
||||||
@@ -75,10 +116,12 @@ async fn run_serial(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── Response to host ───────────────────────────────────
|
|
||||||
Either::Second(response) => {
|
Either::Second(response) => {
|
||||||
if let Ok(buf) = postcard::to_slice_cobs(&response, &mut write_buf) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -12,10 +12,8 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from cobs import cobs
|
from cobs import cobs
|
||||||
|
|
||||||
# TODO: switch to custom VID:PID 1209:5741 (open-source VID, "WA" PID)
|
# Open-source VID 1209, PID "WA" (0x5741)
|
||||||
# once USB OTG CDC-ACM is implemented. For now, ESP32-S3's built-in
|
USB_VID_PID = "1209:5741"
|
||||||
# USB-Serial-JTAG always presents as the Espressif VID:PID.
|
|
||||||
USB_VID_PID = "303a:1001"
|
|
||||||
|
|
||||||
|
|
||||||
def find_serial_port() -> str | None:
|
def find_serial_port() -> str | None:
|
||||||
|
|||||||
Reference in new issue
Block a user