mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
- 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>
179 lines
7.0 KiB
Python
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']
|