feat: native USB CDC-ACM support for Heltec V3

The default heltec_v3 feature now uses native USB OTG (same as V4),
appearing as /dev/ttyACM0 with the DongLoRa VID:PID (1209:5741).
This requires a hardware mod: solder R29/R3 and disconnect the CP2102
from the USB data lines.

Stock (unmodified) V3 boards should use the new heltec_v3_uart feature
which preserves the original UART-over-CP2102 behavior (/dev/ttyUSB0).

- heltec_v3: switched from UART to USB OTG
- heltec_v3_uart: new feature, UART fallback for stock boards
- Removed #[cfg] gates on UsbParts/UartParts (both always defined)
- Updated README with hardware mod instructions and board table
- Updated justfile with heltec_v3_uart board definition
This commit is contained in:
swaits committed 2026-04-07 10:44:25 -06:00
1 parent 07651dce44
commit 6db287c241
9 files changed
+142 -20

No files matched your search

+2 -2
View File
@@ -32,12 +32,12 @@ pub struct RadioParts {
pub delay: Delay,
}
#[cfg(not(feature = "heltec_v3"))]
#[allow(dead_code)] // Not used by heltec_v3_uart
pub struct UsbParts {
pub driver: UsbDriver,
}
#[cfg(feature = "heltec_v3")]
#[allow(dead_code)] // Only used by heltec_v3_uart
pub struct UartParts {
pub driver: UartDriver,
}
+17 -6
View File
@@ -1,3 +1,11 @@
//! Heltec WiFi LoRa 32 V3 — native USB (requires hardware mod).
//!
//! Stock V3 boards route USB through a CP2102 bridge to UART. To use native
//! USB CDC-ACM, solder R29 (D+) and R3 (D-) on the PCB and disconnect the
//! CP2102 from the USB data lines. See the project README for details.
//!
//! For stock (unmodified) V3 boards, use the `heltec_v3_uart` feature instead.
use esp_hal::gpio::{Level, Output, OutputConfig};
use super::esp32s3;
@@ -6,8 +14,8 @@ use crate::hal::esp32s3 as mcu;
#[allow(unused_imports)] // Re-exported for other modules (display, radio, host tasks)
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayI2c, DisplayParts, RadioDriver, RadioParts, UartDriver,
UartParts,
create_display, DisplayDriver, DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver,
UsbParts,
};
pub struct Board {
@@ -19,7 +27,7 @@ impl LoRaBoard for Board {
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UartParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
@@ -32,7 +40,7 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts> {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
let p = self.p;
mcu::start_timer(p.TIMG0);
@@ -43,8 +51,11 @@ impl LoRaBoard for Board {
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
let host = UartParts {
driver: mcu::init_uart(p.UART0, p.GPIO43, p.GPIO44),
// Note: switches internal USB PHY from Serial-JTAG to OTG.
// espflash --monitor will stop working after this point.
let host = UsbParts {
driver: mcu::init_usb(p.USB0, p.GPIO20, p.GPIO19),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
+75
View File
@@ -0,0 +1,75 @@
//! Heltec WiFi LoRa 32 V3 — stock UART (via CP2102 bridge).
//!
//! This is for unmodified V3 boards where USB goes through the CP2102
//! bridge to UART0. The board appears as /dev/ttyUSB* (not ttyACM*).
//!
//! If you've done the hardware mod (solder R29/R3, disconnect CP2102),
//! use the `heltec_v3` feature instead for native USB CDC-ACM support.
use esp_hal::gpio::{Level, Output, OutputConfig};
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::hal::esp32s3 as mcu;
#[allow(unused_imports)] // Re-exported for other modules (display, radio, host tasks)
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayI2c, DisplayParts, RadioDriver, RadioParts, UartDriver,
UartParts,
};
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Heltec V3 (UART)";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UartParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts> {
let p = self.p;
mcu::start_timer(p.TIMG0);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
let host = UartParts {
driver: mcu::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);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
BoardParts {
radio,
host,
display,
mac: Self::mac_address(),
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
//! Each submodule provides low-level bus and peripheral init for one MCU family.
//! Board files call these with specific pins and construct higher-level drivers.
#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))]
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
pub mod esp32s3;
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
+1 -1
View File
@@ -6,7 +6,7 @@
pub mod framing;
cfg_if::cfg_if! {
if #[cfg(feature = "heltec_v3")] {
if #[cfg(feature = "heltec_v3_uart")] {
mod uart;
pub use uart::host_task;
} else {
+2 -2
View File
@@ -44,7 +44,7 @@ cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
use defmt_rtt as _;
use panic_probe as _;
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
use esp_backtrace as _;
use esp_println as _;
}
@@ -66,7 +66,7 @@ cfg_if::cfg_if! {
async fn main(spawner: Spawner) {
run(spawner).await;
}
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
#[esp_rtos::main]
async fn main(spawner: Spawner) {
run(spawner).await;