mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process (#768)
* feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process Four plugins (birdnet-go, mqtt-notifications, on-air, pomodoro-timer) take the screen by writing the display_on_demand_request mailbox, which the display reads once a second while the control socket is up and which stage 5 removes. This is the in-process way in that stage needed. - BasePlugin.request_on_demand(mode=None, duration=None, pinned=False) and end_on_demand(), safe from any thread, go through PluginManager to DisplayController.submit_plugin_on_demand, which only queues (at most 32) and wakes the render thread through ControlServer.wake(). The render thread applies them in _drain_control_commands, after socket commands, through _handle_on_demand_request, so they land within a frame; without a socket, on the next pending-changes pass. - A plugin's stop ends only its own session; a mailbox stop still ends any. - Both answer the request id, or None with no display in the process (web interface, check_plugin.py), a full queue, or a mock manager -- a plugin's cue to write the mailbox, which the display still reads. - docs/PLUGIN_API_REFERENCE.md documents the hasattr pattern for plugins that must keep working on older cores; IPC_CONTROL_SOCKET.md and the CHANGELOG are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): wire the on-demand handler only on a manager that has it Tests and the golden traces stand in simpler plugin managers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+104
-14
@@ -394,6 +394,12 @@ class DisplayController:
|
||||
plugin_time = time.time()
|
||||
self.plugin_manager = None
|
||||
self._plugin_runtime_publisher = None
|
||||
# On-demand requests plugins make in this process (BasePlugin.
|
||||
# request_on_demand / end_on_demand), from any thread; the render
|
||||
# thread drains them with the socket's commands. Created before the
|
||||
# plugins load, because a plugin may ask from its first thread.
|
||||
self._plugin_on_demand: deque = deque()
|
||||
self._plugin_on_demand_lock = threading.Lock()
|
||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||
@@ -508,6 +514,12 @@ class DisplayController:
|
||||
cache_manager=self.cache_manager,
|
||||
font_manager=self.font_manager
|
||||
)
|
||||
# BasePlugin.request_on_demand() / end_on_demand() land here.
|
||||
# Before any plugin loads: a plugin may ask from its first thread.
|
||||
# getattr: tests and golden traces stand in simpler managers.
|
||||
set_handler = getattr(self.plugin_manager, 'set_on_demand_handler', None)
|
||||
if callable(set_handler):
|
||||
set_handler(self.submit_plugin_on_demand)
|
||||
|
||||
# The web UI's loaded / state / error_info for each plugin read
|
||||
# what this publishes. Started before loading, so the loads that
|
||||
@@ -1882,6 +1894,10 @@ class DisplayController:
|
||||
_on_demand_mailbox: Optional[MailboxWatch] = None
|
||||
#: Writers whose mailbox requests have been logged (_note_mailbox_request).
|
||||
_mailbox_writers_logged: FrozenSet[str] = frozenset()
|
||||
#: Most plugin on-demand requests waiting for the render thread at once.
|
||||
#: A plugin that asks faster than the display drains (four times a
|
||||
#: second at worst) is refused, not queued without end.
|
||||
PLUGIN_ON_DEMAND_QUEUE_SIZE = 32
|
||||
|
||||
def _service_pending_changes(self) -> None:
|
||||
"""Apply changes made elsewhere while the display thread is busy.
|
||||
@@ -1906,7 +1922,7 @@ class DisplayController:
|
||||
# A command queued on the control socket skips the floor: it is in
|
||||
# memory, so applying it now costs no disk read.
|
||||
if (last is not None and now - last < self.PENDING_CHANGES_INTERVAL
|
||||
and not (self._control_server and self._control_server.has_pending)):
|
||||
and not self._control_command_pending()):
|
||||
return
|
||||
self._last_pending_service = now
|
||||
|
||||
@@ -2105,11 +2121,15 @@ class DisplayController:
|
||||
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).
|
||||
|
||||
Plugins' own on-demand requests (submit_plugin_on_demand) are
|
||||
applied here too, after the socket's, with or without a socket.
|
||||
"""
|
||||
server = self._control_server
|
||||
if server is None or not server.has_pending:
|
||||
return
|
||||
for command in server.drain():
|
||||
# drain() clears the wake flag before the plugin queue is read below,
|
||||
# so a plugin request queued from here on wakes the next wait.
|
||||
commands = server.drain() if server is not None and server.has_pending else []
|
||||
for command in commands:
|
||||
try:
|
||||
if command.cmd == ControlCommand.BRIGHTNESS_SET:
|
||||
self._apply_control_brightness(command)
|
||||
@@ -2121,24 +2141,78 @@ class DisplayController:
|
||||
logger.exception("Failed to apply control socket command %s",
|
||||
command.request_id)
|
||||
command.fail(ControlErrorCode.INTERNAL, 'the display failed to apply it')
|
||||
self._drain_plugin_on_demand()
|
||||
|
||||
# -- plugins' in-process on-demand requests ---------------------------------
|
||||
|
||||
def submit_plugin_on_demand(self, request: Dict[str, Any]) -> bool:
|
||||
"""Queue a plugin's on-demand request for the render thread. Any thread.
|
||||
|
||||
``PluginManager.request_on_demand`` / ``end_on_demand`` (which
|
||||
BasePlugin's methods of the same names call) build ``request``: the
|
||||
mailbox's shape, with ``source: 'plugin'`` and the asking plugin's
|
||||
id. Nothing here touches the panel or the on-demand state; the render
|
||||
thread applies the request where it applies a socket command
|
||||
(_drain_control_commands), through _handle_on_demand_request, and is
|
||||
woken for it when the control socket is up. True when it was queued;
|
||||
False (logged) when the queue is full.
|
||||
"""
|
||||
pending = self.__dict__.get('_plugin_on_demand')
|
||||
lock = self.__dict__.get('_plugin_on_demand_lock')
|
||||
if pending is None or lock is None:
|
||||
return False # a controller built without __init__ (tests)
|
||||
with lock:
|
||||
if len(pending) >= self.PLUGIN_ON_DEMAND_QUEUE_SIZE:
|
||||
logger.warning("Plugin on-demand queue full; refusing %s %s from %s",
|
||||
request.get('action'), request.get('request_id'),
|
||||
request.get('plugin_id'))
|
||||
return False
|
||||
pending.append(dict(request))
|
||||
server = self._control_server
|
||||
if server is not None:
|
||||
server.wake()
|
||||
return True
|
||||
|
||||
def _plugin_on_demand_pending(self) -> bool:
|
||||
"""A plugin's on-demand request is waiting. A length check: no lock."""
|
||||
return bool(self.__dict__.get('_plugin_on_demand'))
|
||||
|
||||
def _drain_plugin_on_demand(self) -> None:
|
||||
"""Apply the plugins' queued on-demand requests, oldest first. Render thread."""
|
||||
pending = self.__dict__.get('_plugin_on_demand')
|
||||
while pending:
|
||||
try:
|
||||
request = pending.popleft()
|
||||
except IndexError:
|
||||
break
|
||||
try:
|
||||
self._handle_on_demand_request(request)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to apply on-demand request %s from plugin %s",
|
||||
request.get('request_id'), request.get('plugin_id'))
|
||||
|
||||
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.
|
||||
off, tests) this is the plain sleep it replaces, unless a plugin's
|
||||
on-demand request is already waiting.
|
||||
"""
|
||||
server = self._control_server
|
||||
wait = getattr(server, 'wait_for_command', None) if server is not None else None
|
||||
if wait is None:
|
||||
if self._plugin_on_demand_pending():
|
||||
return True
|
||||
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."""
|
||||
"""A socket command or a plugin's on-demand request is queued: Vegas
|
||||
checks this every frame."""
|
||||
server = self._control_server
|
||||
return bool(server is not None and server.has_pending)
|
||||
return bool((server is not None and server.has_pending)
|
||||
or self._plugin_on_demand_pending())
|
||||
|
||||
def _wait_frame_interval(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
|
||||
"""The static screen's sleep between frames, woken by socket commands.
|
||||
@@ -2450,21 +2524,36 @@ class DisplayController:
|
||||
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
|
||||
"""Process one on-demand request, from the mailbox or the control socket.
|
||||
|
||||
A socket command carries ``source: 'socket'``. Only a mailbox request
|
||||
is removed from the mailbox afterwards: a socket command never put
|
||||
anything there, so that would be a disk read and maybe a delete for
|
||||
nothing.
|
||||
A socket command carries ``source: 'socket'``, and a plugin's own
|
||||
request (submit_plugin_on_demand) ``source: 'plugin'``. Only a
|
||||
mailbox request is removed from the mailbox afterwards: the others
|
||||
never put anything there, so that would be a disk read and maybe a
|
||||
delete for nothing.
|
||||
|
||||
A plugin's stop ends only that plugin's own session: a plugin
|
||||
releasing the screen must not end one the user started for
|
||||
another plugin. (A stop through the mailbox ends any session, as it
|
||||
always has.)
|
||||
"""
|
||||
request_id = request.get('request_id')
|
||||
if not request_id:
|
||||
return
|
||||
from_mailbox = request.get('source') != 'socket'
|
||||
source = request.get('source')
|
||||
from_mailbox = source not in ('socket', 'plugin')
|
||||
|
||||
action = request.get('action')
|
||||
|
||||
# For stop requests, always process them (don't check processed_id)
|
||||
# This allows stopping even if the same stop request was sent before
|
||||
if action == 'stop':
|
||||
if source == 'plugin' and not (
|
||||
self.on_demand_active
|
||||
and self.on_demand_plugin_id == request.get('plugin_id')):
|
||||
logger.debug("On-demand stop %s from plugin %s ignored: it does not own "
|
||||
"the screen (on-demand %s, plugin %s)", request_id,
|
||||
request.get('plugin_id'), self.on_demand_status,
|
||||
self.on_demand_plugin_id)
|
||||
return
|
||||
logger.info("Received on-demand stop request %s", request_id)
|
||||
# Always process stop requests, even if same request_id (user might click multiple times)
|
||||
if self.on_demand_active:
|
||||
@@ -2507,8 +2596,9 @@ class DisplayController:
|
||||
self._consume_on_demand_request(request_id)
|
||||
return
|
||||
|
||||
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s)",
|
||||
request_id, action, request.get('plugin_id'), request.get('mode'))
|
||||
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s, via %s)",
|
||||
request_id, action, request.get('plugin_id'), request.get('mode'),
|
||||
'mailbox' if from_mailbox else source)
|
||||
|
||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||
|
||||
Reference in New Issue
Block a user