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
- Capture: VirtualMatrix receives InputActions from cats via ESP-NOW
- Track: Before forwarding to RMK, VirtualMatrix calls trackers
- Publish: Trackers update global atomics (ACTIVE_LAYER, LOCK_STATES)
- Poll: StateMonitor reads atomics at 10Hz
- 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
| Action | QMK Code | Behavior | Stack Impact |
|---|---|---|---|
| Momentary | MO(n) | Active while held | Push to momentary stack |
| Toggle | TG(n) | Toggle on/off | Set bit in toggled_layers |
| Default | TO(n) | Switch to layer (exclusive) | Clear stack, set default |
| One-shot | OSL(n) | Active for next key only | Set one_shot_layer |
| Layer-tap | LT(n, key) | Layer on hold, key on tap | Treat 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:
- Host sends SET_REPORT with current LED state (happens on USB enumeration)
- RMK publishes
ControllerEvent::KeyboardIndicator(LedIndicator)toCONTROLLER_CHANNEL run_host_indicator_sync()task receives event- Task converts RMK's
LedIndicatorto internal lock_bits format - Task updates
LOCK_STATESatomic (StateMonitor reads this) - Task sets
HID_MODE_ACTIVEflag (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:
- Deserialize as
WireFrame(validates enum tag) - Match on variant (
InputorFeedback) - 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