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:
5 files changed
+113
-39
No files matched your search
Generated
+1
@@ -347,6 +347,7 @@ dependencies = [
|
|||||||
"embassy-time",
|
"embassy-time",
|
||||||
"embassy-usb",
|
"embassy-usb",
|
||||||
"embedded-graphics",
|
"embedded-graphics",
|
||||||
|
"embedded-io-async",
|
||||||
"esp-backtrace",
|
"esp-backtrace",
|
||||||
"esp-hal",
|
"esp-hal",
|
||||||
"esp-hal-embassy",
|
"esp-hal-embassy",
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@ static_cell = "2.1"
|
|||||||
embedded-graphics = "0.8"
|
embedded-graphics = "0.8"
|
||||||
ssd1306 = { version = "0.10", features = ["async"] }
|
ssd1306 = { version = "0.10", features = ["async"] }
|
||||||
|
|
||||||
# Async combinators (select)
|
# Async IO traits and combinators
|
||||||
|
embedded-io-async = "0.6"
|
||||||
embassy-futures = "0.1"
|
embassy-futures = "0.1"
|
||||||
|
|
||||||
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
|
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ 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>;
|
||||||
|
|
||||||
// TODO: V3 has CP2102 on UART0 — USB OTG on GPIO19/20 goes nowhere
|
// V3 has CP2102 on UART0 but also has USB-Serial-JTAG on the ESP32-S3
|
||||||
pub type UsbDriver = ();
|
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,8 +111,10 @@ impl Board {
|
|||||||
delay: Delay,
|
delay: Delay,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── USB (placeholder — V3 uses CP2102 on UART0) ───────────
|
// ── USB Serial (via USB-Serial-JTAG) ───────────────────────
|
||||||
let usb = UsbParts { driver: () };
|
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) ───────────────────
|
// ── Display (SSD1306 OLED on I2C, 0x3C) ───────────────────
|
||||||
let i2c = I2c::new(p.I2C0, I2cConfig::default())
|
let i2c = I2c::new(p.I2C0, I2cConfig::default())
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
// TODO: USB CDC-ACM (UsbSerialJtag for dev, or full OTG stack for production)
|
pub type UsbDriver = esp_hal::usb_serial_jtag::UsbSerialJtag<'static, esp_hal::Async>;
|
||||||
pub type UsbDriver = ();
|
|
||||||
|
|
||||||
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
||||||
|
|
||||||
@@ -112,8 +111,10 @@ impl Board {
|
|||||||
delay: Delay,
|
delay: Delay,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── USB (placeholder) ──────────────────────────────────────
|
// ── USB Serial (via USB-Serial-JTAG, shares PHY safely) ───
|
||||||
let usb = UsbParts { driver: () };
|
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) ───────────────────
|
// ── Display (SSD1315 OLED on I2C, 0x3C) ───────────────────
|
||||||
// Reset display controller
|
// Reset display controller
|
||||||
|
|||||||
+99
-30
@@ -1,7 +1,12 @@
|
|||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
|
|
||||||
use crate::board::UsbParts;
|
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]
|
#[task]
|
||||||
pub async fn usb_task(
|
pub async fn usb_task(
|
||||||
@@ -11,37 +16,101 @@ pub async fn usb_task(
|
|||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
) {
|
) {
|
||||||
let _driver = parts.driver;
|
cfg_if::cfg_if! {
|
||||||
let _ = commands;
|
if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
||||||
let _ = responses;
|
run_serial(parts.driver, commands, responses, display_commands, has_display).await;
|
||||||
let _ = display_commands;
|
} else {
|
||||||
let _ = has_display;
|
// 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
|
#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))]
|
||||||
// TODO: COBS-framed postcard ser/de loop:
|
async fn run_serial(
|
||||||
// select! {
|
serial: crate::board::UsbDriver,
|
||||||
// bytes = cdc_read() => {
|
commands: &'static CommandChannel,
|
||||||
// let cmd: Command = deserialize(bytes);
|
responses: &'static ResponseChannel,
|
||||||
// match cmd {
|
display_commands: &'static DisplayCommandChannel,
|
||||||
// Command::DisplayOn => route_display(DisplayCommand::On),
|
has_display: bool,
|
||||||
// Command::DisplayOff => route_display(DisplayCommand::Off),
|
) {
|
||||||
// other => commands.send(other).await,
|
use embassy_futures::select::{select, Either};
|
||||||
// }
|
use embedded_io_async::{Read, Write};
|
||||||
// }
|
use postcard::accumulator::{CobsAccumulator, FeedResult};
|
||||||
// resp = responses.receive() => serialize Response → cdc_write()
|
|
||||||
// }
|
let (mut rx, mut tx) = serial.split();
|
||||||
//
|
let mut cobs_buf: CobsAccumulator<MAX_FRAME> = CobsAccumulator::new();
|
||||||
// Display command routing:
|
let mut read_buf = [0u8; 64];
|
||||||
// async fn route_display(cmd, display_commands, has_display, responses) {
|
let mut write_buf = [0u8; MAX_FRAME];
|
||||||
// if has_display {
|
|
||||||
// display_commands.send(cmd).await;
|
|
||||||
// responses.send(Response::Ok).await;
|
|
||||||
// } else {
|
|
||||||
// responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
loop {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user