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 ReadInputAction

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

InputActionInputFrame

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

InputFrameWireless 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 PacketInputAction

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

InputActionUSB 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::AxisEventStream from 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 ReportHost

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

StageTimeCumulative
Matrix scan + debounce1ms1ms
Event generation + frame build0.1ms1.1ms
ESP-NOW transmission0.5ms1.6ms
Tower deserialize + route0.5ms2.1ms
RMK processing3ms5.1ms
USB poll (worst case)8ms13.1ms
USB poll (best case)1ms6.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());
  • 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