feat(boards): LilyGo T-Beam (classic) support

First classic-ESP32 (non-S3) board in the firmware, and first SX127x
radio. ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC, CP2102 USB-UART
bridge. Pin map verified from MeshCore variants/lilygo_tbeam_SX1276.

The AXP192 gates power to the LoRa chip and OLED. Its I²C bus is
shared with the display, so the firmware builds I²C0 in blocking mode
first for the PMIC init, then converts the same handle to async and
hands it to the display task — avoids double-consuming the peripheral.

Lora-phy's Sx127x support drives DIO0 as the sole IRQ (SX127x has no
BUSY line); the chip runs from a crystal, tx_boost routes +20 dBm
through PA_BOOST.

The `esp-hal` / `esp-rtos` / `esp-backtrace` / `esp-println` deps have
been restructured so the chip flavour (esp32 vs esp32s3) is selected
at the per-board feature level instead of baked into the dep lines.
This keeps the existing S3 boards building on esp32s3 while the new
T-Beam feature selects classic esp32 via the same dependency set.

LoRaBoard gains supported_sf_bitmap() / supported_bw_bitmap() methods
(with SX126x defaults) so the T-Beam can report SF6–SF12 via GET_INFO
— the SX1276 has no SF5. radio_chip_id() reports 0x0011 per
donglora-protocol RadioChipId::Sx1276. freq_range_hz widens to the
SX1276 datasheet span of 137 MHz–1020 MHz.

