LYNXcat Power Management

Progressive power state system for battery-powered LYNXcat devices. Reduces consumption during idle periods through coordinated changes to scan rate, LED brightness, and radio state.

Overview

LYNXcat implements a progressive power management system with three active states:

  • ACTIVE — Full performance during typing/input
  • IDLE — Reduced scan rate and LED dimming after short idle
  • DROWSY — Radio shutdown and minimal LEDs after extended idle

Power management is decoupled from hardware. The power_task state machine writes to a global atomic POWER_STATE. All other tasks read this state to adapt their behavior. No shared mutable state, no locks.

Implemented States

StateScan RateLED0LED1RadioCurrentTimeout
ACTIVE1kHz (1ms)10%10%ON~50mA
IDLE100Hz (10ms)5%5%ON~15mA3min idle
DROWSY50Hz (20ms)1%OFFOFF~8-12mA10min idle

Notes:

  • LED0 — Layer color indicator, or caps lock breathing if active
  • LED1 — Layer color indicator, requires feedback from tower
  • Current draw — Measured on Banana Pi BPI-Leaf-S3 with 500mAh battery
  • DROWSY radio — ESP-NOW idle (not fully deinitialized, see limitations)

Architecture

Progressive power states achieved through atomic state machine and lock-free reads.

┌─────────────────────────────────────────────────────────────────┐
│                       LYNXcat Tasks                              │
│                                                                   │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ finger_matrix│  │ thumb_matrix │  │ scroll_wheel │  ...      │
│  │    _task     │  │    _task     │  │    _task     │          │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘          │
│         │                 │                 │                    │
│         └────────────┬────┴────────────────┘                    │
│                      ▼                                           │
│              ActivityTracker                                     │
│         (atomic timestamp + signal)                              │
│                      │                                           │
│         ┌────────────┴────────────┐                             │
│         ▼                         ▼                              │
│   power_task                POWER_STATE                          │
│   (state machine)          (atomic global)                       │
│         │                         │                              │
│         │              ┌──────────┼──────────┐                  │
│         │              ▼          ▼          ▼                  │
│         │        scan_interval() led_brightness_factor()        │
│         │              │          │                              │
│         │              ▼          ▼                              │
│         │        Scanner tasks  LED task                         │
│         │        (query rate)   (query brightness)               │
└─────────┴────────────────────────────────────────────────────────┘

Components:

ActivityTracker — Lock-free activity timestamp tracking

  • Stores last activity time as AtomicU32 (milliseconds since boot)
  • Provides Signal for immediate wake notification
  • Updated by all scanner tasks on input detection

power_task — State machine loop

  • Reads ActivityTracker to determine target state
  • Writes transitions to POWER_STATE atomic
  • Uses select() to wake on timeout OR activity signal
  • No hardware handles, pure state logic

POWER_STATE — Global atomic AtomicU8

  • Written only by power_task
  • Read by all tasks via current_power_state()
  • Relaxed ordering (no synchronization needed)

Helper functions — Query current state behavior

  • scan_interval() — Matrix scan rate for current state
  • led_brightness_factor() — LED brightness multiplier
  • led1_enabled() — Whether LED1 should be on
  • radio_enabled() — Whether ESP-NOW should transmit

State Transitions

Progressive timeout cascade with instant wake on activity.

ACTIVE ──(3min idle)──▶ IDLE ──(10min idle)──▶ DROWSY
   ▲                     ▲                       │
   │                     │                       │
   └──(any input)───────┴──────(any input)──────┘

Transition Logic:

From ACTIVE:

  • After 3min idle → IDLE (scan to 100Hz, LEDs to 5%)

From IDLE:

  • After 10min cumulative idle → DROWSY (scan to 50Hz, LED0 to 1%, LED1 OFF, radio idle)
  • Any input → ACTIVE (instant)

From DROWSY:

  • Any input → ACTIVE (instant, radio already initialized)

Wake Behavior:

Activity signal provides immediate transition to ACTIVE, bypassing periodic timeout checks. Scanner tasks call ActivityTracker::record_activity() which:

  1. Updates atomic timestamp
  2. Signals power_task via Embassy Signal
  3. power_task wakes from select() and transitions to ACTIVE

Implementation Freeze: Light Sleep & Deep Sleep

Light Sleep (Phase 3) and Deep Sleep (Phase 4) implementations are frozen indefinitely.

Known ESP32-S3 Limitations

Embassy timer loss:

  • TIMG0 is clock-gated during light sleep
  • Embassy doesn't track how much time passed during sleep
  • All Timer::after(), Ticker, and timeout futures have incorrect deadlines
  • No documented compensation mechanism exists

WiFi/ESP-NOW reinit:

  • WiFi must be stopped before light sleep (per ESP-IDF spec)
  • StaticCell RAII prevents re-initialization after wake
  • WiFi restarts on channel 1 (esp-rs issue #9855)
  • Peer info deleted on WiFi stop, requires re-registration

Known Embedded Rust Limitations

No async sleep pattern:

  • No documented example exists for Embassy async + ESP32-S3 light sleep in no_std Rust
  • No esp-hal API for light sleep with GPIO wake sources (as of esp-hal 1.0)
  • No esp-radio API for WiFi/ESP-NOW stop/restart lifecycle

Required for Unfreeze

One or more of these must exist:

  • Embassy light sleep support with automatic time compensation
  • esp-radio reinit API without StaticCell issues
  • Stable esp-hal sleep API with GPIO wake configuration
  • Community example showing working pattern

Practical Floor

The DROWSY state at ~8-12mA is the practical power floor for now. Further reduction requires solving the light sleep integration problem, which is an ecosystem-level challenge beyond this project.

CPU clock reduction (240MHz → 80MHz) could bring DROWSY closer to ~5mA without light sleep complexity, if esp-hal exposes clock configuration APIs.

Code Organization

Location: firmware/cat/src/power/

FilePurpose
mod.rsPowerState enum, POWER_STATE atomic, helper functions
manager.rspower_task state machine loop
config.rsPowerConfig timeout constants
activity.rsActivityTracker atomic timestamp and signal

Integration Points:

  • firmware/cat/src/bin/main.rs — Spawns power_task, passes ActivityTracker to scanner tasks
  • firmware/cat/src/feedback/led_controller.rs — Queries led_brightness_factor() and led1_enabled()
  • firmware/cat/src/comm.rs — Queries radio_enabled() in radio_task
  • Scanner tasks (finger, thumb, scroll, mouse) — Call ActivityTracker::record_activity() on input

See Also

Concept documentation:

  • docs/power/concept.md — Full power state specifications and battery life calculations
  • docs/power/idle.md — Phase 1 implementation plan (ACTIVE ↔ IDLE)
  • docs/power/drowsy.md — Phase 2 implementation plan (DROWSY state)
  • docs/power/light-sleep.md — Phase 3 blocking issues and research notes

Implementation:

  • firmware/cat/src/power/ — Power management source code
  • firmware/cat/src/comm.rs — Radio lifecycle with power state awareness
  • firmware/cat/src/feedback/led_controller.rs — LED rendering with brightness adaptation