Event Pipeline
Complete data flow from physical keypress to USB HID output.
Pipeline Overview
Physical Input
↓
[LYNXcat]
Matrix Scan (1kHz) / Mouse Sensor / Scroll Encoder
↓
Debounce (5ms) / Motion Delta / Detent Count
↓
EventGenerator → InputAction (KeyPress/MouseMotion/ScrollIncrement)
↓
Frame Builder → InputFrame (postcard serialization)
↓
ESP-NOW Broadcast (~0.5ms latency, 250 byte max)
↓
[LYNXtower]
Deserialize → InputFrame
↓
Route by InputAction type
↓
┌─────────────┬──────────────┬─────────────┐
│ │ │ │
▼ ▼ ▼ ▼
Keyboard Pointer Sensor (Reserved)
Pipeline Pipeline (MVP)
↓ ↓
VirtualMatrix VirtualPointing
↓ ↓
RMK Engine AxisEventStream (motion + scroll)
↓ ↓
PointerProcessor
↓
KeyboardReport / MouseReport (merged)
↓
USB HID
↓
Host Computer
Total Latency Target: <10ms (keypress to USB)
Stage 1: LYNXcat Input Scanning
Hardware Read → InputAction
Matrix Scanning
- Poll rate: 1kHz (1ms intervals)
- Hardware: GPIO matrix (4×6 finger, 4×3 thumb)
- Debounce: 5ms window to filter mechanical bounce
Output: Raw key state changes (pressed/released)
Event Generation
Transform hardware events into typed actions:
- Matrix:
InputAction::KeyPress { row, col }/InputAction::KeyRelease { row, col } - Mouse:
InputAction::MouseMotion { delta: (i8, i8) } - Scroll:
InputAction::ScrollIncrement { delta: i8 } - Joystick:
InputAction::JoystickPosition { position: (i16, i16) }
Coordinates are slot-local:
- Finger slot: row 0-3, col 0-5 (4×6 matrix)
- Thumb slot: row 0-3, col 0-2 (4×3 matrix)
- Key index calculation happens on tower side
Timing: <1ms (scan + debounce state machine)
Stage 2: Frame Construction
InputAction → InputFrame
Frame Structure
struct InputFrame {
header: FrameHeader,
payload: InputAction,
}
struct FrameHeader {
sender: DeviceRole, // LeftCat/RightCat
module_slot: ModuleSlot, // Finger/Thumb/Bottom
sequence_id: u32, // Per-device counter
}
Serialization
- Format:
postcard(compact binary) - Size: 8-20 bytes typical (depends on InputAction variant)
- Max: 250 bytes (ESP-NOW limit)
Timing: <0.1ms (serialization overhead)
Stage 3: ESP-NOW Transmission
InputFrame → Wireless Packet
Protocol Characteristics
- Type: Connectionless broadcast (MAC layer)
- Latency: ~0.5ms (air time)
- Range: ~10m indoor
- Reliability: Best-effort (no ACK in broadcast mode)
Packet Loss Handling
- Sequence ID tracking (tower detects gaps)
- Key releases always sent (critical for stuck key prevention)
- No retransmission (latency priority over reliability)
Timing: ~0.5ms
Stage 4: LYNXtower Reception
Wireless Packet → InputAction
Deserialization
postcard::from_bytes::<InputFrame>()on receive callback- Validate sender (LeftCat/RightCat expected)
- Log sequence gaps (packet loss detection)
Action Routing
Route by InputAction type:
- KeyPress/KeyRelease → keyboard_channel → VirtualMatrix → RMK Engine
- MouseMotion, ScrollIncrement → pointer_channel → VirtualPointingDevice → pointer_processor_task
- JoystickPosition/Gyro/Accel → sensor_channel (MVP: logs only)
Timing: <0.5ms (deserialize + queue)
Stage 5: Tower Processing
InputAction → USB HID Report
Keyboard Pipeline
WirelessMatrixDevice (wireless_matrix.rs)
Implements RMK's InputDevice trait:
async fn read_event(&mut self) -> Event {
// Awaits InputFrame from keyboard_channel
// Converts (device, slot, row, col) → key index
// Returns Event::Press/Release(key_index)
}
Key Mapping: Tower uses DirectPin mode (virtual matrix):
- LeftCat indices: 0-35 (Finger 0-23, Thumb 24-35)
- RightCat indices: 36-71 (Finger 36-59, Thumb 60-71)
- RMK sees:
(key_index, 0, pressed)— row = key index, col = 0
Vial GUI sees single 72-key layout (Basic Setup).
RMK Engine:
- Layer activation/deactivation
- Tap-hold detection
- Macro expansion
- Generates KeyboardReport
Timing: <3ms (RMK scan + processing)
Pointer Pipeline
VirtualPointingDevice (virtual_pointing_device.rs)
Implements RMK's InputDevice trait:
async fn read_event(&mut self) -> Event {
// Awaits InputFrame from pointer_channel
// Converts MouseMotion → Event::AxisEventStream(X/Y)
// Converts ScrollIncrement → Event::AxisEventStream(Wheel)
}
PointerProcessor (mouse_processor.rs)
- Receives
Event::AxisEventStreamfrom VirtualPointingDevice - Accumulates X/Y motion and scroll deltas at 125Hz
- Reads button state via
get_mouse_buttons()from VirtualMatrix - Merges motion + scroll + buttons into single MouseReport per 8ms tick
- Sends to USB HID endpoint
- Wakes on any pointer input (motion or scroll) for power efficiency
Cross-Pipeline Button Coordination:
VirtualMatrix (keyboard pipeline) detects mouse button keycodes (MouseBtn1-5) by querying keymap. On detection, updates MOUSE_BUTTON_STATE atomic. PointerProcessor reads this atomic when building reports. Lock-free coordination via portable_atomic with Relaxed ordering ensures button state persists across reports (enables drag operations).
Unified 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 lines on host)
Timing: <1ms (accumulate + merge)
Stage 6: USB HID Output
HID Report → Host
Report Types
- Keyboard: 6-key rollover (NKRO optional)
- Mouse: X/Y motion + buttons + wheel
- Consumer: Media keys (volume, play/pause)
USB Transmission
- Poll rate: 125Hz (8ms) or 1000Hz (1ms) depending on host
- Protocol: USB HID boot protocol
Timing: 1-8ms (depends on USB poll rate)
Latency Budget
| Stage | Time | Cumulative |
|---|---|---|
| Matrix scan + debounce | 1ms | 1ms |
| Event generation + frame build | 0.1ms | 1.1ms |
| ESP-NOW transmission | 0.5ms | 1.6ms |
| Tower deserialize + route | 0.5ms | 2.1ms |
| RMK processing | 3ms | 5.1ms |
| USB poll (worst case) | 8ms | 13.1ms |
| USB poll (best case) | 1ms | 6.1ms |
Observed: 6-13ms total (within target)
Data Loss Scenarios
Packet Loss
- Symptom: Sequence ID gap detected
- Impact: Lost keypress (tower never sees event)
- Mitigation: Key scan runs continuously; user retries press
Stuck Keys
- Scenario: KeyPress received but KeyRelease lost
- Mitigation: Tower timeout logic (optional, not yet implemented)
- Current: Relies on reliable ESP-NOW (99%+ delivery in practice)
USB Buffer Overflow
- Scenario: Too many events queued before USB poll
- Mitigation: RMK internal queue (handles burst input)
Testing & Debugging
Cat-Side Logging
Test mode (default build):
cargo run --release
Logs: Matrix state, InputActions, ESP-NOW send status
Tower-Side Logging
Test mode (ESP-NOW only, no RMK):
cargo run --release # default features
Logs: Frame reception, deserialization, sequence gaps
RMK mode (production):
cargo run --release --features rmk --no-default-features
Minimal logging (USB serial unavailable during HID operation)
Latency Measurement
Add timestamps at each stage:
let t0 = Instant::now();
// ... processing ...
log::info!("Stage X: {:?}", t0.elapsed());
Related Files
- InputAction definitions:
firmware/common/src/input_actions.rs - Wire format (serialization):
firmware/common/src/wire_format.rs - Cat event generation:
firmware/cat/src/event_gen.rs - Tower ESP-NOW receiver:
firmware/tower/src/comm/receiver.rs - Tower WirelessMatrixDevice:
firmware/tower/src/pipelines/virtual_matrix.rs - Tower VirtualPointingDevice:
firmware/tower/src/pipelines/virtual_pointing_device.rs - Tower PointerProcessor:
firmware/tower/src/pipelines/mouse_processor.rs - Key index calculation:
firmware/common/src/key_index.rs - RMK config:
firmware/tower/src/config.rs