Comms-Feedback: Tower → Cats State Delivery

State delivery system that keeps cats synchronized with tower keyboard state. Tower monitors layer and lock states, unicasts changes to paired cats via ESP-NOW for LED visualization on cats.

What It Does

The feedback system solves a critical problem in distributed split keyboards: cats need to display keyboard state (active layer, caps lock) but don't process key events themselves. Tower observes keyboard events as they flow through VirtualMatrix, tracks state changes, and sends updates to paired cats via unicast.

Key insight: Feedback uses shadow state tracking. Tower maintains parallel state outside RMK by observing events before they reach the framework. This enables state delivery without modifying RMK internals.

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│                         VirtualMatrix                                 │
│                                                                       │
│  KeyPress ──┬──> LayerTracker ──> ACTIVE_LAYER (atomic)             │
│             └──> LockTracker ──> LOCK_STATES (atomic, inference)    │
└───────────────────────────────────────────────────────────────────────┘
                              │
    ┌─────────────────────────┼─────────────────────────┐
    │                         │                         │
    ▼                         ▼                         ▼
┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────┐
│ USB Host         │  │  StateMonitor    │  │ HostIndicatorSync    │
│ (SET_REPORT)     │  │  (Embassy task)  │  │ (Embassy task)       │
│                  │  │                  │  │                      │
│ LED indicators   │  │  • 10Hz polling  │  │ CONTROLLER_CHANNEL   │
└────────┬─────────┘  │  • 1Hz heartbeat │  │ subscriber           │
         │            └────────┬─────────┘  └────────┬─────────────┘
         │                     │                     │
         │                     │                     ▼
         │                     │            LOCK_STATES (atomic, HID)
         │                     │            HID_MODE_ACTIVE (flag)
         │                     │                     │
         └─────────────────────┴─────────────────────┘
                               │
                               ▼
                      FEEDBACK_CHANNEL (cap: 4)
                               │
                               ▼
                      ESP-NOW unicast → Paired Cats

Data Flow

  1. Capture: VirtualMatrix receives InputActions from cats via ESP-NOW
  2. Track: Before forwarding to RMK, VirtualMatrix calls trackers
  3. Publish: Trackers update global atomics (ACTIVE_LAYER, LOCK_STATES)
  4. Poll: StateMonitor reads atomics at 10Hz
  5. Deliver: On change or heartbeat, sends FeedbackFrame to paired cats via unicast

Atomic Communication Pattern

Trackers and monitor communicate via global atomics. This decouples state tracking from state delivery.

Why atomics:

  • Lock-free reads in StateMonitor (no blocking)
  • Simple write-only interface for trackers (no channel management)
  • Natural fit for single-writer (trackers), single-reader (monitor) pattern

Atomics:

ACTIVE_LAYER: AtomicU8   // Current keyboard layer (0-15)
LOCK_STATES: AtomicU8    // Bitfield: bit0=Caps, bit1=Num, bit2=Scroll

Layer Tracking

LayerTracker maintains shadow layer state by observing key events and looking up actions in keymap.

Supported Layer Actions

ActionQMK CodeBehaviorStack Impact
MomentaryMO(n)Active while heldPush to momentary stack
ToggleTG(n)Toggle on/offSet bit in toggled_layers
DefaultTO(n)Switch to layer (exclusive)Clear stack, set default
One-shotOSL(n)Active for next key onlySet one_shot_layer
Layer-tapLT(n, key)Layer on hold, key on tapTreat as MO(n) for hold

Layer Priority Algorithm

When multiple layers are active, priority determines which layer is used for keymap lookups:

Priority (highest → lowest):
1. One-shot layer (if pending)
2. Top of momentary stack (last MO key pressed)
3. Highest toggled layer (highest bit set in bitfield)
4. Default layer (set by TO, else 0)

Example:

State:
  default_layer = 0
  toggled_layers = 0b0000_0010 (layer 1)
  momentary_stack = [2, 3]
  one_shot_layer = Some(4)

Result: active_layer = 4 (one-shot wins)

After consuming one-shot:
Result: active_layer = 3 (top of momentary stack)

After releasing MO(3) key:
Result: active_layer = 2 (next in stack)

After releasing MO(2) key:
Result: active_layer = 1 (toggled layer)

Integration

VirtualMatrix embeds LayerTracker and calls it during key event processing:

// In VirtualMatrix::handle_input_action()
match action {
    KeyPress { row, col } => {
        // Track BEFORE forwarding to RMK
        self.layer_tracker.on_key_press(vrow, vcol);

        // Forward to RMK
        self.event_tx.send(KeyEvent::Press(vrow, vcol)).await;
    }

    KeyRelease { row, col } => {
        self.layer_tracker.on_key_release(vrow, vcol);
        self.event_tx.send(KeyEvent::Release(vrow, vcol)).await;
    }
}