New modules:
- src/hal/esp32.rs           — classic-ESP32 MCU primitives
- src/board/esp32.rs         — Sx127x radio + SSD1306 display pipe
- src/board/lilygo_tbeam.rs  — the board file
- src/driver/axp.rs          — AXP192 init added alongside AXP2101
This commit is contained in:
swaits committed 2026-04-23 10:42:42 -06:00
1 parent 7fca5fcb50
commit a1b92356b3
15 files changed
+534 -43

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
//! Shared types and peripheral wiring for classic-ESP32 SX1276 boards.
//!
//! Counterpart to `board::esp32s3` for the SX126x line. Builds an
//! `Sx127x<Sx1276>` driver from an already-initialized SPI bus plus the
//! board's reset and DIO0 pins. SX127x has no `BUSY` line — DIO0 carries
//! every IRQ (TX done, RX done, header valid, …) and lora-phy's
//! `GenericSx127xInterfaceVariant` waits on it directly.
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
use lora_phy::iv::GenericSx127xInterfaceVariant;
use lora_phy::sx127x::{self, Sx127x};
use crate::hal::esp32 as mcu;
// ── Re-export MCU types ─────────────────────────────────────────────
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx127xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx127x<RadioSpiDevice, Iv, sx127x::Sx1276>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display driver ──────────────────────────────────────────────────
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
/// Construct and initialize an SSD1306 display from raw I²C.
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 display init failed");
return None;
}
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
Some(display)
}
// ── Shared peripheral init helpers ──────────────────────────────────
/// Construct an SX1276 radio from an initialized SPI bus.
///
/// `dio0` is the primary IRQ (TX/RX done). The SX1276 module on the T-Beam
/// runs from a crystal oscillator, not a TCXO, and power comes from the
/// AXP192's PA_BOOST rail — `tx_boost = true` routes TX through the PA
/// output for +20 dBm.
pub fn init_radio(
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
nss_pin: impl OutputPin + 'static,
reset_pin: impl OutputPin + 'static,
dio0_pin: impl InputPin + 'static,
) -> RadioParts {
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(reset_pin, Level::High, OutputConfig::default());
let dio0 = Input::new(dio0_pin, InputConfig::default().with_pull(Pull::Down));
let iv = GenericSx127xInterfaceVariant::new(reset, dio0, None, None)
.expect("SX1276 interface init");
let sx_config = sx127x::Config {
chip: sx127x::Sx1276,
tcxo_used: false,
tx_boost: true,
rx_boost: false,
};
RadioParts {
driver: Sx127x::new(spi_device, iv, sx_config),
delay: Delay,
}
}
+137
View File
@@ -0,0 +1,137 @@
//! LilyGo T-Beam (classic) — ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC.
//!
//! First-generation T-Beam. ESP32 (dual-core Xtensa LX6, no native USB),
//! SX1276 module with XTAL (no TCXO), 128×64 SSD1306 OLED, AXP192 PMIC
//! gating the LoRa and OLED rails, CP2102 USB-UART bridge for host I/O.
//!
//! The AXP192 shares an I²C bus with the OLED (SDA=21, SCL=22). PMIC init
//! runs blocking on that bus first, then the same pins are re-initialized
//! in async mode for the display task.
//!
//! Pin map (verified from MeshCore variants/lilygo_tbeam_SX1276/):
//! LoRa SPI: SCK=5, MOSI=27, MISO=19, CS=18
//! LoRa ctl: RESET=23, DIO0=26 (primary IRQ — SX127x has no BUSY)
//! OLED I²C: SDA=21, SCL=22, SSD1306 @ 0x3C (shared with AXP192 @ 0x34)
//! UART host: TX=1, RX=3 via CP2102 bridge (USB 10c4:ea60)
//! LED: GPIO4 (active HIGH — reuses MeshCore's P_LORA_TX_LED)
//!
//! GPS / battery / IMU / PCF8563 RTC are populated on the board but unused
//! by this firmware.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use super::esp32;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::axp;
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32 as mcu;
use crate::protocol::RadioChipId;
pub use super::esp32::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
// ── Concrete peripheral types ───────────────────────────────────────
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
pub type LedDriver = SimpleLed<Output<'static>>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct UartParts {
pub driver: UartDriver,
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "LilyGo T-Beam";
// SX1276 PA_BOOST range per the datasheet: +2 dBm min, +20 dBm max.
const TX_POWER_RANGE: (i8, i8) = (2, 20);
type RadioParts = RadioParts;
type CommParts = UartParts;
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 radio_chip_id() -> u16 {
RadioChipId::Sx1276.as_u16()
}
fn freq_range_hz() -> (u32, u32) {
// SX1276 tuning window per datasheet (LF + HF bands combined).
(137_000_000, 1_020_000_000)
}
fn supported_sf_bitmap() -> u16 {
// SX127x supports SF6–SF12 (no SF5 like the SX126x).
0x1FC0
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// AXP192 and SSD1306 share I²C0 (SDA=21, SCL=22). Build the bus
// once as blocking, drive the PMIC init, then convert to async for
// the display task — avoids re-consuming the I²C0 peripheral.
// PMIC failure isn't fatal to boot: log and proceed so the rest of
// the firmware can still emit diagnostics.
let mut i2c_blocking = I2c::new(p.I2C0, I2cConfig::default())
.expect("I2C0 init")
.with_sda(p.GPIO21)
.with_scl(p.GPIO22);
if let Err(_e) = axp::axp192_init_lora_oled(&mut i2c_blocking) {
defmt::error!("AXP192 init failed — LoRa rail may be dead");
}
// Let DCDC1 / LDO2 settle before SPI or display traffic.
esp_hal::delay::Delay::new().delay_millis(20);
// SPI2 for SX1276: SCK=5, MOSI=27, MISO=19
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_SPI2, p.GPIO5, p.GPIO27, p.GPIO19);
// SX1276: CS=18, RESET=23, DIO0=26 (primary IRQ)
let radio = esp32::init_radio(spi_bus, p.GPIO18, p.GPIO23, p.GPIO26);
// UART0 via CP2102 bridge: TX=1, RX=3 (ESP32 default console)
use esp_hal::uart::{Config as UartConfig, Uart};
let host = UartParts {
driver: Uart::new(p.UART0, UartConfig::default())
.expect("UART init")
.with_tx(p.GPIO1)
.with_rx(p.GPIO3)
.into_async(),
};
// Hand the (now async) I²C bus to the SSD1306 display task.
let display = Some(DisplayParts {
i2c: i2c_blocking.into_async(),
});
// TX activity LED on GPIO4 (active HIGH).
let led_pin = Output::new(p.GPIO4, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
+4
View File
@@ -3,7 +3,11 @@ pub mod traits;
pub use traits::*;
{% for helper, helper_features in helpers|items -%}
{% if helper_features|length == 1 -%}
#[cfg(feature = "{{ helper_features[0] }}")]
{% else -%}
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
{% endif -%}
mod {{ helper }};
{% endfor %}
cfg_if::cfg_if! {
+15
View File
@@ -65,6 +65,21 @@ pub trait LoRaBoard: Sized {
(150_000_000, 960_000_000)
}
/// Bitmap of supported LoRa spreading factors — bit N = SF N. Defaults
/// to SX126x's SF5–SF12 (`0x1FE0`). SX127x boards must override to
/// `0x1FC0` (SF6–SF12); LLCC68 boards to `0x0FE0` (SF5–SF11).
fn supported_sf_bitmap() -> u16 {
0x1FE0
}
/// Bitmap of supported LoRa bandwidths. Bit positions match
/// `LoRaBandwidth::as_u8()` (sub-GHz BW enum values 0..9). Defaults
/// to all sub-GHz BWs (`0x03FF`). SX128x boards flip the 2.4 GHz
/// bits when we add them.
fn supported_bw_bitmap() -> u16 {
0x03FF
}
/// Decompose initialized board into peripheral bundles for each task.
fn into_parts(
self,