mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +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:
+233
-25
@@ -44,7 +44,15 @@ from src.logging_config import get_logger
|
||||
from src.exceptions import PluginError
|
||||
from src.common.frame_timing import HANDOVER_OP
|
||||
from src.common.sync_manager import DisplaySyncManager, SyncRole
|
||||
from src.ipc.server import ControlServer, start_control_server
|
||||
from src.ipc.contract import (
|
||||
BrightnessResult,
|
||||
BrightnessSetArgs,
|
||||
Command as ControlCommand,
|
||||
ErrorCode as ControlErrorCode,
|
||||
PluginReloadArgs,
|
||||
PluginReloadResult,
|
||||
)
|
||||
from src.ipc.server import ControlServer, QueuedCommand, start_control_server
|
||||
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
||||
|
||||
# Get logger with consistent configuration
|
||||
@@ -579,9 +587,13 @@ class DisplayController:
|
||||
# Set up interrupt checker for on-demand/wifi status and follower mode
|
||||
def _vegas_interrupt():
|
||||
return self._check_vegas_interrupt() or self.sync_manager.is_follower_active()
|
||||
# Every 10 frames (~80ms at 125 FPS, ~0.4 s at the 24 a Pi 4
|
||||
# often manages), or at the next frame when a control socket
|
||||
# command is queued: that check is one Event read per frame.
|
||||
self.vegas_coordinator.set_interrupt_checker(
|
||||
_vegas_interrupt,
|
||||
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
|
||||
check_interval=10,
|
||||
urgent=self._control_command_pending,
|
||||
)
|
||||
|
||||
# Run plugin updates inside the Vegas loop so the inter-iteration
|
||||
@@ -714,6 +726,10 @@ class DisplayController:
|
||||
if self._check_wifi_status_message():
|
||||
return True
|
||||
|
||||
# A plugin reload waits for the top of the loop, outside the iteration.
|
||||
if self._plugin_reload_pending:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _timezone(self):
|
||||
@@ -1154,12 +1170,15 @@ class DisplayController:
|
||||
Also services pending changes (see _service_pending_changes), and
|
||||
returns early when one of them changes what the panel should show --
|
||||
an on-demand start or stop, the display schedule turning the panel
|
||||
on or off, a WiFi notice arriving, or a live game taking over
|
||||
(_check_live_takeover) -- so the caller can act on it instead of
|
||||
finishing a dwell that could be a minute long (sixty seconds while
|
||||
scheduled off).
|
||||
on or off, a WiFi notice arriving, a live game taking over
|
||||
(_check_live_takeover), or a plugin reload waiting for the top of
|
||||
the loop -- so the caller can act on it instead of finishing a dwell
|
||||
that could be a minute long (sixty seconds while scheduled off).
|
||||
|
||||
The waits between checks wake for a control socket command, so one
|
||||
is applied within milliseconds rather than at the next 0.25 s tick.
|
||||
"""
|
||||
if duration <= 0:
|
||||
if duration <= 0 or self._plugin_reload_pending:
|
||||
return
|
||||
|
||||
end_time = time.time() + duration
|
||||
@@ -1177,7 +1196,9 @@ class DisplayController:
|
||||
break
|
||||
|
||||
sleep_time = min(tick_interval, remaining)
|
||||
time.sleep(sleep_time)
|
||||
# Woken early by a control socket command, which
|
||||
# _service_pending_changes then applies without its floor.
|
||||
self._wait_for_control(sleep_time)
|
||||
# A dwell can be a minute long (sixty seconds while scheduled
|
||||
# off); the watchdog must hear from this thread throughout.
|
||||
display_watchdog.watchdog.beat()
|
||||
@@ -1188,7 +1209,8 @@ class DisplayController:
|
||||
or self.is_display_active != display_active
|
||||
or self.on_demand_active != on_demand
|
||||
or (not wifi_pending and self.is_display_active
|
||||
and self._wifi_notice_pending())):
|
||||
and self._wifi_notice_pending())
|
||||
or self._plugin_reload_pending):
|
||||
break
|
||||
|
||||
def _note_empty_pass(self) -> None:
|
||||
@@ -1657,22 +1679,192 @@ class DisplayController:
|
||||
'display_active': self.is_display_active}
|
||||
|
||||
def _drain_control_commands(self) -> None:
|
||||
"""Apply on-demand commands that arrived over the control socket.
|
||||
"""Apply the commands that arrived over the control socket.
|
||||
|
||||
Each goes through _handle_on_demand_request, the mailbox's own
|
||||
handler, so both ways in behave the same, and a request that came
|
||||
both ways (a client that timed out and fell back) has one request
|
||||
id and is processed once.
|
||||
On-demand commands go through _handle_on_demand_request, the
|
||||
mailbox's own handler, so both ways in behave the same, and a
|
||||
request that came both ways (a client that timed out and fell back)
|
||||
has one request id and is processed once. A brightness is applied
|
||||
here. A plugin reload waits for the top of the next loop pass, where
|
||||
no plugin is on the stack (_apply_pending_plugin_reloads); until
|
||||
then the current screen ends early (_plugin_reload_pending).
|
||||
"""
|
||||
server = self._control_server
|
||||
if server is None or not server.has_pending:
|
||||
return
|
||||
for command in server.drain():
|
||||
try:
|
||||
self._handle_on_demand_request(command.as_on_demand_request())
|
||||
if command.cmd == ControlCommand.BRIGHTNESS_SET:
|
||||
self._apply_control_brightness(command)
|
||||
elif command.cmd == ControlCommand.PLUGIN_RELOAD:
|
||||
self._pending_plugin_reloads = self._pending_plugin_reloads + (command,)
|
||||
else:
|
||||
self._handle_on_demand_request(command.as_on_demand_request())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to apply control socket command %s",
|
||||
command.request_id)
|
||||
command.fail(ControlErrorCode.INTERNAL, 'the display failed to apply it')
|
||||
|
||||
def _wait_for_control(self, timeout: float) -> bool:
|
||||
"""Sleep up to ``timeout``, waking early for a control socket command.
|
||||
|
||||
True when a command is waiting. Without a socket (Windows, switched
|
||||
off, tests) this is the plain sleep it replaces.
|
||||
"""
|
||||
server = self._control_server
|
||||
wait = getattr(server, 'wait_for_command', None) if server is not None else None
|
||||
if wait is None:
|
||||
time.sleep(timeout)
|
||||
return False
|
||||
return bool(wait(timeout))
|
||||
|
||||
def _control_command_pending(self) -> bool:
|
||||
"""A socket command is queued: Vegas checks this every frame."""
|
||||
server = self._control_server
|
||||
return bool(server is not None and server.has_pending)
|
||||
|
||||
def _screen_preempted(self, active_mode: Optional[str]) -> bool:
|
||||
"""What ends a screen mid-way: the checks the frame loops make."""
|
||||
return (self.current_display_mode != active_mode
|
||||
or not self.is_display_active
|
||||
or self._wifi_notice_pending()
|
||||
or self._plugin_reload_pending)
|
||||
|
||||
def _wait_frame_interval(self, interval: float, active_mode: Optional[str]) -> bool:
|
||||
"""The static screen's sleep between frames, woken by socket commands.
|
||||
|
||||
Each command that arrives is applied at once (_service_pending_changes
|
||||
skips its floor while one is queued). True when that ended the
|
||||
screen; otherwise the wait carries on to the end of the interval, so
|
||||
the frame cadence is unchanged by a command that does not change the
|
||||
screen (a brightness, say).
|
||||
"""
|
||||
if self._control_server is None:
|
||||
time.sleep(interval)
|
||||
return False
|
||||
deadline = time.monotonic() + interval
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
if not self._wait_for_control(remaining):
|
||||
return False
|
||||
self._service_pending_changes()
|
||||
if self._screen_preempted(active_mode):
|
||||
return True
|
||||
|
||||
def _apply_control_brightness(self, command: QueuedCommand) -> None:
|
||||
"""``brightness.set``: the new normal brightness, on the panel now.
|
||||
|
||||
It replaces the configured value in memory only, the way a saved
|
||||
config would once the config watcher saw it, so the dim schedule and
|
||||
the scheduled-off rules treat it exactly like the setting; the next
|
||||
config the watcher loads replaces it again.
|
||||
"""
|
||||
args = command.args
|
||||
if not isinstance(args, BrightnessSetArgs):
|
||||
command.fail(ControlErrorCode.INTERNAL, 'not a brightness command')
|
||||
return
|
||||
self._normal_brightness = args.brightness
|
||||
# The dim schedule's per-minute cache holds the old normal level.
|
||||
self._dim_checked_minute = None
|
||||
# An explicit request retries a level the panel refused before.
|
||||
self._failed_brightness_target = None
|
||||
self._apply_brightness_target(repaint=True)
|
||||
if self.is_display_active:
|
||||
target = self._check_dim_schedule() # cached for the minute: no new work
|
||||
if self.current_brightness != target:
|
||||
command.fail(ControlErrorCode.FAILED,
|
||||
f'the panel did not take brightness {target}')
|
||||
return
|
||||
logger.info("Brightness set to %d%% over the control socket (panel %s%%)",
|
||||
args.brightness, self.current_brightness)
|
||||
result: BrightnessResult = {
|
||||
'brightness': args.brightness,
|
||||
'panel_brightness': int(self.current_brightness),
|
||||
'dimmed': bool(self.is_dimmed),
|
||||
'display_active': bool(self.is_display_active),
|
||||
}
|
||||
command.succeed(dict(result))
|
||||
|
||||
#: Plugin reloads from the control socket, waiting for the top of the
|
||||
#: next loop pass. A tuple, replaced rather than mutated.
|
||||
_pending_plugin_reloads: Tuple[QueuedCommand, ...] = ()
|
||||
|
||||
@property
|
||||
def _plugin_reload_pending(self) -> bool:
|
||||
return bool(self._pending_plugin_reloads)
|
||||
|
||||
def _apply_pending_plugin_reloads(self) -> None:
|
||||
"""Reload the plugins the control socket asked for. Render thread,
|
||||
top of the loop pass: no display() and no Vegas iteration on the stack.
|
||||
|
||||
The same steps as disabling and re-enabling the plugin live
|
||||
(_unregister_plugin, then load and _register_loaded_plugin), with the
|
||||
manifest re-read from disk, so the running set ends up as a restart
|
||||
would build it. A plugin that fails to load stays out of the
|
||||
rotation, as it would after a restart.
|
||||
"""
|
||||
commands, self._pending_plugin_reloads = self._pending_plugin_reloads, ()
|
||||
for command in commands:
|
||||
try:
|
||||
self._reload_plugin_for_command(command)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Plugin reload over the control socket failed")
|
||||
command.fail(ControlErrorCode.INTERNAL, 'the display failed to reload it')
|
||||
|
||||
def _reload_plugin_for_command(self, command: QueuedCommand) -> None:
|
||||
args = command.args
|
||||
if not isinstance(args, PluginReloadArgs):
|
||||
command.fail(ControlErrorCode.INTERNAL, 'not a reload command')
|
||||
return
|
||||
plugin_id = args.plugin_id
|
||||
if self.plugin_manager is None or plugin_id not in self.plugin_display_modes:
|
||||
command.fail(ControlErrorCode.NOT_LOADED, f'{plugin_id} is not running')
|
||||
return
|
||||
if plugin_id in self._on_demand_loaded_plugins:
|
||||
# Loaded only for an on-demand session (config says disabled);
|
||||
# reloading would have to repeat that special load.
|
||||
command.fail(ControlErrorCode.BUSY,
|
||||
f'{plugin_id} is loaded only for on-demand; restart to reload it')
|
||||
return
|
||||
|
||||
previous_mode = self.current_display_mode
|
||||
previous_order = {mode: i for i, mode in enumerate(self.available_modes)}
|
||||
logger.info("Reloading plugin %s over the control socket", plugin_id)
|
||||
self._unregister_plugin(plugin_id, action='Unloaded')
|
||||
loaded = bool(self.plugin_manager.reload_plugin(plugin_id))
|
||||
modes: List[str] = []
|
||||
if loaded:
|
||||
modes = list(self._register_loaded_plugin(plugin_id))
|
||||
# Registering appends; put its modes back where they were in the
|
||||
# rotation (a mode the new version added goes last).
|
||||
self.available_modes.sort(
|
||||
key=lambda mode: previous_order.get(mode, len(previous_order)))
|
||||
self._apply_plugin_rotation_order()
|
||||
self._resync_mode_index_after_change(previous_mode)
|
||||
if not loaded:
|
||||
logger.error("Plugin %s did not load after its update; it is out of the "
|
||||
"rotation until it loads", plugin_id)
|
||||
command.fail(ControlErrorCode.FAILED,
|
||||
f'{plugin_id} did not load; see the display log')
|
||||
return
|
||||
vegas = getattr(self, 'vegas_coordinator', None)
|
||||
if vegas is not None:
|
||||
try:
|
||||
# Fetch its content again rather than scroll the old copy.
|
||||
vegas.mark_plugin_updated(plugin_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("Vegas did not take the reload of %s", plugin_id, exc_info=True)
|
||||
manifest = (getattr(self.plugin_manager, 'plugin_manifests', None) or {}).get(plugin_id)
|
||||
version = manifest.get('version') if isinstance(manifest, dict) else None
|
||||
logger.info("Reloaded plugin %s (version %s, modes %s)", plugin_id, version, modes)
|
||||
result: PluginReloadResult = {
|
||||
'plugin_id': plugin_id, 'reloaded': True,
|
||||
'version': version if isinstance(version, str) else None,
|
||||
'modes': modes,
|
||||
}
|
||||
command.succeed(dict(result))
|
||||
|
||||
def _poll_on_demand_requests(self) -> None:
|
||||
"""Poll cache for new on-demand requests from external controllers."""
|
||||
@@ -3076,6 +3268,12 @@ class DisplayController:
|
||||
if self._pending_plugin_reconcile and not self.on_demand_active:
|
||||
self._service_pending_reconcile()
|
||||
|
||||
# Plugin reloads from the control socket (a store update),
|
||||
# here for the same reason: nothing of the plugin's is on the
|
||||
# stack. The screen that was showing ended early for them.
|
||||
if self._pending_plugin_reloads:
|
||||
self._apply_pending_plugin_reloads()
|
||||
|
||||
if not self.available_modes:
|
||||
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
||||
# every ~1s (rather than a long sleep) so enabling a plugin
|
||||
@@ -3177,6 +3375,10 @@ class DisplayController:
|
||||
# Scheduled off mid-iteration: blank the
|
||||
# panel now rather than render a screen.
|
||||
continue
|
||||
if self._plugin_reload_pending:
|
||||
# Reload first (top of the loop), then
|
||||
# the ticker carries on.
|
||||
continue
|
||||
if self._wifi_notice_pending():
|
||||
# It yielded for a WiFi notice: the next
|
||||
# pass shows it, not a rotation screen
|
||||
@@ -3367,9 +3569,7 @@ class DisplayController:
|
||||
# update threads and the web UI are not starved of the GIL.
|
||||
time.sleep(_remaining if _remaining > 0 else 0.001)
|
||||
|
||||
if (self.current_display_mode != active_mode
|
||||
or not self.is_display_active
|
||||
or self._wifi_notice_pending()):
|
||||
if self._screen_preempted(active_mode):
|
||||
logger.debug("Mode changed during high-FPS loop, breaking early")
|
||||
break
|
||||
|
||||
@@ -3400,7 +3600,13 @@ class DisplayController:
|
||||
)
|
||||
|
||||
while True:
|
||||
time.sleep(display_interval)
|
||||
# Wakes for a control socket command and applies
|
||||
# it at once, instead of up to a second later.
|
||||
if self._wait_frame_interval(display_interval, active_mode):
|
||||
logger.info("Mode changed during display loop from %s to %s, "
|
||||
"breaking early", active_mode,
|
||||
self.current_display_mode)
|
||||
break
|
||||
self._tick_plugin_updates()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
@@ -3432,9 +3638,7 @@ class DisplayController:
|
||||
|
||||
self._service_pending_changes()
|
||||
self._check_live_takeover()
|
||||
if (self.current_display_mode != active_mode
|
||||
or not self.is_display_active
|
||||
or self._wifi_notice_pending()):
|
||||
if self._screen_preempted(active_mode):
|
||||
logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode)
|
||||
break
|
||||
|
||||
@@ -3464,6 +3668,9 @@ class DisplayController:
|
||||
or not self.is_display_active
|
||||
or (not loop_completed and self._wifi_notice_pending())):
|
||||
continue
|
||||
# A screen cut short for a plugin reload is over: the
|
||||
# make-up dwell below returns at once, the rotation
|
||||
# advances, and the next pass reloads before it draws.
|
||||
|
||||
# Ensure we honour minimum duration when not dynamic and loop ended early
|
||||
if (
|
||||
@@ -3792,9 +3999,10 @@ class DisplayController:
|
||||
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||
return display_modes
|
||||
|
||||
def _unregister_plugin(self, plugin_id: str) -> None:
|
||||
def _unregister_plugin(self, plugin_id: str, action: str = 'Disabled') -> None:
|
||||
"""Remove a plugin's modes, config subscription and instance, then
|
||||
unload it. Used by live disable hot-reload."""
|
||||
unload it. Used by live disable hot-reload, and by a reload
|
||||
(``action`` names which in the log line)."""
|
||||
with self._plugin_modes_lock:
|
||||
modes = self.plugin_display_modes.pop(plugin_id, [])
|
||||
for mode in modes:
|
||||
@@ -3826,7 +4034,7 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
|
||||
logger.info("%s plugin %s live (removed modes: %s)", action, plugin_id, modes)
|
||||
|
||||
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
|
||||
"""True if any top-level section's ``enabled`` flag differs between two
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -238,7 +238,9 @@ def display_restart_required(action: str, plugin_enabled: bool, *,
|
||||
config carried over) is not picked up until a restart.
|
||||
- ``update``: the display keeps running the code it loaded until it
|
||||
restarts, if it runs the plugin at all -- only when it is enabled.
|
||||
``changed=False`` (already up to date) needs nothing.
|
||||
``changed=False`` (already up to date) needs nothing. The update route
|
||||
first asks the display to reload it over the control socket
|
||||
(``_reload_after_store_update``); this answer stands when it cannot.
|
||||
- ``uninstall``: removing the plugin's config section flips its enabled
|
||||
flag, and the reconcile unloads it. With ``preserve_config`` the flag
|
||||
stays, and an enabled plugin keeps running until a restart.
|
||||
|
||||
@@ -152,6 +152,8 @@ class VegasModeCoordinator:
|
||||
# Interrupt checker for yielding control back to display controller
|
||||
self._interrupt_check: Optional[Callable[[], bool]] = None
|
||||
self._interrupt_check_interval: int = 10 # Check every N frames
|
||||
# Checked every frame; True runs the interrupt check at once.
|
||||
self._interrupt_urgent: Optional[Callable[[], bool]] = None
|
||||
|
||||
# Plugin update callback — fired from a background thread inside the loop
|
||||
# so the main loop's _tick_plugin_updates() finds nothing due when Vegas
|
||||
@@ -226,7 +228,8 @@ class VegasModeCoordinator:
|
||||
def set_interrupt_checker(
|
||||
self,
|
||||
checker: Callable[[], bool],
|
||||
check_interval: int = 10
|
||||
check_interval: int = 10,
|
||||
urgent: Optional[Callable[[], bool]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set the callback for checking if Vegas should yield control.
|
||||
@@ -237,9 +240,25 @@ class VegasModeCoordinator:
|
||||
Args:
|
||||
checker: Callable that returns True if Vegas should yield
|
||||
check_interval: Check every N frames (default 10)
|
||||
urgent: A cheap per-frame test; when it is True the checker
|
||||
runs at this frame instead of waiting for the interval (the
|
||||
display controller passes "a control socket command is
|
||||
queued", so a command waits one frame, not ten)
|
||||
"""
|
||||
self._interrupt_check = checker
|
||||
self._interrupt_check_interval = max(1, check_interval)
|
||||
self._interrupt_urgent = urgent
|
||||
|
||||
def _interrupt_is_urgent(self) -> bool:
|
||||
"""The per-frame test set with ``urgent``; never raises."""
|
||||
urgent = getattr(self, '_interrupt_urgent', None)
|
||||
if urgent is None:
|
||||
return False
|
||||
try:
|
||||
return bool(urgent()) # pylint: disable=not-callable
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("Urgent interrupt test failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def set_update_callback(self, callback: Callable[[], None]) -> None:
|
||||
"""
|
||||
@@ -709,7 +728,8 @@ class VegasModeCoordinator:
|
||||
frame_times.clear()
|
||||
|
||||
if (self._interrupt_check and
|
||||
frame_count % self._interrupt_check_interval == 0):
|
||||
(frame_count % self._interrupt_check_interval == 0
|
||||
or self._interrupt_is_urgent())):
|
||||
try:
|
||||
if self._interrupt_check():
|
||||
logger.debug(
|
||||
|
||||
Reference in New Issue
Block a user