The heartbeat path called send_leds but never updated last_led_colors,
so the dedup guard in handle_knob had no visibility into what was last
actually sent. Heartbeats arriving mid-knob-turn injected extra serial
writes that bypassed the guard and overwhelmed the device's LED renderer,
causing visible flicker.
- FastAPI + uvicorn server (turnup-ui) serving config API and static files
- React + TypeScript frontend: connection, global LEDs, per-knob/button
cards, preset save/load/apply, toast notifications, dark theme
- Vite builds into src/turnup/ui/static/ so no Node.js needed at install time
- PWA assets (manifest, service worker, icon) in ui/public/
- turnup-ui.service systemd user unit; enabled globally via turnupd.install
- PKGBUILD: add python-fastapi + python-uvicorn deps, install UI service file
- pyproject.toml: ui extras group, turnup-ui entry point
Two targeted fixes:
1. Deduplicate LED writes in handle_knob: compute new colours and only
call send_leds() when they differ from last_led_colors. A single
physical knob turn produces 20-50 ADC samples in quick succession;
without this guard each sample triggers a 47-byte write and the
firmware can't keep up, causing visible flicker.
2. Gate reapply_app_volumes on a 200 ms knob-quiet period. Calling
playerctl/pulsectl while the user is actively turning a knob stalls
the main loop, backing up serial data, missing heartbeats, and
causing the device LEDs to time out mid-turn.
Both state variables (last_led_colors, last_knob_event) are mutable
lists initialised in main() and threaded through handle_knob, keeping
module-level globals out of the picture.
Adds 6 new unit tests (56 total, all passing).
- Add MPRISController class: wraps playerctl with 3s player-list cache,
fuzzy app-name matching, and thread-safe get/set_volume via MPRIS2 D-Bus
- Add PulseController event watcher thread: separate pulsectl connection
listens for sink-input events and queues them so reapply fires within
~0.1s for PA-only apps instead of waiting up to 1s
- Route set_app_volume and get_app_volume_norm through MPRIS first,
falling back to PA stream for non-MPRIS apps (e.g. Brave/Chromium)
- Update reapply_app_volumes to prefer MPRIS path, preventing turnupd
from fighting Spotify on the PA layer during song transitions
- Wire MPRISController and start_watching() into main(); drain_events()
triggers immediate reapply on any PA sink-input event
- Switch config format from JSON to TOML (tomllib built-in on 3.11+,
tomli fallback for 3.10) so the generated config can include comments
- Write an annotated config.toml on first run with descriptions for every
knob/button and hints for all available actions
- Promote playerctl from optdepends to depends so media buttons work
out of the box
- Default buttons 1–3 now pre-configured for playerctl previous/play-pause/next
- Rename contrib/config.example.json → config.example.toml with full
TOML rewrite including per-knob LED colour override examples
- Log a migration hint when a legacy config.json is detected
- Add per-knob RGB LED configuration, validation, and color interpolation
- Add build_led_packet, send_leds, all_led_colors to turnupd
- Update config with VALID_LED_MODES, _validate_color, _validate_leds
- Update contrib/config.example.json with LED configuration examples
- Remove legacy root-level config.py and turnupd.py (moved to src/)