fix(display): resume rotation where it left off after live priority ends (#362)

When a live-priority plugin (e.g. live sports, flights overhead) preempted
the rotation, the controller overwrote current_mode_index with the live
plugin's index. Once live priority ended, rotation continued from after the
live plugin's mode, skipping every mode between the interrupted position and
the live plugin. With a live plugin late in the order, modes just before it
were starved indefinitely.

Save the rotation position on the initial live-priority switch and restore it
when live priority ends, in a new _apply_live_priority() helper. Add
regression tests covering resume, no-double-save during the hold, and the
idle no-op.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ron Pierce
2026-06-04 07:56:02 -07:00
committed by GitHub
parent f27fd260f7
commit b9dcbb5152
2 changed files with 83 additions and 10 deletions

View File

@@ -178,6 +178,10 @@ class DisplayController:
self.on_demand_last_event: Optional[str] = None
self.on_demand_schedule_override = False
self.rotation_resume_index: Optional[int] = None
# Saved rotation position when a live-priority plugin preempts the
# rotation, so it resumes where it left off (not after the live plugin)
# once live priority ends.
self._live_resume_index: Optional[int] = None
# WiFi status message tracking
global WIFI_STATUS_FILE
@@ -1471,6 +1475,36 @@ class DisplayController:
except Exception as e:
logger.debug(f"Error logging memory stats: {e}")
def _apply_live_priority(self, live_priority_mode):
"""Switch to a live-priority mode, or resume rotation when it ends.
When a live-priority plugin preempts the rotation, the position the
rotation had reached is saved so that, once live priority ends, the
rotation resumes from there instead of continuing after the live
plugin's mode (which would skip every mode between the two). The save
happens only on the initial switch, not on each re-check while the
live hold continues.
"""
if live_priority_mode:
if self.current_display_mode != live_priority_mode:
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
if self._live_resume_index is None:
self._live_resume_index = self.current_mode_index
self.current_display_mode = live_priority_mode
self.force_change = True
# Update mode index to match the new mode
try:
self.current_mode_index = self.available_modes.index(live_priority_mode)
except ValueError:
pass
elif self._live_resume_index is not None and self.available_modes:
# Live priority ended — resume rotation where it was interrupted.
self.current_mode_index = self._live_resume_index % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
self.force_change = True
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
self._live_resume_index = None
def _check_live_priority(self):
"""
Check all plugins for live priority content.
@@ -1658,15 +1692,7 @@ class DisplayController:
# Check for live priority content and switch to it immediately
if not self.on_demand_active and not wifi_status_data:
live_priority_mode = self._check_live_priority()
if live_priority_mode and self.current_display_mode != live_priority_mode:
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
self.current_display_mode = live_priority_mode
self.force_change = True
# Update mode index to match the new mode
try:
self.current_mode_index = self.available_modes.index(live_priority_mode)
except ValueError:
pass
self._apply_live_priority(live_priority_mode)
# Vegas scroll mode - continuous ticker across all plugins
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation

View File

@@ -167,6 +167,53 @@ class TestDisplayControllerLivePriority:
assert controller.current_display_mode == "test_plugin_live"
assert controller.force_change is True
def test_live_priority_resume_continues_rotation(self, test_display_controller):
"""Regression: when live priority ends, rotation resumes where it was
interrupted, not after the live plugin's mode.
Without the fix, _apply_live_priority left current_mode_index pointing at
the live plugin's slot, so the next rotation step skipped every mode
between the interrupted position and the live plugin (e.g. elections,
which sits just before a flights plugin in the order)."""
controller = test_display_controller
controller.available_modes = [
"weather", "forecast", "almanac", "election_ticker", "flight_live"
]
# Rotation is about to show the 3rd mode (index 2).
controller.current_mode_index = 2
controller.current_display_mode = "almanac"
controller._live_resume_index = None
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
controller._apply_live_priority("flight_live")
assert controller.current_display_mode == "flight_live"
assert controller.current_mode_index == 4
assert controller._live_resume_index == 2 # saved rotation position
# Re-checks while the hold continues must not move the saved position.
controller._apply_live_priority("flight_live")
assert controller._live_resume_index == 2
# Live priority ends -> resume at the saved index (almanac), so the next
# rotation step lands on election_ticker (index 3) rather than skipping it.
controller._apply_live_priority(None)
assert controller.current_mode_index == 2
assert controller.current_display_mode == "almanac"
assert controller._live_resume_index is None
def test_live_priority_no_resume_when_idle(self, test_display_controller):
"""No saved position + no live content is a no-op (normal rotation)."""
controller = test_display_controller
controller.available_modes = ["a", "b", "c"]
controller.current_mode_index = 1
controller.current_display_mode = "b"
controller._live_resume_index = None
controller._apply_live_priority(None)
assert controller.current_mode_index == 1
assert controller.current_display_mode == "b"
class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling."""