mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(display): stop the run loop spinning when no mode has anything to show (#649)
* fix(display): stop the run loop spinning when no mode has anything to show A mode whose display() reports nothing rotates to the next at once, with no dwell. With every enabled mode empty (only a sports plugin in its off-season, say) the loop went round with no sleep: on ledpi, 169% CPU and ~1,800 "No content" log lines every 10 seconds. After one full rotation of empty passes it now pauses EMPTY_ROTATION_PAUSE (1s) per pass, servicing plugin updates and returning early on on-demand or schedule changes; live priority is still checked at the top of every pass, and the streak resets as soon as any mode shows something. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): restart the loop if the empty-rotation pause starts on-demand; per-rotation streak - an on-demand request serviced during the pause returned early into the on-demand branch, which advanced past the mode just requested; the loop now restarts when the pause changed the mode, on-demand state or schedule - the streak is reset when the rotation changes (on-demand start/stop, a plugin enabled or disabled), so a streak from one rotation can't make another pause before its own modes are tried - docstring: live content is picked up within the pause, not "at once" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- The display loop no longer spins at 100% CPU when no enabled mode has anything to show (for example, only a sports plugin enabled in its off-season). After one full rotation of empty modes it checks one mode per second until something shows; live content still takes over at once.
|
||||||
|
|
||||||
- Contributor tooling and docs:
|
- Contributor tooling and docs:
|
||||||
- `mypy.ini` parses again. A multi-line `exclude` and trailing comments on values made mypy refuse the whole file, so none of its settings applied and the pre-commit hook failed with "Missing target". The mypy hook is now manual (`pre-commit run mypy --hook-stage manual`) while the ~500 existing type errors in `src/` are paid down.
|
- `mypy.ini` parses again. A multi-line `exclude` and trailing comments on values made mypy refuse the whole file, so none of its settings applied and the pre-commit hook failed with "Missing target". The mypy hook is now manual (`pre-commit run mypy --hook-stage manual`) while the ~500 existing type errors in `src/` are paid down.
|
||||||
- `.gitignore` ignores everything in `config/` except the templates; `ytm_auth.json`, `saved_repositories.json`, `wifi_status.json` and `font_overrides.json` weren't ignored.
|
- `.gitignore` ignores everything in `config/` except the templates; `ytm_auth.json`, `saved_repositories.json`, `wifi_status.json` and `font_overrides.json` weren't ignored.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Dict, Any, List, Optional, Callable
|
from typing import Dict, Any, List, Optional, Callable, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||||
import pytz
|
import pytz
|
||||||
@@ -101,6 +101,16 @@ class DisplayController:
|
|||||||
it and start the run loop.
|
it and start the run loop.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
#: How long the run loop pauses per pass once a whole rotation has had
|
||||||
|
#: nothing to show. See _note_empty_pass.
|
||||||
|
EMPTY_ROTATION_PAUSE = 1.0
|
||||||
|
|
||||||
|
#: Consecutive passes whose mode had nothing to show, and the rotation
|
||||||
|
#: (on-demand or not, and its modes) they were counted in. Class-level so
|
||||||
|
#: controllers built without __init__ (tests) have them too.
|
||||||
|
_empty_pass_streak = 0
|
||||||
|
_empty_pass_rotation: Optional[Tuple[bool, Tuple[str, ...]]] = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Starting DisplayController initialization")
|
logger.info("Starting DisplayController initialization")
|
||||||
@@ -1088,6 +1098,38 @@ class DisplayController:
|
|||||||
or self.on_demand_active != on_demand):
|
or self.on_demand_active != on_demand):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def _note_empty_pass(self) -> None:
|
||||||
|
"""Record a pass whose mode had nothing to show; pause once a whole
|
||||||
|
rotation has been empty.
|
||||||
|
|
||||||
|
A mode with no content rotates to the next one at once, with no dwell.
|
||||||
|
When every mode is empty -- say, only a sports plugin enabled in its
|
||||||
|
off-season -- the loop went round with no sleep at all: 100% of a core,
|
||||||
|
a plugin-executor thread per pass and several log lines each time,
|
||||||
|
indefinitely. After one full rotation of empty passes, each further
|
||||||
|
one pauses EMPTY_ROTATION_PAUSE seconds. Live content is still picked
|
||||||
|
up within that second (the live-priority check runs at the top of
|
||||||
|
every pass), the pause services plugin updates, and it returns early
|
||||||
|
on an on-demand request or a schedule change. The streak resets as
|
||||||
|
soon as any mode shows something, and when the rotation itself
|
||||||
|
changes (on-demand starting or stopping, a plugin enabled or
|
||||||
|
disabled): a streak counted in one rotation says nothing about the
|
||||||
|
modes of another, which haven't been tried yet.
|
||||||
|
"""
|
||||||
|
modes = self.on_demand_modes if self.on_demand_active else self.available_modes
|
||||||
|
rotation_key = (bool(self.on_demand_active), tuple(modes))
|
||||||
|
if rotation_key != self._empty_pass_rotation:
|
||||||
|
self._empty_pass_rotation = rotation_key
|
||||||
|
self._empty_pass_streak = 0
|
||||||
|
self._empty_pass_streak += 1
|
||||||
|
rotation = max(1, len(modes))
|
||||||
|
if self._empty_pass_streak < rotation:
|
||||||
|
return
|
||||||
|
if self._empty_pass_streak == rotation:
|
||||||
|
logger.info("No mode has anything to show; checking one mode every %.0fs "
|
||||||
|
"until one does", self.EMPTY_ROTATION_PAUSE)
|
||||||
|
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
||||||
|
|
||||||
def _get_display_duration(self, mode_key):
|
def _get_display_duration(self, mode_key):
|
||||||
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
||||||
(display.display_durations), else the plugin's own duration.
|
(display.display_durations), else the plugin's own duration.
|
||||||
@@ -2347,6 +2389,16 @@ class DisplayController:
|
|||||||
|
|
||||||
# If display() returned False, skip to next mode immediately
|
# If display() returned False, skip to next mode immediately
|
||||||
if not display_result:
|
if not display_result:
|
||||||
|
was_on_demand = self.on_demand_active
|
||||||
|
self._note_empty_pass()
|
||||||
|
# The pause returns early when an on-demand request, its
|
||||||
|
# end, or the schedule decides what comes next. Rotating
|
||||||
|
# past this empty mode now would skip that: an on-demand
|
||||||
|
# start would advance past the mode just requested.
|
||||||
|
if (self.current_display_mode != active_mode
|
||||||
|
or self.on_demand_active != was_on_demand
|
||||||
|
or not self.is_display_active):
|
||||||
|
continue
|
||||||
if self.on_demand_active:
|
if self.on_demand_active:
|
||||||
logger.info("No content for on-demand mode %s, skipping to next mode", active_mode)
|
logger.info("No content for on-demand mode %s, skipping to next mode", active_mode)
|
||||||
if not self.on_demand_modes:
|
if not self.on_demand_modes:
|
||||||
@@ -2395,6 +2447,7 @@ class DisplayController:
|
|||||||
# If no exception (just no content), fall through to normal rotation logic
|
# If no exception (just no content), fall through to normal rotation logic
|
||||||
# This allows trying other modes (recent, upcoming) from the same plugin
|
# This allows trying other modes (recent, upcoming) from the same plugin
|
||||||
else:
|
else:
|
||||||
|
self._empty_pass_streak = 0
|
||||||
# Get base duration for current mode
|
# Get base duration for current mode
|
||||||
base_duration = self._get_display_duration(active_mode)
|
base_duration = self._get_display_duration(active_mode)
|
||||||
dynamic_enabled = self._plugin_supports_dynamic(manager_to_display)
|
dynamic_enabled = self._plugin_supports_dynamic(manager_to_display)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""The run loop must not spin when no mode has anything to show.
|
||||||
|
|
||||||
|
A mode with no content rotates to the next one at once. When every enabled
|
||||||
|
mode is empty (only a sports plugin enabled in its off-season, say), the loop
|
||||||
|
used to go round with no sleep at all: a full core, a plugin-executor thread
|
||||||
|
per pass and several log lines each time, forever. After one empty rotation it
|
||||||
|
now pauses between passes, and it stops pausing as soon as something shows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("EMULATOR", "true")
|
||||||
|
|
||||||
|
from src.display_controller import DisplayController # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_controller(modes, on_demand_modes=None):
|
||||||
|
dc = object.__new__(DisplayController)
|
||||||
|
dc.available_modes = list(modes)
|
||||||
|
dc.on_demand_active = on_demand_modes is not None
|
||||||
|
dc.on_demand_modes = list(on_demand_modes or [])
|
||||||
|
dc.sleeps = []
|
||||||
|
dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration)
|
||||||
|
return dc
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoteEmptyPass:
|
||||||
|
def test_a_partly_empty_rotation_never_pauses(self):
|
||||||
|
dc = _bare_controller(["a", "b", "c"])
|
||||||
|
dc._note_empty_pass()
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert dc.sleeps == []
|
||||||
|
|
||||||
|
def test_a_fully_empty_rotation_pauses_every_further_pass(self):
|
||||||
|
dc = _bare_controller(["a", "b", "c"])
|
||||||
|
for _ in range(5):
|
||||||
|
dc._note_empty_pass()
|
||||||
|
# Passes 3, 4 and 5 are at or past one full rotation.
|
||||||
|
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3
|
||||||
|
|
||||||
|
def test_content_resets_the_streak(self):
|
||||||
|
dc = _bare_controller(["a", "b"])
|
||||||
|
dc._note_empty_pass()
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 1
|
||||||
|
dc._empty_pass_streak = 0 # what the run loop does when a mode shows
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 1
|
||||||
|
|
||||||
|
def test_on_demand_counts_its_own_modes(self):
|
||||||
|
dc = _bare_controller(["a", "b", "c", "d"], on_demand_modes=["x"])
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE]
|
||||||
|
|
||||||
|
def test_a_single_mode_pauses_straight_away(self):
|
||||||
|
dc = _bare_controller(["only"])
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunLoopWithNothingToShow:
|
||||||
|
def test_run_pauses_after_one_empty_rotation(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
# Modes with no plugin behind them take the "nothing to display" path.
|
||||||
|
controller.available_modes = ["m1", "m2", "m3"]
|
||||||
|
controller.current_mode_index = 0
|
||||||
|
controller.plugin_modes = {}
|
||||||
|
|
||||||
|
# Count passes at the top of the loop. The cap stops a spinning loop
|
||||||
|
# (the old behaviour) instead of hanging the test run.
|
||||||
|
passes = []
|
||||||
|
original_poll = controller._poll_on_demand_requests
|
||||||
|
|
||||||
|
def counting_poll():
|
||||||
|
passes.append(1)
|
||||||
|
if len(passes) > 50:
|
||||||
|
raise RuntimeError("stop-test-loop: spinning")
|
||||||
|
original_poll()
|
||||||
|
|
||||||
|
sleeps = []
|
||||||
|
|
||||||
|
def fake_sleep(duration, tick_interval=1.0):
|
||||||
|
sleeps.append((len(passes), duration))
|
||||||
|
if len(sleeps) >= 2:
|
||||||
|
raise RuntimeError("stop-test-loop")
|
||||||
|
|
||||||
|
controller._poll_on_demand_requests = counting_poll
|
||||||
|
controller._sleep_with_plugin_updates = fake_sleep
|
||||||
|
|
||||||
|
controller.run()
|
||||||
|
|
||||||
|
# Three empty passes (one full rotation) before the first pause, then
|
||||||
|
# one pause per further pass -- not thousands of passes a second.
|
||||||
|
assert [n for n, _ in sleeps] == [3, 4]
|
||||||
|
assert all(d == DisplayController.EMPTY_ROTATION_PAUSE for _, d in sleeps)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRotationChanges:
|
||||||
|
def test_a_new_rotation_starts_a_new_streak(self):
|
||||||
|
# A long empty streak in a one-mode on-demand session must not make
|
||||||
|
# the normal rotation pause before its own modes have been tried.
|
||||||
|
dc = _bare_controller(["a", "b", "c"], on_demand_modes=["x"])
|
||||||
|
for _ in range(4):
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 4
|
||||||
|
dc.on_demand_active = False
|
||||||
|
dc._note_empty_pass()
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 4
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 5
|
||||||
|
|
||||||
|
def test_a_changed_mode_list_starts_a_new_streak(self):
|
||||||
|
dc = _bare_controller(["a"])
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 1
|
||||||
|
dc.available_modes = ["a", "b"] # a plugin was enabled
|
||||||
|
dc._note_empty_pass()
|
||||||
|
assert len(dc.sleeps) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnDemandDuringThePause:
|
||||||
|
def test_the_requested_mode_is_shown_first(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = ["m1", "m2", "m3"]
|
||||||
|
controller.current_mode_index = 0
|
||||||
|
controller.plugin_modes = {}
|
||||||
|
|
||||||
|
tried = []
|
||||||
|
original_note = controller._note_empty_pass
|
||||||
|
|
||||||
|
def recording_note():
|
||||||
|
tried.append(controller.current_display_mode)
|
||||||
|
if len(tried) > 50:
|
||||||
|
raise RuntimeError("stop-test-loop: spinning")
|
||||||
|
original_note()
|
||||||
|
|
||||||
|
sleeps = []
|
||||||
|
|
||||||
|
def fake_sleep(duration, tick_interval=1.0):
|
||||||
|
sleeps.append(duration)
|
||||||
|
if len(sleeps) == 1:
|
||||||
|
# What the real pause does when an on-demand request lands:
|
||||||
|
# activate it and return early.
|
||||||
|
controller.on_demand_active = True
|
||||||
|
controller.on_demand_modes = ["x", "y"]
|
||||||
|
controller.on_demand_mode_index = 0
|
||||||
|
controller.current_display_mode = "x"
|
||||||
|
else:
|
||||||
|
raise RuntimeError("stop-test-loop")
|
||||||
|
|
||||||
|
controller._note_empty_pass = recording_note
|
||||||
|
controller._sleep_with_plugin_updates = fake_sleep
|
||||||
|
|
||||||
|
controller.run()
|
||||||
|
|
||||||
|
# m1, m2, m3 empty -> pause -> on-demand starts; "x" must come next,
|
||||||
|
# not be skipped by rotating the empty pass that was interrupted.
|
||||||
|
assert tried[:3] == ["m1", "m2", "m3"]
|
||||||
|
assert tried[3] == "x"
|
||||||
Reference in New Issue
Block a user