feat(ipc): control socket stage 2 - wake the render thread, brightness.set, plugin.reload (#720)

Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 20:37:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f72d69c2b0
commit 746dcfcadb
22 changed files with 1998 additions and 119 deletions
+35
View File
@@ -15,6 +15,7 @@ import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence
from src.ipc.contract import (
AWAIT_SECONDS,
MAX_MESSAGE_BYTES,
PROTOCOL_VERSION,
SUPPORTED_VERSIONS,
@@ -188,6 +189,40 @@ def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
#: Headroom over the display's own wait for an awaited command, so its
#: ``pending`` answer arrives before the client gives up.
_AWAIT_MARGIN_SECONDS = 1.0
def _awaited_timeout(cmd: str) -> float:
return AWAIT_SECONDS[cmd] + _AWAIT_MARGIN_SECONDS
def brightness_set(brightness: int, *, timeout: Optional[float] = None,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Set the panel's normal brightness now (transient: config.json is not
written). Returns the applied :class:`~src.ipc.contract.BrightnessResult`;
raises :class:`ControlError`.
"""
return request(Command.BRIGHTNESS_SET, {'brightness': brightness},
timeout=_awaited_timeout(Command.BRIGHTNESS_SET) if timeout is None
else timeout, paths=paths)
def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Have the display reload a running plugin from disk.
Returns :class:`~src.ipc.contract.PluginReloadResult` once the new code is
running. Raises :class:`ControlError`: ``not_loaded`` (not running it),
``failed`` (the new version did not load), ``pending`` (not done in
time; it will still happen), or a transport reason.
"""
return request(Command.PLUGIN_RELOAD, {'plugin_id': plugin_id},
timeout=_awaited_timeout(Command.PLUGIN_RELOAD) if timeout is None
else timeout, paths=paths)
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
return request(Command.PING, {}, timeout=timeout, paths=paths)
+110 -4
View File
@@ -26,7 +26,14 @@ order. Commands that change what the panel shows are *acknowledged*, not
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
has the command queued and will apply it at its next on-demand check. Its
outcome is published the way it always was (``display_on_demand_state``,
later the state stream).
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`.
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
envelope or the meaning of an existing command changes.
See docs/IPC_CONTROL_SOCKET.md for the full description.
"""
@@ -133,19 +140,42 @@ class Command:
ON_DEMAND_START = 'on_demand.start'
ON_DEMAND_STOP = 'on_demand.stop'
ON_DEMAND_STATUS = 'on_demand.status'
BRIGHTNESS_SET = 'brightness.set'
PLUGIN_RELOAD = 'plugin.reload'
#: 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).
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
Command.ON_DEMAND_START,
Command.ON_DEMAND_STOP,
Command.ON_DEMAND_STATUS,
Command.BRIGHTNESS_SET,
Command.PLUGIN_RELOAD,
)
#: Commands that are queued for the render thread and answered with an ack.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP})
#: Commands that are queued for the render thread.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
#: Queued commands whose answer waits for the render thread's outcome
#: instead of being an ack. The value is how long the display waits before
#: answering ``pending``; the command stays queued and is still applied.
#: A plugin reload first lets the current screen end (within a frame on a
#: scrolling screen, at once on a static one) and then imports the plugin,
#: which can take a few seconds on a slow board.
AWAIT_SECONDS: Dict[str, float] = {
Command.BRIGHTNESS_SET: 2.0,
Command.PLUGIN_RELOAD: 10.0,
}
AWAITED_COMMANDS = frozenset(AWAIT_SECONDS)
#: Brightness, in percent, as the display's hardware setting takes it.
MIN_BRIGHTNESS = 0
MAX_BRIGHTNESS = 100
class ErrorCode:
@@ -159,6 +189,10 @@ class ErrorCode:
BUSY = 'busy' # queue full / too many clients
FORBIDDEN = 'forbidden' # peer credentials refused
INTERNAL = 'internal' # a bug on the display side
# From the awaited commands (stage 2):
PENDING = 'pending' # accepted, not applied within AWAIT_SECONDS; still queued
NOT_LOADED = 'not_loaded' # plugin.reload: the display is not running that plugin
FAILED = 'failed' # the render thread tried, and it did not work
class ProtocolError(Exception):
@@ -398,7 +432,56 @@ class NoArgs:
return cls()
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs]
@dataclass(frozen=True)
class BrightnessSetArgs:
"""``brightness.set``: the panel's normal brightness, in percent, now.
Transient: nothing is written to config.json, and the next config change
the display picks up (or a restart) goes back to the configured value.
The web interface sends it after saving the setting, so the two agree.
The dim schedule still applies on top, as it does to the saved value.
"""
brightness: int
def to_dict(self) -> Dict[str, Any]:
return {'brightness': self.brightness}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
value = args.get('brightness')
if not _is_int(value) or not MIN_BRIGHTNESS <= value <= MAX_BRIGHTNESS:
raise ProtocolError(ErrorCode.INVALID_ARGS,
f'brightness must be an integer from {MIN_BRIGHTNESS} '
f'to {MAX_BRIGHTNESS}')
return cls(brightness=value)
@dataclass(frozen=True)
class PluginReloadArgs:
"""``plugin.reload``: load a running plugin again from disk.
For a plugin the store has just updated. Only a plugin the display is
running can be reloaded (``not_loaded`` otherwise), so the id never
makes the display import anything it was not already running.
"""
plugin_id: str
def to_dict(self) -> Dict[str, Any]:
return {'plugin_id': self.plugin_id}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
plugin_id = _optional_name(args, 'plugin_id')
if plugin_id is None:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id is required')
return cls(plugin_id=plugin_id)
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
BrightnessSetArgs, PluginReloadArgs]
#: The arguments of a command that goes on the render thread's queue.
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
_ARG_TYPES: Dict[str, Any] = {
Command.HELLO: HelloArgs,
@@ -406,6 +489,8 @@ _ARG_TYPES: Dict[str, Any] = {
Command.ON_DEMAND_START: OnDemandStartArgs,
Command.ON_DEMAND_STOP: OnDemandStopArgs,
Command.ON_DEMAND_STATUS: NoArgs,
Command.BRIGHTNESS_SET: BrightnessSetArgs,
Command.PLUGIN_RELOAD: PluginReloadArgs,
}
@@ -457,6 +542,27 @@ class AckResult(TypedDict):
queued: int
class BrightnessResult(TypedDict):
"""``brightness.set``, once applied.
``panel_brightness`` is what the panel shows now: the dim schedule's
level while it dims, and unchanged while the schedule has the display
off (the new level applies when it comes back on).
"""
brightness: int
panel_brightness: int
dimmed: bool
display_active: bool
class PluginReloadResult(TypedDict):
"""``plugin.reload``, once the plugin is running again."""
plugin_id: str
reloaded: bool
version: Optional[str]
modes: List[str]
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
"""The highest version both sides speak, or None."""
common = set(client_versions) & set(SUPPORTED_VERSIONS)
+116 -10
View File
@@ -8,6 +8,12 @@ where it reads the file mailbox (``DisplayController._poll_on_demand_requests``)
handing each command to the same code. Queries (``on_demand.status``) are
answered from a snapshot callable the display provides.
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
is what it waits on in place of a sleep, so a command lands within a frame on
every kind of screen. An awaited command (``brightness.set``,
``plugin.reload``) carries a :class:`CommandOutcome` that the render thread
fills in; its connection thread waits for that, bounded, before answering.
Robustness rules, because this runs inside the display process:
* every connection has its own daemon thread, at most :data:`MAX_CLIENTS` at
@@ -39,10 +45,12 @@ import stat
import struct
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional, Union
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional
from src.ipc.contract import (
AWAIT_SECONDS,
AWAITED_COMMANDS,
COMMANDS,
DEFAULT_SOCKET_DIR,
DEFAULT_SOCKET_PATH,
@@ -51,6 +59,7 @@ from src.ipc.contract import (
QUEUED_COMMANDS,
SUPPORTED_VERSIONS,
AckResult,
BrightnessSetArgs,
Command,
ErrorCode,
FrameReader,
@@ -58,7 +67,9 @@ from src.ipc.contract import (
HelloResult,
OnDemandStartArgs,
OnDemandStopArgs,
PluginReloadArgs,
ProtocolError,
QueuedArgs,
Request,
Response,
configured_socket_path,
@@ -100,19 +111,75 @@ _LISTEN_BACKLOG = 64
# -- queued work ---------------------------------------------------------------------
class CommandOutcome:
"""How an awaited command turned out, handed from the render thread back
to the connection thread that is waiting to answer.
The render thread calls :meth:`succeed` or :meth:`fail` once; the first
call wins. The connection thread may have stopped waiting already (it
answered ``pending``), and then nobody reads it.
"""
def __init__(self) -> None:
self._done = threading.Event()
self._lock = threading.Lock()
self.result: Optional[Dict[str, Any]] = None
self.error_code: Optional[str] = None
self.error_message = ''
@property
def done(self) -> bool:
return self._done.is_set()
def succeed(self, result: Mapping[str, Any]) -> None:
with self._lock:
if self._done.is_set():
return
self.result = dict(result)
self._done.set()
def fail(self, code: str, message: str) -> None:
with self._lock:
if self._done.is_set():
return
self.error_code = code
self.error_message = message
self._done.set()
def wait(self, timeout: float) -> bool:
return self._done.wait(timeout)
@dataclass(frozen=True)
class QueuedCommand:
"""A command waiting for the render thread."""
"""A command waiting for the render thread.
``outcome`` is set for an awaited command (``AWAITED_COMMANDS``): the
render thread reports through it, and the client's answer waits for it.
"""
request_id: str
cmd: str
args: Union[OnDemandStartArgs, OnDemandStopArgs]
args: QueuedArgs
received_at: float # time.time() when it was accepted
peer_uid: Optional[int] = None
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
def as_on_demand_request(self) -> Dict[str, Any]:
"""The mailbox-shaped payload the display's on-demand handler takes."""
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
raise TypeError(f'{self.cmd} is not an on-demand command')
return on_demand_request(self.request_id, self.args, self.received_at)
def succeed(self, result: Mapping[str, Any]) -> None:
"""Report success to a waiting client (a no-op for an acked command)."""
if self.outcome is not None:
self.outcome.succeed(result)
def fail(self, code: str, message: str) -> None:
"""Report failure to a waiting client (a no-op for an acked command)."""
if self.outcome is not None:
self.outcome.fail(code, message)
# -- peer credentials ------------------------------------------------------------------
@@ -247,8 +314,12 @@ class ControlServer:
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
check_peer: bool = True):
check_peer: bool = True,
await_seconds: Optional[Mapping[str, float]] = None):
self.path = path
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
if await_seconds:
self._await_seconds.update(await_seconds)
self._status_provider = status_provider
self._group = group
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
@@ -418,6 +489,17 @@ class ControlServer:
"""Cheap check for queued commands, for the render thread's fast path."""
return self._pending.is_set()
def wait_for_command(self, timeout: float) -> bool:
"""Block up to ``timeout`` seconds for a queued command; True if one is.
The render thread waits here instead of sleeping, in the dwell and
on a static screen, so a command wakes it at once. It is a timed
wait on an Event: no polling, and nothing more than the sleep it
replaces when no command comes. The flag stays set until drain(),
so a caller that does not drain would return at once every time.
"""
return self._pending.wait(timeout)
def drain(self) -> List[QueuedCommand]:
"""Every queued command, oldest first. Called from the render thread."""
commands: List[QueuedCommand] = []
@@ -596,11 +678,13 @@ class ControlServer:
v=request.v)
return Response.success(request.id, self._status_provider(), v=request.v)
if request.cmd in QUEUED_COMMANDS and isinstance(args, (OnDemandStartArgs,
OnDemandStopArgs)):
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
awaited = request.cmd in AWAITED_COMMANDS
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
received_at=time.time(),
peer_uid=peer.uid if peer is not None else None)
peer_uid=peer.uid if peer is not None else None,
outcome=CommandOutcome() if awaited else None)
try:
self._queue.put_nowait(command)
except queue.Full:
@@ -610,15 +694,37 @@ class ControlServer:
'the display is not taking commands right now',
v=request.v)
self._pending.set()
logger.info("Control socket accepted %s %s", request.cmd, request.id)
if command.outcome is not None:
return self._await_outcome(request, command.outcome)
ack: AckResult = {'accepted': True, 'request_id': request.id,
'queued': self._queue.qsize()}
logger.info("Control socket accepted %s %s", request.cmd, request.id)
return Response.success(request.id, dict(ack), v=request.v)
# A command in COMMANDS with no handler here is a bug in this module.
return Response.failure(request.id, ErrorCode.INTERNAL,
f'{request.cmd} is not implemented', v=request.v)
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
"""Answer an awaited command once the render thread has applied it.
Waits on this connection's thread, never the render thread's. If the
render thread does not get to it in time, the answer is ``pending``:
the command stays queued and is still applied, so a client treats
that as "not known to be done" rather than as a refusal.
"""
timeout = self._await_seconds.get(request.cmd, 0.0)
if not outcome.wait(timeout):
logger.warning("Control socket: %s %s not applied within %.1fs; answering pending",
request.cmd, request.id, timeout)
return Response.failure(request.id, ErrorCode.PENDING,
f'accepted, but not applied within {timeout:g}s; '
'the display will still apply it', v=request.v)
if outcome.error_code is not None:
return Response.failure(request.id, outcome.error_code, outcome.error_message,
v=request.v)
return Response.success(request.id, outcome.result or {}, v=request.v)
def start_control_server(status_provider: Optional[StatusProvider] = None,
cache_dir: Optional[str] = None,
@@ -637,7 +743,7 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
__all__ = [
'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
'CommandOutcome', 'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
'peer_allowed', 'peer_credentials', 'process_groups', 'resolve_socket_group',
'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
]