Files
LEDMatrix/web_interface/display_state.py
T
ChuckandClaude Opus 5.5 54fb42180f fix(web): schedule saves, restart_required, health and current-status agree with the rig
- POST /config/schedule and /config/dim-schedule accept a disabled per-day
  schedule with every day off (config.template.json's own shape); a day
  that is off keeps its posted times.
- POST /config/main sets restart_required from what the save changed:
  brightness, mode durations and plugin sections are applied live.
- /health is degraded (display_loop: stopped) when the service is inactive,
  the socket does not answer and there is no live heartbeat.
- /display/current-status no longer serves a stopped display's cached state
  when the socket and heartbeat both say it is gone (display_gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:11:32 -04:00

179 lines
7.0 KiB
Python

"""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 display_gone(snapshot: Optional[Dict[str, Any]]) -> bool:
"""Is there positively no display behind a fallback to the cache?
True only when the socket should be there (this platform has one and it
is not switched off) but gave no ``snapshot``, and the render loop's
heartbeat file says nothing is running either: it is absent (systemd
removes its directory when the service stops), stale, or written by a
process that no longer exists -- #726's rules for the runtime snapshot.
Then what the display last left in the cache is a dead process's answer.
False whenever the answer is in doubt: a snapshot came in, the socket is
off or unsupported (Windows, the test suite, a deliberate ``off``), or a
live heartbeat says the display is running without a socket (an older
display). Those read the cache exactly as before.
"""
if snapshot is not None:
return False
if not socket_supported() or not client_socket_paths():
return False
from src import display_watchdog
from src.plugin_system.plugin_runtime import process_exists
heartbeat = display_watchdog.read_heartbeat(display_watchdog.HEARTBEAT_PATH)
if heartbeat is None:
return True
age = display_watchdog.heartbeat_age(heartbeat)
if age is None or age >= display_watchdog.HEARTBEAT_STALE_SECONDS:
return True
pid = heartbeat.get('pid')
if isinstance(pid, int) and not isinstance(pid, bool) and process_exists(pid) is False:
return True
return False
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', 'display_gone', 'loop_heartbeat_age', 'on_demand_state',
'read_state', 'stop_subscription']