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:
Nonefor boards without displaysSt7735(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)