Tower Board Support

LYNXtower firmware supports different USB dongle hardware via compile-time feature flags. Each board provides boot button, LED feedback, and optional display.

Architecture

Board abstraction uses Cargo features + trait-based initialization.

main.rs
   ↓
board::SelectedBoard::init()  ← compile-time selection via features
   ↓
BoardPeripherals {
  boot_button: Input<GPIO0>
  led_driver: LedDriver          ← enum: WS2812 | APA102
  display: OptionalDisplay       ← enum: None | ST7735
}

Compile guarantees:

  • Exactly one board feature must be active (checked at compile time)
  • Board-specific code only compiles when feature is enabled
  • No runtime overhead for board selection

Supported Boards

BPI-Leaf-S3 (Default)

Minimal board—LED feedback only.

Hardware:

  • Boot button: GPIO0 (pull-up)
  • WS2812 LED: GPIO48 (RMT interface)
  • Display: None

Feature flag:

cargo build --release  # default includes board-bpi-leaf

Pin mapping:

GPIO0  ← Boot button (active LOW, internal pull-up)
GPIO48 → WS2812 RGB LED (RMT channel 0)

LED patterns:

  • Off: Idle state
  • Solid violet: Pairing mode
  • Blinking violet: Pairing after clear-all
  • Green flash: Device paired successfully
  • Cyan triple-blink: Already-paired device detected

T-Dongle-S3 (LILYGO)

Full-featured board—LED + display for visual feedback.

Hardware:

  • Boot button: GPIO0 (pull-up)
  • APA102 LED: GPIO39/40 (SPI3 interface)
  • ST7735 display: 160x80 pixels, landscape, SPI2 interface
  • Backlight: GPIO38 (active LOW)

Feature flag:

cargo build --release --no-default-features --features rmk,board-t-dongle

Pin mapping:

GPIO0  ← Boot button (active LOW, internal pull-up)

APA102 LED (SPI3):
  GPIO39 → SCK
  GPIO40 → MOSI

ST7735 Display (SPI2):
  GPIO5  → SCLK
  GPIO3  → MOSI
  GPIO4  → CS (chip select)
  GPIO2  → DC (data/command)
  GPIO1  → RST (reset)
  GPIO38 → Backlight (active LOW)

Display configuration:

  • Resolution: 160x80 pixels (physical: 80x160 rotated 90°)
  • Color order: BGR
  • Inversion: Inverted
  • Offset: X=26, Y=1
  • SPI frequency: 10MHz

Display features:

Normal mode (keyboard active):

  • Layer number: Large centered text (L0-L3)
  • Caps lock: Red "CAPS" when active
  • Backlight: Auto-off after 30s inactivity

Pairing mode (override):

  • "PAIRING" in violet (matches LED color)
  • Device count: "N/8 DEV"
  • Backlight: Always on during pairing
  • Updates live as devices pair

LED patterns: Same as BPI-Leaf-S3 (violet/green/cyan feedback).

Board Trait API

Each board implements the Board trait:

pub trait Board {
    fn init() -> BoardPeripherals;
    fn name() -> &'static str;
}

BoardPeripherals struct:

pub struct BoardPeripherals {
    pub boot_button: Input<'static>,
    pub led_driver: LedDriver,
    pub display: OptionalDisplay,
}

LedDriver enum:

  • Unified interface via write_rgb(RGB8) method
  • Abstracts WS2812 vs APA102 differences
  • Single LED support for both types

OptionalDisplay enum:

  • None for boards without displays
  • St7735(DisplayDriver) for T-Dongle-S3
  • Display task only spawned when variant is not None

Adding New Boards

Follow these steps to add a new board:

1. Create board module

Add firmware/tower/src/board/your_board.rs:

use super::{Board, BoardPeripherals, LedDriver, OptionalDisplay};
use esp_hal::peripherals::Peripherals;

pub struct YourBoard;

