parse_messages() previously advanced past a 0xFE start byte whenever no
complete frame could be matched, silently consuming partial frames at the
end of each ser.read(64) call as garbage. When the device sends 20-50
rapid knob messages during a fast turn and one message straddles the
64-byte read boundary, the split message was discarded: the daemon skipped
a knob-position update, the LED color lagged, and when the next complete
message arrived the color jumped — producing a visible flicker.
Fix: break out of the parse loop when a 0xFE is followed by a known type
byte (0x02/0x03/0x06/0x07) but the buffer does not yet hold a complete
frame. The partial bytes are returned in the remainder and prepended to
the next ser.read() chunk, so no message is ever lost.
Also sync last_led_colors after the initial send_leds() on connect so the
dedup guard is accurate from the first knob event instead of from the
first heartbeat.
Adds 5 tests covering partial heartbeat, button, and knob frames, the
split-read round-trip, and unknown frame type skipping (61 pass total).
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).