mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
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:
@@ -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
@@ -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
@@ -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',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user