WIP: get Heltec V3 working

This commit is contained in:
swaits committed 2026-04-07 07:58:57 -06:00
1 parent 0ae1c84085
commit 73878b792f
9 files changed
+292 -27

No files matched your search

+7
View File
@@ -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
+1 -1
View File
@@ -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"; \
+30 -1
View File
@@ -28,8 +28,12 @@ type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
#[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>,
+15 -7
View File
@@ -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<DisplayParts>) {
pub fn into_parts(self) -> (RadioParts, UartParts, Option<DisplayParts>) {
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)
}
}
+1 -1
View File
@@ -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,
};
+37 -17
View File
@@ -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 = <board::Board as LoRaBoard>::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
+124
View File
@@ -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;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
//! UART interface with COBS-framed command/response protocol.
mod task;
pub use task::uart_task;
+73
View File
@@ -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");
}
}
}
}
}
}