Critical ordering: Layer tracking happens BEFORE RMK receives events. This ensures ACTIVE_LAYER reflects state when RMK processes the event.

Lock Tracking

LockTracker monitors Caps Lock, Num Lock, and Scroll Lock states. Supports two modes.

Tracking Modes

Inference Mode (default):

  • Toggles state when lock key detected in keymap
  • No host confirmation - assumes key toggles lock
  • Sufficient for LED feedback (eventual consistency via USB reports)
  • Active until host sends first LED indicator

HID Mode (auto-enabled):

  • Updates from USB HID LED output reports via host indicator sync
  • Host is authoritative (handles edge cases like OS-level caps lock, initial state sync)
  • Preferred when available
  • Automatically enabled when tower receives first SET_REPORT from host

Mode switching is automatic and one-way. Once HID mode activates, inference stops permanently.

Lock State Bitfield

LOCK_STATES: AtomicU8

Bit layout:
  bit 0: Caps Lock
  bit 1: Num Lock
  bit 2: Scroll Lock

Host Indicator Sync

Tower receives USB HID LED state via RMK's controller event system.

Architecture:

USB Host → SET_REPORT (LED byte) → RMK USB stack
                                      ↓
                      ControllerEvent::KeyboardIndicator
                                      ↓
                            CONTROLLER_CHANNEL
                                      ↓
                      run_host_indicator_sync() task
                                      ↓
                          LOCK_STATES atomic + HID_MODE_ACTIVE flag
                                      ↓
                      StateMonitor → FeedbackFrame → Cats

Problem solved: When tower connects to host with Caps Lock already ON, tower learns initial state via SET_REPORT. Without this sync, tower starts with all locks OFF, causing inverted behavior.

How it works:

  1. Host sends SET_REPORT with current LED state (happens on USB enumeration)
  2. RMK publishes ControllerEvent::KeyboardIndicator(LedIndicator) to CONTROLLER_CHANNEL
  3. run_host_indicator_sync() task receives event
  4. Task converts RMK's LedIndicator to internal lock_bits format
  5. Task updates LOCK_STATES atomic (StateMonitor reads this)
  6. Task sets HID_MODE_ACTIVE flag (disables inference in LockTracker)

Bit format conversion:

RMK's LedIndicator provides boolean methods:

indicator.caps_lock()    // true if ON
indicator.num_lock()
indicator.scroll_lock()

Converted to internal lock_bits format:

lock_bits::CAPS_LOCK    // bit 0
lock_bits::NUM_LOCK     // bit 1
lock_bits::SCROLL_LOCK  // bit 2

Integration

Inference Mode (VirtualMatrix):

VirtualMatrix embeds LockTracker and calls it during key presses:

// Track layer first (lock tracker needs current layer)
self.layer_tracker.on_key_press(vrow, vcol);

// Track lock state (uses current layer for keymap lookup)
// Only effective when HID mode not active
self.lock_tracker.on_key_press(
    vrow,
    vcol,
    self.layer_tracker.active_layer()
);

Critical ordering: Lock tracking happens AFTER layer tracking. Lock keys may exist on different layers, so correct layer must be known before keymap lookup.

HID Mode (Host Indicator Sync Task):

Runs as separate Embassy task, joined with RMK tasks:

use embassy_futures::join::join;

join(
    run_devices!(...),
    keyboard.run(),
    run_rmk(...),
    run_state_monitor(feedback_tx),
    run_host_indicator_sync(),  // New task
).await;

Task listens to CONTROLLER_CHANNEL for LED indicator updates from host. Once first update received, sets HID_MODE_ACTIVE flag which disables inference in LockTracker::on_key_press().

State Monitor

StateMonitor polls atomics and unicasts changes to paired cats.

Polling Strategy

10Hz polling: Reads ACTIVE_LAYER and LOCK_STATES every 100ms

  • Detects changes quickly without excessive CPU usage
  • Balances latency (100ms max) with efficiency

1Hz heartbeat: Sends current state to paired cats every second regardless of changes

  • Ensures eventual consistency (cats resync if they missed updates)
  • Tolerates brief network disruptions

Change Detection

StateMonitor maintains last known state. Send only on change:

pub fn update(&mut self, new_state: KeyboardState) {
    if new_state != self.last_state {
        self.sequence_id = self.sequence_id.wrapping_add(1);

        let frame = FeedbackFrame {
            sequence_id: self.sequence_id,
            state: new_state,
        };

        let _ = self.feedback_tx.try_send(frame);
        self.last_state = new_state;
    }
}

