mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): on-demand no longer restarts a running display service POST /display/on-demand/start treated start_service (default true, sent by "Preview on display", the on-demand dialog and the MQTT bridge) as "restart": with the service running it ran systemctl stop, slept 1.5s and started it again. Every request cold-started the display process -- every plugin reloaded, panel blank -- to deliver a request the running process already reads from the cache mailbox every ON_DEMAND_POLL_INTERVAL (0.25s), including mid-dwell, mid-screen and mid-Vegas. The restart bought nothing: startup only restores a session the display saved itself (display_on_demand_config), so the new request arrived through the same mailbox either way. start_service now means "start it if it is not running". The stop route coerces stop_service to a boolean so "false" no longer stops the service. test_api_v3_on_demand_restart.py pinned the old restart path; it now pins the replacement. Docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on-demand loads a disabled plugin live instead of failing The display process only loads enabled plugins, so an on-demand request for a disabled one -- "Preview on display" offers it on every config page, with a note that the plugin will be enabled for the preview -- failed with invalid-mode. Nothing enabled it short of a restart, and the on-demand route no longer restarts the service. _activate_on_demand now loads an installed-but-not-running plugin through the live-enable path (load_plugin + _register_loaded_plugin), with a new load_plugin(force_enabled=True) so the instance runs enabled while config.json keeps saying disabled. The plugin is tracked in _on_demand_loaded_plugins, and the main loop unloads it through _unregister_plugin once on-demand moves off it (stop, expiry, another request, or a failed request that ends the session) -- right after its own poll, where no display() is on the stack. A failed load publishes status error with load-failed. A plugin enabled during the session stays loaded. A session restored after a restart uses the same tracking instead of setting enabled in the config dict config_manager caches, so its plugin is unloaded when the session ends rather than staying loaded until the next restart. Ending a session no longer resumes the rotation onto a plugin that is about to be unloaded, which a restored session did. Also: a stop sent while on-demand is inactive clears a failed request's error, instead of /display/on-demand/status reporting status: error until the state aged out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
9.9 KiB
Python
236 lines
9.9 KiB
Python
"""On-demand behaviour that a person can ask for but the controller ignored.
|
|
|
|
Three separate gaps, all reachable from the web UI's force-display dialog:
|
|
|
|
* `pinned` was accepted by the API, stored on the controller and published
|
|
back in the status payload, but never narrowed the rotation -- a pinned
|
|
request still cycled every mode its plugin owns;
|
|
* restarting while on-demand was active loaded *only* the on-demand plugin,
|
|
so normal rotation had nothing to return to for the life of the process;
|
|
* a stop request was exempt from the duplicate guards on purpose and was
|
|
never removed from the mailbox, so it was re-processed on every poll
|
|
forever.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
def _plugin_with_modes(controller, plugin_id, modes):
|
|
"""Register `modes` as loaded modes belonging to `plugin_id`."""
|
|
controller.plugin_display_modes[plugin_id] = list(modes)
|
|
for mode in modes:
|
|
controller.plugin_modes[mode] = MagicMock(spec=[])
|
|
controller.mode_to_plugin_id[mode] = plugin_id
|
|
|
|
|
|
class TestPinnedNarrowsTheRotation:
|
|
"""A pinned request shows the one mode that was asked for.
|
|
|
|
Unpinned stays the default: a sports plugin's modes are views of one
|
|
subject, so rotating them is right. A Starlark plugin's modes are
|
|
unrelated widgets, so it is not.
|
|
"""
|
|
|
|
MODES = ['app_a', 'app_b', 'app_c']
|
|
|
|
def _activate(self, controller, pinned):
|
|
_plugin_with_modes(controller, 'starlark-apps', self.MODES)
|
|
controller.available_modes = list(self.MODES)
|
|
controller._activate_on_demand({
|
|
'request_id': 'r1',
|
|
'action': 'start',
|
|
'plugin_id': 'starlark-apps',
|
|
'mode': 'app_b',
|
|
'pinned': pinned,
|
|
})
|
|
|
|
def test_pinned_rotation_holds_the_requested_mode(self, test_display_controller):
|
|
c = test_display_controller
|
|
self._activate(c, pinned=True)
|
|
assert c.on_demand_modes == ['app_b']
|
|
|
|
def test_unpinned_still_rotates_the_whole_plugin(self, test_display_controller):
|
|
c = test_display_controller
|
|
self._activate(c, pinned=False)
|
|
assert set(c.on_demand_modes) == set(self.MODES)
|
|
|
|
def test_pinned_still_starts_on_the_requested_mode(self, test_display_controller):
|
|
c = test_display_controller
|
|
self._activate(c, pinned=True)
|
|
assert c.current_display_mode == 'app_b'
|
|
|
|
def test_the_pin_is_recorded_for_the_status_payload(self, test_display_controller):
|
|
c = test_display_controller
|
|
self._activate(c, pinned=True)
|
|
assert c.on_demand_pinned is True
|
|
|
|
def test_an_unresolvable_pin_does_not_empty_the_rotation(self, test_display_controller):
|
|
"""A pin naming a mode outside the plugin must not leave nothing to show."""
|
|
c = test_display_controller
|
|
_plugin_with_modes(c, 'starlark-apps', self.MODES)
|
|
assert c._apply_on_demand_pin(list(self.MODES), 'not_a_mode', True) == self.MODES
|
|
|
|
|
|
class TestPinSurvivesARestart:
|
|
"""The pin is part of the request being resumed, not a per-session flag."""
|
|
|
|
def test_restored_pinned_state_narrows_the_rotation(self, test_display_controller):
|
|
c = test_display_controller
|
|
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
|
|
c.on_demand_active = True
|
|
c.on_demand_plugin_id = 'starlark-apps'
|
|
c.on_demand_mode = 'app_c'
|
|
c.on_demand_pinned = True
|
|
|
|
c._populate_on_demand_modes_from_plugin()
|
|
assert c.on_demand_modes == ['app_c']
|
|
|
|
def test_restored_unpinned_state_keeps_every_mode(self, test_display_controller):
|
|
c = test_display_controller
|
|
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
|
|
c.on_demand_active = True
|
|
c.on_demand_plugin_id = 'starlark-apps'
|
|
c.on_demand_mode = 'app_c'
|
|
c.on_demand_pinned = False
|
|
|
|
c._populate_on_demand_modes_from_plugin()
|
|
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
|
|
|
|
|
|
class TestASecondRequestKeepsTheResumePoint:
|
|
"""Clearing returns to where the normal rotation was, not the last request.
|
|
|
|
A second start while on-demand was showing overwrote the saved resume
|
|
index with the first request's mode, so stopping resumed rotation there.
|
|
"""
|
|
|
|
def test_clear_resumes_the_original_rotation(self, test_display_controller):
|
|
c = test_display_controller
|
|
_plugin_with_modes(c, 'clock', ['clock'])
|
|
_plugin_with_modes(c, 'weather', ['weather'])
|
|
_plugin_with_modes(c, 'stocks', ['stocks'])
|
|
c.available_modes = ['clock', 'weather', 'stocks']
|
|
c.current_mode_index = 0
|
|
c.current_display_mode = 'clock'
|
|
|
|
c._activate_on_demand({'plugin_id': 'weather', 'mode': 'weather'})
|
|
c._activate_on_demand({'plugin_id': 'stocks', 'mode': 'stocks'})
|
|
assert c.current_display_mode == 'stocks'
|
|
|
|
c._clear_on_demand(reason='requested-stop')
|
|
assert c.current_mode_index == 0
|
|
assert c.current_display_mode == 'clock'
|
|
|
|
|
|
class TestRestartDoesNotStarveTheOtherPlugins:
|
|
"""Restarting mid-on-demand used to load only the on-demand plugin.
|
|
|
|
Restarts during an on-demand session are routine -- it is how an update or
|
|
a config change is applied -- and the panel came back cycling one plugin's
|
|
modes and nothing else until the on-demand cache was cleared by hand.
|
|
"""
|
|
|
|
DISCOVERED = ['clock', 'weather', 'starlark-apps', 'disabled-one']
|
|
|
|
@pytest.fixture
|
|
def controller(self, test_display_controller):
|
|
c = test_display_controller
|
|
c.config.update({
|
|
'clock': {'enabled': True},
|
|
'weather': {'enabled': True},
|
|
'starlark-apps': {'enabled': True},
|
|
'disabled-one': {'enabled': False},
|
|
})
|
|
return c
|
|
|
|
def test_every_enabled_plugin_still_loads(self, controller):
|
|
selected = controller._select_startup_plugins(
|
|
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
|
|
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
|
|
|
def test_disabled_plugins_are_still_left_out(self, controller):
|
|
selected = controller._select_startup_plugins(
|
|
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
|
|
assert 'disabled-one' not in selected
|
|
|
|
def test_the_on_demand_state_is_still_restored(self, controller):
|
|
controller._select_startup_plugins(
|
|
self.DISCOVERED,
|
|
{'plugin_id': 'starlark-apps', 'mode': 'app_a', 'pinned': True})
|
|
assert controller.on_demand_active is True
|
|
assert controller.on_demand_plugin_id == 'starlark-apps'
|
|
assert controller.on_demand_mode == 'app_a'
|
|
assert controller.on_demand_pinned is True
|
|
|
|
def test_a_disabled_on_demand_plugin_is_still_loaded(self, controller):
|
|
"""Otherwise the mode being resumed has nothing behind it. It loads
|
|
for on-demand only; its config section is left disabled."""
|
|
selected = controller._select_startup_plugins(
|
|
self.DISCOVERED, {'plugin_id': 'disabled-one', 'mode': 'x'})
|
|
assert 'disabled-one' in selected
|
|
assert controller._on_demand_loaded_plugins == {'disabled-one'}
|
|
assert controller.config['disabled-one']['enabled'] is False
|
|
|
|
def test_an_unknown_on_demand_plugin_falls_back_to_normal(self, controller):
|
|
selected = controller._select_startup_plugins(
|
|
self.DISCOVERED, {'plugin_id': 'uninstalled', 'mode': 'x'})
|
|
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
|
assert controller.on_demand_active is False
|
|
|
|
def test_no_on_demand_config_is_a_normal_startup(self, controller):
|
|
selected = controller._select_startup_plugins(self.DISCOVERED, None)
|
|
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
|
assert controller.on_demand_active is False
|
|
|
|
|
|
class TestStopRequestsAreConsumed:
|
|
"""A stop request is exempt from the duplicate guards, so the mailbox
|
|
delete is the only thing that ends it."""
|
|
|
|
STOP = {'request_id': 'S1', 'action': 'stop'}
|
|
|
|
def _arrange(self, controller, active):
|
|
controller.on_demand_active = active
|
|
controller.on_demand_status = 'active' if active else 'idle'
|
|
controller._last_on_demand_poll = None
|
|
controller.cache_manager.get = MagicMock(
|
|
side_effect=lambda key, *a, **kw:
|
|
self.STOP if key == 'display_on_demand_request' else None)
|
|
controller.cache_manager.set = MagicMock()
|
|
controller.cache_manager.delete = MagicMock()
|
|
controller._clear_on_demand = MagicMock()
|
|
|
|
def test_a_handled_stop_is_removed_from_the_mailbox(self, test_display_controller):
|
|
c = test_display_controller
|
|
self._arrange(c, active=True)
|
|
c._poll_on_demand_requests()
|
|
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
|
|
|
def test_a_stop_arriving_while_idle_is_also_removed(self, test_display_controller):
|
|
"""Otherwise a stop sent to an idle display re-fires forever."""
|
|
c = test_display_controller
|
|
self._arrange(c, active=False)
|
|
c._poll_on_demand_requests()
|
|
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
|
|
|
def test_the_stop_is_still_acted_on(self, test_display_controller):
|
|
c = test_display_controller
|
|
self._arrange(c, active=True)
|
|
c._poll_on_demand_requests()
|
|
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
|
|
|
def test_a_start_racing_in_behind_a_stop_is_not_discarded(self, test_display_controller):
|
|
"""The compare-before-delete applies to stops too."""
|
|
c = test_display_controller
|
|
self._arrange(c, active=True)
|
|
newer = {'request_id': 'S2', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
|
reads = iter([self.STOP, newer])
|
|
c.cache_manager.get = MagicMock(
|
|
side_effect=lambda key, *a, **kw:
|
|
next(reads, newer) if key == 'display_on_demand_request' else None)
|
|
|
|
c._poll_on_demand_requests()
|
|
assert c.cache_manager.delete.call_count == 0
|