mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
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>
452 lines
20 KiB
Python
452 lines
20 KiB
Python
"""Display control, on-demand playback and preview.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
|
so their endpoint names are unchanged by living here.
|
|
"""
|
|
from web_interface.blueprints.api_v3 import (
|
|
_QUIET_SOCKET_REASONS, _REPORTABLE_SOCKET_REASONS, # noqa: F401 - tests read them here
|
|
_coerce_to_bool, _ensure_display_service_running,
|
|
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
|
|
jsonify, logger, request, uuid,
|
|
)
|
|
from web_interface import display_preview, display_state
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
from src.ipc import client as control_client
|
|
# Read through the module rather than bound by value: tests patch these
|
|
# as module attributes, and a value binding would not see the patch.
|
|
# Several are also called from helpers that live in __init__, so the
|
|
# package is the only patch point that covers every caller.
|
|
|
|
|
|
def _cache_manager():
|
|
"""The web process's CacheManager, the one app.py puts on the blueprint.
|
|
|
|
Created on first use when nothing set it (a test app, an embedder), and
|
|
stored back on the blueprint so every route shares that one instance.
|
|
"""
|
|
cache = getattr(api_v3, 'cache_manager', None)
|
|
if cache is None:
|
|
from src.cache_manager import CacheManager
|
|
cache = api_v3.cache_manager = CacheManager()
|
|
return cache
|
|
|
|
|
|
|
|
|
|
#: How long the start route waits for a display it has just started (or one
|
|
#: systemd already reports running, which may still be loading its plugins)
|
|
#: to serve its control socket, before it gives up. The socket comes up when
|
|
#: the display's run loop starts, after every plugin has loaded.
|
|
ON_DEMAND_SOCKET_WAIT_SECONDS = 45.0
|
|
#: The same wait when the service was already running: a display that has
|
|
#: just been restarted by someone else. Shorter, because a running display
|
|
#: normally has its socket.
|
|
ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS = 10.0
|
|
#: Gap between two attempts while waiting for the socket.
|
|
ON_DEMAND_SOCKET_RETRY_INTERVAL = 0.5
|
|
|
|
|
|
def _send_on_demand(payload):
|
|
"""Hand an on-demand request to the display over the control socket.
|
|
|
|
Returns the display's acknowledgement: it has the command queued for
|
|
its render thread. Raises ``control_client.ControlError`` when it did
|
|
not take it; there is no other way to reach the display (the file
|
|
mailbox ``display_on_demand_request`` is gone).
|
|
"""
|
|
if payload['action'] == 'start':
|
|
return control_client.on_demand_start(
|
|
payload['request_id'], payload.get('plugin_id'), payload.get('mode'),
|
|
payload.get('duration'), bool(payload.get('pinned', False)))
|
|
return control_client.on_demand_stop(payload['request_id'])
|
|
|
|
|
|
def _send_on_demand_when_listening(payload, wait_seconds):
|
|
"""_send_on_demand, retried while no display is listening yet (a display
|
|
still starting), for up to ``wait_seconds``. Any other failure, or the
|
|
last one once the time is up, raises ``control_client.ControlError``."""
|
|
deadline = _pkg.time.monotonic() + wait_seconds
|
|
while True:
|
|
try:
|
|
return _send_on_demand(payload)
|
|
except control_client.ControlError as e:
|
|
if (not control_client.display_not_listening(e)
|
|
or _pkg.time.monotonic() + ON_DEMAND_SOCKET_RETRY_INTERVAL > deadline):
|
|
raise
|
|
_pkg.time.sleep(ON_DEMAND_SOCKET_RETRY_INTERVAL)
|
|
|
|
|
|
def _socket_error_response(request_id, action, reason, message=None, **extra):
|
|
"""The answer when the display did not take an on-demand request: ``400``
|
|
for arguments it refused, else ``503``."""
|
|
status = 400 if reason == 'invalid_args' else 503
|
|
data = {'request_id': request_id, 'transport': 'socket', 'socket_error': reason}
|
|
data.update(extra)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': message or (f'The display service did not accept the on-demand {action} '
|
|
f'request ({reason})'),
|
|
'data': data,
|
|
}), status
|
|
|
|
|
|
def _socket_failure_reason(error):
|
|
"""The reportable reason code for an exception from _send_on_demand, logged."""
|
|
if isinstance(error, control_client.ControlError):
|
|
reason = _socket_reason_code(error.reason)
|
|
if reason in _QUIET_SOCKET_REASONS:
|
|
logger.debug("On-demand request not taken over the control socket: %s", error)
|
|
else:
|
|
logger.warning("On-demand request not taken over the control socket: %s", error)
|
|
return reason
|
|
logger.error("Control socket client failed", exc_info=error)
|
|
return 'internal'
|
|
|
|
|
|
@api_v3.route('/display/current', methods=['GET'])
|
|
def get_display_current():
|
|
"""The latest display preview, as the /stream/display SSE stream sends it.
|
|
|
|
``data`` is ``{timestamp, width, height, image}``; ``image`` is the
|
|
snapshot PNG base64-encoded, or null when there is none to show.
|
|
"""
|
|
# Get display dimensions from config: the logical size DisplayManager
|
|
# renders at, so double-sided setups preview one screen
|
|
from src.display_geometry import logical_size
|
|
try:
|
|
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
|
width, height = logical_size(config)
|
|
except Exception:
|
|
width, height = logical_size({})
|
|
|
|
try:
|
|
image = display_preview.read_snapshot_base64()
|
|
except FileNotFoundError:
|
|
image = None # the display service has not written one yet
|
|
except OSError:
|
|
logger.warning("Could not read the display preview snapshot", exc_info=True)
|
|
image = None
|
|
|
|
return jsonify({'status': 'success',
|
|
'data': display_preview.preview_payload(width, height, image)})
|
|
@api_v3.route('/display/modes', methods=['GET'])
|
|
def get_display_modes():
|
|
"""Every display mode that can be requested on-demand, with its plugin.
|
|
|
|
/plugins/installed carries no mode information, so anything driving the
|
|
display from outside the web UI (the Home Assistant MQTT bridge, a script)
|
|
had to read each plugin's manifest.json off disk and reimplement the
|
|
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
|
|
same list the force-display dialog offers, from the source that owns it.
|
|
|
|
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
|
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
|
lookup only sees modes declared in a static manifest -- a plugin whose
|
|
modes are generated (each installed Starlark app is one) 404s there.
|
|
Sending the plugin_id from this list skips the lookup entirely.
|
|
|
|
Query params:
|
|
include_disabled: '1' to list modes of disabled plugins too. They can
|
|
still be requested on-demand -- the controller enables the plugin
|
|
for the duration -- so they are reported with enabled: false
|
|
rather than omitted.
|
|
"""
|
|
if not api_v3.plugin_catalog:
|
|
return jsonify({'status': 'error', 'message': 'Plugin catalog not initialized'}), 500
|
|
|
|
# Discovery is lazy and normally triggered by whichever endpoint runs
|
|
# first, which is a person opening the dashboard. A caller that never
|
|
# visits it would otherwise see an empty list.
|
|
api_v3.plugin_catalog.discover_plugins()
|
|
|
|
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
|
|
|
modes = []
|
|
for plugin_id, manifest in sorted(api_v3.plugin_catalog.plugin_manifests.items()):
|
|
# A hand-edited or migrated config.json can hold a non-dict under a
|
|
# plugin id; DisplayController._reconcile guards the same shape, so
|
|
# it happens in practice. Without this, .get() raises AttributeError,
|
|
# the loop aborts and the endpoint answers 500 with no modes at all
|
|
# -- one bad section would blank every entity the MQTT bridge builds
|
|
# from this list.
|
|
plugin_config = full_config.get(plugin_id)
|
|
if not isinstance(plugin_config, dict):
|
|
if plugin_config is not None:
|
|
logger.warning(
|
|
"Config for plugin %r is %s, not an object; treating it as disabled",
|
|
plugin_id, type(plugin_config).__name__)
|
|
plugin_config = {}
|
|
enabled = bool(plugin_config.get('enabled', False))
|
|
if not enabled and not include_disabled:
|
|
continue
|
|
plugin_name = (manifest or {}).get('name') or plugin_id
|
|
plugin_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) or [plugin_id]
|
|
for mode in plugin_modes:
|
|
# A single-mode plugin's mode is the plugin, so its own name is
|
|
# the readable label. Multi-mode plugins have no per-mode name
|
|
# anywhere, so the raw mode string is the only thing to show.
|
|
modes.append({
|
|
'mode': mode,
|
|
'plugin_id': plugin_id,
|
|
'plugin_name': plugin_name,
|
|
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
|
'enabled': enabled,
|
|
})
|
|
|
|
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
|
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
|
def get_on_demand_status():
|
|
"""Return the current on-demand display state.
|
|
|
|
From the display's state stream over the control socket when it is
|
|
available (``source: "socket"``), else the cache key it also writes
|
|
(``source: "cache"``).
|
|
"""
|
|
state = display_state.on_demand_state(display_state.read_state())
|
|
source = 'socket'
|
|
if state is None:
|
|
source = 'cache'
|
|
cache = _cache_manager()
|
|
# memory_ttl=0: the display service writes this key, so only the file
|
|
# is current. This process's memory tier would keep serving the first
|
|
# copy it read for the full max_age -- "active" for two minutes after
|
|
# the display had already stopped.
|
|
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
|
|
if state is None:
|
|
state = {
|
|
'active': False,
|
|
'status': 'idle',
|
|
'last_updated': None
|
|
}
|
|
service_status = _get_display_service_status()
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'state': state,
|
|
'service': service_status,
|
|
'source': source,
|
|
}
|
|
})
|
|
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
|
def start_on_demand_display():
|
|
"""Request the display controller to run a specific plugin on-demand."""
|
|
data = request.get_json(silent=True) or {}
|
|
plugin_id = data.get('plugin_id')
|
|
mode = data.get('mode')
|
|
duration = data.get('duration')
|
|
# _coerce_to_bool: bool("false") is True, so a string "false" pinned the
|
|
# mode or (re)started the service it asked to leave alone.
|
|
pinned = _coerce_to_bool(data.get('pinned', False))
|
|
start_service = _coerce_to_bool(data.get('start_service', True))
|
|
|
|
if not plugin_id and not mode:
|
|
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
|
|
|
resolved_plugin = plugin_id
|
|
resolved_mode = mode
|
|
|
|
if api_v3.plugin_catalog:
|
|
if resolved_plugin and resolved_plugin not in _pkg._discovered_plugin_manifests(resolved_plugin):
|
|
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
|
|
|
if resolved_plugin and not resolved_mode:
|
|
modes = api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin)
|
|
resolved_mode = modes[0] if modes else resolved_plugin
|
|
elif resolved_mode and not resolved_plugin:
|
|
_pkg._discovered_plugin_manifests()
|
|
resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)
|
|
if not resolved_plugin:
|
|
# Not among what was discovered: the plugin that declares
|
|
# it may have been installed since. Scan once more.
|
|
_pkg._discovered_plugin_manifests(rescan=True)
|
|
resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)
|
|
if not resolved_plugin:
|
|
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
|
|
|
# On-demand works with disabled plugins: the running display loads one
|
|
# for the session and unloads it afterwards, leaving config.json alone
|
|
# (DisplayController._load_plugin_for_on_demand). Logged for debugging.
|
|
if api_v3.config_manager and resolved_plugin:
|
|
config = api_v3.config_manager.load_config()
|
|
plugin_config = config.get(resolved_plugin, {})
|
|
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
|
logger.info(
|
|
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
|
resolved_plugin,
|
|
)
|
|
|
|
# The request goes over the control socket, the only way to reach the
|
|
# display. A display that is not listening (stopped, or still starting)
|
|
# is started when start_service asks for it, and the request is sent
|
|
# again once its socket is up.
|
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
|
request_payload = {
|
|
'request_id': request_id,
|
|
'action': 'start',
|
|
'plugin_id': resolved_plugin,
|
|
'mode': resolved_mode,
|
|
'duration': duration,
|
|
'pinned': pinned,
|
|
'timestamp': _pkg.time.time()
|
|
}
|
|
try:
|
|
_send_on_demand(request_payload)
|
|
except Exception as e: # pylint: disable=broad-except
|
|
error = e
|
|
else:
|
|
# A socket acknowledgement is the display itself answering: it is
|
|
# running and has the request queued, whatever systemd says (a
|
|
# display run by hand or in the emulator has no active unit). So
|
|
# nothing is checked or started for it. The service is still
|
|
# reported the way _ensure_display_service_running reports a
|
|
# running one.
|
|
service_result = (dict(_get_display_service_status(), started=False)
|
|
if start_service else None)
|
|
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
|
duration, pinned, service_result)
|
|
|
|
reason = _socket_failure_reason(error)
|
|
if not control_client.display_not_listening(error):
|
|
# The display had it and refused it, is too old for the command, or
|
|
# this web process cannot use the socket at all (switched off, no
|
|
# Unix sockets): starting a service would not change that.
|
|
return _socket_error_response(request_id, 'start', reason)
|
|
|
|
service_status = _get_display_service_status()
|
|
if not service_status.get('active') and not start_service:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
|
'service_status': service_status,
|
|
'data': {'request_id': request_id, 'transport': 'socket', 'socket_error': reason},
|
|
}), 400
|
|
|
|
# start_service means "start it if it is not running", as the UI's
|
|
# checkbox says; _ensure_display_service_running leaves a running
|
|
# service alone (restarting it cost seconds of blank panel for nothing).
|
|
# Either way the display has no socket yet: wait for it, then send the
|
|
# request again.
|
|
wait = ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS
|
|
service_result = None
|
|
if not service_status.get('active'):
|
|
service_result = _ensure_display_service_running()
|
|
if service_result and not service_result.get('active'):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
|
'service_result': service_result
|
|
}), 500
|
|
wait = ON_DEMAND_SOCKET_WAIT_SECONDS
|
|
elif start_service:
|
|
service_result = dict(service_status, started=False)
|
|
|
|
try:
|
|
_send_on_demand_when_listening(request_payload, wait)
|
|
except Exception as e: # pylint: disable=broad-except
|
|
reason = _socket_failure_reason(e)
|
|
if control_client.display_not_listening(e):
|
|
message = (f'The display service is running but its control socket did not '
|
|
f'answer within {int(wait)} seconds ({reason}). It may still be '
|
|
f'starting; try again shortly, or check its logs.')
|
|
return _socket_error_response(request_id, 'start', reason, message,
|
|
service=service_result)
|
|
return _socket_error_response(request_id, 'start', reason, service=service_result)
|
|
|
|
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
|
duration, pinned, service_result)
|
|
|
|
|
|
def _on_demand_started(request_id, plugin_id, mode, duration, pinned, service_result):
|
|
"""The success answer of /display/on-demand/start."""
|
|
response_data = {
|
|
'request_id': request_id,
|
|
'plugin_id': plugin_id,
|
|
'mode': mode,
|
|
'duration': duration,
|
|
'pinned': pinned,
|
|
'service': service_result,
|
|
'transport': 'socket',
|
|
}
|
|
return jsonify({'status': 'success', 'data': response_data})
|
|
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
|
def stop_on_demand_display():
|
|
"""Request the display controller to stop on-demand mode."""
|
|
data = request.get_json(silent=True) or {}
|
|
# _coerce_to_bool: bool("false") is True, which stopped the service.
|
|
stop_service = _coerce_to_bool(data.get('stop_service', False))
|
|
|
|
# The running display takes the stop over the control socket and
|
|
# resumes normal rotation in place (_clear_on_demand); nothing is
|
|
# restarted.
|
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
|
request_payload = {
|
|
'request_id': request_id,
|
|
'action': 'stop',
|
|
'timestamp': _pkg.time.time()
|
|
}
|
|
socket_error = None
|
|
try:
|
|
_send_on_demand(request_payload)
|
|
except Exception as e: # pylint: disable=broad-except
|
|
socket_error = _socket_failure_reason(e)
|
|
if not stop_service:
|
|
if control_client.display_not_listening(e):
|
|
service_status = _get_display_service_status()
|
|
message = ('Display service is not running, so the stop could not be '
|
|
'delivered. If it resumes an on-demand session when it starts, '
|
|
'stop it then.' if not service_status.get('active') else
|
|
f'The display service is running but its control socket did '
|
|
f'not answer ({socket_error}). It may still be starting; '
|
|
f'try again shortly.')
|
|
return _socket_error_response(request_id, 'stop', socket_error, message,
|
|
service=service_status)
|
|
return _socket_error_response(request_id, 'stop', socket_error)
|
|
# Stopping the service ends on-demand too, whatever the display did
|
|
# with the request.
|
|
|
|
service_result = None
|
|
if stop_service:
|
|
service_result = _stop_display_service()
|
|
|
|
response_data = {
|
|
'request_id': request_id,
|
|
'service': service_result,
|
|
'transport': 'socket',
|
|
}
|
|
if socket_error:
|
|
response_data['socket_error'] = socket_error
|
|
return jsonify({'status': 'success', 'data': response_data})
|
|
|
|
|
|
@api_v3.route('/display/current-status', methods=['GET'])
|
|
def get_current_display_status():
|
|
"""Return the display mode/plugin currently intended to be shown.
|
|
|
|
Read from the display's state stream over the control socket when it is
|
|
available (``source: "socket"``). Otherwise from what the display
|
|
publishes to the shared cache (display_controller._publish_current_mode_state)
|
|
when the active mode changes (``source: "cache"``). Unknown (every field
|
|
None) when the socket and the heartbeat both say the display is gone
|
|
(display_state.display_gone).
|
|
"""
|
|
snapshot = display_state.read_state()
|
|
state = display_state.current_status(snapshot)
|
|
source = 'socket'
|
|
if state is None:
|
|
source = 'cache'
|
|
# A stopped display leaves its last answer in the cache, where it
|
|
# read as on (is_display_active: true) for the 120 s max_age. With
|
|
# no socket and no live heartbeat there is no display behind it.
|
|
if not display_state.display_gone(snapshot):
|
|
cache = _cache_manager()
|
|
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
|
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
|
if state is None:
|
|
state = {
|
|
'mode': None,
|
|
'plugin_id': None,
|
|
'last_updated': None,
|
|
}
|
|
return jsonify({'status': 'success', 'data': dict(state, source=source)})
|