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:

ModeCommandPurpose
test (default)cargo run --releaseESP-NOW debugging, verbose logging, no USB HID
rmkcargo run --release --features rmk --no-default-featuresProduction 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 InputDevice trait
  • Receives InputAction::MouseMotion and InputAction::ScrollIncrement from pointer_channel
  • Converts to Event::AxisEventStream (X, Y, and Wheel axes)

PointerProcessor (pipelines/mouse_processor.rs)

  • Implements RMK InputProcessor trait
  • Receives Event::AxisEventStream from VirtualPointingDevice
  • Accumulates X/Y motion and scroll deltas at 125Hz
  • Reads button state via get_mouse_buttons() from VirtualMatrix
  • Generates merged MouseReport with 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

DirectoryModulePurpose
rootbin/main.rsEntry points (test/rmk modes), system initialization
rootconfig.rsUSB descriptors, keymap, Vial config
comm/receiver.rsESP-NOW RX, MAC filtering, routing by InputAction type
comm/sequence_tracker.rsPer-device packet loss detection
pipelines/virtual_matrix.rsInputFrame → RMK Event, InputDevice trait
pipelines/mouse_processor.rsPointer accumulation (motion + scroll), MouseReport @ 125Hz
pipelines/sensor_handler.rsSensor diagnostics (MVP)
feedback/layer_tracker.rsLayer state tracking via keymap observation
feedback/lock_tracker.rsLock state tracking (inference mode)
feedback/host_indicator_sync.rsUSB LED sync via RMK CONTROLLER_CHANNEL
feedback/state_monitor.rsState polling (10Hz) and unicast delivery to paired cats
pairing/manager.rsPairing state machine
pairing/controller.rsCoordinates button/pairing/LED
pairing/button_monitor.rsBOOT button monitoring
pairing/led_feedback.rsWS2812 LED patterns
pairing/flash_manager.rsFlash Manager Service (exclusive access)
pairing/storage.rsPairing 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)