diff --git a/clients/python/donglora/__init__.py b/clients/python/donglora/__init__.py index 99116883..bb58efad 100644 --- a/clients/python/donglora/__init__.py +++ b/clients/python/donglora/__init__.py @@ -19,9 +19,17 @@ from cobs import cobs USB_VID_PID = "1209:5741" +# Known USB-UART bridge VID:PIDs found on some board revisions. +BRIDGE_VID_PIDS = {"10c4:ea60", "1a86:55d4", "1a86:7523", "0403:6001"} + def find_port() -> str | None: - """Find the DongLoRa serial port by USB VID:PID.""" + """Find the DongLoRa serial port by USB VID:PID. + + Checks for native USB CDC-ACM first (1209:5741), then falls back to + known USB-UART bridge chips found on some board revisions. + """ + bridge_match = None for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): try: result = subprocess.run( @@ -33,11 +41,16 @@ def find_port() -> str | None: ) vid = props.get("ID_VENDOR_ID", "").lower() pid = props.get("ID_MODEL_ID", "").lower() - if f"{vid}:{pid}" == USB_VID_PID: + vid_pid = f"{vid}:{pid}" + if vid_pid == USB_VID_PID: return path + if bridge_match is None and vid_pid in BRIDGE_VID_PIDS: + bridge_match = path except Exception: continue - ports = sorted(glob.glob("/dev/ttyACM*")) + if bridge_match is not None: + return bridge_match + ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")) return ports[0] if ports else None @@ -345,7 +358,7 @@ def connect(port: str | None = None, timeout: float = 2.0) -> serial.Serial | Mu # No mux available — direct USB serial if port is None: port = wait_for_device() - ser = serial.Serial(port, timeout=timeout) + ser = serial.Serial(port, baudrate=115200, timeout=timeout) ser.reset_input_buffer() return ser diff --git a/clients/rust/src/discovery.rs b/clients/rust/src/discovery.rs index f5392a83..0ce8d8dd 100644 --- a/clients/rust/src/discovery.rs +++ b/clients/rust/src/discovery.rs @@ -13,18 +13,41 @@ pub const USB_VID: u16 = 0x1209; /// DongLoRa USB Product ID. pub const USB_PID: u16 = 0x5741; +/// Known USB-UART bridge VID:PIDs found on some board revisions. +const BRIDGE_VID_PIDS: &[(u16, u16)] = &[ + (0x10C4, 0xEA60), // CP2102 (Silicon Labs) + (0x1A86, 0x55D4), // CH9102 + (0x1A86, 0x7523), // CH340 + (0x0403, 0x6001), // FT232R (FTDI) +]; + /// Find the DongLoRa serial port by USB VID:PID. /// +/// Checks for native USB CDC-ACM first (VID:PID 1209:5741), then falls +/// back to known USB-UART bridge chips found on some board revisions. /// Returns the first matching port path, or `None` if no device is found. pub fn find_port() -> Option { let ports = serialport::available_ports().ok()?; + + // Prefer native USB (our custom VID:PID) + if let Some(port) = ports.iter().find(|p| { + matches!( + &p.port_type, + serialport::SerialPortType::UsbPort(info) + if info.vid == USB_VID && info.pid == USB_PID + ) + }) { + return Some(port.port_name.clone()); + } + + // Fall back to known USB-UART bridge chips ports .into_iter() .find(|p| { matches!( &p.port_type, serialport::SerialPortType::UsbPort(info) - if info.vid == USB_VID && info.pid == USB_PID + if BRIDGE_VID_PIDS.contains(&(info.vid, info.pid)) ) }) .map(|p| p.port_name) diff --git a/firmware/TODO.md b/firmware/TODO.md new file mode 100644 index 00000000..b957424d --- /dev/null +++ b/firmware/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/firmware/justfile b/firmware/justfile index 24e3d855..86c2011b 100644 --- a/firmware/justfile +++ b/firmware/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/firmware/src/board/esp32s3.rs b/firmware/src/board/esp32s3.rs index 6d8249ee..df74a8a1 100644 --- a/firmware/src/board/esp32s3.rs +++ b/firmware/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/firmware/src/board/heltec_v3.rs b/firmware/src/board/heltec_v3.rs index 031bd3bc..02f7121b 100644 --- a/firmware/src/board/heltec_v3.rs +++ b/firmware/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/firmware/src/board/rak_wisblock_4631.rs b/firmware/src/board/rak_wisblock_4631.rs index d0671f5b..2af4a0ea 100644 --- a/firmware/src/board/rak_wisblock_4631.rs +++ b/firmware/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/firmware/src/main.rs b/firmware/src/main.rs index 25c1c37e..c9d9f47a 100644 --- a/firmware/src/main.rs +++ b/firmware/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/firmware/src/protocol_io.rs b/firmware/src/protocol_io.rs new file mode 100644 index 00000000..e1d17915 --- /dev/null +++ b/firmware/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/firmware/src/uart/mod.rs b/firmware/src/uart/mod.rs new file mode 100644 index 00000000..3a53430b --- /dev/null +++ b/firmware/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/firmware/src/uart/task.rs b/firmware/src/uart/task.rs new file mode 100644 index 00000000..46f23daa --- /dev/null +++ b/firmware/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"); + } + } + } + } + } +}