mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
feat(ipc): control socket stage 3 - a state stream replaces polled cache keys (#735)
Adds state.get / state.subscribe to the display's control socket (StateHub in src/ipc/server.py). The web interface holds one subscription per process (web_interface/display_state.py) and reads current-status, on-demand status, plugin runtime and /health's display_loop from it, falling back to the cache keys and heartbeat file. While the socket serves readers, display_current_state and plugin_runtime_snapshot are written less often (about 1.5 instead of 5 cache writes a minute for 15 s screens). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -998,12 +998,14 @@ def _run_startup_reconciliation() -> None:
|
||||
|
||||
try:
|
||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||
from src.plugin_system.plugin_runtime import read_plugin_runtime
|
||||
# The display's runtime view: its control socket's state stream when
|
||||
# it serves one, else the cache snapshot (both judged the same way).
|
||||
from web_interface.blueprints.api_v3 import _plugin_runtime_view
|
||||
reconciler = StateReconciliation(
|
||||
config_manager=config_manager,
|
||||
plugins_dir=plugins_dir,
|
||||
store_manager=plugin_store_manager,
|
||||
runtime_source=lambda: read_plugin_runtime(api_v3.cache_manager),
|
||||
runtime_source=_plugin_runtime_view,
|
||||
)
|
||||
result = reconciler.reconcile_state()
|
||||
if result.inconsistencies_found:
|
||||
|
||||
@@ -735,8 +735,16 @@ def _plugin_runtime_view():
|
||||
|
||||
Only a ``live`` view reports those facts; a stale, stopped or missing
|
||||
snapshot answers None for them (see src/plugin_system/plugin_runtime.py).
|
||||
|
||||
The display's state stream over the control socket comes first
|
||||
(``source: "socket"``); without it, the cache snapshot and the heartbeat
|
||||
file (``source: "cache"``). Both are judged by the same rules.
|
||||
"""
|
||||
from src.plugin_system.plugin_runtime import read_plugin_runtime
|
||||
from src.plugin_system.plugin_runtime import read_plugin_runtime, view_from_socket_state
|
||||
from web_interface import display_state
|
||||
view = view_from_socket_state(display_state.read_state())
|
||||
if view is not None:
|
||||
return view
|
||||
return read_plugin_runtime(getattr(api_v3, 'cache_manager', None))
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
|
||||
jsonify, logger, request, uuid,
|
||||
)
|
||||
from web_interface import display_preview
|
||||
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
|
||||
@@ -163,13 +163,22 @@ def get_display_modes():
|
||||
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."""
|
||||
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)
|
||||
"""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,
|
||||
@@ -181,7 +190,8 @@ def get_on_demand_status():
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'state': state,
|
||||
'service': service_status
|
||||
'service': service_status,
|
||||
'source': source,
|
||||
}
|
||||
})
|
||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||
@@ -327,18 +337,22 @@ def stop_on_demand_display():
|
||||
def get_current_display_status():
|
||||
"""Return the display mode/plugin currently intended to be shown.
|
||||
|
||||
Published by the display process (display_controller._publish_current_mode_state)
|
||||
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
||||
System Logs page) can show what's on screen without querying the display
|
||||
process directly.
|
||||
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"``).
|
||||
"""
|
||||
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)
|
||||
state = display_state.current_status(display_state.read_state())
|
||||
source = 'socket'
|
||||
if state is None:
|
||||
source = 'cache'
|
||||
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': state})
|
||||
return jsonify({'status': 'success', 'data': dict(state, source=source)})
|
||||
|
||||
@@ -17,7 +17,7 @@ from src.common.path_safety import safe_path_component
|
||||
from src import display_watchdog
|
||||
from src.common import sync_manager as _sync
|
||||
from src import error_aggregator as _errors
|
||||
from web_interface import display_preview
|
||||
from web_interface import display_preview, display_state
|
||||
from web_interface.auth import request_is_authenticated
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -100,20 +100,30 @@ def get_health():
|
||||
# service is "active". No heartbeat at all (the dev server, an older
|
||||
# display) is not a failure: the preview-frame check below is then
|
||||
# the only signal, as it always was.
|
||||
# The display reports the same beat's age over the control socket's
|
||||
# state stream, measured in memory; the file is the fallback.
|
||||
try:
|
||||
heartbeat = display_watchdog.read_heartbeat(display_watchdog.HEARTBEAT_PATH)
|
||||
age = display_watchdog.heartbeat_age(heartbeat) if heartbeat else None
|
||||
snapshot = display_state.read_state()
|
||||
if snapshot is not None:
|
||||
source = 'socket'
|
||||
age = display_state.loop_heartbeat_age(snapshot)
|
||||
else:
|
||||
source = 'heartbeat_file'
|
||||
heartbeat = display_watchdog.read_heartbeat(display_watchdog.HEARTBEAT_PATH)
|
||||
age = display_watchdog.heartbeat_age(heartbeat) if heartbeat else None
|
||||
if age is None:
|
||||
health_status['checks']['display_loop'] = {
|
||||
'status': 'not_reported',
|
||||
'note': 'The display is not writing a heartbeat (not started yet, '
|
||||
'or a version or setup without one)',
|
||||
'source': source,
|
||||
}
|
||||
else:
|
||||
fresh = age < display_watchdog.HEARTBEAT_STALE_SECONDS
|
||||
health_status['checks']['display_loop'] = {
|
||||
'status': 'running' if fresh else 'stalled',
|
||||
'heartbeat_age_seconds': round(age, 1),
|
||||
'source': source,
|
||||
}
|
||||
except Exception:
|
||||
logger.warning("Health check could not read the display heartbeat", exc_info=True)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""The display's live state, as the web interface reads it.
|
||||
|
||||
The display process serves its state over the control socket (stage 3, see
|
||||
docs/IPC_CONTROL_SOCKET.md): what it is showing, the on-demand session, the
|
||||
brightness, its plugin runtime snapshot and whether its render loop is still
|
||||
going round. This module holds one ``state.subscribe`` connection for the
|
||||
web process (:class:`src.ipc.client.StateSubscription`, started on first
|
||||
use), so a route answers from memory rather than reading a file the display
|
||||
had to write to the SD card.
|
||||
|
||||
Every reader here returns None when the socket cannot vouch for the answer
|
||||
-- the socket is off or missing (a stopped display, an older one, Windows,
|
||||
the test suite), the subscription went quiet, or the display's copy is too
|
||||
old by the same rules the cache readers apply -- and the route then reads
|
||||
the cache keys and the heartbeat file exactly as it did before.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from src.ipc import client as control_client
|
||||
from src.ipc.contract import client_socket_paths, socket_supported
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The cache readers' max_age for display_current_state: a ``display``
|
||||
#: section the render thread has not refreshed for this long is unknown, as
|
||||
#: the cache key would be.
|
||||
CURRENT_STATE_MAX_AGE_SECONDS = 120.0
|
||||
|
||||
#: A one-shot ``state.get``, for a request that arrives before the
|
||||
#: subscription has its first snapshot. Short: the display answers it from
|
||||
#: its socket thread, never the render thread.
|
||||
ONE_SHOT_TIMEOUT_SECONDS = 0.5
|
||||
|
||||
_feed: Optional[control_client.StateSubscription] = None
|
||||
_feed_lock = threading.Lock()
|
||||
|
||||
|
||||
def _subscription() -> Optional[control_client.StateSubscription]:
|
||||
"""This process's subscription, started on first use; None without a socket."""
|
||||
global _feed
|
||||
if not socket_supported() or not client_socket_paths():
|
||||
return None
|
||||
with _feed_lock:
|
||||
if _feed is None:
|
||||
_feed = control_client.StateSubscription().start()
|
||||
return _feed
|
||||
|
||||
|
||||
def stop_subscription() -> None:
|
||||
"""Stop the subscription (tests; a process that is shutting down)."""
|
||||
global _feed
|
||||
with _feed_lock:
|
||||
feed, _feed = _feed, None
|
||||
if feed is not None:
|
||||
feed.stop()
|
||||
|
||||
|
||||
def read_state() -> Optional[Dict[str, Any]]:
|
||||
"""The display's latest state snapshot, or None (read the cache instead).
|
||||
|
||||
From the subscription when it is live; otherwise one ``state.get``.
|
||||
"""
|
||||
feed = _subscription()
|
||||
if feed is None:
|
||||
return None
|
||||
snapshot = feed.latest()
|
||||
if snapshot is not None:
|
||||
return snapshot
|
||||
if feed.last_error == 'unknown_command':
|
||||
# A display older than stage 3: a one-shot would fail the same way
|
||||
# on every request. The subscription retries every 30 s.
|
||||
return None
|
||||
try:
|
||||
snapshot = control_client.state_get(timeout=ONE_SHOT_TIMEOUT_SECONDS)
|
||||
except control_client.ControlError as e:
|
||||
logger.debug("Display state not available over the control socket: %s", e)
|
||||
return None
|
||||
if not isinstance(snapshot.get('state'), dict):
|
||||
return None
|
||||
snapshot['received_mono'] = time.monotonic()
|
||||
return snapshot
|
||||
|
||||
|
||||
def _section(snapshot: Optional[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
state = snapshot.get('state')
|
||||
value = state.get(name) if isinstance(state, dict) else None
|
||||
return dict(value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def current_status(snapshot: Optional[Dict[str, Any]],
|
||||
now: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||
"""``/display/current-status``'s data from a snapshot.
|
||||
|
||||
None when the snapshot has no ``display`` section (fall back to the
|
||||
cache). A section the render thread last refreshed more than
|
||||
CURRENT_STATE_MAX_AGE_SECONDS ago -- the loop is stuck -- is reported
|
||||
as unknown, the same answer the cache key gives once it ages out.
|
||||
"""
|
||||
display = _section(snapshot, 'display')
|
||||
if display is None:
|
||||
return None
|
||||
now = time.time() if now is None else now
|
||||
updated = display.get('last_updated')
|
||||
if (not isinstance(updated, (int, float)) or isinstance(updated, bool)
|
||||
or now - updated > CURRENT_STATE_MAX_AGE_SECONDS):
|
||||
return {'mode': None, 'plugin_id': None, 'last_updated': None}
|
||||
return display
|
||||
|
||||
|
||||
def on_demand_state(snapshot: Optional[Dict[str, Any]],
|
||||
now: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||
"""``/display/on-demand/status``'s state from a snapshot; None to fall back.
|
||||
|
||||
``remaining`` is worked out again from ``expires_at``: the display set it
|
||||
when it last published, which may have been minutes ago.
|
||||
"""
|
||||
state = _section(snapshot, 'on_demand')
|
||||
if state is None:
|
||||
return None
|
||||
expires_at = state.get('expires_at')
|
||||
if (state.get('active') and isinstance(expires_at, (int, float))
|
||||
and not isinstance(expires_at, bool)):
|
||||
now = time.time() if now is None else now
|
||||
state['remaining'] = max(0.0, expires_at - now)
|
||||
return state
|
||||
|
||||
|
||||
def loop_heartbeat_age(snapshot: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""The render loop's heartbeat age now; None when the display has no
|
||||
beat to report yet (or there is no snapshot)."""
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
return control_client.snapshot_loop_age(snapshot)
|
||||
|
||||
|
||||
__all__ = ['current_status', 'loop_heartbeat_age', 'on_demand_state', 'read_state',
|
||||
'stop_subscription']
|
||||
Reference in New Issue
Block a user