From 73878b792fecdd714adf9d9f800952e0d89599a6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 6 Apr 2026 22:53:00 -0600 Subject: [PATCH] WIP: get Heltec V3 working --- TODO.md | 7 ++ justfile | 2 +- src/board/esp32s3.rs | 31 ++++++++- src/board/heltec_v3.rs | 22 ++++-- src/board/rak_wisblock_4631.rs | 2 +- src/main.rs | 54 +++++++++----- src/protocol_io.rs | 124 +++++++++++++++++++++++++++++++++ src/uart/mod.rs | 4 ++ src/uart/task.rs | 73 +++++++++++++++++++ 9 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 TODO.md create mode 100644 src/protocol_io.rs create mode 100644 src/uart/mod.rs create mode 100644 src/uart/task.rs diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b957424 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +- firmware file naming, no "release", add version +- LED blinking on TX/RX? colored? +- display having board specific stuff in it +- publish new client, mux, bridge (updates in client for UART crap) +- try to get heltec v3 back to the right USB port+VID:PID +- split out mux and client repos +- move to GH organization diff --git a/justfile b/justfile index 24e3d85..86c2011 100644 --- a/justfile +++ b/justfile @@ -73,7 +73,7 @@ flash board: @read -r feat target chip <<< "$(just _info {{board}})"; \ case "$target" in \ xtensa-*) \ - port=$(just _find_port "303a:1001" "1209:5741"); \ + port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \ if [ -n "$port" ]; then \ echo "Using $port"; \ espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-release.elf"; \ diff --git a/src/board/esp32s3.rs b/src/board/esp32s3.rs index 6d8249e..df74a8a 100644 --- a/src/board/esp32s3.rs +++ b/src/board/esp32s3.rs @@ -28,8 +28,12 @@ type Iv = GenericSx126xInterfaceVariant, Input<'static>>; type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; pub type RadioDriver = Sx126x; +#[cfg(not(feature = "heltec_v3"))] pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>; +#[cfg(feature = "heltec_v3")] +pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>; + pub type DisplayI2c = I2c<'static, esp_hal::Async>; // ── Peripheral bundles ─────────────────────────────────────────────── @@ -39,10 +43,16 @@ pub struct RadioParts { pub delay: Delay, } +#[cfg(not(feature = "heltec_v3"))] pub struct UsbParts { pub driver: UsbDriver, } +#[cfg(feature = "heltec_v3")] +pub struct UartParts { + pub driver: UartDriver, +} + pub struct DisplayParts { pub i2c: DisplayI2c, pub mac: [u8; 6], @@ -107,7 +117,7 @@ pub fn init_radio( let sx_config = sx126x::Config { chip: Sx1262, - tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7), + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8), use_dcdc: true, rx_boost: false, }; @@ -118,6 +128,7 @@ pub fn init_radio( } } +#[cfg(not(feature = "heltec_v3"))] /// Initialize USB OTG CDC-ACM driver. pub fn init_usb( usb0: esp_hal::peripherals::USB0<'static>, @@ -136,6 +147,24 @@ pub fn init_usb( } } +#[cfg(feature = "heltec_v3")] +/// Initialize UART0 for boards with USB-UART bridge chips (e.g. CP2102). +pub fn init_uart( + uart0: esp_hal::peripherals::UART0<'static>, + tx: esp_hal::peripherals::GPIO43<'static>, + rx: esp_hal::peripherals::GPIO44<'static>, +) -> UartParts { + use esp_hal::uart::{Config as UartConfig, Uart}; + + let uart = Uart::new(uart0, UartConfig::default()) + .expect("UART init") + .with_tx(tx) + .with_rx(rx) + .into_async(); + + UartParts { driver: uart } +} + /// Initialize I2C bus for display. pub fn init_display_i2c( i2c0: esp_hal::peripherals::I2C0<'static>, diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs index 031bd3b..02f7121 100644 --- a/src/board/heltec_v3.rs +++ b/src/board/heltec_v3.rs @@ -3,8 +3,8 @@ use esp_hal::gpio::{Level, Output, OutputConfig}; use super::esp32s3; use super::traits::LoRaBoard; -#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks) -pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts}; +#[allow(unused_imports)] // Re-exported for other modules (display, radio, uart tasks) +pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UartDriver, UartParts}; // ── Board init ─────────────────────────────────────────────────────── @@ -27,23 +27,31 @@ impl LoRaBoard for Board { } impl Board { - pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { + pub fn into_parts(self) -> (RadioParts, UartParts, Option) { let p = self.p; esp32s3::start_timer(p.TIMG0); - // Vext power: GPIO21, active LOW to enable OLED - let vext = Output::new(p.GPIO21, Level::Low, OutputConfig::default()); + // Vext power: GPIO36, active LOW to enable peripherals + let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default()); core::mem::forget(vext); // hold pin low permanently; drop would reset it let radio = esp32s3::init_radio( p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13, ); - let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19); + let uart = esp32s3::init_uart(p.UART0, p.GPIO43, p.GPIO44); + + // SSD1306 display reset: pulse GPIO21 low->high before I2C init + let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default()); + esp_hal::delay::Delay::new().delay_millis(10); + display_rst.set_high(); + esp_hal::delay::Delay::new().delay_millis(10); + core::mem::forget(display_rst); // hold reset high permanently + let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18); let mac = Self::mac_address(); let display = Some(DisplayParts { i2c, mac }); - (radio, usb, display) + (radio, uart, display) } } diff --git a/src/board/rak_wisblock_4631.rs b/src/board/rak_wisblock_4631.rs index d0671f5..2af4a0e 100644 --- a/src/board/rak_wisblock_4631.rs +++ b/src/board/rak_wisblock_4631.rs @@ -112,7 +112,7 @@ impl Board { let sx_config = sx126x::Config { chip: Sx1262, - tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7), + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8), use_dcdc: true, rx_boost: false, }; diff --git a/src/main.rs b/src/main.rs index 25c1c37..c9d9f47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,11 @@ //! Three async tasks communicate via static channels: //! //! ```text -//! usb_task ──Command──► radio_task ──► SX1262 -//! ◄──Response── │ -//! StatusWatch -//! ▼ -//! display_task (optional) +//! host_task ──Command──► radio_task ──► SX1262 +//! ◄──Response── │ +//! StatusWatch +//! ▼ +//! display_task (optional) //! ``` //! //! The host drives everything. The radio idles until commanded. @@ -24,7 +24,11 @@ mod display; mod protocol; #[cfg(not(test))] mod radio; -#[cfg(not(test))] +#[cfg(all(not(test), feature = "heltec_v3"))] +mod protocol_io; +#[cfg(all(not(test), feature = "heltec_v3"))] +mod uart; +#[cfg(all(not(test), not(feature = "heltec_v3")))] mod usb; #[cfg(not(test))] @@ -74,23 +78,39 @@ cfg_if::cfg_if! { async fn run(spawner: Spawner) { let board = ::init(); let mac = board::Board::mac_address(); - let (radio, usb, display) = board.into_parts(); + let (radio, comm, display) = board.into_parts(); let has_display = display.is_some(); spawner .spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS)) .expect("spawn radio_task"); - spawner - .spawn(usb::usb_task( - usb, - &COMMANDS, - &RESPONSES, - &DISPLAY_COMMANDS, - has_display, - mac, - )) - .expect("spawn usb_task"); + + cfg_if::cfg_if! { + if #[cfg(feature = "heltec_v3")] { + spawner + .spawn(uart::uart_task( + comm, + &COMMANDS, + &RESPONSES, + &DISPLAY_COMMANDS, + has_display, + mac, + )) + .expect("spawn uart_task"); + } else { + spawner + .spawn(usb::usb_task( + comm, + &COMMANDS, + &RESPONSES, + &DISPLAY_COMMANDS, + has_display, + mac, + )) + .expect("spawn usb_task"); + } + } if let Some(dp) = display { spawner diff --git a/src/protocol_io.rs b/src/protocol_io.rs new file mode 100644 index 0000000..e1d1791 --- /dev/null +++ b/src/protocol_io.rs @@ -0,0 +1,124 @@ +//! Shared COBS protocol helpers for USB and UART transports. +//! +//! Both `usb_task` and `uart_task` use these helpers for frame +//! accumulation, response encoding, and command routing. + +use defmt::warn; + +use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; +use crate::protocol::{Command, ErrorCode, Response}; + +/// Maximum COBS frame size (bytes). +pub const MAX_FRAME: usize = 512; + +// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes. +// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room. +const _: () = assert!( + MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64, + "MAX_FRAME too small for max payload + COBS overhead" +); + +/// Accumulates bytes into complete COBS frames. +pub struct FrameAccumulator { + buf: [u8; MAX_FRAME], + len: usize, +} + +impl FrameAccumulator { + pub const fn new() -> Self { + Self { + buf: [0u8; MAX_FRAME], + len: 0, + } + } + + /// Reset the accumulator (discard partial frame). + pub fn reset(&mut self) { + self.len = 0; + } + + /// Feed a chunk of bytes, calling `on_command` for each decoded command. + pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) { + for &byte in data { + if byte == 0x00 { + // End of COBS frame — decode and dispatch + if self.len > 0 { + let mut decode_buf = [0u8; MAX_FRAME]; + if let Some(decoded_len) = + ucobs::decode(&self.buf[..self.len], &mut decode_buf) + { + if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) { + on_command(cmd); + } + } + } + self.len = 0; + } else if self.len < MAX_FRAME { + self.buf[self.len] = byte; + self.len += 1; + } else { + // Frame too large — discard + self.len = 0; + } + } + } +} + +/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel. +/// +/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow. +pub fn encode_response<'a>( + response: Response, + write_buf: &mut [u8; MAX_FRAME], + encode_buf: &'a mut [u8; MAX_FRAME], +) -> Option<&'a [u8]> { + let raw_len = response.write_to(write_buf); + let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0); + if encoded_len < encode_buf.len() { + encode_buf[encoded_len] = 0x00; + Some(&encode_buf[..encoded_len + 1]) + } else { + warn!("COBS encode buffer overflow"); + None + } +} + +/// Route a parsed command to the appropriate handler. +/// +/// Display and MAC commands are handled locally (response sent immediately). +/// All other commands are forwarded to the radio task (response sent later). +/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command +/// rule in PROTOCOL.md is required to keep solicited responses ordered. +pub async fn route_command( + cmd: Command, + commands: &CommandChannel, + responses: &ResponseChannel, + display_commands: &DisplayCommandChannel, + has_display: bool, + mac: [u8; 6], +) { + 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; + } + } + Command::GetMac => { + responses.send(Response::MacAddress(mac)).await; + } + other => { + commands.send(other).await; + } + } +} diff --git a/src/uart/mod.rs b/src/uart/mod.rs new file mode 100644 index 0000000..3a53430 --- /dev/null +++ b/src/uart/mod.rs @@ -0,0 +1,4 @@ +//! UART interface with COBS-framed command/response protocol. + +mod task; +pub use task::uart_task; diff --git a/src/uart/task.rs b/src/uart/task.rs new file mode 100644 index 0000000..46f23da --- /dev/null +++ b/src/uart/task.rs @@ -0,0 +1,73 @@ +//! UART task: COBS-framed fixed-size LE command/response protocol. +//! +//! Reads COBS frames from UART, decodes them, parses commands, routes them +//! to the radio or display task, and sends COBS-framed responses back. +//! Used for boards with USB-UART bridge chips (e.g. CP2102 on some Heltec V3). + +use defmt::warn; +use embassy_executor::task; +use embedded_io_async::Write; + +use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel}; +use crate::protocol_io::{self, FrameAccumulator, MAX_FRAME}; + +#[task] +pub async fn uart_task( + parts: crate::board::UartParts, + commands: &'static CommandChannel, + responses: &'static ResponseChannel, + display_commands: &'static DisplayCommandChannel, + has_display: bool, + mac: [u8; 6], +) { + let (mut rx, mut tx) = parts.driver.split(); + + let mut read_buf = [0u8; 64]; + let mut write_buf = [0u8; MAX_FRAME]; + let mut cobs_encode_buf = [0u8; MAX_FRAME]; + let mut accumulator = FrameAccumulator::new(); + + loop { + use embassy_futures::select::select; + use embassy_futures::select::Either; + + match select( + embedded_io_async::Read::read(&mut rx, &mut read_buf), + responses.receive(), + ) + .await + { + Either::First(result) => { + let n = match result { + Ok(0) => continue, + Ok(n) => n, + Err(_) => { + accumulator.reset(); + embassy_time::Timer::after_millis(100).await; + continue; + } + }; + + let mut cmds = heapless::Vec::<_, 4>::new(); + accumulator.feed(&read_buf[..n], |cmd| { + let _ = cmds.push(cmd); + }); + for cmd in cmds { + protocol_io::route_command( + cmd, commands, responses, display_commands, has_display, mac, + ) + .await; + } + } + Either::Second(response) => { + if let Some(frame) = + protocol_io::encode_response(response, &mut write_buf, &mut cobs_encode_buf) + { + if tx.write_all(frame).await.is_err() { + warn!("UART write failed, response dropped"); + } + } + } + } + } +}