Sequence ID: Monotonic counter (wraps at u32::MAX). Cats can detect missed updates by checking sequence gaps.

Channel Behavior

Non-blocking send: Uses try_send() to avoid blocking on full channel

  • If channel full (4 frames buffered), drop oldest frame
  • Non-blocking send decouples state tracking from ESP-NOW delivery
  • Heartbeat ensures eventual consistency despite drops

Task Integration

StateMonitor runs as Embassy task, joined with RMK tasks:

use embassy_futures::join::join4;

join4(
    run_devices!(...),
    keyboard.run(),
    run_rmk(...),
    run_state_monitor(feedback_tx),
).await;

All four tasks run concurrently. StateMonitor operates independently, reading atomics at its own pace.

Wire Protocol

FeedbackFrame is serialized and sent via ESP-NOW unicast to each paired cat. All frames are wrapped in WireFrame enum for type-safe deserialization.

Frame Structure

pub struct FeedbackFrame {
    pub sequence_id: u32,
    pub state: KeyboardState,
}

pub struct KeyboardState {
    pub active_layer: u8,
    pub lock_states: u8,
    pub battery_level: Option<u8>,
}

pub enum WireFrame {
    Input(InputFrame),      // Cat → Tower
    Feedback(FeedbackFrame), // Tower → Cats
}

Serialization: Uses postcard (efficient binary format)

Typical size: ~9-10 bytes (1 byte enum tag + ~8 bytes payload, fits comfortably in ESP-NOW 250-byte limit)

Frame Type Discrimination

WireFrame enum provides type-safe deserialization and prevents frame confusion bugs.

Why needed:

Without type tags, postcard deserializes bytes without validation. In broadcast environments where multiple device types transmit (cats send InputFrames, tower sends FeedbackFrames), a cat receiving another cat's InputFrame could accidentally interpret it as a FeedbackFrame. This caused LED flashing bugs when InputFrame byte patterns matched FeedbackFrame structure.

How it works:

The enum tag (1 byte) discriminates between frame types at deserialization time. When a device receives an ESP-NOW packet:

  1. Deserialize as WireFrame (validates enum tag)
  2. Match on variant (Input or Feedback)
  3. Process only the expected type, ignore others

Tower usage:

// Serialize feedback wrapped in WireFrame
let wire_frame = WireFrame::Feedback(feedback);
let bytes = common::serialize(&wire_frame)?;
// Send unicast to each paired cat
for mac in paired_macs.iter() {
    esp_now.send_async(mac, bytes.as_slice()).await?;
}

Cat usage:

// Serialize input wrapped in WireFrame
let wire_frame = WireFrame::Input(input_frame);
let bytes = wire_format::serialize(&wire_frame)?;
esp_now.send_async(&dongle_mac, bytes.as_slice()).await?;

// Deserialize and filter by type
match wire_format::deserialize::<WireFrame>(data) {
    Ok(WireFrame::Feedback(feedback)) => {
        // Process feedback from tower
        led_signal.signal(feedback);
    }
    Ok(WireFrame::Input(_)) => {
        // Another cat's frame - ignore
    }
    Err(_) => {
        // Corrupt packet - ignore
    }
}

Result: Cats only process FeedbackFrames from tower. InputFrames from other cats are explicitly ignored, preventing cross-deserialization errors.

Unicast Characteristics

ESP-NOW unicast:

  • ACK + hardware retries (reliable delivery per paired cat)
  • Low latency (~0.5ms tower → cat)
  • Only paired cats receive frames (rogue tower protection)

Reliability strategy:

  • Change-triggered updates (low latency)
  • Periodic heartbeat (eventual consistency)
  • Sequence ID (missed update detection)

Combined strategy tolerates brief network disruptions while maintaining responsive feedback.

Module Organization

firmware/tower/src/feedback/
├── mod.rs                   # Module exports
├── layer_tracker.rs         # Layer state tracking
├── lock_tracker.rs          # Lock state tracking (inference + atomics)
├── host_indicator_sync.rs   # USB LED sync via RMK controller events
└── state_monitor.rs         # Polling and delivery

firmware/common/src/feedback.rs  # Wire protocol types

Feature Gating

rmk feature:

  • LayerTracker struct (requires keymap access)
  • LockTracker struct (requires keymap access)
  • run_state_monitor() function (requires trackers)
  • run_host_indicator_sync() function (requires CONTROLLER_CHANNEL)

Always available:

  • Atomics (ACTIVE_LAYER, LOCK_STATES)
  • HID_MODE_ACTIVE flag (read via is_hid_mode_active())
  • Helper functions (set_active_layer, toggle_caps_lock, etc.)
  • StateMonitor struct (for manual state updates)

