From 110172ded7d6878bf045c409d1e2269afdee6240 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 26 Mar 2026 17:23:31 -0600 Subject: [PATCH] feat: add Heltec V4, wire up V3/V4 peripherals, restructure entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add heltec_v4 board: ESP32-S3R2, SX1262, SSD1315 display, native USB, Vext on GPIO36 (active HIGH) - Wire up heltec_v3 with real esp-hal peripherals (was a stub) - V3 and V4 share radio/I2C pins, differ in Vext pin/polarity and display reset - Restructure main.rs: cfg_if selects embassy_executor::main (Cortex-M) vs esp_hal_embassy::main (Xtensa) per board - Display detection via SSD1306 init in display_task — boards always provide I2C bus, task exits gracefully if no display responds Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 6 ++ Justfile | 4 +- src/board/heltec_v3.rs | 123 ++++++++++++++++++++++++++++++----- src/board/heltec_v4.rs | 142 +++++++++++++++++++++++++++++++++++++++++ src/board/rak_4631.rs | 3 +- src/main.rs | 20 +++++- 6 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 src/board/heltec_v4.rs diff --git a/Cargo.toml b/Cargo.toml index bd770636..76586c23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,12 @@ heltec_v3 = [ "dep:esp-hal-embassy", "dep:esp-println", ] +heltec_v4 = [ + "dep:esp-backtrace", + "dep:esp-hal", + "dep:esp-hal-embassy", + "dep:esp-println", +] rak_4631 = [ "dep:cortex-m-rt", "dep:embassy-nrf", diff --git a/Justfile b/Justfile index b57e877b..e85b575c 100644 --- a/Justfile +++ b/Justfile @@ -4,12 +4,13 @@ set shell := ["bash", "-c"] # Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core. # check-all gracefully skips boards whose toolchain isn't installed. heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3" +heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3" rak_4631 := "rak_4631 thumbv7em-none-eabihf nRF52840_xxAA" firmware_dir := "firmware" # All known boards -boards := "heltec_v3 rak_4631" +boards := "heltec_v3 heltec_v4 rak_4631" # Check all boards that can build with the available toolchain check-all: @@ -79,5 +80,6 @@ _copy_firmware board profile: [private] _info name: @if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \ + elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \ elif [ "{{name}}" == "rak_4631" ]; then echo "{{rak_4631}}"; \ else echo "Unknown board: {{name}}" >&2; exit 1; fi diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs index d1c6604c..67095396 100644 --- a/src/board/heltec_v3.rs +++ b/src/board/heltec_v3.rs @@ -1,21 +1,30 @@ +use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice; +use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_time::Delay; +use esp_hal::dma::{Dma, DmaPriority}; +use esp_hal::gpio::{Input, Level, Output, Pull}; +use esp_hal::i2c::master::{Config as I2cConfig, I2c}; +use esp_hal::prelude::*; +use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus}; +use esp_hal::spi::SpiMode; +use esp_hal::timer::timg::TimerGroup; +use static_cell::StaticCell; use lora_phy::iv::GenericSx126xInterfaceVariant; -use lora_phy::sx126x::{Sx1262, Sx126x}; +use lora_phy::sx126x::{self, Sx1262, Sx126x}; // ── Concrete peripheral types ──────────────────────────────────────── -// TODO: replace these placeholders with real esp-hal peripheral types -// SPI2 for SX1262 (SCK=9, MOSI=10, MISO=11, NSS=8) -// GPIO for radio control (RST=12, DIO1=14, BUSY=13) -// Vext pin (GPIO21) LOW to power OLED -// I2C0 for SSD1306 (SDA=17, SCL=18) -// USB OTG for CDC-ACM - -type Iv = GenericSx126xInterfaceVariant<(), ()>; -type RadioSpiDevice = (); +type SpiBus = SpiDmaBus<'static, esp_hal::Async>; +type Nss = Output<'static>; +type Iv = GenericSx126xInterfaceVariant, Input<'static>>; +type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; pub type RadioDriver = Sx126x; +pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>; + +pub type DisplayI2c = I2c<'static, esp_hal::Async>; + // ── Peripheral bundles ─────────────────────────────────────────────── pub struct RadioParts { @@ -24,25 +33,105 @@ pub struct RadioParts { } pub struct UsbParts { - pub driver: (), + pub driver: UsbDriver, } pub struct DisplayParts { - pub i2c: (), + pub i2c: DisplayI2c, } // ── Board init ─────────────────────────────────────────────────────── -pub struct Board; +pub struct Board { + p: esp_hal::peripherals::Peripherals, +} impl Board { pub fn init() -> Self { - // TODO: call esp_hal::init(esp_hal::Config::default()) - Self + let mut p = esp_hal::init(esp_hal::Config::default()); + + let timg0 = TimerGroup::new(core::mem::replace( + &mut p.TIMG0, + unsafe { esp_hal::peripherals::TIMG0::steal() }, + )); + esp_hal_embassy::init(timg0.timer0); + + Self { p } } pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { - // TODO: wire up real peripherals once building with nightly + -Zbuild-std - todo!("Heltec V3 peripheral init") + let p = self.p; + + // ── Vext power: GPIO21, active LOW to enable OLED ────────── + let _vext = Output::new(p.GPIO21, Level::Low); + + // ── DMA for SPI ──────────────────────────────────────────── + let dma = Dma::new(p.DMA); + let dma_ch = dma.channel0.configure(false, DmaPriority::Priority0); + + // ── SPI + DMA for SX1262 ─────────────────────────────────── + let spi = Spi::new_with_config(p.SPI2, SpiConfig { + frequency: 1u32.MHz(), + mode: SpiMode::Mode0, + ..Default::default() + }) + .with_sck(p.GPIO9) + .with_mosi(p.GPIO10) + .with_miso(p.GPIO11) + .with_dma(dma_ch); + + let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256); + let dma_rx = esp_hal::dma::DmaRxBuf::new(rx_desc, rx_buf).unwrap(); + let dma_tx = esp_hal::dma::DmaTxBuf::new(tx_desc, tx_buf).unwrap(); + + let spi = spi.with_buffers(dma_rx, dma_tx).into_async(); + + static SPI_BUS: StaticCell> = + StaticCell::new(); + let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi)); + + let nss = Output::new(p.GPIO8, Level::High); + let spi_device = SpiDevice::new(spi_bus, nss); + + // ── SX1262 control pins ──────────────────────────────────── + let reset = Output::new(p.GPIO12, Level::High); + let dio1 = Input::new(p.GPIO14, Pull::Down); + let busy = Input::new(p.GPIO13, Pull::Down); + + let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).unwrap(); + + let sx_config = sx126x::Config { + chip: Sx1262, + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7), + use_dcdc: true, + rx_boost: false, + }; + + let radio = RadioParts { + driver: Sx126x::new(spi_device, iv, sx_config), + delay: Delay, + }; + + // ── USB (native ESP32-S3 OTG) ───────────────────────────── + let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19); + static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); + let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]); + let usb = UsbParts { + driver: esp_hal::otg_fs::asynch::Driver::new( + usb_inst, + ep_out_buf, + esp_hal::otg_fs::asynch::Config::default(), + ), + }; + + // ── Display (optional SSD1306 OLED on I2C, 0x3C) ────────── + // Always provide I2C; display_task detects presence via SSD1306 init. + let i2c = I2c::new(p.I2C0, I2cConfig::default()) + .with_sda(p.GPIO17) + .with_scl(p.GPIO18) + .into_async(); + let display = Some(DisplayParts { i2c }); + + (radio, usb, display) } } diff --git a/src/board/heltec_v4.rs b/src/board/heltec_v4.rs new file mode 100644 index 00000000..54b331ae --- /dev/null +++ b/src/board/heltec_v4.rs @@ -0,0 +1,142 @@ +use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice; +use embassy_sync::blocking_mutex::raw::NoopRawMutex; +use embassy_time::Delay; +use esp_hal::dma::{Dma, DmaPriority}; +use esp_hal::gpio::{Input, Level, Output, Pull}; +use esp_hal::i2c::master::{Config as I2cConfig, I2c}; +use esp_hal::prelude::*; +use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus}; +use esp_hal::spi::SpiMode; +use esp_hal::timer::timg::TimerGroup; +use static_cell::StaticCell; + +use lora_phy::iv::GenericSx126xInterfaceVariant; +use lora_phy::sx126x::{self, Sx1262, Sx126x}; + +// ── Concrete peripheral types ──────────────────────────────────────── + +type SpiBus = SpiDmaBus<'static, esp_hal::Async>; +type Nss = Output<'static>; +type Iv = GenericSx126xInterfaceVariant, Input<'static>>; +type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; +pub type RadioDriver = Sx126x; + +pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>; + +pub type DisplayI2c = I2c<'static, esp_hal::Async>; + +// ── Peripheral bundles ─────────────────────────────────────────────── + +pub struct RadioParts { + pub driver: RadioDriver, + pub delay: Delay, +} + +pub struct UsbParts { + pub driver: UsbDriver, +} + +pub struct DisplayParts { + pub i2c: DisplayI2c, +} + +// ── Board init ─────────────────────────────────────────────────────── + +pub struct Board { + p: esp_hal::peripherals::Peripherals, +} + +impl Board { + pub fn init() -> Self { + let mut p = esp_hal::init(esp_hal::Config::default()); + + // Init embassy time driver before consuming peripherals + let timg0 = TimerGroup::new(core::mem::replace( + &mut p.TIMG0, + unsafe { esp_hal::peripherals::TIMG0::steal() }, + )); + esp_hal_embassy::init(timg0.timer0); + + Self { p } + } + + pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { + let p = self.p; + + // ── Vext power: GPIO36, active HIGH to enable OLED ───────── + let _vext = Output::new(p.GPIO36, Level::High); + + // ── DMA for SPI ──────────────────────────────────────────── + let dma = Dma::new(p.DMA); + let dma_ch = dma.channel0.configure(false, DmaPriority::Priority0); + + // ── SPI + DMA for SX1262 ─────────────────────────────────── + let spi = Spi::new_with_config(p.SPI2, SpiConfig { + frequency: 1u32.MHz(), + mode: SpiMode::Mode0, + ..Default::default() + }) + .with_sck(p.GPIO9) + .with_mosi(p.GPIO10) + .with_miso(p.GPIO11) + .with_dma(dma_ch); + + let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256); + let dma_rx = esp_hal::dma::DmaRxBuf::new(rx_desc, rx_buf).unwrap(); + let dma_tx = esp_hal::dma::DmaTxBuf::new(tx_desc, tx_buf).unwrap(); + + let spi = spi.with_buffers(dma_rx, dma_tx).into_async(); + + static SPI_BUS: StaticCell> = + StaticCell::new(); + let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi)); + + let nss = Output::new(p.GPIO8, Level::High); + let spi_device = SpiDevice::new(spi_bus, nss); + + // ── SX1262 control pins ──────────────────────────────────── + let reset = Output::new(p.GPIO12, Level::High); + let dio1 = Input::new(p.GPIO14, Pull::Down); + let busy = Input::new(p.GPIO13, Pull::Down); + + let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).unwrap(); + + let sx_config = sx126x::Config { + chip: Sx1262, + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7), + use_dcdc: true, + rx_boost: false, + }; + + let radio = RadioParts { + driver: Sx126x::new(spi_device, iv, sx_config), + delay: Delay, + }; + + // ── USB (native ESP32-S3 OTG) ───────────────────────────── + let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19); + static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); + let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]); + let usb = UsbParts { + driver: esp_hal::otg_fs::asynch::Driver::new( + usb_inst, + ep_out_buf, + esp_hal::otg_fs::asynch::Config::default(), + ), + }; + + // ── Display (optional SSD1315 OLED on I2C, 0x3C) ────────── + // Reset display controller via GPIO21 + let mut display_rst = Output::new(p.GPIO21, Level::Low); + display_rst.set_high(); + + // Always provide I2C; display_task detects presence via SSD1306 init. + let i2c = I2c::new(p.I2C0, I2cConfig::default()) + .with_sda(p.GPIO17) + .with_scl(p.GPIO18) + .into_async(); + let display = Some(DisplayParts { i2c }); + + (radio, usb, display) + } +} diff --git a/src/board/rak_4631.rs b/src/board/rak_4631.rs index e8f9fef7..0a7c43e3 100644 --- a/src/board/rak_4631.rs +++ b/src/board/rak_4631.rs @@ -103,7 +103,8 @@ impl Board { }; // ── Display (optional RAK1921 SSD1306 OLED on I2C) ────── - // TODO: probe I2C address 0x3C to detect presence + // Always provide the I2C bus; display_task detects presence + // via SSD1306 init (fails gracefully if no display attached). let i2c_cfg = twim::Config::default(); let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg); let display = Some(DisplayParts { i2c }); diff --git a/src/main.rs b/src/main.rs index 323163a2..71b08305 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, Sta cfg_if::cfg_if! { if #[cfg(feature = "rak_4631")] { use panic_probe as _; - } else if #[cfg(feature = "heltec_v3")] { + } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] { use esp_backtrace as _; } } @@ -25,8 +25,22 @@ static RESPONSES: ResponseChannel = ResponseChannel::new(); static STATUS: StatusWatch = StatusWatch::new(); static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new(); -#[embassy_executor::main] -async fn main(spawner: Spawner) { +// Entry point: each platform needs its own executor macro. +cfg_if::cfg_if! { + if #[cfg(feature = "rak_4631")] { + #[embassy_executor::main] + async fn main(spawner: Spawner) { + run(spawner).await; + } + } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] { + #[esp_hal_embassy::main] + async fn main(spawner: Spawner) { + run(spawner).await; + } + } +} + +async fn run(spawner: Spawner) { let board = board::Board::init(); let (radio, usb, display) = board.into_parts();