feat(boards): LilyGo T-Beam S3 Supreme support

ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC, native USB-C.

The AXP2101 PMIC gates power to the LoRa chip and the OLED. Without
its init sequence (ALDO3 = 3.3 V for LoRa, ALDO1 = 3.3 V for OLED)
the SX1262 never responds on SPI and radio init fails.

Pin map verified against MeshCore's variants/lilygo_tbeam_supreme_SX1262.
PMIC lives on a dedicated I²C1 bus (SDA=42, SCL=41) separate from the
SH1106 on I²C0 (SDA=17, SCL=18); I²C1 is used blocking for a one-shot
init and then dropped. TCXO at 1.6 V via DIO3.

New src/driver/axp.rs holds the AXP2101 init sequence — kept minimal
(only the LoRa + OLED rails), since DongLoRa is a LoRa-over-USB bridge
and every other rail (GPS, BME280, IMU, SD, batt ADC) is out of scope.
This commit is contained in:
swaits committed 2026-04-23 10:29:48 -06:00
1 parent 6867439044
commit 7fca5fcb50
9 files changed
+260 -6

No files matched your search

+14
View File
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- LilyGo T-Beam S3 Supreme board support (ESP32-S3 + SX1262 + SH1106
OLED + AXP2101 PMIC, native USB-C).
- Pin map verified against MeshCore's
`variants/lilygo_tbeam_supreme_SX1262`.
- New `src/driver/axp.rs` module with an AXP2101 init sequence
(ALDO3 = 3.3 V LoRa, ALDO1 = 3.3 V OLED). The PMIC gates LoRa and
display power — without it the SX1262 never responds on SPI.
- PMIC lives on a dedicated I²C1 bus (SDA=42, SCL=41), separate
from the SH1106 on I²C0 (SDA=17, SCL=18). I²C1 is used blocking
for a one-shot init and then dropped.
- TCXO driven at 1.6 V via DIO3 (MeshCore uses the same).
## [1.2.1] - 2026-04-23 ## [1.2.1] - 2026-04-23
### Fixed ### Fixed
+7
View File
@@ -132,6 +132,13 @@ elecrow_thinknode_m2 = [
"dep:esp-println", "dep:esp-println",
"dep:esp-rtos", "dep:esp-rtos",
] ]
lilygo_tbeam_supreme = [
"embassy",
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
rak_wisblock_4631 = [ rak_wisblock_4631 = [
"embassy", "embassy",
"dep:cortex-m", "dep:cortex-m",
+1
View File
@@ -24,6 +24,7 @@ All commands are run from the `firmware/` directory.
| Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf | | Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf | | Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf | | ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
| LilyGo T-Beam S3 Supreme | `lilygo_tbeam_supreme` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf | | RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
| Waveshare RP2040-LoRa | `waveshare_rp2040_lora` | RP2040 | SX1262 | Native CDC-ACM | thumbv6m-none-eabi | | Waveshare RP2040-LoRa | `waveshare_rp2040_lora` | RP2040 | SX1262 | Native CDC-ACM | thumbv6m-none-eabi |
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf | | Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
+3 -1
View File
@@ -10,6 +10,7 @@ heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
heltec_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3" heltec_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3" heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
elecrow_thinknode_m2 := "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3" elecrow_thinknode_m2 := "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3"
lilygo_tbeam_supreme := "lilygo_tbeam_supreme xtensa-esp32s3-none-elf esp32s3"
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA" rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA" wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
waveshare_rp2040_lora := "waveshare_rp2040_lora thumbv6m-none-eabi rp2040" waveshare_rp2040_lora := "waveshare_rp2040_lora thumbv6m-none-eabi rp2040"
@@ -18,7 +19,7 @@ builds_dir := "builds"
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml` version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
# All known boards # All known boards
boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1" boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
# Install all required tools and toolchains # Install all required tools and toolchains
setup: setup:
@@ -214,6 +215,7 @@ _info name:
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \ elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \ elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
elif [ "{{name}}" == "elecrow_thinknode_m2" ]; then echo "{{elecrow_thinknode_m2}}"; \ elif [ "{{name}}" == "elecrow_thinknode_m2" ]; then echo "{{elecrow_thinknode_m2}}"; \
elif [ "{{name}}" == "lilygo_tbeam_supreme" ]; then echo "{{lilygo_tbeam_supreme}}"; \
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \ elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
elif [ "{{name}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \ elif [ "{{name}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \ elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
+153
View File
@@ -0,0 +1,153 @@
//! LilyGo T-Beam S3 Supreme — ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC.
//!
//! Big-brother of the classic T-Beam: ESP32-S3-WROOM-1 module, SX1262 with a
//! TCXO, 128×64 SH1106 OLED, AXP2101 PMIC on a dedicated I²C1 bus. Native USB
//! (no UART bridge chip).
//!
//! The AXP2101 gates power to the SX1262 and OLED. Without its init sequence
//! the LoRa chip never responds on SPI and `LoRa::new` fails.
//!
//! Pin map (verified from MeshCore variants/lilygo_tbeam_supreme_SX1262/):
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
//! LoRa ctl: RESET=5, DIO1=1, BUSY=4 (TCXO @ 1.6 V via DIO3)
//! OLED I²C: SDA=17, SCL=18, SH1106 @ 0x3C (I²C0)
//! PMIC I²C: SDA=42, SCL=41, AXP2101 @ 0x34 (I²C1, blocking, PMIC-only)
//! USB: native USB-C on GPIO19/20 (fixed ESP32-S3 D+/D-)
//! LED: GPIO6 (active HIGH indicator, reuses MeshCore's P_LORA_TX_LED)
//!
//! GPS / battery / IMU / SD / BME280 are unused — DongLoRa is a transparent
//! LoRa-over-USB bridge. The PMIC init only enables ALDO1 (OLED) and ALDO3
//! (LoRa); other rails are left at their AXP2101 power-on defaults.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::axp;
use crate::driver::sh1106::Sh1106;
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
// ── Concrete peripheral types ───────────────────────────────────────
pub type DisplayDriver = Sh1106<DisplayI2c>;
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
let mut display = Sh1106::new(i2c, 0x3C);
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SH1106 display init failed");
return None;
}
let _ = display.set_brightness(0xFF).await;
Some(display)
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "LilyGo T-Beam S3 Supreme";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
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, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// AXP2101 PMIC on I²C1 (SDA=42, SCL=41). Blocking, one-shot: we only
// need to flip the LoRa/OLED rails on, then the bus can be dropped.
// If this fails the LoRa chip stays unpowered — log and keep going so
// the rest of the boot path can still produce diagnostics.
{
let mut pmic = I2c::new(p.I2C1, I2cConfig::default())
.expect("I2C1 init")
.with_sda(p.GPIO42)
.with_scl(p.GPIO41);
if let Err(_e) = axp::axp2101_init_lora_oled(&mut pmic) {
defmt::error!("AXP2101 init failed — LoRa rail may be dead");
}
}
// Let ALDO1/ALDO3 settle before touching the LoRa SPI or OLED I²C.
esp_hal::delay::Delay::new().delay_millis(20);
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
// SX1262: CS=10, RESET=5, DIO1=1, BUSY=4, TCXO @ 1.6 V via DIO3.
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO10,
p.GPIO5,
p.GPIO1,
p.GPIO4,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V6,
);
// Native USB-C on GPIO19/20. Switches the internal USB PHY from
// Serial-JTAG to 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 host = UsbParts {
driver: esp_hal::otg_fs::asynch::Driver::new(
usb_inst,
ep_out_buf,
esp_hal::otg_fs::asynch::Config::default(),
),
};
// I2C0 for SH1106 OLED: SDA=17, SCL=18 (async, handed to display task).
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// Activity LED on GPIO6 (active HIGH).
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//! AXP PMIC init — turn on the rails that feed LoRa and the OLED.
//!
//! T-Beam–class boards gate LoRa, OLED (and GPS/sensors, which this firmware
//! does not use) through an X-Powers PMIC on I²C. Until the PMIC is told to
//! enable the right LDOs/DCDCs, the SX126x/SX127x chip sits unpowered and
//! `LoRa::new` fails. This module's only job is to flip those switches.
//!
//! Two PMICs are covered:
//!
//! * [`axp2101_init_lora_oled`] — LilyGo T-Beam S3 Supreme. ALDO3 = 3.3 V
//! feeds the SX1262; ALDO1 = 3.3 V feeds the SH1106 OLED. Both live on a
//! dedicated I²C1 bus (separate from the display).
//! * [`axp192_init_lora_oled`] — LilyGo T-Beam (classic). LDO2 = 3.3 V feeds
//! the SX1276; DCDC1 = 3.3 V feeds the SSD1306 OLED. PMIC shares the
//! display I²C bus.
//!
//! Everything unrelated to LoRa + OLED (GPS, sensors, SD, batt ADC) is left
//! alone. Out-of-scope for a transparent LoRa-over-USB bridge.
//!
//! Init is blocking on purpose. It runs once during `into_parts`, which is a
//! synchronous function called before task spawning. Using the blocking
//! `embedded_hal` trait keeps the PMIC wiring out of the async machinery and
//! lets the caller drop the I²C bus immediately after init (no bus handoff).
use embedded_hal::i2c::I2c;
/// Both chips respond at this 7-bit I²C address.
const PMIC_ADDR: u8 = 0x34;
// ── AXP2101 (T-Beam S3 Supreme) ──────────────────────────────────────
/// LDO on/off control register. Bits:
/// 0 = ALDO1, 1 = ALDO2, 2 = ALDO3, 3 = ALDO4, 4 = BLDO1, 5 = BLDO2.
const AXP2101_REG_LDO_ONOFF: u8 = 0x90;
/// ALDO1 voltage register (OLED rail on T-Beam Supreme).
const AXP2101_REG_ALDO1_V: u8 = 0x92;
/// ALDO3 voltage register (LoRa rail on T-Beam Supreme).
const AXP2101_REG_ALDO3_V: u8 = 0x94;
/// Voltage code for 3.3 V on ALDOx: 0.5 V + 28 × 100 mV.
const AXP2101_V_3V3: u8 = 28;
/// Mask enabling ALDO1 + ALDO3 in [`AXP2101_REG_LDO_ONOFF`].
const AXP2101_ALDO1_ALDO3: u8 = 0b0000_0101;
/// Bring up LoRa (ALDO3) and OLED (ALDO1) rails at 3.3 V.
///
/// Voltage registers are set before the enable bits to avoid glitching the
/// rails through the reset-default voltage. Other rails are left untouched
/// (read-modify-write on the enable register) so any board-specific OTP
/// defaults stay intact.
pub fn axp2101_init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
i2c.write(PMIC_ADDR, &[AXP2101_REG_ALDO3_V, AXP2101_V_3V3])?;
i2c.write(PMIC_ADDR, &[AXP2101_REG_ALDO1_V, AXP2101_V_3V3])?;
let mut onoff = [0u8; 1];
i2c.write_read(PMIC_ADDR, &[AXP2101_REG_LDO_ONOFF], &mut onoff)?;
i2c.write(
PMIC_ADDR,
&[AXP2101_REG_LDO_ONOFF, onoff[0] | AXP2101_ALDO1_ALDO3],
)?;
Ok(())
}
+14 -2
View File
@@ -1,11 +1,19 @@
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))] #[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
))]
pub mod sh1106; pub mod sh1106;
#[cfg(feature = "lilygo_tbeam_supreme")]
pub mod axp;
#[cfg(any( #[cfg(any(
feature = "heltec_v3", feature = "heltec_v3",
feature = "heltec_v3_uart", feature = "heltec_v3_uart",
feature = "heltec_v4", feature = "heltec_v4",
feature = "elecrow_thinknode_m2", feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme",
feature = "wio_tracker_l1", feature = "wio_tracker_l1",
feature = "waveshare_rp2040_lora" feature = "waveshare_rp2040_lora"
))] ))]
@@ -51,7 +59,11 @@ where
} }
} }
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))] #[cfg(any(
feature = "wio_tracker_l1",
feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
))]
impl<I> DisplayBrightness for sh1106::Sh1106<I> impl<I> DisplayBrightness for sh1106::Sh1106<I>
where where
I: embedded_hal_async::i2c::I2c, I: embedded_hal_async::i2c::I2c,
+2 -1
View File
@@ -7,7 +7,8 @@
feature = "heltec_v3", feature = "heltec_v3",
feature = "heltec_v3_uart", feature = "heltec_v3_uart",
feature = "heltec_v4", feature = "heltec_v4",
feature = "elecrow_thinknode_m2" feature = "elecrow_thinknode_m2",
feature = "lilygo_tbeam_supreme"
))] ))]
pub mod esp32s3; pub mod esp32s3;
+2 -2
View File
@@ -48,7 +48,7 @@ cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] { if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
use defmt_rtt as _; use defmt_rtt as _;
use panic_probe as _; use panic_probe as _;
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] { } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme"))] {
use esp_backtrace as _; use esp_backtrace as _;
use esp_println as _; use esp_println as _;
} }
@@ -72,7 +72,7 @@ cfg_if::cfg_if! {
async fn main(spawner: Spawner) { async fn main(spawner: Spawner) {
run(spawner).await; run(spawner).await;
} }
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] { } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme"))] {
#[esp_rtos::main] #[esp_rtos::main]
async fn main(spawner: Spawner) { async fn main(spawner: Spawner) {
run(spawner).await; run(spawner).await;