LYNXtower Firmware
LYNXtower is an event aggregator with virtual InputDevice pipelines. It receives wireless InputFrames from LYNXcats via ESP-NOW, routes them through type-specific pipelines (keyboard, pointer), and generates composite USB HID reports to the host.
Key Architecture Insight: Mouse motion and scroll require a PointerProcessor to convert Event::AxisEventStream to USB mouse reports. Keyboard events flow through RMK's Keyboard::run() which handles the keymap lookup and report generation.
┌─────────────────────────────────────────────────────────────────────┐
│ LYNXtower │
│ │
│ ┌─────────────┐ │
│ │ ESP-NOW RX │ ← InputFrames from LYNXcats │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Router │ Route by InputAction type │
│ └──────┬──────┘ │
│ │ │
│ ┌──────┴──────────────────────────────────────┬────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ KEYBOARD PIPELINE │ │ POINTER PIPELINE │ │
│ │ │ │ │ │
│ │ keyboard_channel │ │ pointer_channel │ │
│ │ ↓ │ │ ↓ │ │
│ │ VirtualMatrix │ │ VirtualPointing │ │
│ │ (InputDevice trait) │ │ (InputDevice) │ │
│ │ ↓ │ │ ↓ │ │
│ │ Event::Key │ │ AxisEventStream │ │
│ │ ↓ │ │ ↓ │ │
│ │ Keyboard::run() │ │ PointerProcessor │ │
│ │ (keymap lookup) │ │ (merge motion + scroll @ 125Hz)│ │
│ │ ↓ │ │ ↓ │ │
│ │ KeyboardReport │ │ MouseReport (merged) │ │
│ └────────────┬─────────────┘ └────────────┬────────────────────┘ │
│ │ │ │
│ └─────────────┬───────────────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ USB HID │ Composite (Keyboard+Mouse) │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
Host PC
Build Modes
Two mutually exclusive modes via Cargo features:
| Mode | Command | Purpose |
|---|---|---|
test (default) | cargo run --release | ESP-NOW debugging, verbose logging, no USB HID |
rmk | cargo run --release --features rmk --no-default-features | Production mode with USB HID and Vial |
Why two modes? RMK owns USB for HID, blocking serial logging. Test mode enables ESP-NOW debugging with full log visibility.
Architecture (Pipelines)
ESP-NOW RX ──> Router ───┬──> KEYBOARD PIPELINE ──> VirtualMatrix ──> Keyboard::run() ──> USB Keyboard
│
└──> POINTER PIPELINE ─────> VirtualPointingDevice ──> PointerProcessor ──> USB Mouse
(motion + scroll)
Key Insight from Testing: Event::AxisEventStream is NOT automatically processed by RMK—it requires a PointerProcessor component to convert to USB mouse reports. Keyboard events (Event::Key) are handled by Keyboard::run() which does keymap lookup.
Directory Structure
firmware/tower/src/
├── bin/main.rs # Entry point (test/rmk modes)
├── lib.rs # Module declarations
├── config.rs # USB/storage/RMK configuration
├── test_sequence_tracker.rs # Test utilities
│
├── pipelines/ # Input processing (RMK mode only)
│ ├── mod.rs
│ ├── virtual_matrix.rs # Keyboard: InputFrame → RMK Events
│ ├── mouse_processor.rs # Pointer: motion + scroll accumulation @ 125Hz
│ └── sensor_handler.rs # Sensor: diagnostics (MVP)
│
├── feedback/ # State delivery subsystem
│ ├── mod.rs
│ ├── layer_tracker.rs # Layer state tracking
│ ├── lock_tracker.rs # Lock state tracking (inference)
│ ├── host_indicator_sync.rs # USB LED sync via RMK
│ └── state_monitor.rs # State polling and delivery
│
├── pairing/ # Device pairing subsystem
│ ├── mod.rs
│ ├── manager.rs # Pairing state machine
│ ├── controller.rs # Button/pairing/LED coordinator
│ ├── button_monitor.rs # BOOT button (GPIO0)
│ ├── led_feedback.rs # WS2812 LED patterns
│ ├── flash_manager.rs # Flash I/O service
│ └── storage.rs # Pairing config persistence
│
└── comm/ # Communication layer
├── mod.rs
├── receiver.rs # ESP-NOW RX + routing
└── sequence_tracker.rs # Packet loss detection
Layers
1. ESP-NOW Receiver
Module: comm/receiver.rs
- Receives packets asynchronously
- Deserializes InputFrame (postcard)
- MAC filtering via PairingManager
- Routes by InputAction type to pipelines:
- KeyPress/KeyRelease → Keyboard Pipeline (keyboard_channel)
- MouseMotion, ScrollIncrement → Pointer Pipeline (pointer_channel)
- JoystickPosition/Gyro/Accel → sensor channel (MVP: logs only)
- DeviceAnnouncement → logged only (MVP)
2. Pipeline Channels
Static allocations (Embassy channels):
Channel<InputFrame, 64> // keyboard pipeline (high capacity, blocking send)
Channel<InputFrame, 16> // pointer pipeline (motion + scroll, try_send)
Channel<InputFrame, 8> // sensor (low capacity, MVP, try_send)
Routing strategy:
- Keyboard events: Blocking send with 10ms timeout — KeyRelease MUST be delivered to prevent stuck keys
- Pointer/sensor: Best-effort
try_send()— dropped frames acceptable (motion is lossy anyway)
3. WirelessMatrixDevice
Module: pipelines/virtual_matrix.rs
Implements RMK InputDevice trait.
- Converts InputFrame → RMK Event
- Key index calculation:
(device, slot, row, col) → [0, 71] - Sequence tracking (per-device packet loss detection)
- Packet loss recovery: releases all keys from affected device
DirectPin mode: row = key_index, col = 0
4. RMK Engine
Module: config.rs
- Keymap processing (layers, macros, tap-hold)
- USB HID report generation (125Hz)
- Vial GUI support
5. Mouse & Scroll Pipeline Components
VirtualPointingDevice (pipelines/virtual_pointing_device.rs)
- Implements RMK
InputDevicetrait - Receives
InputAction::MouseMotionandInputAction::ScrollIncrementfrom pointer_channel - Converts to
Event::AxisEventStream(X, Y, and Wheel axes)
PointerProcessor (pipelines/mouse_processor.rs)
- Implements RMK
InputProcessortrait - Receives
Event::AxisEventStreamfrom VirtualPointingDevice - Accumulates X/Y motion and scroll deltas at 125Hz
- Reads button state via
get_mouse_buttons()from VirtualMatrix - Generates merged
MouseReportwith motion + scroll + buttons - Single USB report per 8ms tick
- Wakes on any pointer input (motion or scroll) for power efficiency
Unified Pointer Processing:
- Both MouseMotion and ScrollIncrement route to same channel
- Single processor handles all pointer input types
- Scroll-only setups work correctly (scroll wakes processor from idle)
- Simpler architecture (no atomic coordination needed between motion and scroll)
- SCROLL_MULTIPLIER = 1 (1 detent ≈ 3 scroll lines on host)
Mouse Button Coordination:
MOUSE_BUTTON_STATE: AtomicU8- shared button state (lock-free)- VirtualMatrix detects mouse button keycodes (MouseBtn1-5) by querying keymap
- On press/release, updates atomic via
set_mouse_button()/clear_mouse_button() - PointerProcessor reads via
get_mouse_buttons()when building reports - Ensures button state and motion merge in same USB HID report (enables drag)
Key Finding: AxisEventStream events are NOT automatically processed by RMK. Pointer input requires dedicated processor for USB HID output.
6. Sequence Tracker
Module: comm/sequence_tracker.rs
Per-device sequence tracking.
- Detects gaps in u32 sequence IDs
- Handles rollover (wrapping arithmetic)
- Triggers packet loss recovery
7. Pairing System
Modules: pairing/ directory (manager, controller, button_monitor, led_feedback, flash_manager, storage)
- BOOT button (GPIO0): enter pairing mode (3s hold)
- LED (GPIO48 WS2812): visual feedback
- Flash persistence: pairing config stored at 0x350000
- Flash Manager Service: exclusive flash access via channels
- Auto-save: persists config on pairing timeout or max devices
Virtual Keyboard Layout
Basic Setup: 72 keys (LeftCat + RightCat)
Total: 204 keys (8 devices × ~36 keys each)
Current:
LeftCat: indices 0-35 (Finger 24 + Thumb 12)
RightCat: indices 36-71 (Finger 24 + Thumb 12)
Future:
Center, LeftAux, RightAux, Extra1, Extra2: indices 72-203
Matrix mode: DirectPin (72 rows × 1 col)
Must match vial.json: {"rows": 72, "cols": 1}
USB Configuration
VID: 0x4653 // LYNXware
PID: 0x0004 // LYNXtower
Product: "LYNXtower"
Serial: "vial:f64c2b3c:tower001" // Vial magic prefix
Vial unlock: keys (0,0) + (0,1) (LeftCat Finger row 0, cols 0-1)
Flash Storage
Two non-overlapping regions:
RMK keymap: 0x3f0000 (2 sectors)
Pairing config: 0x350000 (1 sector, 4KB)
SAFETY: Two FlashStorage instances safe because:
- Non-overlapping regions
- Flash Manager Service ensures exclusive pairing access
- RMK's async_flash_wrapper ensures exclusive keymap access
- esp-storage uses ROM functions (no hardware state)
Key Modules
| Directory | Module | Purpose |
|---|---|---|
| root | bin/main.rs | Entry points (test/rmk modes), system initialization |
| root | config.rs | USB descriptors, keymap, Vial config |
| comm/ | receiver.rs | ESP-NOW RX, MAC filtering, routing by InputAction type |
| comm/ | sequence_tracker.rs | Per-device packet loss detection |
| pipelines/ | virtual_matrix.rs | InputFrame → RMK Event, InputDevice trait |
| pipelines/ | mouse_processor.rs | Pointer accumulation (motion + scroll), MouseReport @ 125Hz |
| pipelines/ | sensor_handler.rs | Sensor diagnostics (MVP) |
| feedback/ | layer_tracker.rs | Layer state tracking via keymap observation |
| feedback/ | lock_tracker.rs | Lock state tracking (inference mode) |
| feedback/ | host_indicator_sync.rs | USB LED sync via RMK CONTROLLER_CHANNEL |
| feedback/ | state_monitor.rs | State polling (10Hz) and unicast delivery to paired cats |
| pairing/ | manager.rs | Pairing state machine |
| pairing/ | controller.rs | Coordinates button/pairing/LED |
| pairing/ | button_monitor.rs | BOOT button monitoring |
| pairing/ | led_feedback.rs | WS2812 LED patterns |
| pairing/ | flash_manager.rs | Flash Manager Service (exclusive access) |
| pairing/ | storage.rs | Pairing config persistence |
Performance Targets
Latency: <10ms end-to-end
USB report rate: 125Hz (8ms interval)
Memory: <100KB heap
Packet loss: <1% tolerance
Task Architecture (RMK mode)
main() spawns:
1. flash_manager_service (owns FlashStorage for pairing)
2. button_monitor_task (GPIO0 BOOT button)
3. pairing_controller_task (state machine)
4. led_feedback_task (WS2812 LED)
5. esp_now_receiver (high priority)
6. pointer_processor_task (medium priority, merges motion + scroll)
7. sensor_handler (low priority)
main() joins:
- run_devices!(wireless_matrix, virtual_pointing_device)
- keyboard.run()
- run_rmk()
- run_state_monitor() (feedback unicast to paired cats, 10Hz polling)
- run_host_indicator_sync() (USB LED sync via RMK controller events)
Test Mode
Simplified architecture for ESP-NOW debugging:
- Same receiver task + pairing system
- Channels created but only logged
- No RMK initialization
- No USB stack
- test_mode_loop: logs all InputFrames with detailed diagnostics
- Sequence tracking, packet loss detection
- Per-device statistics (every 100 frames)
- Heartbeat (every 10s)