impl Board for YourBoard {
    fn init() -> BoardPeripherals {
        // SAFETY: Steal peripherals for GPIO access
        let p = unsafe { Peripherals::steal() };

        // Initialize boot button (GPIO0 standard)
        let boot_button = Input::new(
            p.GPIO0,
            InputConfig::default().with_pull(Pull::Up)
        );

        // Initialize LED driver (choose type)
        let led_driver = /* WS2812 or APA102 init */;

        // Initialize display if present
        let display = OptionalDisplay::None;

        BoardPeripherals {
            boot_button,
            led_driver,
            display,
        }
    }

    fn name() -> &'static str {
        "Your-Board-Name"
    }
}

2. Add feature in Cargo.toml

[features]
board-your-board = [
    # Add optional dependencies needed for your board
    # e.g., "dep:mipidsi" for displays
]

3. Register in board/mod.rs

#[cfg(feature = "board-your-board")]
mod your_board;
#[cfg(feature = "board-your-board")]
pub use your_board::YourBoard as SelectedBoard;

4. Update compile checks

Add your feature to the mutual exclusion check:

#[cfg(all(
    feature = "board-bpi-leaf",
    any(feature = "board-t-dongle", feature = "board-your-board")
))]
compile_error!("Multiple boards selected");

5. Document pin usage

Create hardware documentation in docs/plan-your-board.md showing:

  • Pin assignments (GPIO numbers)
  • Peripheral usage (SPI, RMT, I2C channels)
  • Electrical characteristics (voltage, current)
  • Physical layout (connector pinout)

Feature Flag Reference

Default build (BPI-Leaf-S3):

cargo build --release
# Equivalent to: --features rmk,board-bpi-leaf

T-Dongle-S3 build:

cargo build --release --no-default-features --features rmk,board-t-dongle

Test mode (no RMK, any board):

cargo build --release --no-default-features --features test,board-bpi-leaf
cargo build --release --no-default-features --features test,board-t-dongle

Feature combinations:

  • rmk + board feature → Production mode (USB HID keyboard)
  • test + board feature → Debug mode (ESP-NOW logging only)
  • Multiple board features → Compile error (mutual exclusion enforced)

Display Integration

Boards with displays integrate via the display subsystem:

Task spawning (main.rs):

match board.display {
    OptionalDisplay::None => {
        // No display task needed
    }
    OptionalDisplay::St7735(driver) => {
        spawner.spawn(display_task(&DISPLAY_SIGNAL, driver)).ok();
    }
}

State updates (feedback/state_monitor.rs):

#[cfg(feature = "board-t-dongle")]
DISPLAY_SIGNAL.signal(DisplayEvent::StateChanged(new_state));

Pairing feedback (pairing/controller.rs):

#[cfg(feature = "board-t-dongle")]
DISPLAY_SIGNAL.signal(DisplayEvent::PairingModeEntered { count });

All display code is conditionally compiled—zero overhead when display is None.

Performance Characteristics

LED feedback:

  • WS2812 (BPI-Leaf): RMT hardware, <1ms update
  • APA102 (T-Dongle): SPI3 @ 1MHz, <1ms update

Display refresh:

  • ST7735: SPI2 @ 10MHz
  • Full screen clear: ~15ms
  • Text rendering: ~5ms per element
  • Update only on state change (not polled)

Pairing button:

  • GPIO0 debounced via 100ms async timer
  • Long press detection: 3 seconds
  • No blocking—event-driven via Signal

Known Limitations

T-Dongle-S3 display:

  • No double-buffering (flicker possible on rapid updates)
  • Fixed font (FONT_10X20 from embedded-graphics)
  • No partial updates (full clear + redraw)
  • Backlight timeout fixed at 30s (not configurable)

BPI-Leaf-S3:

  • No visual feedback for layer/lock state
  • LED-only pairing feedback (less informative than display)

Both boards:

  • GPIO0 boot button conflicts with USB bootloader entry (expected)
  • No multi-button support (single button for pairing)