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
+233 -25
View File
@@ -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