mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
feat(ipc): control socket stage 2 - wake the render thread, brightness.set, plugin.reload (#720)
Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -391,8 +391,9 @@ class FakeVegas:
|
||||
|
||||
run_iteration() renders frames at 125 Hz on the fake clock for
|
||||
``cycle`` seconds and returns True, or returns False as soon as the
|
||||
interrupt checker (every 10 frames) or the live-priority checker (every
|
||||
0.25 s) asks it to yield -- the same cadence the real coordinator uses.
|
||||
interrupt checker (every 10 frames, or at the next frame when the
|
||||
``urgent`` test says so) or the live-priority checker (every 0.25 s)
|
||||
asks it to yield -- the same cadence the real coordinator uses.
|
||||
A live-priority pause is lifted by the next call, as in the real one.
|
||||
"""
|
||||
|
||||
@@ -408,14 +409,16 @@ class FakeVegas:
|
||||
self.vegas_config = SimpleNamespace(live_in_ticker=live_in_ticker)
|
||||
self.render_pipeline = None
|
||||
self._interrupt: Optional[Callable[[], bool]] = None
|
||||
self._urgent: Optional[Callable[[], bool]] = None
|
||||
self._live: Optional[Callable[[], Any]] = None
|
||||
self._paused_for_live = False
|
||||
|
||||
def set_live_priority_checker(self, fn):
|
||||
self._live = fn
|
||||
|
||||
def set_interrupt_checker(self, fn, check_interval=10):
|
||||
def set_interrupt_checker(self, fn, check_interval=10, urgent=None):
|
||||
self._interrupt = fn
|
||||
self._urgent = urgent
|
||||
|
||||
def apply_pending_config_if_idle(self):
|
||||
pass
|
||||
@@ -444,13 +447,68 @@ class FakeVegas:
|
||||
h.log("vegas-frame", None, quiet=True)
|
||||
clock.sleep(self.FRAME)
|
||||
frames += 1
|
||||
if self._interrupt and frames % self.INTERRUPT_EVERY == 0 and self._interrupt():
|
||||
due = frames % self.INTERRUPT_EVERY == 0 or (self._urgent and self._urgent())
|
||||
if self._interrupt and due and self._interrupt():
|
||||
h.log("vegas-interrupt")
|
||||
return False
|
||||
if clock.now - start >= self.cycle:
|
||||
return True
|
||||
|
||||
|
||||
class FakeControlServer:
|
||||
"""The ControlServer surface run() uses (src/ipc/server.py), on the fake clock.
|
||||
|
||||
``post()`` queues a real QueuedCommand when the clock reaches ``t``, as a
|
||||
connection thread would. ``wait_for_command`` advances the clock to the
|
||||
first of: a command being queued, or the timeout -- the timed Event wait
|
||||
the real server does, without threads.
|
||||
"""
|
||||
|
||||
def __init__(self, harness: "RunLoopHarness"):
|
||||
self._h = harness
|
||||
self.queue: List[Any] = []
|
||||
self.posted: List[Any] = []
|
||||
self.closed = False
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
return bool(self.queue)
|
||||
|
||||
def drain(self) -> List[Any]:
|
||||
out, self.queue = self.queue, []
|
||||
return out
|
||||
|
||||
def wait_for_command(self, timeout: float) -> bool:
|
||||
clock = self._h.clock
|
||||
target = clock.now + max(0.0, timeout)
|
||||
while not self.queue:
|
||||
nxt = clock._alarms[0][0] if clock._alarms else None
|
||||
if nxt is None or nxt > target:
|
||||
clock.sleep(target - clock.now)
|
||||
return bool(self.queue)
|
||||
clock.sleep(max(0.0, nxt - clock.now))
|
||||
return True
|
||||
|
||||
def post(self, t: float, cmd: str, args: Dict[str, Any], request_id: str = "sock"):
|
||||
from src.ipc.contract import AWAITED_COMMANDS, parse_args
|
||||
from src.ipc.server import CommandOutcome, QueuedCommand
|
||||
|
||||
command = QueuedCommand(
|
||||
request_id=request_id, cmd=cmd, args=parse_args(cmd, args), # type: ignore[arg-type]
|
||||
received_at=0.0,
|
||||
outcome=CommandOutcome() if cmd in AWAITED_COMMANDS else None)
|
||||
self.posted.append(command)
|
||||
|
||||
def enqueue():
|
||||
self._h.log("socket", cmd)
|
||||
self.queue.append(command)
|
||||
self._h.clock.at(t, enqueue)
|
||||
return command
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harness
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -669,6 +727,12 @@ class RunLoopHarness:
|
||||
encoding="utf-8")
|
||||
self.clock.at(t, write)
|
||||
|
||||
def control_socket(self) -> FakeControlServer:
|
||||
"""Serve the control socket (a FakeControlServer) for this run."""
|
||||
server = FakeControlServer(self)
|
||||
self.controller._control_server = server
|
||||
return server
|
||||
|
||||
def enable_vegas(self, cycle: float = 30.0, live_in_ticker: bool = False) -> FakeVegas:
|
||||
"""Install FakeVegas, wired up as _initialize_vegas_mode wires the real one."""
|
||||
dc = self.controller
|
||||
@@ -676,7 +740,7 @@ class RunLoopHarness:
|
||||
vegas.set_live_priority_checker(dc._check_live_priority)
|
||||
vegas.set_interrupt_checker(
|
||||
lambda: dc._check_vegas_interrupt() or dc.sync_manager.is_follower_active(),
|
||||
check_interval=10)
|
||||
check_interval=10, urgent=dc._control_command_pending)
|
||||
dc.vegas_coordinator = vegas
|
||||
return vegas
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""POST /api/v3/config/main with a brightness: on the panel at once.
|
||||
|
||||
The saved brightness used to reach the panel when the display's config
|
||||
watcher next noticed config.json (it polls every 2 s) and the render thread
|
||||
then applied it (every 0.25 s). After a successful save the route now also
|
||||
sends ``brightness.set`` over the control socket (src/ipc), which the
|
||||
display applies on its render thread at once. When the socket cannot carry
|
||||
it, nothing changes: the watcher applies the saved value as before. The
|
||||
response says which (``brightness_transport``), and why
|
||||
(``brightness_socket_error``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
from src.ipc import client as control_client # noqa: E402
|
||||
|
||||
SET = "web_interface.blueprints.api_v3.control_client.brightness_set"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saved(api_v3_module, monkeypatch):
|
||||
captured = {'ok': True}
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {}
|
||||
|
||||
def fake_save(_manager, config, **_kwargs):
|
||||
captured['config'] = config
|
||||
return (True, '') if captured['ok'] else (False, 'disk full')
|
||||
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||
return captured
|
||||
|
||||
|
||||
def _post(client, body):
|
||||
return client.post('/api/v3/config/main', data=json.dumps(body),
|
||||
content_type='application/json')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [55, '55'], ids=['number', 'form-string'])
|
||||
def test_a_saved_brightness_goes_over_the_socket(api_v3_client, saved, value):
|
||||
with patch(SET, return_value={'brightness': 55, 'panel_brightness': 55,
|
||||
'dimmed': False, 'display_active': True}) as send:
|
||||
resp = _post(api_v3_client, {'brightness': value})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
send.assert_called_once_with(55)
|
||||
body = resp.get_json()
|
||||
assert body['brightness_transport'] == 'socket'
|
||||
assert 'brightness_socket_error' not in body
|
||||
# Saved as well: the socket's value is transient on the display.
|
||||
assert saved['config']['display']['hardware']['brightness'] == 55
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason', ['no_socket', 'unknown_command', 'pending', 'failed',
|
||||
'timeout', 'busy'])
|
||||
def test_without_the_socket_the_config_watcher_applies_it(api_v3_client, saved, reason):
|
||||
with patch(SET, side_effect=control_client.ControlError(reason, 'x')):
|
||||
resp = _post(api_v3_client, {'brightness': 40})
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body['brightness_transport'] == 'config'
|
||||
assert body['brightness_socket_error'] == reason
|
||||
assert saved['config']['display']['hardware']['brightness'] == 40
|
||||
|
||||
|
||||
def test_a_client_bug_never_fails_the_save(api_v3_client, saved):
|
||||
with patch(SET, side_effect=RuntimeError('bug')):
|
||||
resp = _post(api_v3_client, {'brightness': 40})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['brightness_socket_error'] == 'internal'
|
||||
|
||||
|
||||
def test_the_test_suite_has_no_socket(api_v3_client, saved):
|
||||
"""LEDMATRIX_CONTROL_SOCKET=off (conftest): the real client says so."""
|
||||
body = _post(api_v3_client, {'brightness': 40}).get_json()
|
||||
assert body['brightness_transport'] == 'config'
|
||||
assert body['brightness_socket_error'] in ('disabled', 'unsupported')
|
||||
|
||||
|
||||
def test_a_save_without_a_brightness_sends_nothing(api_v3_client, saved):
|
||||
with patch(SET) as send:
|
||||
resp = _post(api_v3_client, {'rows': 32})
|
||||
assert resp.status_code == 200
|
||||
send.assert_not_called()
|
||||
assert 'brightness_transport' not in resp.get_json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [{'brightness': 0}, {'brightness': 101}, {'brightness': 'x'}])
|
||||
def test_a_refused_brightness_is_not_sent(api_v3_client, saved, body):
|
||||
with patch(SET) as send:
|
||||
resp = _post(api_v3_client, body)
|
||||
assert resp.status_code == 400
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
def test_a_failed_save_is_not_sent(api_v3_client, saved):
|
||||
saved['ok'] = False
|
||||
with patch(SET) as send:
|
||||
resp = _post(api_v3_client, {'brightness': 40})
|
||||
assert resp.status_code == 500
|
||||
send.assert_not_called()
|
||||
@@ -165,7 +165,9 @@ class TestOnDemandArgs:
|
||||
|
||||
@pytest.mark.parametrize('cmd', c.COMMANDS)
|
||||
def test_every_command_has_an_argument_type(self, cmd):
|
||||
args = {'plugin_id': 'p'} if cmd == Command.ON_DEMAND_START else {}
|
||||
args = {Command.ON_DEMAND_START: {'plugin_id': 'p'},
|
||||
Command.PLUGIN_RELOAD: {'plugin_id': 'p'},
|
||||
Command.BRIGHTNESS_SET: {'brightness': 50}}.get(cmd, {})
|
||||
c.parse_args(cmd, args)
|
||||
|
||||
def test_hello_versions(self):
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""DisplayController's side of control socket stage 2.
|
||||
|
||||
* ``brightness.set`` is applied on the render thread at once, repainted,
|
||||
and answered with what the panel shows -- including when the dim schedule
|
||||
holds it lower, when the panel refuses it, and when the schedule has the
|
||||
display off;
|
||||
* ``plugin.reload`` is answered from the top of the loop pass, for every
|
||||
outcome: reloaded (new instance registered, Vegas told), not running,
|
||||
loaded only for on-demand, failed to load, or raised;
|
||||
* the render thread wakes for a command in real time, not just on the fake
|
||||
clock (test_run_loop_socket_wake.py): within milliseconds on a static
|
||||
screen and in a dwell, while a command that does not end the screen
|
||||
leaves the frame wait to run its course;
|
||||
* Vegas checks the queue every frame and runs its interrupt check at once.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ipc.contract import BrightnessSetArgs, Command, ErrorCode, PluginReloadArgs
|
||||
from src.ipc.server import CommandOutcome, ControlServer, QueuedCommand
|
||||
|
||||
|
||||
def _command(cmd, args):
|
||||
return QueuedCommand(request_id='t1', cmd=cmd, args=args, received_at=time.time(),
|
||||
outcome=CommandOutcome())
|
||||
|
||||
|
||||
def _brightness(value):
|
||||
return _command(Command.BRIGHTNESS_SET, BrightnessSetArgs(value))
|
||||
|
||||
|
||||
def _reload(plugin_id):
|
||||
return _command(Command.PLUGIN_RELOAD, PluginReloadArgs(plugin_id))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dc(test_display_controller):
|
||||
c = test_display_controller
|
||||
c.display_manager.set_brightness = MagicMock(return_value=True)
|
||||
c.display_manager.update_display = MagicMock()
|
||||
c.config = {'timezone': 'UTC', 'display': {'hardware': {'brightness': 90}}}
|
||||
c._normal_brightness = 90
|
||||
c.current_brightness = 90
|
||||
c.is_display_active = True
|
||||
c._tz = None
|
||||
return c
|
||||
|
||||
|
||||
class TestBrightness:
|
||||
def test_applied_now_and_repainted(self, dc):
|
||||
cmd = _brightness(40)
|
||||
dc._apply_control_brightness(cmd)
|
||||
dc.display_manager.set_brightness.assert_called_once_with(40)
|
||||
dc.display_manager.update_display.assert_called()
|
||||
assert dc.current_brightness == 40
|
||||
assert cmd.outcome.result == {'brightness': 40, 'panel_brightness': 40,
|
||||
'dimmed': False, 'display_active': True}
|
||||
|
||||
def test_the_dim_schedule_still_applies(self, dc):
|
||||
now = datetime.now(timezone.utc)
|
||||
dc.config['dim_schedule'] = {
|
||||
'enabled': True, 'dim_brightness': 20,
|
||||
'start_time': (now - timedelta(hours=1)).strftime('%H:%M'),
|
||||
'end_time': (now + timedelta(hours=1)).strftime('%H:%M')}
|
||||
# A stale per-minute answer from before the change must not stick.
|
||||
dc._dim_checked_minute = (now.hour, now.minute)
|
||||
dc._cached_target_brightness = 90
|
||||
cmd = _brightness(70)
|
||||
dc._apply_control_brightness(cmd)
|
||||
assert cmd.outcome.result == {'brightness': 70, 'panel_brightness': 20,
|
||||
'dimmed': True, 'display_active': True}
|
||||
assert dc._normal_brightness == 70
|
||||
|
||||
def test_a_refused_brightness_is_reported(self, dc):
|
||||
dc.display_manager.set_brightness.return_value = False
|
||||
cmd = _brightness(40)
|
||||
dc._apply_control_brightness(cmd)
|
||||
assert cmd.outcome.error_code == ErrorCode.FAILED
|
||||
assert dc.current_brightness == 90
|
||||
|
||||
def test_scheduled_off_keeps_it_for_later(self, dc):
|
||||
dc.is_display_active = False
|
||||
cmd = _brightness(40)
|
||||
dc._apply_control_brightness(cmd)
|
||||
dc.display_manager.set_brightness.assert_not_called()
|
||||
assert cmd.outcome.result['display_active'] is False
|
||||
assert cmd.outcome.result['panel_brightness'] == 90
|
||||
# When the schedule turns the display back on, the new level is used.
|
||||
dc.is_display_active = True
|
||||
dc._apply_brightness_target()
|
||||
dc.display_manager.set_brightness.assert_called_once_with(40)
|
||||
|
||||
def test_the_next_config_reload_wins(self, dc):
|
||||
dc._apply_control_brightness(_brightness(40))
|
||||
dc._refresh_config_cache({'display': {'hardware': {'brightness': 75}}})
|
||||
assert dc._normal_brightness == 75
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self, *commands):
|
||||
self.commands = list(commands)
|
||||
|
||||
@property
|
||||
def has_pending(self):
|
||||
return bool(self.commands)
|
||||
|
||||
def drain(self):
|
||||
out, self.commands = self.commands, []
|
||||
return out
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def running(dc):
|
||||
"""dc running 'clock' (two modes) and 'weather', with a reloadable manager."""
|
||||
old = SimpleNamespace(modes=['clock_a', 'clock_b'])
|
||||
new = SimpleNamespace(modes=['clock_a', 'clock_b'])
|
||||
weather = SimpleNamespace(modes=['weather'])
|
||||
pm = MagicMock()
|
||||
pm.plugins = {'clock': old, 'weather': weather}
|
||||
pm.plugin_manifests = {'clock': {'version': '1.0.0'}, 'weather': {}}
|
||||
pm.get_plugin = lambda pid: pm.plugins.get(pid)
|
||||
calls = []
|
||||
|
||||
def unload(pid):
|
||||
calls.append(('unload', pid))
|
||||
pm.plugins.pop(pid, None)
|
||||
return True
|
||||
|
||||
def reload(pid):
|
||||
calls.append(('reload', pid))
|
||||
pm.plugins[pid] = new
|
||||
pm.plugin_manifests[pid] = {'version': '2.0.0'}
|
||||
return True
|
||||
|
||||
pm.unload_plugin = MagicMock(side_effect=unload)
|
||||
pm.reload_plugin = MagicMock(side_effect=reload)
|
||||
dc.plugin_manager = pm
|
||||
dc.plugin_display_modes = {'clock': ['clock_a', 'clock_b'], 'weather': ['weather']}
|
||||
dc.available_modes = ['clock_a', 'clock_b', 'weather']
|
||||
dc.plugin_modes = {'clock_a': old, 'clock_b': old, 'weather': weather}
|
||||
dc.mode_to_plugin_id = {'clock_a': 'clock', 'clock_b': 'clock', 'weather': 'weather'}
|
||||
dc.current_mode_index = 2
|
||||
dc.current_display_mode = 'weather'
|
||||
dc.vegas_coordinator = MagicMock()
|
||||
return SimpleNamespace(dc=dc, pm=pm, old=old, new=new, calls=calls)
|
||||
|
||||
|
||||
class TestReload:
|
||||
def _run(self, dc, *commands):
|
||||
dc._control_server = FakeServer(*commands)
|
||||
dc._poll_on_demand_requests() # the drain: queued, not applied
|
||||
assert dc._plugin_reload_pending
|
||||
dc._apply_pending_plugin_reloads() # the top of the next pass
|
||||
assert not dc._plugin_reload_pending
|
||||
|
||||
def test_reloaded(self, running):
|
||||
dc = running.dc
|
||||
cmd = _reload('clock')
|
||||
self._run(dc, cmd)
|
||||
assert running.calls == [('unload', 'clock'), ('reload', 'clock')]
|
||||
assert cmd.outcome.result == {'plugin_id': 'clock', 'reloaded': True,
|
||||
'version': '2.0.0', 'modes': ['clock_a', 'clock_b']}
|
||||
assert dc.plugin_modes['clock_a'] is running.new
|
||||
# Its modes keep their place in the rotation.
|
||||
assert dc.available_modes == ['clock_a', 'clock_b', 'weather']
|
||||
# The screen that was showing is still the current one.
|
||||
assert dc.current_display_mode == 'weather'
|
||||
assert dc.available_modes[dc.current_mode_index] == 'weather'
|
||||
dc.vegas_coordinator.mark_plugin_updated.assert_called_once_with('clock')
|
||||
|
||||
def test_not_running(self, running):
|
||||
cmd = _reload('nope')
|
||||
self._run(running.dc, cmd)
|
||||
assert cmd.outcome.error_code == ErrorCode.NOT_LOADED
|
||||
assert running.calls == []
|
||||
|
||||
def test_loaded_only_for_on_demand(self, running):
|
||||
running.dc._on_demand_loaded_plugins = {'clock'}
|
||||
cmd = _reload('clock')
|
||||
self._run(running.dc, cmd)
|
||||
assert cmd.outcome.error_code == ErrorCode.BUSY
|
||||
assert running.calls == []
|
||||
|
||||
def test_does_not_load_again(self, running):
|
||||
running.pm.reload_plugin = MagicMock(return_value=False)
|
||||
cmd = _reload('clock')
|
||||
self._run(running.dc, cmd)
|
||||
assert cmd.outcome.error_code == ErrorCode.FAILED
|
||||
assert running.dc.available_modes == ['weather']
|
||||
assert 'clock' not in running.dc.plugin_display_modes
|
||||
|
||||
def test_raises(self, running):
|
||||
running.pm.reload_plugin = MagicMock(side_effect=RuntimeError('boom'))
|
||||
cmd = _reload('clock')
|
||||
self._run(running.dc, cmd)
|
||||
assert cmd.outcome.error_code == ErrorCode.INTERNAL
|
||||
|
||||
def test_a_pending_reload_ends_vegas_and_the_dwell(self, running):
|
||||
dc = running.dc
|
||||
dc._pending_plugin_reloads = (_reload('clock'),)
|
||||
dc._check_wifi_status_message = MagicMock(return_value=None)
|
||||
dc._service_pending_changes = MagicMock()
|
||||
assert dc._check_vegas_interrupt() is True
|
||||
started = time.monotonic()
|
||||
dc._sleep_with_plugin_updates(5)
|
||||
assert time.monotonic() - started < 0.1
|
||||
|
||||
def test_a_failing_brightness_does_not_stop_the_rest(self, running):
|
||||
dc = running.dc
|
||||
bad = _brightness(40)
|
||||
dc._apply_control_brightness = MagicMock(side_effect=RuntimeError('x'))
|
||||
good = _reload('clock')
|
||||
self._run(dc, bad, good)
|
||||
assert bad.outcome.error_code == ErrorCode.INTERNAL
|
||||
assert good.outcome.result['reloaded'] is True
|
||||
|
||||
|
||||
class TestRealTimeWake:
|
||||
"""With a real ControlServer and real threads: what the fake clock tests
|
||||
show, measured. A connection thread queues the command with
|
||||
handle_line(), as it does for a socket client."""
|
||||
|
||||
@pytest.fixture
|
||||
def server(self, dc, tmp_path):
|
||||
server = ControlServer(str(tmp_path / 'unused.sock'))
|
||||
dc._control_server = server
|
||||
dc.current_display_mode = 'clock'
|
||||
dc.on_demand_active = False
|
||||
dc._activate_on_demand = MagicMock(
|
||||
side_effect=lambda request: setattr(dc, 'current_display_mode', 'weather'))
|
||||
dc._wifi_notice_pending = MagicMock(return_value=False)
|
||||
dc._tick_plugin_updates = MagicMock()
|
||||
dc._check_live_takeover = MagicMock()
|
||||
dc.cache_manager.get = MagicMock(return_value=None)
|
||||
dc.cache_manager.set = MagicMock()
|
||||
return server
|
||||
|
||||
@staticmethod
|
||||
def _post_later(server, line, delay, stamps):
|
||||
def run():
|
||||
time.sleep(delay)
|
||||
stamps.append(time.monotonic())
|
||||
server.handle_line(line)
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
@staticmethod
|
||||
def _start_line(rid):
|
||||
import json
|
||||
return json.dumps({'v': 1, 'id': rid, 'cmd': Command.ON_DEMAND_START,
|
||||
'args': {'plugin_id': 'weather'}}).encode()
|
||||
|
||||
def test_static_screen(self, dc, server):
|
||||
latencies = []
|
||||
for i in range(10):
|
||||
dc.current_display_mode = 'clock'
|
||||
stamps = []
|
||||
t = self._post_later(server, self._start_line(f's{i}'), 0.05, stamps)
|
||||
ended = dc._wait_frame_interval(1.0, 'clock')
|
||||
woke = time.monotonic()
|
||||
t.join()
|
||||
assert ended is True
|
||||
latencies.append(woke - stamps[0])
|
||||
latencies.sort()
|
||||
print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, "
|
||||
f"max {latencies[-1] * 1000:.2f} ms")
|
||||
assert latencies[-1] < 0.05, latencies
|
||||
|
||||
def test_dwell(self, dc, server):
|
||||
latencies = []
|
||||
for i in range(10):
|
||||
dc.current_display_mode = 'clock'
|
||||
stamps = []
|
||||
t = self._post_later(server, self._start_line(f'd{i}'), 0.05, stamps)
|
||||
dc._sleep_with_plugin_updates(5.0)
|
||||
woke = time.monotonic()
|
||||
t.join()
|
||||
assert dc.current_display_mode == 'weather'
|
||||
latencies.append(woke - stamps[0])
|
||||
latencies.sort()
|
||||
print(f"dwell wake latency: median {latencies[5] * 1000:.2f} ms, "
|
||||
f"max {latencies[-1] * 1000:.2f} ms")
|
||||
assert latencies[-1] < 0.05, latencies
|
||||
|
||||
def test_a_brightness_does_not_cut_the_frame_wait_short(self, dc, server):
|
||||
import json
|
||||
stamps = []
|
||||
line = json.dumps({'v': 1, 'id': 'b1', 'cmd': Command.BRIGHTNESS_SET,
|
||||
'args': {'brightness': 33}}).encode()
|
||||
started = time.monotonic()
|
||||
t = self._post_later(server, line, 0.1, stamps)
|
||||
assert dc._wait_frame_interval(0.5, 'clock') is False
|
||||
assert time.monotonic() - started >= 0.49
|
||||
t.join()
|
||||
dc.display_manager.set_brightness.assert_called_once_with(33)
|
||||
|
||||
def test_without_a_socket_it_is_a_plain_sleep(self, dc):
|
||||
dc._control_server = None
|
||||
started = time.monotonic()
|
||||
assert dc._wait_frame_interval(0.2, 'clock') is False
|
||||
assert time.monotonic() - started >= 0.19
|
||||
|
||||
|
||||
class TestVegasChecksEveryFrame:
|
||||
def _coordinator(self, urgent_from):
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||
'enabled': True, 'max_cycle_duration': 60, 'continuous_scroll': True}}})
|
||||
coord.render_pipeline = MagicMock()
|
||||
coord.render_pipeline.frame_interval = 0.0
|
||||
coord.render_pipeline.target_fps = 90
|
||||
coord.display_manager = MagicMock()
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
coord._should_stop = False
|
||||
coord._fps_last_health_log = 0.0
|
||||
coord._fps_was_degraded = False
|
||||
coord._live_priority_check = None
|
||||
coord._live_priority_active = False
|
||||
coord._update_callback = None
|
||||
coord._update_tick_running = False
|
||||
coord._check_static_plugin_trigger = lambda: None
|
||||
frames = [0]
|
||||
|
||||
def run_frame():
|
||||
frames[0] += 1
|
||||
return True
|
||||
coord.run_frame = run_frame
|
||||
checked_at = []
|
||||
|
||||
def checker():
|
||||
checked_at.append(frames[0])
|
||||
return frames[0] >= urgent_from
|
||||
coord.set_interrupt_checker(checker, check_interval=10,
|
||||
urgent=lambda: frames[0] >= urgent_from)
|
||||
return coord, checked_at
|
||||
|
||||
def test_an_urgent_frame_runs_the_check_at_once(self):
|
||||
coord, checked_at = self._coordinator(urgent_from=3)
|
||||
assert coord.run_iteration() is False
|
||||
assert checked_at == [3]
|
||||
|
||||
def test_without_urgency_the_interval_holds(self):
|
||||
coord, checked_at = self._coordinator(urgent_from=25)
|
||||
coord._interrupt_urgent = None
|
||||
assert coord.run_iteration() is False
|
||||
assert checked_at == [10, 20, 30]
|
||||
|
||||
def test_a_raising_urgency_test_is_ignored(self):
|
||||
coord, checked_at = self._coordinator(urgent_from=10)
|
||||
coord._interrupt_urgent = MagicMock(side_effect=RuntimeError('x'))
|
||||
assert coord.run_iteration() is False
|
||||
assert checked_at == [10]
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Control socket stage 2 (src/ipc): waking the render thread, and the
|
||||
awaited commands ``brightness.set`` and ``plugin.reload``.
|
||||
|
||||
What these pin, on every platform unless marked:
|
||||
|
||||
* the new commands' arguments refuse what the display could not act on;
|
||||
* an awaited command is answered with the render thread's outcome -- its
|
||||
result, its error code, or ``pending`` when the render thread did not get
|
||||
to it in time (the command stays queued and is still applied);
|
||||
* ``wait_for_command`` returns as soon as a command is queued, and otherwise
|
||||
sleeps for the whole timeout without spinning;
|
||||
* over a real socket (Linux/macOS), the client waits for that outcome and
|
||||
maps each error code to its ``ControlError`` reason.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ipc import client
|
||||
from src.ipc import contract as c
|
||||
from src.ipc.contract import (
|
||||
BrightnessSetArgs, Command, ErrorCode, PluginReloadArgs, ProtocolError,
|
||||
)
|
||||
from src.ipc.server import CommandOutcome, ControlServer
|
||||
|
||||
needs_unix_sockets = pytest.mark.skipif(not c.socket_supported(),
|
||||
reason='AF_UNIX sockets are Linux/macOS only')
|
||||
|
||||
|
||||
def _req(cmd, args=None, rid='r1'):
|
||||
return json.dumps({'v': 1, 'id': rid, 'cmd': cmd, 'args': args or {}}).encode()
|
||||
|
||||
|
||||
def _server(tmp_path, **kw):
|
||||
kw.setdefault('await_seconds', {Command.BRIGHTNESS_SET: 2.0, Command.PLUGIN_RELOAD: 2.0})
|
||||
return ControlServer(str(tmp_path / 'unused.sock'), **kw)
|
||||
|
||||
|
||||
class RenderThread:
|
||||
"""Stands in for the display's render thread: waits on the server the
|
||||
way DisplayController does and settles each command with ``handler``."""
|
||||
|
||||
def __init__(self, server, handler):
|
||||
self.server = server
|
||||
self.handler = handler
|
||||
self.stop = threading.Event()
|
||||
self.woke_at = []
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
|
||||
def _run(self):
|
||||
while not self.stop.is_set():
|
||||
if self.server.wait_for_command(0.05):
|
||||
self.woke_at.append(time.monotonic())
|
||||
for command in self.server.drain():
|
||||
self.handler(command)
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.stop.set()
|
||||
self.thread.join(2)
|
||||
|
||||
|
||||
class TestArguments:
|
||||
@pytest.mark.parametrize('value', [0, 1, 55, 100])
|
||||
def test_brightness_in_range(self, value):
|
||||
assert BrightnessSetArgs.from_dict({'brightness': value}).brightness == value
|
||||
|
||||
@pytest.mark.parametrize('value', [-1, 101, 50.0, '50', True, None, [50]])
|
||||
def test_brightness_refused(self, value):
|
||||
with pytest.raises(ProtocolError) as e:
|
||||
BrightnessSetArgs.from_dict({'brightness': value})
|
||||
assert e.value.code == ErrorCode.INVALID_ARGS
|
||||
|
||||
def test_reload_needs_a_plugin_id(self):
|
||||
assert PluginReloadArgs.from_dict({'plugin_id': 'clock'}).plugin_id == 'clock'
|
||||
for bad in ({}, {'plugin_id': ''}, {'plugin_id': 5}, {'plugin_id': 'x' * 200},
|
||||
{'plugin_id': 'a\nb'}):
|
||||
with pytest.raises(ProtocolError) as e:
|
||||
PluginReloadArgs.from_dict(bad)
|
||||
assert e.value.code == ErrorCode.INVALID_ARGS
|
||||
|
||||
def test_the_new_commands_are_queued_and_awaited(self):
|
||||
for cmd in (Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD):
|
||||
assert cmd in c.COMMANDS and cmd in c.QUEUED_COMMANDS and cmd in c.AWAITED_COMMANDS
|
||||
# On-demand stays an ack: its outcome is published, not awaited.
|
||||
assert Command.ON_DEMAND_START not in c.AWAITED_COMMANDS
|
||||
# Additive within version 1 (see the contract's docstring).
|
||||
assert c.SUPPORTED_VERSIONS == (1,)
|
||||
|
||||
def test_the_client_outwaits_the_display(self):
|
||||
"""The client's timeout must cover the display's wait, or a slow
|
||||
reload would be reported as a timeout instead of ``pending``."""
|
||||
for cmd in c.AWAITED_COMMANDS:
|
||||
assert client._awaited_timeout(cmd) > c.AWAIT_SECONDS[cmd]
|
||||
|
||||
|
||||
class TestOutcome:
|
||||
def test_first_settlement_wins(self):
|
||||
outcome = CommandOutcome()
|
||||
outcome.succeed({'a': 1})
|
||||
outcome.fail(ErrorCode.FAILED, 'late')
|
||||
assert outcome.done and outcome.result == {'a': 1} and outcome.error_code is None
|
||||
|
||||
def test_wait_times_out(self):
|
||||
assert CommandOutcome().wait(0.01) is False
|
||||
|
||||
|
||||
class TestAwaitedCommands:
|
||||
def test_brightness_answers_with_the_render_thread_result(self, tmp_path):
|
||||
server = _server(tmp_path)
|
||||
|
||||
def apply(command):
|
||||
assert command.cmd == Command.BRIGHTNESS_SET
|
||||
command.succeed({'brightness': command.args.brightness, 'panel_brightness': 40,
|
||||
'dimmed': False, 'display_active': True})
|
||||
|
||||
with RenderThread(server, apply):
|
||||
resp = server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 40}))
|
||||
assert resp.ok, resp.error
|
||||
assert resp.result == {'brightness': 40, 'panel_brightness': 40,
|
||||
'dimmed': False, 'display_active': True}
|
||||
|
||||
@pytest.mark.parametrize('code', [ErrorCode.NOT_LOADED, ErrorCode.FAILED, ErrorCode.BUSY])
|
||||
def test_a_failure_carries_its_code(self, tmp_path, code):
|
||||
server = _server(tmp_path)
|
||||
with RenderThread(server, lambda command: command.fail(code, 'no')):
|
||||
resp = server.handle_line(_req(Command.PLUGIN_RELOAD, {'plugin_id': 'clock'}))
|
||||
assert not resp.ok and resp.error.code == code and resp.id == 'r1'
|
||||
|
||||
def test_no_render_thread_means_pending_and_the_command_stays_queued(self, tmp_path):
|
||||
server = _server(tmp_path, await_seconds={Command.PLUGIN_RELOAD: 0.05})
|
||||
started = time.monotonic()
|
||||
resp = server.handle_line(_req(Command.PLUGIN_RELOAD, {'plugin_id': 'clock'}))
|
||||
assert time.monotonic() - started < 1.0
|
||||
assert not resp.ok and resp.error.code == ErrorCode.PENDING
|
||||
# Still applied when the render thread gets there; nobody reads it.
|
||||
queued = server.drain()
|
||||
assert [(q.cmd, q.args.plugin_id) for q in queued] == [(Command.PLUGIN_RELOAD, 'clock')]
|
||||
queued[0].succeed({'reloaded': True})
|
||||
|
||||
def test_bad_arguments_are_refused_before_queueing(self, tmp_path):
|
||||
server = _server(tmp_path)
|
||||
resp = server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 400}))
|
||||
assert not resp.ok and resp.error.code == ErrorCode.INVALID_ARGS
|
||||
assert not server.has_pending and server.drain() == []
|
||||
|
||||
def test_a_full_queue_is_busy_without_waiting(self, tmp_path):
|
||||
server = _server(tmp_path, queue_size=1)
|
||||
server.handle_line(_req(Command.ON_DEMAND_STOP, rid='fill'))
|
||||
started = time.monotonic()
|
||||
resp = server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 10}))
|
||||
assert not resp.ok and resp.error.code == ErrorCode.BUSY
|
||||
assert time.monotonic() - started < 0.5
|
||||
|
||||
def test_on_demand_is_still_acked_without_waiting(self, tmp_path):
|
||||
server = _server(tmp_path)
|
||||
resp = server.handle_line(_req(Command.ON_DEMAND_STOP))
|
||||
assert resp.ok and resp.result['accepted'] is True
|
||||
(queued,) = server.drain()
|
||||
assert queued.outcome is None
|
||||
queued.succeed({}) # a no-op for an acked command
|
||||
|
||||
def test_an_on_demand_payload_is_only_built_for_on_demand(self, tmp_path):
|
||||
server = _server(tmp_path, await_seconds={Command.BRIGHTNESS_SET: 0.01})
|
||||
server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 10}))
|
||||
(queued,) = server.drain()
|
||||
with pytest.raises(TypeError):
|
||||
queued.as_on_demand_request()
|
||||
|
||||
|
||||
class TestWake:
|
||||
"""``wait_for_command`` is what the render thread waits on in place of a
|
||||
sleep: it must return as soon as a command is queued, and not before."""
|
||||
|
||||
def test_returns_at_once_when_a_command_is_queued(self, tmp_path):
|
||||
server = _server(tmp_path)
|
||||
latencies = []
|
||||
for _ in range(20):
|
||||
queued_at = []
|
||||
|
||||
def post():
|
||||
time.sleep(0.02)
|
||||
queued_at.append(time.monotonic())
|
||||
server.handle_line(_req(Command.ON_DEMAND_STOP))
|
||||
|
||||
t = threading.Thread(target=post)
|
||||
t.start()
|
||||
assert server.wait_for_command(2.0) is True
|
||||
woke = time.monotonic()
|
||||
t.join()
|
||||
latencies.append(woke - queued_at[0])
|
||||
server.drain()
|
||||
latencies.sort()
|
||||
# Typically well under a millisecond; 50 ms is the budget for a
|
||||
# static screen, and a loaded CI box needs the slack.
|
||||
assert latencies[len(latencies) // 2] < 0.01, latencies
|
||||
assert latencies[-1] < 0.05, latencies
|
||||
|
||||
def test_sleeps_the_whole_timeout_when_nothing_comes(self, tmp_path):
|
||||
server = _server(tmp_path)
|
||||
started = time.monotonic()
|
||||
cpu = time.process_time()
|
||||
assert server.wait_for_command(0.3) is False
|
||||
assert time.monotonic() - started >= 0.29
|
||||
# A timed wait, not a polling loop.
|
||||
assert time.process_time() - cpu < 0.05
|
||||
|
||||
def test_drain_rearms_it(self, tmp_path):
|
||||
server = _server(tmp_path)
|
||||
server.handle_line(_req(Command.ON_DEMAND_STOP))
|
||||
assert server.wait_for_command(0) is True
|
||||
server.drain()
|
||||
assert server.wait_for_command(0.01) is False
|
||||
|
||||
|
||||
# -- the real socket ------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def sock_path():
|
||||
import shutil
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp(prefix='lmipc2-')
|
||||
yield os.path.join(d, 'control.sock')
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(sock_path):
|
||||
servers = []
|
||||
|
||||
def make(**kwargs):
|
||||
s = ControlServer(sock_path, status_provider=lambda: {}, **kwargs)
|
||||
assert s.start()
|
||||
servers.append(s)
|
||||
return s
|
||||
|
||||
yield make
|
||||
for s in servers:
|
||||
s.close()
|
||||
|
||||
|
||||
@needs_unix_sockets
|
||||
class TestOverTheSocket:
|
||||
def test_brightness_round_trip(self, live, sock_path):
|
||||
server = live()
|
||||
|
||||
def apply(command):
|
||||
command.succeed({'brightness': command.args.brightness, 'panel_brightness': 30,
|
||||
'dimmed': True, 'display_active': True})
|
||||
|
||||
with RenderThread(server, apply):
|
||||
started = time.monotonic()
|
||||
result = client.brightness_set(70, paths=[sock_path])
|
||||
elapsed = time.monotonic() - started
|
||||
assert result == {'brightness': 70, 'panel_brightness': 30, 'dimmed': True,
|
||||
'display_active': True}
|
||||
assert elapsed < 0.5, elapsed
|
||||
|
||||
def test_reload_round_trip(self, live, sock_path):
|
||||
server = live()
|
||||
|
||||
def apply(command):
|
||||
command.succeed({'plugin_id': command.args.plugin_id, 'reloaded': True,
|
||||
'version': '2.0.0', 'modes': ['clock']})
|
||||
|
||||
with RenderThread(server, apply):
|
||||
result = client.plugin_reload('clock', paths=[sock_path])
|
||||
assert result['reloaded'] is True and result['version'] == '2.0.0'
|
||||
|
||||
@pytest.mark.parametrize('code', [ErrorCode.NOT_LOADED, ErrorCode.FAILED])
|
||||
def test_reload_errors_reach_the_client(self, live, sock_path, code):
|
||||
server = live()
|
||||
with RenderThread(server, lambda command: command.fail(code, 'x')):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.plugin_reload('clock', paths=[sock_path])
|
||||
assert e.value.reason == code
|
||||
|
||||
def test_pending_reaches_the_client_before_its_own_timeout(self, live, sock_path):
|
||||
live(await_seconds={Command.PLUGIN_RELOAD: 0.1})
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.plugin_reload('clock', paths=[sock_path], timeout=2.0)
|
||||
assert e.value.reason == ErrorCode.PENDING
|
||||
|
||||
def test_invalid_arguments_never_leave_the_client(self, sock_path):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.brightness_set(500, paths=[sock_path])
|
||||
assert e.value.reason == 'invalid_request'
|
||||
|
||||
def test_hello_lists_the_new_commands(self, live, sock_path):
|
||||
live()
|
||||
commands = client.hello(paths=[sock_path])['commands']
|
||||
assert Command.BRIGHTNESS_SET in commands and Command.PLUGIN_RELOAD in commands
|
||||
@@ -0,0 +1,179 @@
|
||||
"""A control socket command wakes the run loop (control socket stage 2).
|
||||
|
||||
Stage 1's socket was no faster than the file mailbox: a queued command
|
||||
waited for the same polls the mailbox does -- the 1 s frame sleep of a static
|
||||
screen, the 0.25 s dwell tick, and Vegas's interrupt check every 10 frames
|
||||
(about 0.4 s at the 24 fps a Pi 4 manages). Now the render thread waits on
|
||||
the socket's queue instead of sleeping, and Vegas checks the queue every
|
||||
frame, so a command is applied:
|
||||
|
||||
* at once on a static screen and in a dwell (here: at the instant it is
|
||||
queued, on the fake clock);
|
||||
* at the next frame in Vegas (8 ms here, at 125 fps).
|
||||
|
||||
Each test runs the real run() loop on the fake clock of
|
||||
test/_run_loop_harness.py and compares the socket with the mailbox for the
|
||||
same request. The latencies are the fake clock's, so they are exact.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.ipc.contract import Command, ErrorCode # noqa: E402
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
|
||||
|
||||
POSTED = 10.3
|
||||
|
||||
|
||||
def _event_time(trace, kind):
|
||||
return next(e[0] for e in trace["events"] if e[1] == kind)
|
||||
|
||||
|
||||
def _on_demand_latency(tmp_path, build, via, horizon=40):
|
||||
h = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(h)
|
||||
if via == "socket":
|
||||
h.control_socket().post(POSTED, Command.ON_DEMAND_START, {"plugin_id": "weather"})
|
||||
else:
|
||||
h.on_demand_request(POSTED, "mb1", plugin_id="weather")
|
||||
trace = h.run()
|
||||
return round(_event_time(trace, "on-demand-start") - POSTED, 3), trace
|
||||
|
||||
|
||||
def _static(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
|
||||
|
||||
|
||||
def _dwell(h):
|
||||
# Content on the first frame only: the 1 s loop ends at t=1 and the rest
|
||||
# of the 30 s is made up in the dwell sleep (_sleep_with_plugin_updates).
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=30, first_frame_only=True))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
|
||||
|
||||
|
||||
def _vegas(h):
|
||||
_static(h)
|
||||
h.enable_vegas(cycle=30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("build, mailbox_latency", [
|
||||
(_static, 0.7), # the next 1 s frame, at t=11
|
||||
(_dwell, 0.2), # the next 0.25 s tick, at t=10.5
|
||||
(_vegas, 0.02), # the next 10-frame check: 80 ms at 125 fps, ~0.4 s on a Pi 4
|
||||
], ids=["static-screen", "dwell", "vegas"])
|
||||
def test_a_socket_command_lands_at_once(tmp_path, build, mailbox_latency):
|
||||
(tmp_path / "s").mkdir()
|
||||
(tmp_path / "m").mkdir()
|
||||
socket_latency, trace = _on_demand_latency(tmp_path / "s", build, "socket")
|
||||
via_mailbox, _ = _on_demand_latency(tmp_path / "m", build, "mailbox")
|
||||
|
||||
assert via_mailbox == pytest.approx(mailbox_latency, abs=0.002)
|
||||
if build is _vegas:
|
||||
# One 8 ms frame: Vegas checks the queue every frame now.
|
||||
assert socket_latency <= 0.008
|
||||
else:
|
||||
assert socket_latency == 0.0
|
||||
# And the requested plugin is what the panel shows next, from then on.
|
||||
row = next(r for r in trace["screens"] if r[1] == "weather")
|
||||
assert row[0] == pytest.approx(POSTED + socket_latency, abs=0.001)
|
||||
|
||||
|
||||
def test_a_brightness_lands_at_once_without_ending_the_screen(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=40)
|
||||
_static(h)
|
||||
command = h.control_socket().post(POSTED, Command.BRIGHTNESS_SET, {"brightness": 40})
|
||||
trace = h.run()
|
||||
|
||||
assert [e for e in trace["events"] if e[1] == "brightness"] == [[POSTED, "brightness", 40]]
|
||||
# The clock screen runs its full 30 s and the next starts on time.
|
||||
assert trace["screens"][0][:4] == [0.0, "clock", 30.0, "duration"]
|
||||
assert trace["screens"][1][:2] == [30.0, "weather"]
|
||||
assert command.outcome.done and command.outcome.result["panel_brightness"] == 40
|
||||
|
||||
|
||||
def test_the_static_frame_cadence_is_unchanged_by_a_command(tmp_path):
|
||||
"""The wait resumes after a command that does not end the screen, so the
|
||||
plugin is still drawn once a second, not once more at the command."""
|
||||
h = RunLoopHarness(tmp_path, horizon=31)
|
||||
_static(h)
|
||||
quiet = h.run()
|
||||
|
||||
(tmp_path / "b").mkdir()
|
||||
h2 = RunLoopHarness(tmp_path / "b", horizon=31)
|
||||
_static(h2)
|
||||
h2.control_socket().post(POSTED, Command.BRIGHTNESS_SET, {"brightness": 40})
|
||||
busy = h2.run()
|
||||
assert busy["screens"][0] == quiet["screens"][0]
|
||||
|
||||
|
||||
def _with_reloadable(h, version="2.0.0", loads=True):
|
||||
new = FakePlugin("clock", ["clock"], duration=30)
|
||||
|
||||
def reload_plugin(plugin_id):
|
||||
h.log("reload", plugin_id)
|
||||
if not loads:
|
||||
return False
|
||||
new._h = h
|
||||
h.pm.plugins[plugin_id] = new
|
||||
h.pm.plugin_manifests[plugin_id] = {"version": version, "display_modes": ["clock"]}
|
||||
return True
|
||||
|
||||
h.pm.reload_plugin = reload_plugin
|
||||
return new
|
||||
|
||||
|
||||
def test_a_plugin_reload_ends_the_screen_and_reloads_before_the_next(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=70)
|
||||
_static(h)
|
||||
new = _with_reloadable(h)
|
||||
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
|
||||
trace = h.run()
|
||||
|
||||
# Reloaded the moment it was asked for: the clock screen ends then, and
|
||||
# the rotation moves on.
|
||||
assert _event_time(trace, "reload") == POSTED
|
||||
assert trace["screens"][0][:3] == [0.0, "clock", POSTED]
|
||||
assert trace["screens"][1][:2] == [POSTED, "weather"]
|
||||
assert command.outcome.result == {"plugin_id": "clock", "reloaded": True,
|
||||
"version": "2.0.0", "modes": ["clock"]}
|
||||
# The next clock screen is drawn by the reloaded instance.
|
||||
assert h.controller.plugin_modes["clock"] is new
|
||||
assert trace["screens"][2][1] == "clock"
|
||||
|
||||
|
||||
def test_a_plugin_reload_during_vegas_yields_at_the_next_frame(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=70)
|
||||
_vegas(h)
|
||||
_with_reloadable(h)
|
||||
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
|
||||
trace = h.run()
|
||||
|
||||
assert 0 <= _event_time(trace, "reload") - POSTED <= 0.008
|
||||
assert command.outcome.result["reloaded"] is True
|
||||
# The ticker resumes after the reload; no rotation screen in between.
|
||||
after = [r for r in trace["screens"] if r[0] > POSTED]
|
||||
assert after and after[0][1] == "<vegas>"
|
||||
|
||||
|
||||
def test_a_plugin_that_does_not_load_again_is_reported(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=40)
|
||||
_static(h)
|
||||
_with_reloadable(h, loads=False)
|
||||
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
|
||||
h.run()
|
||||
assert command.outcome.error_code == ErrorCode.FAILED
|
||||
assert "clock" not in h.controller.available_modes
|
||||
|
||||
|
||||
def test_reloading_a_plugin_that_is_not_running(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=40)
|
||||
_static(h)
|
||||
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "nope"})
|
||||
trace = h.run()
|
||||
assert command.outcome.error_code == ErrorCode.NOT_LOADED
|
||||
# It still cost the clock screen nothing but its end: rotation goes on.
|
||||
assert [r[1] for r in trace["screens"][:2]] == ["clock", "weather"]
|
||||
@@ -382,6 +382,95 @@ class TestStoreOperationsReachTheDisplay:
|
||||
display_restart_required("reinstall", True)
|
||||
|
||||
|
||||
RELOAD = "web_interface.blueprints.api_v3.control_client.plugin_reload"
|
||||
|
||||
|
||||
class TestAnUpdateIsReloadedByTheDisplay:
|
||||
"""Control socket stage 2: instead of asking for a restart, the update
|
||||
route asks the running display to reload the plugin (``plugin.reload``),
|
||||
and only falls back to ``restart_required`` when that does not work. The
|
||||
web process still runs none of the plugin's code."""
|
||||
|
||||
def test_reloaded_over_the_socket(self, web):
|
||||
from unittest.mock import patch
|
||||
_bump_version(web, "2.0.0")
|
||||
with patch(RELOAD, return_value={"plugin_id": PLUGIN_ID, "reloaded": True,
|
||||
"version": "2.0.0", "modes": ["tripwire"]}) as reload:
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
reload.assert_called_once_with(PLUGIN_ID)
|
||||
assert body["restart_required"] is False
|
||||
assert body["reloaded"] is True and body["reloaded_version"] == "2.0.0"
|
||||
assert "restart_message" not in body and "reload_error" not in body
|
||||
assert "running the new version" in body["message"]
|
||||
assert web.ran() == []
|
||||
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"no_socket", # display stopped, or predates the socket
|
||||
"unknown_command", # a stage-1 display
|
||||
"not_loaded", "failed", "pending", "busy", "timeout", "refused",
|
||||
])
|
||||
def test_any_failure_falls_back_to_a_restart(self, web, reason):
|
||||
from unittest.mock import patch
|
||||
from src.ipc import client as control_client
|
||||
_bump_version(web, "2.0.0")
|
||||
with patch(RELOAD, side_effect=control_client.ControlError(reason, "x")):
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is True
|
||||
assert "restart the display" in body["restart_message"]
|
||||
assert body["reload_error"] == reason
|
||||
assert "reloaded" not in body
|
||||
|
||||
def test_a_client_bug_falls_back_too(self, web):
|
||||
from unittest.mock import patch
|
||||
_bump_version(web, "2.0.0")
|
||||
with patch(RELOAD, side_effect=RuntimeError("bug")):
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is True and body["reload_error"] == "internal"
|
||||
|
||||
def test_without_a_socket_it_is_the_restart_banner_as_before(self, web):
|
||||
# The test suite runs with LEDMATRIX_CONTROL_SOCKET=off (conftest).
|
||||
_bump_version(web, "2.0.0")
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is True
|
||||
# 'unsupported' on Windows, which has no Unix sockets at all.
|
||||
assert body["reload_error"] in ("disabled", "unsupported")
|
||||
|
||||
def test_an_unchanged_plugin_asks_nothing(self, web):
|
||||
from unittest.mock import patch
|
||||
with patch(RELOAD) as reload:
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["data"]["update_status"] == "up_to_date"
|
||||
assert body["restart_required"] is False
|
||||
reload.assert_not_called()
|
||||
|
||||
def test_a_disabled_plugin_asks_nothing(self, disabled_web):
|
||||
from unittest.mock import patch
|
||||
_bump_version(disabled_web, "2.0.0")
|
||||
with patch(RELOAD) as reload:
|
||||
body = disabled_web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is False
|
||||
reload.assert_not_called()
|
||||
|
||||
def test_the_display_is_asked_by_the_manifest_id(self, web):
|
||||
"""A store id can differ from the id the display runs the plugin
|
||||
under (a registry alias: weather / ledmatrix-weather). The config
|
||||
section and the reload both use the manifest's."""
|
||||
from unittest.mock import patch
|
||||
manifest_id = "ledmatrix-alias"
|
||||
_write_plugin(web.plugins_dir, web.log, plugin_id=manifest_id)
|
||||
web.config_file.write_text(json.dumps({manifest_id: {"enabled": True}}),
|
||||
encoding="utf-8")
|
||||
|
||||
def update(plugin_id):
|
||||
_write_plugin(web.plugins_dir, web.log, version="2.0.0", plugin_id=manifest_id)
|
||||
return True
|
||||
web.api.plugin_store_manager.update_plugin.side_effect = update
|
||||
with patch(RELOAD, return_value={"reloaded": True, "version": "2.0.0"}) as reload:
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": "alias"})
|
||||
reload.assert_called_once_with(manifest_id)
|
||||
assert body["restart_required"] is False
|
||||
|
||||
|
||||
class TestCatalogReadsWhatIsInstalled:
|
||||
"""Real plugin directories: the repository's plugin-repos/ and fixtures."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user