This allows test mode to manually update atomics without full tracker infrastructure.

Usage Examples

Full Integration (RMK Mode)

use crate::feedback::{LayerTracker, LockTracker, run_state_monitor};

// In VirtualMatrix
pub struct VirtualMatrix {
    keymap: &'static TowerKeymap,
    layer_tracker: LayerTracker,
    lock_tracker: LockTracker,
}

impl VirtualMatrix {
    pub fn new(keymap: &'static TowerKeymap) -> Self {
        Self {
            keymap,
            layer_tracker: LayerTracker::new(keymap),
            lock_tracker: LockTracker::new(keymap),
        }
    }

    async fn handle_input_action(&mut self, action: InputAction) {
        match action {
            InputAction::KeyPress { row, col } => {
                // Track state BEFORE forwarding to RMK
                self.layer_tracker.on_key_press(vrow, vcol);
                self.lock_tracker.on_key_press(
                    vrow,
                    vcol,
                    self.layer_tracker.active_layer()
                );

                // Forward to RMK
                self.event_tx.send(KeyEvent::Press(vrow, vcol)).await;
            }
            InputAction::KeyRelease { row, col } => {
                self.layer_tracker.on_key_release(vrow, vcol);
                self.event_tx.send(KeyEvent::Release(vrow, vcol)).await;
            }
        }
    }
}

// In main task (RMK mode)
join(
    run_devices!(...),
    keyboard.run(),
    run_rmk(...),
    run_state_monitor(feedback_tx),
    run_host_indicator_sync(),
).await;

Test Mode (Manual Updates)

use crate::feedback::{set_active_layer, toggle_caps_lock};

// Simulate layer change
set_active_layer(2);

// Simulate lock key press
toggle_caps_lock();

// StateMonitor will unicast these changes to paired cats

Host Indicator Sync Task

use crate::feedback::run_host_indicator_sync;

// Join with other RMK tasks
join(
    run_devices!(...),
    keyboard.run(),
    run_rmk(...),
    run_state_monitor(feedback_tx),
    run_host_indicator_sync(),  // Subscribes to CONTROLLER_CHANNEL
).await;

Task automatically receives LED updates from RMK's controller system. No manual integration needed beyond spawning the task.

Performance Characteristics

Memory:

  • LayerTracker: ~50 bytes (stacks + state)
  • LockTracker: ~10 bytes (bitfield + flags)
  • StateMonitor: ~20 bytes (sequence + last state)
  • Total: ~80 bytes per VirtualMatrix

CPU:

  • Layer tracking: ~10 μs per key event (keymap lookup + stack ops)
  • Lock tracking: ~5 μs per key event (keymap lookup + bitfield)
  • State monitoring: ~2 μs per poll (atomic reads + comparison)
  • Unicast send: ~20 μs per cat (postcard serialization + ESP-NOW)

Network:

  • Feedback frame: ~8 bytes
  • Send rate: ~10 Hz (change-driven) + 1 Hz (heartbeat)
  • Typical traffic: <100 bytes/sec

Limitations and Tradeoffs

Shadow state tracking:

  • State can desync if RMK behavior differs from expected
  • Example: RMK applies special handling to certain layer combinations
  • Mitigation: Heartbeat provides eventual consistency

Delivery reliability:

  • Unicast has ACK + retries per paired cat
  • Cats may briefly show stale state if retries exhaust
  • Mitigation: Heartbeat ensures resync within 1 second

Inference mode lock tracking:

  • Assumes lock keys toggle state
  • Doesn't handle OS-level lock key presses
  • Mitigation: HID mode (when available) provides ground truth

No per-cat state:

  • All paired cats receive identical FeedbackFrames (no individualization)
  • Can't track per-device battery level yet
  • Future: Device-specific feedback content (addressing already unicast)

Future Enhancements

Per-cat battery monitoring:

  • Cats include battery level in InputFrame header
  • Tower aggregates and unicasts back to each cat (each cat sees all battery levels)
  • Requires FeedbackFrame extension

Advanced LED patterns:

  • Layer-specific color schemes
  • Animation triggers (layer switch, lock toggle)
  • Requires cat-side LED pattern engine

Diagnostic feedback:

  • Send connection quality metrics to each cat
  • Notify cats of packet loss
  • Help users identify RF interference issues

See Also

  • /firmware/docs/architecture.md - Overall system architecture
  • /firmware/docs/esp-now-integration.md - ESP-NOW unicast mechanics
  • /firmware/common/src/feedback.rs - Wire protocol types
  • /firmware/tower/src/pipelines/virtual_matrix.rs - Tracker integration