From b9dcbb5152b9f2556a34255c75f64e4b8de1f3ca Mon Sep 17 00:00:00 2001 From: Ron Pierce Date: Thu, 4 Jun 2026 07:56:02 -0700 Subject: [PATCH] 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 --- src/display_controller.py | 46 +++++++++++++++++++++++++------- test/test_display_controller.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/display_controller.py b/src/display_controller.py index 7ce44bc5..7d760953 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -178,7 +178,11 @@ 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 if WIFI_STATUS_FILE is None: @@ -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 diff --git a/test/test_display_controller.py b/test/test_display_controller.py index 0b7d4433..bda53efe 100644 --- a/test/test_display_controller.py +++ b/test/test_display_controller.py @@ -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."""