fix: eliminate LED flicker during knob turns

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).
This commit is contained in:
Sean Doran committed 2026-02-27 19:04:53 -05:00
1 parent 983ba276e0
commit d69d94eaa2
2 files changed
+126 -4

No files matched your search

+88 -1
View File
@@ -4,8 +4,17 @@ No external dependencies — these are pure-function tests.
"""
import pytest
from unittest.mock import MagicMock, call, patch
from turnup.turnupd import knob_to_norm, knob_to_volume, parse_messages, KNOB_MAX, VOLUME_MAX
from turnup.turnupd import (
KNOB_MAX,
NUM_KNOBS,
VOLUME_MAX,
handle_knob,
knob_to_norm,
knob_to_volume,
parse_messages,
)
# ── knob_to_norm ──────────────────────────────────────────────────────────────
@@ -126,3 +135,81 @@ class TestParseMessages:
msgs, remainder = parse_messages(bytearray())
assert msgs == []
assert remainder == bytearray()
# ── handle_knob ───────────────────────────────────────────────────────────────
def _make_knob_fixtures(knob_id: int = 0, action: str = "sink_volume"):
"""Return (config, pulse_mock, ser_mock, knob_norms, last_led_colors, last_knob_event)."""
config = {
"knobs": {
str(knob_id): {
"action": action,
"target": "default",
"led": {"low_color": "#000000", "high_color": "#ffffff"},
}
}
}
pulse = MagicMock()
ser = MagicMock()
knob_norms = [0.0] * NUM_KNOBS
last_led_colors = [(0, 0, 0)] * NUM_KNOBS
last_knob_event = [0.0]
return config, pulse, ser, knob_norms, last_led_colors, last_knob_event
class TestHandleKnobLEDDedup:
"""Fix 1 — duplicate LED packets must be suppressed."""
def test_sends_leds_when_color_changes(self):
config, pulse, ser, knob_norms, llc, lke = _make_knob_fixtures()
# Turn knob 0 to max; color should change from (0,0,0).
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
assert ser.write.called
def test_skips_leds_when_color_unchanged(self):
config, pulse, ser, knob_norms, llc, lke = _make_knob_fixtures()
# First call sets the color.
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
write_count_after_first = ser.write.call_count
# Second call with same value — color is identical, no new write.
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
assert ser.write.call_count == write_count_after_first
def test_last_led_colors_updated_after_send(self):
config, pulse, ser, knob_norms, llc, lke = _make_knob_fixtures()
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
# last_led_colors must not remain all-zero after a successful send.
assert llc != [(0, 0, 0)] * NUM_KNOBS
def test_color_change_after_stable_triggers_send(self):
config, pulse, ser, knob_norms, llc, lke = _make_knob_fixtures()
# Reach a stable state at max value.
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
count_stable = ser.write.call_count
# Now change to zero — should trigger another send.
handle_knob(0, 0, config, pulse, ser, knob_norms, llc, lke)
assert ser.write.call_count == count_stable + 1
class TestHandleKnobLastKnobEvent:
"""Fix 2 — last_knob_event[0] must be updated on every call."""
def test_last_knob_event_updated(self):
import time
config, pulse, ser, knob_norms, llc, lke = _make_knob_fixtures()
before = time.monotonic()
handle_knob(0, 500, config, pulse, ser, knob_norms, llc, lke)
after = time.monotonic()
assert before <= lke[0] <= after
def test_last_knob_event_updated_even_when_led_unchanged(self):
import time
config, pulse, ser, knob_norms, llc, lke = _make_knob_fixtures()
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
first_ts = lke[0]
import time as _t; _t.sleep(0.01)
handle_knob(0, KNOB_MAX, config, pulse, ser, knob_norms, llc, lke)
# Timestamp must advance even though color did not change.
assert lke[0] > first_ts