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:
Chuck
2026-10-03 12:56:47 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6bf7c3fa51
commit 515248b34e
16 changed files with 2487 additions and 97 deletions
+185 -3
View File
@@ -30,6 +30,11 @@ later the state stream). A few commands (:data:`AWAITED_COMMANDS`) are
answered only once the render thread has applied them, or with ``pending``
when it has not within :data:`AWAIT_SECONDS`.
``state.subscribe`` is the one exception to "one response per request": its
response is followed, on the same connection, by :class:`StateEvent` lines
the display pushes until either side hangs up. Events carry ``event``
instead of ``ok``.
New commands are added within a protocol version: a display that does not
know one answers ``unknown_command``, the client falls back, and ``hello``
lists the commands a display knows. The version changes only when the
@@ -142,11 +147,14 @@ class Command:
ON_DEMAND_STATUS = 'on_demand.status'
BRIGHTNESS_SET = 'brightness.set'
PLUGIN_RELOAD = 'plugin.reload'
STATE_GET = 'state.get'
STATE_SUBSCRIBE = 'state.subscribe'
#: Every command version 1 defines, in the order ``hello`` reports them.
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, within version 1
#: (see the module docstring on adding commands).
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, and ``state.get``
#: and ``state.subscribe`` in stage 3, all within version 1 (see the module
#: docstring on adding commands).
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
@@ -155,6 +163,8 @@ COMMANDS: Tuple[str, ...] = (
Command.ON_DEMAND_STATUS,
Command.BRIGHTNESS_SET,
Command.PLUGIN_RELOAD,
Command.STATE_GET,
Command.STATE_SUBSCRIBE,
)
#: Commands that are queued for the render thread.
@@ -173,6 +183,25 @@ AWAIT_SECONDS: Dict[str, float] = {
}
AWAITED_COMMANDS = frozenset(AWAIT_SECONDS)
#: The state stream (stage 3). ``state.subscribe`` turns its connection into
#: a one-way stream of :class:`StateEvent` lines. Subscribers have their own
#: bound, separate from the short request connections, so they can never
#: take the slots a command needs.
MAX_SUBSCRIBERS = 4
#: A subscriber hears from the display at least this often: a ``state``
#: event when something changed, else a ``tick`` carrying the render loop's
#: liveness. A client that has heard nothing for a few of these treats its
#: copy as unknown.
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
#: The shape of the ``state`` object in a state snapshot. Bumped only when a
#: field changes meaning; new fields are added within a schema.
STATE_SCHEMA = 1
#: The sections of a state snapshot, in the order they are documented.
STATE_SECTIONS: Tuple[str, ...] = ('display', 'on_demand', 'brightness', 'plugins', 'loop')
#: Brightness, in percent, as the display's hardware setting takes it.
MIN_BRIGHTNESS = 0
MAX_BRIGHTNESS = 100
@@ -477,8 +506,65 @@ class PluginReloadArgs:
return cls(plugin_id=plugin_id)
def _optional_version(args: Mapping[str, Any], key: str) -> Optional[int]:
value = args.get(key)
if value is None:
return None
if not _is_int(value) or value < 0:
raise ProtocolError(ErrorCode.INVALID_ARGS, f'{key} must be a non-negative integer')
return value
def _optional_epoch(args: Mapping[str, Any]) -> Optional[str]:
value = args.get('epoch')
if value is None or value == '':
return None
if not _valid_id(value):
raise ProtocolError(ErrorCode.INVALID_ARGS,
f'epoch must be a printable string of 1-{MAX_ID_LENGTH} characters')
return str(value)
@dataclass(frozen=True)
class StateGetArgs:
"""``state.get``: the display's state, as a versioned snapshot.
With ``since`` and the ``epoch`` it came from, the answer is only
``{changed: false, version, epoch, served_at, loop}`` while the state is
still at that version, so a poller that already has it is sent no state.
"""
since: Optional[int] = None
epoch: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {'since': self.since, 'epoch': self.epoch}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'StateGetArgs':
return cls(since=_optional_version(args, 'since'), epoch=_optional_epoch(args))
@dataclass(frozen=True)
class StateSubscribeArgs:
"""``state.subscribe``: the snapshot now, then a push stream of changes.
The response is the snapshot ``state.get`` returns. After it the
connection carries only :class:`StateEvent` lines from the display: a
``state`` event whenever the state changes (always the latest version,
so a reader that falls behind skips versions instead of queueing them),
and a ``tick`` at least every :data:`SUBSCRIBE_KEEPALIVE_SECONDS`.
"""
def to_dict(self) -> Dict[str, Any]:
return {}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'StateSubscribeArgs':
return cls()
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
BrightnessSetArgs, PluginReloadArgs]
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs]
#: The arguments of a command that goes on the render thread's queue.
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
@@ -491,6 +577,8 @@ _ARG_TYPES: Dict[str, Any] = {
Command.ON_DEMAND_STATUS: NoArgs,
Command.BRIGHTNESS_SET: BrightnessSetArgs,
Command.PLUGIN_RELOAD: PluginReloadArgs,
Command.STATE_GET: StateGetArgs,
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
}
@@ -563,6 +651,100 @@ class PluginReloadResult(TypedDict):
modes: List[str]
class LoopState(TypedDict):
"""``loop``: is the render loop still going round?
``heartbeat_age_seconds`` is the age of the render thread's last beat,
measured in memory by the display when it answered -- the same beat that
writes ``display-heartbeat.json``. None until the loop has drawn its first
frame. At ``stale_after`` or more the loop is stalled: the threshold
``/api/v3/health`` uses.
"""
heartbeat_age_seconds: Optional[float]
armed: bool
stale_after: float
class StateSnapshot(TypedDict, total=False):
"""The answer to ``state.get`` and ``state.subscribe``, and the
``result`` of a ``state`` event.
``version`` counts changes to the state within one ``epoch`` (one run of
the display process): a reader that sees a new epoch starts over.
``changed`` is False only for a ``state.get`` whose ``since`` is still
current, and then ``state`` is absent. ``served_at`` is the display's
wall clock when it answered. ``loop`` is measured at that moment, so it
is also inside ``state``.
``state`` holds the sections in :data:`STATE_SECTIONS`:
* ``display``: what ``display_current_state`` holds (mode, plugin_id,
mode_index, total_modes, on_demand_active, is_display_active,
last_updated);
* ``on_demand``: what ``display_on_demand_state`` holds;
* ``brightness``: ``{brightness, panel_brightness, dimmed}``;
* ``plugins``: the plugin runtime snapshot (``plugin_runtime_snapshot``),
or None when there is none (or it was too large to send);
* ``loop``: :class:`LoopState`.
A section the display has not published yet is None.
"""
schema: int
version: int
epoch: str
pid: int
served_at: float
changed: bool
state: Dict[str, Any]
loop: LoopState
class StateEventKind:
STATE = 'state' # result: a full StateSnapshot, the latest version
TICK = 'tick' # result: {version, epoch, pid, served_at, loop}; nothing changed
@dataclass(frozen=True)
class StateEvent:
"""One message the display pushes to a subscriber.
``{"v": 1, "id": "<the subscribe request's id>", "event": "state" | "tick",
"result": {...}}``. It has no ``ok``, which is how a reader tells it from
a response.
"""
id: str
event: str
result: Dict[str, Any]
v: int = PROTOCOL_VERSION
def to_dict(self) -> Dict[str, Any]:
return {'v': self.v, 'id': self.id, 'event': self.event, 'result': dict(self.result)}
@classmethod
def from_dict(cls, obj: Any) -> 'StateEvent':
"""Validate an event. Raises :class:`ProtocolError` (BAD_REQUEST)."""
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'an event must be a JSON object')
version = obj.get('v')
if not _is_int(version):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer')
raw_id = obj.get('id')
if not isinstance(raw_id, str):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'id must be a string')
event = obj.get('event')
if event not in (StateEventKind.STATE, StateEventKind.TICK):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'event must be "state" or "tick"')
result = obj.get('result')
if not isinstance(result, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'result must be a JSON object')
return cls(id=raw_id, event=event, result=result, v=version)
def is_event(obj: Any) -> bool:
"""Whether a decoded message is a pushed event rather than a response."""
return isinstance(obj, dict) and 'event' in obj and 'ok' not in obj
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
"""The highest version both sides speak, or None."""
common = set(client_versions) & set(SUPPORTED_VERSIONS)