diff --git a/Cargo.lock b/Cargo.lock index 5e7b695..bdc0fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,6 +608,7 @@ dependencies = [ "embedded-graphics", "embedded-hal 1.0.0", "embedded-hal-async", + "embedded-hal-bus", "embedded-io-async 0.7.0", "esp-backtrace", "esp-bootloader-esp-idf", @@ -616,6 +617,7 @@ dependencies = [ "esp-rtos", "heapless 0.9.2", "hsmc", + "lcd-async", "lora-phy", "minijinja", "owo-colors", @@ -986,6 +988,17 @@ dependencies = [ "embedded-hal 1.0.0", ] +[[package]] +name = "embedded-hal-bus" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "embedded-hal-async", +] + [[package]] name = "embedded-hal-nb" version = "1.0.0" @@ -1744,6 +1757,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lcd-async" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359805d2afdff45f3abad226ae27a61ee980309562d14084842c542af29b422a" +dependencies = [ + "embedded-graphics", + "embedded-graphics-core", + "embedded-hal 1.0.0", + "embedded-hal-async", +] + [[package]] name = "libc" version = "0.2.186" diff --git a/Cargo.toml b/Cargo.toml index ec2d086..6af25a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,21 @@ static_cell = "2.1" display-interface = "0.5" embedded-graphics = "0.8" ssd1306 = { version = "0.10", features = ["async"] } +# T114 ST7789 TFT (color, async SPI). Optional — only pulled in by the +# heltec_mesh_node_t114 feature. Mono OLED boards keep their existing +# display-interface + ssd1306/sh1106 stack and don't pay for these. +# +# `lcd-async` is an async fork of mipidsi: same model/builder shape but +# `init()` and pixel writes are `async fn`s. Combined with embassy-nrf's +# DMA-backed async Spim, the dashboard repaint yields the executor for +# the duration of the SPI transfer instead of busy-looping for ~65 ms, +# so the radio task isn't starved. +# +# embedded-hal-bus needs the `async` feature for `ExclusiveDevice` to +# implement `embedded_hal_async::spi::SpiDevice` (in addition to the +# blocking impl). +lcd-async = { version = "0.1", optional = true } +embedded-hal-bus = { version = "0.3", optional = true, features = ["async"] } # Async IO traits and combinators embedded-io-async = "0.7" @@ -252,6 +267,20 @@ wio_tracker_l1 = [ "embassy-executor/platform-cortex-m", "embassy-executor/executor-thread", ] +heltec_mesh_node_t114 = [ + "embassy", + "trace-defmt", + "dep:cortex-m", + "dep:cortex-m-rt", + "cortex-m-rt/set-vtor", + "dep:defmt-rtt", + "dep:embassy-nrf", + "dep:panic-probe", + "dep:lcd-async", + "dep:embedded-hal-bus", + "embassy-executor/platform-cortex-m", + "embassy-executor/executor-thread", +] waveshare_rp2040_lora = [ "embassy", "trace-defmt", diff --git a/README.md b/README.md index de38a1d..5eb1364 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ All commands are run from the `firmware/` directory. | Board | Feature | MCU | Radio | USB | Target | |-------|---------|-----|-------|-----|--------| +| Heltec Mesh Node T114 | `heltec_mesh_node_t114` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf | | Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | 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 | diff --git a/justfile b/justfile index c40e935..475d4e2 100644 --- a/justfile +++ b/justfile @@ -13,7 +13,7 @@ version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml` # Xtensa targets are built with `+esp` + `-Zbuild-std=core,alloc` (the # self-contained esp toolchain ships its own rust-src; the public # nightly drifts ahead and breaks core-stdlib compilation). -boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1" +boards := "heltec_mesh_node_t114 heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1" # Install all required tools and toolchains setup: @@ -246,6 +246,7 @@ _flash_uf2 board: [private] _info name: @case "{{name}}" in \ + heltec_mesh_node_t114) echo "heltec_mesh_node_t114 thumbv7em-none-eabihf nRF52840_xxAA" ;; \ heltec_v3) echo "heltec_v3 xtensa-esp32s3-none-elf esp32s3" ;; \ heltec_v3_uart) echo "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3" ;; \ heltec_v4) echo "heltec_v4 xtensa-esp32s3-none-elf esp32s3" ;; \ diff --git a/src/board/PORTING.md b/src/board/PORTING.md index be816b9..0eb4e24 100644 --- a/src/board/PORTING.md +++ b/src/board/PORTING.md @@ -70,8 +70,10 @@ impl LoRaBoard for Board { ### 5. Add display init ```rust -pub async fn create_display(i2c: DisplayI2c) -> Option { - // Construct and initialize your display driver (SSD1306, SH1106, etc.) +pub async fn create_display(parts: DisplayParts) -> Option { + // Construct and initialize your display driver (SSD1306, SH1106, + // ST7789, etc.) using whatever pins/buses you bundled into + // DisplayParts in `into_parts()`. } ``` diff --git a/src/board/elecrow_thinknode_m2.rs b/src/board/elecrow_thinknode_m2.rs index 85edc3b..c611cda 100644 --- a/src/board/elecrow_thinknode_m2.rs +++ b/src/board/elecrow_thinknode_m2.rs @@ -48,8 +48,8 @@ pub struct DisplayParts { // ── Display init ──────────────────────────────────────────────────── -pub async fn create_display(i2c: DisplayI2c) -> Option { - let mut display = Sh1106::new(i2c, 0x3C); +pub async fn create_display(parts: DisplayParts) -> Option { + let mut display = Sh1106::new(parts.i2c, 0x3C); embassy_time::Timer::after_millis(100).await; if display.init().await.is_err() { diff --git a/src/board/esp32.rs b/src/board/esp32.rs index 8c2c221..54247dd 100644 --- a/src/board/esp32.rs +++ b/src/board/esp32.rs @@ -47,13 +47,13 @@ pub type DisplayDriver = ssd1306::Ssd1306Async< >; /// Construct and initialize an SSD1306 display from raw I²C. -pub async fn create_display(i2c: DisplayI2c) -> Option { +pub async fn create_display(parts: DisplayParts) -> Option { use ssd1306::mode::DisplayConfigAsync; use ssd1306::prelude::{Brightness, DisplayRotation}; use ssd1306::size::DisplaySize128x64; use ssd1306::{I2CDisplayInterface, Ssd1306Async}; - let interface = I2CDisplayInterface::new(i2c); + let interface = I2CDisplayInterface::new(parts.i2c); let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); diff --git a/src/board/esp32s3.rs b/src/board/esp32s3.rs index 2b1bc3d..596efce 100644 --- a/src/board/esp32s3.rs +++ b/src/board/esp32s3.rs @@ -63,13 +63,13 @@ pub type DisplayDriver = ssd1306::Ssd1306Async< feature = "heltec_v3_uart", feature = "heltec_v4" ))] -pub async fn create_display(i2c: DisplayI2c) -> Option { +pub async fn create_display(parts: DisplayParts) -> Option { use ssd1306::mode::DisplayConfigAsync; use ssd1306::prelude::{Brightness, DisplayRotation}; use ssd1306::size::DisplaySize128x64; use ssd1306::{I2CDisplayInterface, Ssd1306Async}; - let interface = I2CDisplayInterface::new(i2c); + let interface = I2CDisplayInterface::new(parts.i2c); let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); diff --git a/src/board/heltec_mesh_node_t114.rs b/src/board/heltec_mesh_node_t114.rs new file mode 100644 index 0000000..d9c218d --- /dev/null +++ b/src/board/heltec_mesh_node_t114.rs @@ -0,0 +1,285 @@ +//! Heltec Mesh Node T114 (nRF52840 + SX1262 + 1.14" ST7789 TFT). +//! +//! Hardware reference: cross-verified across MeshCore, Meshtastic, and +//! RNode firmwares. Pin maps quoted from +//! `meshcore-spec/references/MeshCore/variants/heltec_t114/variant.h` and +//! `meshtastic/firmware/variants/nrf52840/heltec_mesh_node_t114/variant.h`. +//! +//! Stage 2: panel comes up with static colors via the `St7789Color` +//! driver. Stage 3 swaps in the proper color renderer module. + +use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice; +use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull}; +use embassy_nrf::pwm::{Prescaler, SimpleConfig, SimplePwm}; +use embassy_nrf::spim::{self, Spim}; +use embassy_nrf::usb::Driver; +use embassy_sync::blocking_mutex::raw::NoopRawMutex; +use embassy_time::{Delay, Timer}; + +use embedded_hal_bus::spi::ExclusiveDevice; +use lcd_async::interface::SpiInterface; +use lcd_async::models::ST7789; +use lcd_async::options::{ColorInversion, Orientation, Rotation}; +use lora_phy::iv::GenericSx126xInterfaceVariant; +use lora_phy::sx126x::{self, Sx1262, Sx126x}; +use static_cell::ConstStaticCell; + +use super::traits::{BoardParts, LoRaBoard}; +use crate::driver::inverted_pin::InvertedPin; +use crate::driver::simple_led::SimpleLed; +use crate::driver::st7789::{St7789Color, FB_BYTES}; +use crate::hal::nrf52840 as mcu; + +// ── Concrete peripheral types ─────────────────────────────────────── + +type Nss = Output<'static>; +type Iv = GenericSx126xInterfaceVariant, Input<'static>>; +type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>; +pub type RadioDriver = Sx126x; +pub type UsbDriver = mcu::UsbNrfDriver; +// Green LED on P1.03, active LOW. +pub type LedDriver = SimpleLed>>; + +// ── TFT bus and driver type aliases ───────────────────────────────── + +type TftSpim = Spim<'static>; +type TftSpiDevice = ExclusiveDevice, Delay>; +type TftInterface = SpiInterface>; +pub type DisplayDriver = St7789Color>; + +// ── Peripheral bundles ────────────────────────────────────────────── + +pub struct RadioParts { + pub driver: RadioDriver, + pub delay: Delay, +} + +pub struct UsbParts { + pub driver: UsbDriver, +} + +/// Assembled-but-not-yet-initialized TFT plumbing. Real initialization +/// (power gate, RST cycle, mipidsi init) happens in `create_display`. +pub struct DisplayParts { + interface: TftInterface, + rst_pin: Output<'static>, + vdd_en: Output<'static>, + backlight: SimplePwm<'static>, +} + +// ── Display init ──────────────────────────────────────────────────── + +pub async fn create_display(parts: DisplayParts) -> Option { + let DisplayParts { + interface, + rst_pin, + mut vdd_en, + mut backlight, + } = parts; + + // 1. Drive TFT VDD enable LOW (active LOW per Meshtastic) and let + // the rail settle. Heltec's schematic has the panel power gated + // by P0.03 — touching SPI before VDD is stable produces snow. + vdd_en.set_low(); + Timer::after_millis(50).await; + + // 2. Build the lcd-async display. + // The T114 panel is wired natively portrait (135w × 240h) at + // chip framebuffer offset (52, 40) — the standard 1.14" 135×240 + // ST7789 die mapping. Deg90 rotation rotates the user-facing + // surface to landscape (240w × 135h). Meshtastic's variant.h + // sets TFT_OFFSET_X=0/Y=0 because their fork of TFT_eSPI applies + // these offsets internally; lcd-async expects them in display_offset. + // If the image comes up mirrored or upside-down, swap + // Deg90 ↔ Deg270 or set `mirrored = true`. + let mut delay = Delay; + let panel = match lcd_async::Builder::new(ST7789, interface) + .reset_pin(rst_pin) + .display_size(135, 240) + .display_offset(52, 40) + .orientation(Orientation::new().rotate(Rotation::Deg90)) + .invert_colors(ColorInversion::Inverted) + .init(&mut delay) + .await + { + Ok(p) => p, + Err(_) => { + defmt::error!("ST7789 init failed"); + return None; + } + }; + defmt::info!("ST7789 init OK"); + + // 3. Bring the backlight up at full brightness. Stage 2 uses the + // full-on duty; the splash dimming logic handled by the display + // statechart will hand off to set_dim/set_bright shortly. + backlight.set_duty(0, embassy_nrf::pwm::DutyCycle::inverted(0)); + + // Forget vdd_en so it stays driven for the lifetime of the program. + // We took it by move so we could call set_low() during init. + core::mem::forget(vdd_en); + + // Take the static framebuffer (240×135 × 2 bytes Rgb565 BE + // = 64,800 bytes zero-initialized in .bss). Stored as raw bytes + // so present() can DMA-blit the slice straight to the panel + // without an iterator-conversion pass — the lcd-async + // `RawFrameBuf` wrapper handles BE byte packing on + // each pixel write. + static FRAMEBUFFER: ConstStaticCell<[u8; FB_BYTES]> = + ConstStaticCell::new([0u8; FB_BYTES]); + let fb = FRAMEBUFFER.take(); + + Some(St7789Color::new(panel, backlight, fb)) +} + +// ── Board init ────────────────────────────────────────────────────── + +pub struct Board { + p: embassy_nrf::Peripherals, +} + +impl LoRaBoard for Board { + const NAME: &'static str = "Heltec Mesh Node T114"; + 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 { + // USB requires the 32 MHz HFXO (±50 ppm); the RC oscillator is out of spec. + let mut config = embassy_nrf::config::Config::default(); + config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal; + config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal; + let p = embassy_nrf::init(config); + Self { p } + } + + fn mac_address() -> [u8; 6] { + mcu::mac_address() + } + + fn into_parts(self) -> BoardParts { + let p = self.p; + + // ── VEXT_ENABLE (P0.21, active HIGH) ──────────────────── + // Powers the SX1262 *and* the TFT/peripheral 3V3 rail. Must be + // driven HIGH before any SPI traffic to the radio or panel — + // without this, lora-phy init can succeed (SPI bus works + // electrically) but TX_DONE never fires because the PA is + // unpowered. Held forever via core::mem::forget. Mirrors + // Meshtastic's `VEXT_ENABLE = P0.21, VEXT_ON_VALUE = HIGH`. + let vext_en = Output::new(p.P0_21, Level::High, OutputDrive::Standard); + core::mem::forget(vext_en); + + // ── SPI3 bus for SX1262 ───────────────────────────────── + // Per Heltec T114 schematic + Meshtastic variant.h: + // SCK=P0.19, MOSI=P0.22, MISO=P0.23 + // NSS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20 + // No external RXEN/TXEN — antenna switch is driven internally + // by SX1262 DIO2. TCXO is on DIO3 at 1.8 V. + let mut spi_cfg = spim::Config::default(); + spi_cfg.frequency = spim::Frequency::M1; + let spi = Spim::new(p.SPI3, mcu::Irqs, p.P0_19, p.P0_23, p.P0_22, spi_cfg); + let spi_bus = mcu::share_spi_bus(spi); + + let nss = Output::new(p.P0_24, Level::High, OutputDrive::Standard); + let spi_device = SpiDevice::new(spi_bus, nss); + + let reset = Output::new(p.P0_25, Level::High, OutputDrive::Standard); + let dio1 = Input::new(p.P0_20, Pull::Down); + // BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet + // p. 53); an MCU-side Pull fights it. See lora-rs/lora-rs#260. + let busy = Input::new(p.P0_17, Pull::None); + + let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None) + .expect("SX1262 interface init"); + + let sx_config = sx126x::Config { + chip: Sx1262, + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8), + use_dcdc: true, + rx_boost: false, + }; + + let radio = RadioParts { + driver: Sx126x::new(spi_device, iv, sx_config), + delay: Delay, + }; + + // ── USB ───────────────────────────────────────────────── + let vbus = mcu::alloc_vbus_detect(true, true); + let host = UsbParts { + driver: Driver::new(p.USBD, mcu::Irqs, vbus), + }; + + // Battery divider control (P0.06) LOW disables the divider — + // saves a few tens of µA. Battery sense (P0.04 / AIN2) deferred + // until the protocol gets a battery field. + let bat_ctl = Output::new(p.P0_06, Level::Low, OutputDrive::Standard); + core::mem::forget(bat_ctl); + + // ── SPI2 bus for ST7789 TFT ───────────────────────────── + // Per Meshtastic variant.h: + // SCK=P1.08, MOSI=P1.09, CS=P0.11, DC=P0.12, RST=P0.02 + // VDD enable=P0.03 (active HIGH), Backlight=P0.15 (active LOW PWM) + // Write-only bus — embassy_nrf::Spim still requires a MISO slot, + // so we wire a free unused pin (P1.06) as a no-op input. + let mut tft_spi_cfg = spim::Config::default(); + // 8 MHz lets us repaint the 240×135 panel comfortably. Higher + // rates work with mipidsi but are platform-dependent (board + // routing, pull strengths). 8 MHz is the safe default. + tft_spi_cfg.frequency = spim::Frequency::M8; + let tft_spi = Spim::new(p.SPI2, mcu::Irqs, p.P1_08, p.P1_06, p.P1_09, tft_spi_cfg); + + let tft_cs = Output::new(p.P0_11, Level::High, OutputDrive::Standard); + let tft_dc = Output::new(p.P0_12, Level::Low, OutputDrive::Standard); + let tft_rst = Output::new(p.P0_02, Level::High, OutputDrive::Standard); + // VTFT_CTRL is active LOW per Meshtastic + // (`#define TFT_BACKLIGHT_ON LOW`, `digitalWrite(VTFT_CTRL, LOW)` + // at boot). Start HIGH (panel rail off); create_display drives + // LOW to enable the panel. + let tft_vdd = Output::new(p.P0_03, Level::High, OutputDrive::Standard); + + let tft_spi_device = ExclusiveDevice::new(tft_spi, tft_cs, Delay) + .expect("TFT ExclusiveDevice init"); + + // lcd-async's SpiInterface streams pixel data directly from + // the caller's slice — no scratch buffer required (mipidsi's + // sync version needed one for batching). + let tft_interface = SpiInterface::new(tft_spi_device, tft_dc); + + // ── PWM backlight (active-LOW on P0.15) ───────────────── + // Default SimpleConfig is fine — countertop=1000 → ~1 kHz + // (well above flicker threshold), prescaler default. Channel 0 + // idle level matches the active-LOW wiring. + let mut bl_cfg = SimpleConfig::default(); + bl_cfg.max_duty = crate::driver::st7789::BL_MAX; + bl_cfg.prescaler = Prescaler::Div128; + bl_cfg.ch0_idle_level = Level::High; + let backlight = SimplePwm::new_1ch(p.PWM0, p.P0_15, &bl_cfg); + + let display_parts = DisplayParts { + interface: tft_interface, + rst_pin: tft_rst, + vdd_en: tft_vdd, + backlight, + }; + + // ── Green LED (P1.03, active LOW) ─────────────────────── + // Idle level HIGH = LED off. InvertedPin flips polarity so + // SimpleLed can use normal "set_high = on" semantics. + let led_pin = Output::new(p.P1_03, Level::High, OutputDrive::Standard); + let led = SimpleLed(InvertedPin(led_pin)); + + BoardParts { + radio, + host, + display: Some(display_parts), + led, + mac: Self::mac_address(), + } + } +} diff --git a/src/board/lilygo_tbeam_supreme.rs b/src/board/lilygo_tbeam_supreme.rs index ee70216..f97f2de 100644 --- a/src/board/lilygo_tbeam_supreme.rs +++ b/src/board/lilygo_tbeam_supreme.rs @@ -50,8 +50,8 @@ pub struct DisplayParts { // ── Display init ──────────────────────────────────────────────────── -pub async fn create_display(i2c: DisplayI2c) -> Option { - let mut display = Sh1106::new(i2c, 0x3C); +pub async fn create_display(parts: DisplayParts) -> Option { + let mut display = Sh1106::new(parts.i2c, 0x3C); embassy_time::Timer::after_millis(100).await; if display.init().await.is_err() { diff --git a/src/board/rak_wisblock_4631.rs b/src/board/rak_wisblock_4631.rs index 46dcaad..0e14cbe 100644 --- a/src/board/rak_wisblock_4631.rs +++ b/src/board/rak_wisblock_4631.rs @@ -52,8 +52,8 @@ pub struct DisplayParts { // ── Display init ──────────────────────────────────────────────────── -pub async fn create_display(i2c: DisplayI2c) -> Option { - let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3C); +pub async fn create_display(parts: DisplayParts) -> Option { + let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3C); // Retry 3× with 100ms backoff. The SH1106's first transaction // after power-on can return Err if the chip's internal voltage diff --git a/src/board/traits.rs b/src/board/traits.rs index c744bc6..5a16aff 100644 --- a/src/board/traits.rs +++ b/src/board/traits.rs @@ -6,10 +6,7 @@ //! //! See `src/board/PORTING.md` for a step-by-step guide to adding a new board. -use embedded_graphics::draw_target::DrawTarget; -use embedded_graphics::pixelcolor::BinaryColor; - -use crate::driver::DisplayBrightness; +use crate::display::BoardDisplay; /// Async RGB LED control. Boards without an LED use `()` which is a no-op. pub trait RgbLed { @@ -37,9 +34,10 @@ pub trait LoRaBoard: Sized { /// Display peripheral bundle (I2C bus for display init). type DisplayParts; - /// Concrete display driver type (must implement DrawTarget for rendering - /// and DisplayBrightness for the two-level dim control). - type DisplayDriver: DrawTarget + DisplayBrightness; + /// Concrete display driver type. Each board's driver implements + /// [`BoardDisplay`] to render the splash + dashboard content into its + /// native pixel format and resolution and to flush / dim the panel. + type DisplayDriver: BoardDisplay; /// RGB LED driver. Boards without an LED use `()`. type LedDriver: RgbLed; diff --git a/src/board/waveshare_rp2040_lora.rs b/src/board/waveshare_rp2040_lora.rs index c0b3883..a2bbfd5 100644 --- a/src/board/waveshare_rp2040_lora.rs +++ b/src/board/waveshare_rp2040_lora.rs @@ -41,12 +41,16 @@ pub struct UsbParts { } pub struct DisplayParts { + // No display on this board, but the trait still wants concrete + // peripheral types. The i2c bus type is reserved for the future + // possibility of an external OLED add-on. + #[allow(dead_code)] pub i2c: DisplayI2c, } // ── Display init ─────────────────────────────────────────────────── -pub async fn create_display(_i2c: DisplayI2c) -> Option { +pub async fn create_display(_parts: DisplayParts) -> Option { None } diff --git a/src/board/wio_tracker_l1.rs b/src/board/wio_tracker_l1.rs index e7e3b27..7b3d8a5 100644 --- a/src/board/wio_tracker_l1.rs +++ b/src/board/wio_tracker_l1.rs @@ -42,8 +42,8 @@ pub struct DisplayParts { // ── Display init ──────────────────────────────────────────────────── -pub async fn create_display(i2c: DisplayI2c) -> Option { - let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3D); +pub async fn create_display(parts: DisplayParts) -> Option { + let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3D); embassy_time::Timer::after_millis(100).await; if display.init().await.is_err() { diff --git a/src/display.rs b/src/display.rs index de47d46..766b148 100644 --- a/src/display.rs +++ b/src/display.rs @@ -38,27 +38,29 @@ use hsmc::statechart; use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed}; use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel}; -use crate::driver::DisplayBrightness; use crate::protocol::LoRaConfig; -use render::{BoardInfo, RSSI_HISTORY_LEN}; - const BOARD_NAME: &str = Board::NAME; const VERSION: &str = env!("CARGO_PKG_VERSION"); -// Splash board-name font selection. Every OLED we drive is 128 px wide; -// the primary font (FONT_6X10, 6 px advance) fits up to 21 ASCII chars -// across the screen, the fallback (FONT_5X8, 5 px advance) fits up to -// 25. If even the fallback can't fit, fail at compile time so the board -// porter shortens NAME rather than silently overflowing the splash. +/// Number of slots in the dashboard's RSSI/TX sparkline ring buffer. +pub const RSSI_HISTORY_LEN: usize = 128; + +// Mono splash board-name font selection. The mono OLEDs we drive are +// 128 px wide; the primary font (FONT_6X10, 6 px advance) fits up to 21 +// ASCII chars across, the fallback (FONT_5X8, 5 px advance) fits up to +// 25. If even the fallback can't fit on a mono OLED, fail at compile +// time so the board porter shortens NAME rather than silently +// overflowing the splash. Color renderers have their own size budget +// and simply use the same NAME with their own font choice. const SPLASH_SCREEN_W_PX: u32 = 128; const SPLASH_FONT_PRIMARY_W_PX: u32 = 6; const SPLASH_FONT_FALLBACK_W_PX: u32 = 5; -const BOARD_NAME_FITS_PRIMARY_FONT: bool = +pub(crate) const BOARD_NAME_FITS_PRIMARY_FONT: bool = (BOARD_NAME.len() as u32) * SPLASH_FONT_PRIMARY_W_PX <= SPLASH_SCREEN_W_PX; const _: () = assert!( (BOARD_NAME.len() as u32) * SPLASH_FONT_FALLBACK_W_PX <= SPLASH_SCREEN_W_PX, - "Board::NAME is too long for the 128 px splash screen even with the FONT_5X8 fallback \ + "Board::NAME is too long for the 128 px mono splash screen even with the FONT_5X8 fallback \ (max 25 ASCII chars). Shorten the NAME constant in src/board/.rs.", ); @@ -66,6 +68,49 @@ const _: () = assert!( /// floor (-120 dBm), so it cannot be confused with a real RSSI value. const NO_SIGNAL: i16 = -121; +/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash +/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is +/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so when +/// drawn with `BinaryColor::On` = lit, the QR appears with the +/// traditional dark-on-light look including a proper quiet zone. The +/// color renderer remaps bit 1 → white, bit 0 → black for the same +/// visual contrast on a black-background TFT. +pub(crate) const QR_WIDTH_PX: u32 = 64; +pub(crate) const QR_BITMAP: [u8; 512] = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, + 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, + 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, + 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, + 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, + 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, + 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, + 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, + 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, + 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, + 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, + 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFF, + 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, + 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, + 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, + 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, + 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, + 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, + 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, + 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, + 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, + 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, + 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, + 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +]; + /// Which dashboard badge is in view (drives the RX/TX indicator only). #[derive(Debug, Clone, Copy)] pub enum Badge { @@ -73,6 +118,136 @@ pub enum Badge { Tx, } +// ── Display content types (shared across all renderers) ─────────────── +// +// `BoardInfo` and `DashboardCtx` describe *what* to draw on the splash +// and dashboard screens. Each board's `BoardDisplay` impl decides *how* +// to render that content for its native pixel format and resolution. + +/// Static-ish board identity rendered on the splash + dashboard. +pub struct BoardInfo<'a> { + pub name: &'a str, + pub version: &'a str, + pub mac: &'a str, +} + +/// Live dashboard data at the moment of a render call. +pub struct DashboardCtx<'a> { + pub badge: Badge, + pub config: Option, + pub rx_count: u32, + pub tx_count: u32, + pub last_rssi: Option, + pub last_snr: Option, + pub rssi_history: &'a [i16; RSSI_HISTORY_LEN], + pub tx_history: &'a [bool; RSSI_HISTORY_LEN], + pub rssi_count: usize, + pub current_slot_rssi: i16, + pub current_slot_tx: bool, + pub board: &'a BoardInfo<'a>, +} + +// ── BoardDisplay trait ──────────────────────────────────────────────── +// +// One logical display surface, four content states (splash×2, dashboard, +// blank), plus brightness control and a flush hook. Each board's display +// driver implements this directly — color depth, resolution, dimming +// strategy, and any framebuffer flushing live behind the trait. + +/// Single trait every board's display driver implements. The display +/// task talks to the trait; the per-driver impl handles native format. +/// +/// `present()` is named distinctly from any inherent `flush()` method on +/// concrete display types so method resolution doesn't get ambiguous. +pub trait BoardDisplay { + fn present(&mut self) -> impl core::future::Future; + fn set_bright(&mut self) -> impl core::future::Future; + fn set_dim(&mut self) -> impl core::future::Future; + + fn render_splash_learn_more(&mut self); + fn render_splash_info(&mut self, info: &BoardInfo<'_>); + fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>); + fn render_blank(&mut self); +} + +// Color renderer module — used by `crate::driver::st7789::St7789Color` on +// the Heltec T114 to draw native 240×135 Rgb565 dashboards/splash screens. +// Gated to the T114 feature so other boards don't pay for the imports. +#[cfg(feature = "heltec_mesh_node_t114")] +pub mod render_color; + +// Shared QR-code blit, generic over color. Used by both the mono and +// color splash screens to render the bitmap with proper quiet zones. +mod qr; + +// ── Mono-OLED adapters ──────────────────────────────────────────────── +// +// SH1106 + SSD1306Async both render `BinaryColor` content into a 128×64 +// framebuffer flushed via I2C. Their `BoardDisplay` impls all delegate +// to `mod render` — the existing pixel-perfect mono renderer. + +#[cfg(any( + feature = "wio_tracker_l1", + feature = "elecrow_thinknode_m2", + feature = "lilygo_tbeam_supreme", + feature = "rak_wisblock_4631" +))] +impl BoardDisplay for crate::driver::sh1106::Sh1106 +where + I: embedded_hal_async::i2c::I2c, +{ + async fn present(&mut self) { + let _ = crate::driver::sh1106::Sh1106::flush(self).await; + } + async fn set_bright(&mut self) { + crate::driver::DisplayBrightness::set_bright(self).await; + } + async fn set_dim(&mut self) { + crate::driver::DisplayBrightness::set_dim(self).await; + } + fn render_splash_learn_more(&mut self) { + render::learn_more(self); + } + fn render_splash_info(&mut self, info: &BoardInfo<'_>) { + render::info(self, info); + } + fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) { + render::dashboard(self, ctx); + } + fn render_blank(&mut self) { + render::blank(self); + } +} + +impl BoardDisplay + for ssd1306::Ssd1306Async> +where + DI: display_interface::AsyncWriteOnlyDataCommand, + SIZE: ssd1306::size::DisplaySizeAsync, +{ + async fn present(&mut self) { + let _ = ssd1306::Ssd1306Async::flush(self).await; + } + async fn set_bright(&mut self) { + crate::driver::DisplayBrightness::set_bright(self).await; + } + async fn set_dim(&mut self) { + crate::driver::DisplayBrightness::set_dim(self).await; + } + fn render_splash_learn_more(&mut self) { + render::learn_more(self); + } + fn render_splash_info(&mut self, info: &BoardInfo<'_>) { + render::info(self, info); + } + fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) { + render::dashboard(self, ctx); + } + fn render_blank(&mut self) { + render::blank(self); + } +} + /// Fine-grained events driving the `Display` statechart. #[derive(Debug, Clone)] pub enum DisplayEvent { @@ -180,14 +355,14 @@ impl DisplayContext { version: VERSION, mac: mac_str, }; - render::info(display, &info); - let _ = display.flush().await; + display.render_splash_info(&info); + display.present().await; } async fn flush_learn_more(&mut self) { let display = &mut self.peripherals.display; - render::learn_more(display); - let _ = display.flush().await; + display.render_splash_learn_more(); + display.present().await; } async fn flush_dashboard(&mut self, badge: Badge) { @@ -199,27 +374,28 @@ impl DisplayContext { version: VERSION, mac: mac_str, }; - render::dashboard( - display, + let ctx = DashboardCtx { badge, - self.run.config, - self.run.rx_count, - self.run.tx_count, - self.run.last_rssi, - self.run.last_snr, - &self.run.rssi_history, - &self.run.tx_history, - self.run.rssi_count, - self.run.current_slot_rssi, - self.run.current_slot_tx, - &info, - ); - let _ = display.flush().await; + config: self.run.config, + rx_count: self.run.rx_count, + tx_count: self.run.tx_count, + last_rssi: self.run.last_rssi, + last_snr: self.run.last_snr, + rssi_history: &self.run.rssi_history, + tx_history: &self.run.tx_history, + rssi_count: self.run.rssi_count, + current_slot_rssi: self.run.current_slot_rssi, + current_slot_tx: self.run.current_slot_tx, + board: &info, + }; + display.render_dashboard(&ctx); + display.present().await; } async fn flush_blank(&mut self) { - render::blank(&mut self.peripherals.display); - let _ = self.peripherals.display.flush().await; + let display = &mut self.peripherals.display; + display.render_blank(); + display.present().await; } // Boards without an LED use `LedDriver = ()`; the `RgbLed for ()` impl @@ -453,7 +629,7 @@ pub async fn display_task( ), ); - let Some(display) = board::create_display(parts.i2c).await else { + let Some(display) = board::create_display(parts).await else { defmt::error!("display init failed, running LED-only loop"); loop { match display_commands.receive().await { @@ -516,10 +692,8 @@ mod render { use embedded_graphics::text::{Alignment, Text}; use heapless::String; - use super::Badge; - use crate::protocol::{LoRaBandwidth, LoRaConfig}; - - pub const RSSI_HISTORY_LEN: usize = 128; + use super::{Badge, BoardInfo, DashboardCtx, QR_WIDTH_PX, RSSI_HISTORY_LEN}; + use crate::protocol::LoRaBandwidth; const CHAR_W: i32 = 6; const FONT_H: i32 = 10; @@ -528,72 +702,20 @@ mod render { const RSSI_MIN: i16 = -120; const RSSI_MAX: i16 = 0; - /// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash - /// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is - /// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so - /// when drawn with `BinaryColor::On` = lit, the QR appears with the - /// traditional dark-on-light look including a proper quiet zone. - const QR_WIDTH_PX: u32 = 64; - const QR_BITMAP: [u8; 512] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F, - 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6, - 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60, - 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, - 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, - 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, - 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, - 0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, - 0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, - 0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x00, 0x60, - 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, 0x01, - 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, - 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, - 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, - 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, - 0x99, 0xFF, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80, - 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, - 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06, - 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF, - 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE, - 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, - 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, - 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, - 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, - 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x60, 0x66, 0x67, - 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x7F, 0xE6, - 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, 0x00, - 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, - ]; - pub struct BoardInfo<'a> { - pub name: &'a str, - pub version: &'a str, - pub mac: &'a str, - } - - #[allow(clippy::too_many_arguments)] - pub fn dashboard( - target: &mut impl DrawTarget, - badge: Badge, - config: Option, - rx_count: u32, - tx_count: u32, - last_rssi: Option, - last_snr: Option, - rssi_history: &[i16; RSSI_HISTORY_LEN], - tx_history: &[bool; RSSI_HISTORY_LEN], - rssi_count: usize, - current_slot_rssi: i16, - current_slot_tx: bool, - board: &BoardInfo, - ) { + pub fn dashboard(target: &mut impl DrawTarget, ctx: &DashboardCtx<'_>) { + let badge = ctx.badge; + let config = ctx.config; + let rx_count = ctx.rx_count; + let tx_count = ctx.tx_count; + let last_rssi = ctx.last_rssi; + let last_snr = ctx.last_snr; + let rssi_history = ctx.rssi_history; + let tx_history = ctx.tx_history; + let rssi_count = ctx.rssi_count; + let current_slot_rssi = ctx.current_slot_rssi; + let current_slot_tx = ctx.current_slot_tx; + let board = ctx.board; let bb = target.bounding_box(); let w = bb.size.width as i32; let h = bb.size.height as i32; @@ -833,10 +955,9 @@ mod render { } /// Alternate splash screen: 64 × 64 pixel-perfect QR bitmap on the - /// right (with its own quiet zone baked in), centered text column on - /// the left inviting the user to visit the URL. + /// right (centered within its quiet zone via `super::qr::draw`), + /// centered text column on the left inviting the user to visit the URL. pub fn learn_more(target: &mut impl DrawTarget) { - use embedded_graphics::image::{Image, ImageRaw}; use embedded_graphics::mono_font::ascii::{FONT_4X6, FONT_7X14_BOLD}; let bb = target.bounding_box(); @@ -844,10 +965,14 @@ mod render { let _ = target.clear(BinaryColor::Off); - // ── QR code: flush to the right edge, 1:1 pixel rendering ─── + // ── QR code: rightmost 64 × 64 region, centered cells ─────── let qr_x = w - QR_WIDTH_PX as i32; - let qr_raw: ImageRaw = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX); - Image::new(&qr_raw, Point::new(qr_x, 0)).draw(target).ok(); + super::qr::draw( + target, + Point::new(qr_x, 0), + BinaryColor::On, + BinaryColor::Off, + ); // ── Text column (everything left of the QR) ───────────────── let col_w = qr_x; diff --git a/src/display/qr.rs b/src/display/qr.rs new file mode 100644 index 0000000..ca88f51 --- /dev/null +++ b/src/display/qr.rs @@ -0,0 +1,74 @@ +//! Shared QR-code blit for splash screens. +//! +//! The 512-byte QR bitmap in the parent module is laid out asymmetrically +//! within its 64×64 frame: rows 0-6 and 57-63 are top/bottom quiet zones, +//! but the QR cells start at col 0 (no left quiet zone) and extend to +//! col ~49, leaving ~14 px of white padding on the right plus one stray +//! black pixel at col 63. Rendering the bitmap as-is leaves the QR +//! visually shifted toward the left of its rendering area, which looks +//! wrong on any layout that doesn't tuck the bitmap flush against a +//! screen edge. +//! +//! [`draw`] re-centers the QR data: it skips the bitmap's right padding +//! (cols 50-63), fills the rendering area's left and right 7 px with +//! the foreground "quiet zone" color, and renders the actual QR cells +//! (bitmap cols 0..50) at +7 horizontal offset. Rows are rendered as-is +//! so the bitmap's existing top/bottom quiet zones land where expected. +//! +//! Generic over color so the mono renderer (`BinaryColor`) and the +//! T114's color renderer (`Rgb565`) share a single implementation — +//! they pass their own foreground (white / lit) and background (black / +//! unlit) values. + +use embedded_graphics::draw_target::DrawTarget; +use embedded_graphics::geometry::{Point, Size}; +use embedded_graphics::pixelcolor::PixelColor; +use embedded_graphics::primitives::Rectangle; +use embedded_graphics::Pixel; + +use super::{QR_BITMAP, QR_WIDTH_PX}; + +/// Width of the actual QR data within the bitmap (cols 0..QR_DATA_W). +const QR_DATA_W: i32 = 50; +/// White padding on left + right to center the QR cells. With +/// QR_DATA_W=50 and total area=64 (= QR_WIDTH_PX), padding = 7 each side. +const PAD_X: i32 = (QR_WIDTH_PX as i32 - QR_DATA_W) / 2; + +/// Render the 64×64 QR with proper quiet zones on all four sides. +/// +/// `fg` is the "quiet zone / light cell" color (white on a TFT, "on" +/// on a mono OLED). `bg` is the "dark cell" color. Bitmap rows 0-6 and +/// 57-63 already encode top/bottom quiet zones in `fg`; this function +/// fills the missing left/right padding columns explicitly. +pub fn draw(target: &mut D, origin: Point, fg: C, bg: C) +where + D: DrawTarget, + C: PixelColor, +{ + // Fill left padding (cols 0..PAD_X) with fg. + let _ = target.fill_solid( + &Rectangle::new(origin, Size::new(PAD_X as u32, QR_WIDTH_PX)), + fg, + ); + // Fill right padding (cols PAD_X+QR_DATA_W..QR_WIDTH_PX) with fg. + let right_pad_w = QR_WIDTH_PX as i32 - PAD_X - QR_DATA_W; + let _ = target.fill_solid( + &Rectangle::new( + Point::new(origin.x + PAD_X + QR_DATA_W, origin.y), + Size::new(right_pad_w as u32, QR_WIDTH_PX), + ), + fg, + ); + + // Render bitmap cols 0..QR_DATA_W at +PAD_X horizontal offset. + let bytes_per_row = (QR_WIDTH_PX / 8) as usize; + let pixels = (0..QR_WIDTH_PX as i32).flat_map(move |y| { + (0..QR_DATA_W).map(move |x| { + let byte = QR_BITMAP[y as usize * bytes_per_row + (x as usize) / 8]; + let bit = (byte >> (7 - (x as u8 & 7))) & 1; + let color = if bit == 1 { fg } else { bg }; + Pixel(Point::new(origin.x + PAD_X + x, origin.y + y), color) + }) + }); + let _ = target.draw_iter(pixels); +} diff --git a/src/display/render_color.rs b/src/display/render_color.rs new file mode 100644 index 0000000..e5548a0 --- /dev/null +++ b/src/display/render_color.rs @@ -0,0 +1,422 @@ +//! Native 240×135 Rgb565 renderer for the Heltec T114 ST7789 panel. +//! +//! Mirrors the content shape of the mono OLED renderer in `display::render` +//! (same `BoardInfo` / `DashboardCtx` inputs) but lays things out for the +//! T114's bigger color screen with native font sizes and colored badges. +//! Each board's `BoardDisplay` impl picks the renderer that matches its +//! native pixel format — no per-pixel color quantization, no scaling, +//! no aliasing. + +use core::fmt::Write; + +use embedded_graphics::mono_font::ascii::{ + FONT_6X10, FONT_8X13, FONT_8X13_BOLD, FONT_9X15_BOLD, FONT_10X20, +}; +use embedded_graphics::mono_font::MonoTextStyle; +use embedded_graphics::pixelcolor::Rgb565; +use embedded_graphics::prelude::*; +use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle}; +use embedded_graphics::text::renderer::CharacterStyle; +use embedded_graphics::text::{Alignment, Text}; +use heapless::String; + +use super::{qr, Badge, BoardInfo, DashboardCtx, QR_WIDTH_PX, RSSI_HISTORY_LEN}; +use crate::protocol::LoRaBandwidth; + +// ── Panel dimensions ────────────────────────────────────────────────── + +const W: i32 = 240; +const H: i32 = 135; + +// ── Palette ─────────────────────────────────────────────────────────── + +const BG: Rgb565 = Rgb565::BLACK; +const FG: Rgb565 = Rgb565::WHITE; +const DIM: Rgb565 = Rgb565::new(12, 24, 12); // muted gray +const ACCENT: Rgb565 = Rgb565::new(8, 30, 31); // bright cyan +const RX_COLOR: Rgb565 = Rgb565::new(8, 60, 8); // bright green +const TX_COLOR: Rgb565 = Rgb565::new(31, 16, 8); // amber-red +const SPARK_COLOR: Rgb565 = Rgb565::new(20, 50, 24); // soft green + +// ── Sparkline ──────────────────────────────────────────────────────── + +const RSSI_MIN: i16 = -120; +const RSSI_MAX: i16 = 0; +/// Sentinel: no packet received in this slot. Mirrors `display::NO_SIGNAL`. +const NO_SIGNAL: i16 = -121; + +// ── Public render entry points ──────────────────────────────────────── + +pub fn blank>(target: &mut D) { + let _ = target.clear(BG); +} + +pub fn splash_learn_more>(target: &mut D) { + let _ = target.clear(BG); + + // QR pinned to the right side, vertically centered. 64×64 fits with + // ~36 px headroom top/bottom on the 135-tall panel. + let qr_w = QR_WIDTH_PX as i32; + let qr_x = W - qr_w - 8; + let qr_y = (H - qr_w) / 2; + qr::draw(target, Point::new(qr_x, qr_y), FG, BG); + + // Text column to the left of the QR. + let col_w = qr_x - 8; + let col_center = col_w / 2 + 4; + + let title_style = MonoTextStyle::new(&FONT_10X20, ACCENT); + let url_style = MonoTextStyle::new(&FONT_8X13, FG); + let caption_style = MonoTextStyle::new(&FONT_6X10, DIM); + + let _ = Text::with_alignment( + "DongLoRa", + Point::new(col_center, 45), + title_style, + Alignment::Center, + ) + .draw(target); + + let _ = Text::with_alignment( + "donglora.com", + Point::new(col_center, 73), + url_style, + Alignment::Center, + ) + .draw(target); + + let _ = Text::with_alignment( + "Scan for docs", + Point::new(col_center, 95), + caption_style, + Alignment::Center, + ) + .draw(target); +} + +pub fn splash_info>(target: &mut D, info: &BoardInfo<'_>) { + let _ = target.clear(BG); + + let title_style = MonoTextStyle::new(&FONT_10X20, ACCENT); + let board_style = MonoTextStyle::new(&FONT_9X15_BOLD, FG); + let mac_style = MonoTextStyle::new(&FONT_8X13, FG); + let small_style = MonoTextStyle::new(&FONT_6X10, DIM); + + // Title at top. + let _ = Text::with_alignment( + "DongLoRa", + Point::new(W / 2, 22), + title_style, + Alignment::Center, + ) + .draw(target); + + // Board name. + let _ = Text::with_alignment( + info.name, + Point::new(W / 2, 50), + board_style, + Alignment::Center, + ) + .draw(target); + + // MAC. + let _ = Text::with_alignment( + info.mac, + Point::new(W / 2, 75), + mac_style, + Alignment::Center, + ) + .draw(target); + + // Version. + let mut version_buf: String<24> = String::new(); + let _ = write!(version_buf, "v{}", info.version); + let _ = Text::with_alignment( + &version_buf, + Point::new(W / 2, 95), + small_style, + Alignment::Center, + ) + .draw(target); + + // Waiting line. + let _ = Text::with_alignment( + "Waiting for host...", + Point::new(W / 2, 120), + small_style, + Alignment::Center, + ) + .draw(target); +} + +pub fn dashboard>(target: &mut D, ctx: &DashboardCtx<'_>) { + let _ = target.clear(BG); + + // ── Top bar (badge + radio config) ────────────────────────── + let top_h = 24; + let badge_w = 38; + let (badge_color, badge_text) = match ctx.badge { + Badge::Rx => (RX_COLOR, "RX"), + Badge::Tx => (TX_COLOR, "TX"), + }; + + let badge_rect = Rectangle::new(Point::zero(), Size::new(badge_w as u32, top_h as u32)); + let _ = badge_rect + .into_styled(PrimitiveStyle::with_fill(badge_color)) + .draw(target); + + let mut badge_style = MonoTextStyle::new(&FONT_8X13_BOLD, FG); + badge_style.set_background_color(Some(badge_color)); + let _ = Text::with_alignment( + badge_text, + Point::new(badge_w / 2, 16), + badge_style, + Alignment::Center, + ) + .draw(target); + + // Radio config text right of the badge. + let cfg_style = MonoTextStyle::new(&FONT_8X13, FG); + let mut buf: String<48> = String::new(); + if let Some(cfg) = ctx.config { + let bw_str = bw_label(cfg.bw); + let cr_str = cr_label(cfg.cr); + let freq_mhz = cfg.freq_hz / 1_000_000; + let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; + let _ = write!( + buf, + "{}.{:03} {} SF{} CR{}", + freq_mhz, freq_khz, bw_str, cfg.sf, cr_str + ); + } else { + let _ = write!(buf, "Awaiting config"); + } + let _ = Text::with_alignment( + &buf, + Point::new(W - 4, 16), + cfg_style, + Alignment::Right, + ) + .draw(target); + + // Separator line under top bar. + let _ = Line::new(Point::new(0, top_h + 1), Point::new(W - 1, top_h + 1)) + .into_styled(PrimitiveStyle::with_stroke(DIM, 1)) + .draw(target); + + // ── Bottom bar (counters + RSSI) ─────────────────────────── + let bottom_h = 30; + let bottom_top = H - bottom_h; + let _ = Line::new( + Point::new(0, bottom_top - 1), + Point::new(W - 1, bottom_top - 1), + ) + .into_styled(PrimitiveStyle::with_stroke(DIM, 1)) + .draw(target); + + let stat_style = MonoTextStyle::new(&FONT_8X13, FG); + + let mut rxtx_buf: String<24> = String::new(); + let _ = write!( + rxtx_buf, + "RX {} TX {}", + compact_count(ctx.rx_count).as_str(), + compact_count(ctx.tx_count).as_str() + ); + let _ = Text::with_alignment( + &rxtx_buf, + Point::new(4, bottom_top + 11), + stat_style, + Alignment::Left, + ) + .draw(target); + + let mut rssi_buf: String<32> = String::new(); + match (ctx.last_rssi, ctx.last_snr) { + (Some(rssi), Some(snr)) => { + let _ = write!(rssi_buf, "{} dBm SNR {}", rssi, snr); + } + (Some(rssi), None) => { + let _ = write!(rssi_buf, "{} dBm", rssi); + } + _ => { + let _ = write!(rssi_buf, "no signal"); + } + } + let _ = Text::with_alignment( + &rssi_buf, + Point::new(W - 4, bottom_top + 11), + stat_style, + Alignment::Right, + ) + .draw(target); + + let secondary = MonoTextStyle::new(&FONT_6X10, DIM); + let _ = Text::with_alignment( + ctx.board.mac, + Point::new(W / 2, bottom_top + 25), + secondary, + Alignment::Center, + ) + .draw(target); + + // ── Sparkline ────────────────────────────────────────────── + let spark_top = top_h + 4; + let spark_h = (bottom_top - 2) - spark_top; + if spark_h > 0 { + // 240 wide / 2 px per bar = 120 visible bars (subset of the + // 128-slot ring). Matches the mono renderer's + // `visible_bars = w / 2` heuristic. + let visible_bars = ((W / 2) as usize).min(RSSI_HISTORY_LEN); + rssi_sparkline( + target, + ctx.rssi_history, + ctx.tx_history, + ctx.rssi_count, + ctx.current_slot_rssi, + ctx.current_slot_tx, + spark_top, + spark_h, + visible_bars, + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +fn bw_label(bw: LoRaBandwidth) -> &'static str { + match bw { + LoRaBandwidth::Khz7 => "7.8k", + LoRaBandwidth::Khz10 => "10.4k", + LoRaBandwidth::Khz15 => "15.6k", + LoRaBandwidth::Khz20 => "20.8k", + LoRaBandwidth::Khz31 => "31.2k", + LoRaBandwidth::Khz41 => "41.7k", + LoRaBandwidth::Khz62 => "62.5k", + LoRaBandwidth::Khz125 => "125k", + LoRaBandwidth::Khz250 => "250k", + LoRaBandwidth::Khz500 => "500k", + LoRaBandwidth::Khz200 => "200k", + LoRaBandwidth::Khz400 => "400k", + LoRaBandwidth::Khz800 => "800k", + LoRaBandwidth::Khz1600 => "1.6M", + } +} + +fn cr_label(cr: crate::protocol::LoRaCodingRate) -> &'static str { + match cr { + crate::protocol::LoRaCodingRate::Cr4_5 => "5", + crate::protocol::LoRaCodingRate::Cr4_6 => "6", + crate::protocol::LoRaCodingRate::Cr4_7 => "7", + crate::protocol::LoRaCodingRate::Cr4_8 => "8", + } +} + +fn compact_count(n: u32) -> String<10> { + let mut s: String<10> = String::new(); + if n > 9_999_999 { + let _ = write!(s, "{}M", n / 1_000_000); + } else if n > 999_999 { + let _ = write!(s, "{}k", n / 1_000); + } else { + let _ = write!(s, "{}", n); + } + s +} + +#[allow(clippy::too_many_arguments)] +fn rssi_sparkline>( + target: &mut D, + history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], + count: usize, + current_rssi: i16, + current_tx: bool, + spark_top: i32, + spark_h: i32, + visible_bars: usize, +) { + let live = current_rssi > RSSI_MIN || current_tx; + let committed = count.min(RSSI_HISTORY_LEN); + let hist_slots = if live { + committed.min(visible_bars.saturating_sub(1)) + } else { + committed.min(visible_bars) + }; + let total = hist_slots + if live { 1 } else { 0 }; + + if total == 0 { + return; + } + + for i in 0..hist_slots { + let idx = if count <= RSSI_HISTORY_LEN { + let effective_bars = if live { visible_bars - 1 } else { visible_bars }; + i + committed.saturating_sub(effective_bars) + } else { + let start = count - RSSI_HISTORY_LEN; + let skip = RSSI_HISTORY_LEN.saturating_sub(if live { + visible_bars - 1 + } else { + visible_bars + }); + (start + skip + i) % RSSI_HISTORY_LEN + }; + let is_tx = tx_history[idx]; + let rssi = history[idx]; + + if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) { + let x = (visible_bars - total + i) as i32 * 2; + draw_bar(target, x, bar_h, is_tx, spark_top, spark_h); + } + } + + if live { + if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) { + let x = (visible_bars - 1) as i32 * 2; + draw_bar(target, x, bar_h, current_tx, spark_top, spark_h); + } + } +} + +fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option { + let h = if rssi <= RSSI_MIN || rssi == NO_SIGNAL { + if is_tx { + spark_h / 3 + } else { + return None; + } + } else { + let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); + ((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32 + }; + if h == 0 { None } else { Some(h) } +} + +fn draw_bar>( + target: &mut D, + x: i32, + bar_h: i32, + is_tx: bool, + spark_top: i32, + spark_h: i32, +) { + let y = spark_top + spark_h - bar_h; + let color = if is_tx { TX_COLOR } else { SPARK_COLOR }; + if is_tx { + // Hatched bar — every other pixel row — mirrors the mono + // renderer's TX visual cue. + for row in 0..bar_h { + if row % 2 == 0 { + let _ = Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) + .into_styled(PrimitiveStyle::with_fill(color)) + .draw(target); + } + } + } else { + let _ = Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) + .into_styled(PrimitiveStyle::with_fill(color)) + .draw(target); + } +} diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 8901b5e..2ddf490 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -9,10 +9,14 @@ pub mod sh1106; #[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] pub mod axp; -#[cfg(feature = "lilygo_tbeam")] +#[cfg(any(feature = "lilygo_tbeam", feature = "heltec_mesh_node_t114"))] pub mod inverted_pin; +#[cfg(feature = "heltec_mesh_node_t114")] +pub mod st7789; + #[cfg(any( + feature = "heltec_mesh_node_t114", feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", diff --git a/src/driver/st7789.rs b/src/driver/st7789.rs new file mode 100644 index 0000000..0d06dd8 --- /dev/null +++ b/src/driver/st7789.rs @@ -0,0 +1,123 @@ +//! ST7789 240×135 IPS TFT driver wrapper for the Heltec Mesh Node T114. +//! +//! Wraps `lcd_async::Display<…, models::ST7789, …>` (async fork of +//! mipidsi) with an in-RAM raw byte framebuffer (240×135 × 2 bytes +//! Rgb565 BE = 64,800 bytes) and an active-LOW PWM backlight on PWM0. +//! +//! Renderers draw into the framebuffer via `lcd_async::raw_framebuf::RawFrameBuf`; +//! `present()` awaits a single async DMA blit of the entire buffer to +//! the panel via `panel.show_raw_data(...).await`. The await lets +//! embassy-nrf's DMA-backed Spim yield the executor for the duration +//! of the ~65 ms SPI transfer, so the radio task can run alongside +//! and the SX1262 IRQ handler isn't starved during repaints. + +use embassy_nrf::pwm::{DutyCycle, SimplePwm}; +use embedded_graphics::pixelcolor::Rgb565; +use lcd_async::interface::Interface; +use lcd_async::models::ST7789; +use lcd_async::raw_framebuf::RawFrameBuf; +use lcd_async::Display; + +use crate::display::{render_color, BoardDisplay, BoardInfo, DashboardCtx}; + +/// PWM countertop. Drives the backlight at ~1 kHz (well above flicker). +pub const BL_MAX: u16 = 1000; + +fn bl_bright() -> DutyCycle { + // Inverted polarity: HIGH for 0 counts → always LOW → full bright. + DutyCycle::inverted(0) +} + +fn bl_dim() -> DutyCycle { + // ~80% HIGH → ~20% LOW → 20% brightness. + DutyCycle::inverted((BL_MAX * 4) / 5) +} + +#[allow(dead_code)] +fn bl_off() -> DutyCycle { + DutyCycle::inverted(BL_MAX) +} + +// ── Framebuffer ─────────────────────────────────────────────────────── + +pub const FB_W: usize = 240; +pub const FB_H: usize = 135; +/// Bytes for one full Rgb565 frame (2 bytes/pixel, big-endian — matches +/// what ST7789 expects in 16-bit color mode). +pub const FB_BYTES: usize = FB_W * FB_H * 2; + +// ── St7789Color ─────────────────────────────────────────────────────── + +pub struct St7789Color +where + DI: Interface, + RST: embedded_hal::digital::OutputPin, +{ + panel: Display, + backlight: SimplePwm<'static>, + fb: &'static mut [u8; FB_BYTES], +} + +impl St7789Color +where + DI: Interface, + RST: embedded_hal::digital::OutputPin, +{ + pub fn new( + panel: Display, + backlight: SimplePwm<'static>, + fb: &'static mut [u8; FB_BYTES], + ) -> Self { + Self { panel, backlight, fb } + } + + /// Wrap the raw byte buffer as a `RawFrameBuf` so + /// `embedded-graphics` primitives can draw into it, then run `f`. + /// The borrow ends when this returns, freeing `self.fb` for the + /// subsequent blit in `present()`. + fn with_fb)>(&mut self, f: F) { + let mut fbuf = RawFrameBuf::::new(&mut self.fb[..], FB_W, FB_H); + f(&mut fbuf); + } +} + +impl BoardDisplay for St7789Color +where + DI: Interface, + RST: embedded_hal::digital::OutputPin, +{ + /// Blit the entire framebuffer to the panel via the async SPI + /// interface. embassy-nrf's Spim runs the transfer over EasyDMA; + /// the `.await` yields the executor for the duration so the radio + /// task can process SX1262 IRQs concurrently. + async fn present(&mut self) { + let _ = self + .panel + .show_raw_data(0, 0, FB_W as u16, FB_H as u16, self.fb.as_ref()) + .await; + } + + async fn set_bright(&mut self) { + self.backlight.set_duty(0, bl_bright()); + } + + async fn set_dim(&mut self) { + self.backlight.set_duty(0, bl_dim()); + } + + fn render_splash_learn_more(&mut self) { + self.with_fb(|fb| render_color::splash_learn_more(fb)); + } + + fn render_splash_info(&mut self, info: &BoardInfo<'_>) { + self.with_fb(|fb| render_color::splash_info(fb, info)); + } + + fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) { + self.with_fb(|fb| render_color::dashboard(fb, ctx)); + } + + fn render_blank(&mut self) { + self.with_fb(|fb| render_color::blank(fb)); + } +} diff --git a/src/hal/mod.rs b/src/hal/mod.rs index 248c56b..4950714 100644 --- a/src/hal/mod.rs +++ b/src/hal/mod.rs @@ -15,7 +15,11 @@ pub mod esp32s3; #[cfg(feature = "lilygo_tbeam")] pub mod esp32; -#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] +#[cfg(any( + feature = "heltec_mesh_node_t114", + feature = "rak_wisblock_4631", + feature = "wio_tracker_l1" +))] pub mod nrf52840; #[cfg(feature = "waveshare_rp2040_lora")] diff --git a/src/hal/nrf52840.rs b/src/hal/nrf52840.rs index b16eb77..fc55abe 100644 --- a/src/hal/nrf52840.rs +++ b/src/hal/nrf52840.rs @@ -13,11 +13,23 @@ bind_interrupts!(pub struct Irqs { USBD => embassy_nrf::usb::InterruptHandler; SPIM3 => embassy_nrf::spim::InterruptHandler; TWISPI0 => embassy_nrf::twim::InterruptHandler; + // SPIM2 is the second free SPI peripheral on nRF52840. Only used + // today by the Heltec T114 (ST7789 TFT panel on a write-only bus + // that can't share with the SX1262 on SPIM3 because the pins + // differ). Boards that don't use SPI2 don't construct anything + // against it and this binding is a no-op for them. + SPI2 => embassy_nrf::spim::InterruptHandler; }); // ── MCU-level types ───────────────────────────────────────────────── pub type SpiBus = embassy_nrf::spim::Spim<'static>; +// I2C is used by some nRF boards (RAK4631 / Wio Tracker L1 OLED) but not +// others (T114 uses SPI for the TFT). The `#[allow(dead_code)]` keeps +// the type available for any board that wants it without per-feature +// gating; rustc otherwise fires a dead-code lint when the active board +// doesn't construct one. +#[allow(dead_code)] pub type I2cBus = embassy_nrf::twim::Twim<'static>; pub type UsbNrfDriver = embassy_nrf::usb::Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>; @@ -37,6 +49,8 @@ pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex &'static mut [u8; 256] { static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new(); TWIM_BUF.init([0u8; 256]) diff --git a/src/main.rs b/src/main.rs index 1a94b92..289737c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,7 @@ use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN}; #[cfg(not(test))] cfg_if::cfg_if! { - if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] { + if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] { use defmt_rtt as _; use panic_probe as _; } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] { @@ -71,7 +71,7 @@ static mut INFO: Option = None; #[cfg(not(test))] cfg_if::cfg_if! { - if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] { + if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] { #[embassy_executor::main] async fn main(spawner: Spawner) { run(spawner).await; @@ -93,7 +93,7 @@ async fn run(spawner: Spawner) { // of the RTT log. Critical for soak-debugging silent crash-reboots: // a self-rebooting firmware otherwise looks like the log just // restarted with no explanation. nRF52840-only. - #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] + #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] crate::hal::nrf52840::dump_reset_reason(); let parts = board.into_parts();