feat: implement USB serial with COBS-framed postcard protocol

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) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-03-29 20:42:55 -06:00
1 parent f1df675363
commit b6fd85495a
5 files changed
+113 -39

No files matched your search

Generated
+1
View File
@@ -347,6 +347,7 @@ dependencies = [
"embassy-time",
"embassy-usb",
"embedded-graphics",
"embedded-io-async",
"esp-backtrace",
"esp-hal",
"esp-hal-embassy",
+2 -1
View File
@@ -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)
+6 -4
View File
@@ -21,8 +21,8 @@ type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
// 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())
+5 -4
View File
@@ -21,8 +21,7 @@ type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
// 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
+99 -30
View File
@@ -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<MAX_FRAME> = 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::<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::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;
}
}
}