mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 03:49:13 +00:00
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a delivered on-demand start reads as starting until the display acts on it On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
7.7 KiB
Python
169 lines
7.7 KiB
Python
"""GET /api/v3/display/modes -- the list of modes an on-demand request can name.
|
|
|
|
Anything driving the display from outside the web UI needs two things that no
|
|
existing endpoint gave it: the set of display modes, and which plugin owns each
|
|
one. /plugins/installed carries neither, so callers read every plugin's
|
|
manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
|
api_v3_client, api_v3_module,
|
|
)
|
|
|
|
|
|
MANIFESTS = {
|
|
'clock-simple': {'name': 'Simple Clock', 'display_modes': ['clock-simple']},
|
|
'football-scoreboard': {
|
|
'name': 'Football Scoreboard',
|
|
'display_modes': ['nfl_live', 'nfl_recent', 'nfl_upcoming'],
|
|
},
|
|
'ledmatrix-weather': {'name': 'Weather', 'display_modes': ['weather']},
|
|
}
|
|
|
|
CONFIG = {
|
|
'clock-simple': {'enabled': True},
|
|
'football-scoreboard': {'enabled': True},
|
|
'ledmatrix-weather': {'enabled': False},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(api_v3_module, api_v3_client):
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = MANIFESTS
|
|
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
pm.get_plugin_display_modes = MagicMock(
|
|
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value=CONFIG)
|
|
return api_v3_client
|
|
|
|
|
|
def _modes(response):
|
|
return {m['mode']: m for m in response.get_json()['data']['modes']}
|
|
|
|
|
|
class TestTheModeListing:
|
|
def test_enabled_plugins_contribute_every_mode(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert set(modes) == {'clock-simple', 'nfl_live', 'nfl_recent', 'nfl_upcoming'}
|
|
|
|
def test_each_mode_names_its_plugin(self, client):
|
|
"""on-demand/start's find_plugin_for_mode fallback cannot see generated
|
|
modes, so the caller has to send plugin_id -- it must come from here."""
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['nfl_live']['plugin_id'] == 'football-scoreboard'
|
|
|
|
def test_disabled_plugins_are_left_out_by_default(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert 'weather' not in modes
|
|
|
|
def test_disabled_plugins_can_be_asked_for(self, client):
|
|
"""They are still valid on-demand targets: the controller enables them
|
|
for the duration of the request."""
|
|
modes = _modes(client.get('/api/v3/display/modes?include_disabled=1'))
|
|
assert modes['weather']['enabled'] is False
|
|
|
|
def test_a_single_mode_plugin_is_labelled_with_its_own_name(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['clock-simple']['name'] == 'Simple Clock'
|
|
|
|
def test_a_multi_mode_plugin_labels_each_mode_distinctly(self, client):
|
|
"""There is no per-mode name anywhere, and three modes all called
|
|
"Football Scoreboard" are not a usable dropdown."""
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['nfl_live']['name'] == 'nfl_live'
|
|
assert modes['nfl_live']['plugin_name'] == 'Football Scoreboard'
|
|
|
|
|
|
class TestItWorksForACallerThatNeverOpensTheDashboard:
|
|
def test_discovery_is_triggered(self, client, api_v3_module):
|
|
"""Discovery is lazy and normally runs because a person loaded the
|
|
dashboard; a bridge or script would otherwise get an empty list."""
|
|
client.get('/api/v3/display/modes')
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once()
|
|
|
|
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
|
|
api_v3_module.api_v3.plugin_catalog = None
|
|
response = api_v3_client.get('/api/v3/display/modes')
|
|
assert response.status_code == 500
|
|
assert response.get_json()['status'] == 'error'
|
|
|
|
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
|
|
"""Its mode is its own id -- the same fallback the controller uses."""
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
|
|
pm.get_plugin_display_modes = MagicMock(return_value=[])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(
|
|
return_value={'starlark-apps': {'enabled': True}})
|
|
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['starlark-apps']['plugin_id'] == 'starlark-apps'
|
|
|
|
|
|
class TestOneBadConfigSectionDoesNotBlankTheList:
|
|
"""config.json can hold a non-dict under a plugin id.
|
|
|
|
DisplayController guards the same shape, so it happens in practice. Here it
|
|
used to raise AttributeError mid-loop and answer 500 with no modes at all --
|
|
and the MQTT bridge builds every one of its entities from this list, so one
|
|
hand-edited section would empty the Home Assistant dropdown.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def client_with_bad_section(self, api_v3_module, api_v3_client):
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = MANIFESTS
|
|
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
pm.get_plugin_display_modes = MagicMock(
|
|
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={
|
|
'clock-simple': {'enabled': True},
|
|
'football-scoreboard': "true", # a string, not an object
|
|
'ledmatrix-weather': {'enabled': True},
|
|
})
|
|
return api_v3_client
|
|
|
|
def test_the_endpoint_still_answers(self, client_with_bad_section):
|
|
assert client_with_bad_section.get('/api/v3/display/modes').status_code == 200
|
|
|
|
def test_the_healthy_plugins_are_still_listed(self, client_with_bad_section):
|
|
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
assert 'clock-simple' in modes and 'weather' in modes
|
|
|
|
def test_the_bad_section_is_treated_as_disabled(self, client_with_bad_section):
|
|
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
assert 'nfl_live' not in modes
|
|
|
|
def test_a_failure_is_reported_the_way_every_other_handler_reports_one(
|
|
self, api_v3_module, api_v3_client):
|
|
"""describe_exception, per test_web_error_detail's contract -- an
|
|
opaque "see logs for details" is what that test exists to prevent."""
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
|
side_effect=RuntimeError("disk is gone"))
|
|
resp = api_v3_client.get('/api/v3/display/modes')
|
|
assert resp.status_code == 500
|
|
assert resp.get_json()['details'] == 'RuntimeError'
|
|
assert 'disk is gone' not in json.dumps(resp.get_json())
|
|
|
|
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
|
"""describe_exception is what makes returning detail safe."""
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
|
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
|
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
|
assert 'SEC123' not in json.dumps(body)
|
|
|
|
|
|
class TestOnDemandUsesTheRegisteredSpelling:
|
|
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
|
|
with patch('web_interface.blueprints.api_v3.display._send_on_demand') as deliver:
|
|
response = client.post('/api/v3/display/on-demand/start',
|
|
json={'plugin_id': 'football-scoreboard',
|
|
'mode': 'NFL_LIVE', 'start_service': False})
|
|
assert response.status_code == 200, response.get_json()
|
|
assert deliver.call_args.args[0]['mode'] == 'nfl_live'
|