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:
Chuck
2026-10-05 01:39:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5a7893b11a
commit 0577c807eb
8 changed files with 799 additions and 21 deletions
+418
View File
@@ -0,0 +1,418 @@
"""Plugins asking for the screen in-process: BasePlugin.request_on_demand()
and end_on_demand().
A plugin running in the display process used to write the
``display_on_demand_request`` mailbox, which the display reads once a second
while the control socket is up. These tests pin the way in that replaces it:
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
which only queues, from any thread;
* the render thread applies the queue where it applies socket commands,
through the mailbox's own handler, without the mailbox's read floor, and
woken by the control socket when it is up;
* a plugin's stop ends only its own session;
* no display to ask (the web interface's plugin manager, an old core's
plugin manager) answers None, which is a plugin's cue to fall back to the
mailbox;
* the mailbox still works for plugins that write it.
"""
import logging
import threading
import time
from unittest.mock import MagicMock
import pytest
from src.ipc.server import ControlServer
from src.plugin_system.base_plugin import BasePlugin
from src.plugin_system.plugin_manager import PluginManager
class _Plugin(BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
def _plugin(plugin_id, manager):
plugin = _Plugin.__new__(_Plugin)
plugin.plugin_id = plugin_id
plugin.plugin_manager = manager
return plugin
def _manager(handler=None):
manager = PluginManager.__new__(PluginManager)
manager.logger = logging.getLogger('test.plugin_on_demand')
if handler is not None:
manager.set_on_demand_handler(handler)
return manager
class _WakeServer:
"""The parts of ControlServer the controller uses, with no socket."""
def __init__(self):
self.woken = 0
self.has_pending = False
def wake(self):
self.woken += 1
self.has_pending = True
def drain(self):
self.has_pending = False
return []
@pytest.fixture
def controller(test_display_controller):
c_ = test_display_controller
c_.on_demand_active = False
c_.on_demand_request_id = None
c_._last_on_demand_poll = None
mailbox = {'value': None}
def fake_get(key, *a, **kw):
if key == 'display_on_demand_request':
return mailbox['value']
return None
c_.cache_manager.get = MagicMock(side_effect=fake_get)
c_.cache_manager.set = MagicMock()
c_.cache_manager.delete = MagicMock()
c_._activate_on_demand = MagicMock()
c_.mailbox = mailbox
return c_
@pytest.fixture
def wired(controller):
"""A real PluginManager wired to the controller, as __init__ wires it."""
manager = _manager(controller.submit_plugin_on_demand)
return controller, manager
class TestWiring:
def test_the_controller_wires_its_plugin_manager(self, controller):
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
controller.submit_plugin_on_demand)
def test_a_start_reaches_the_mailbox_handler(self, wired):
controller, manager = wired
rid = _plugin('pomodoro-timer', manager).request_on_demand(
mode='pomodoro', duration=30, pinned=True)
assert isinstance(rid, str) and rid
controller._activate_on_demand.assert_not_called() # only queued
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
request = controller._activate_on_demand.call_args.args[0]
assert request['request_id'] == rid
assert request['action'] == 'start'
assert request['plugin_id'] == 'pomodoro-timer'
assert request['mode'] == 'pomodoro'
assert request['duration'] == 30.0 and request['pinned'] is True
assert request['source'] == 'plugin'
assert controller.on_demand_request_id == rid
def test_a_plugin_request_never_touches_the_mailbox(self, wired):
controller, manager = wired
_plugin('on-air', manager).request_on_demand(mode='on_air')
controller._drain_control_commands()
controller._activate_on_demand.assert_called_once()
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list
if call.args[0] == 'display_on_demand_request']
assert mailbox_reads == []
controller.cache_manager.delete.assert_not_called()
def test_requests_apply_in_order(self, wired):
controller, manager = wired
seen = []
controller._activate_on_demand = MagicMock(
side_effect=lambda r: seen.append(r['mode']))
plugin = _plugin('p', manager)
for mode in ('a', 'b', 'c'):
plugin.request_on_demand(mode=mode)
controller._poll_on_demand_requests()
assert seen == ['a', 'b', 'c']
def test_the_mailbox_still_works_for_older_plugins(self, wired):
controller, manager = wired
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start',
'plugin_id': 'birdnet-go'}
_plugin('on-air', manager).request_on_demand()
controller._poll_on_demand_requests()
ids = [call.args[0]['request_id'] for call in controller._activate_on_demand.call_args_list]
assert 'mb' in ids and len(ids) == 2
def test_a_failing_request_is_contained(self, wired):
controller, manager = wired
calls = []
def activate(request):
calls.append(request['mode'])
if request['mode'] == 'bad':
raise RuntimeError('plugin exploded')
controller._activate_on_demand = MagicMock(side_effect=activate)
plugin = _plugin('p', manager)
plugin.request_on_demand(mode='bad')
plugin.request_on_demand(mode='good')
controller._poll_on_demand_requests()
assert calls == ['bad', 'good']
class TestPromptness:
def test_a_plugin_request_skips_the_pending_changes_floor(self, wired):
controller, manager = wired
controller._control_server = None
controller._service_pending_changes()
_plugin('p', manager).request_on_demand()
controller._service_pending_changes() # well inside the 0.25 s floor
controller._activate_on_demand.assert_called_once()
def test_a_plugin_request_skips_the_mailbox_floor(self, wired):
controller, manager = wired
controller._control_server = _WakeServer()
controller._poll_on_demand_requests() # sets the 1 s mailbox floor
_plugin('p', manager).request_on_demand()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
def test_it_wakes_the_control_socket_wait(self, wired):
controller, manager = wired
server = ControlServer('/nonexistent/control.sock') # never started
controller._control_server = server
assert not server.wait_for_command(0)
_plugin('p', manager).request_on_demand()
assert server.has_pending
assert controller._wait_for_control(5.0) is True # returns at once
assert controller._control_command_pending()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
assert not server.has_pending
assert not controller._control_command_pending()
def test_without_a_socket_a_waiting_request_cuts_the_sleep(self, wired):
controller, manager = wired
controller._control_server = None
_plugin('p', manager).request_on_demand()
started = time.monotonic()
assert controller._wait_for_control(5.0) is True
assert time.monotonic() - started < 1.0
assert controller._control_command_pending()
def test_nothing_waiting_keeps_the_floor(self, controller):
controller._control_server = None
controller._poll_on_demand_requests = MagicMock()
controller._service_pending_changes()
controller._service_pending_changes()
assert controller._poll_on_demand_requests.call_count == 1
class TestThreads:
def test_requests_from_many_threads_all_land_in_order_per_thread(self, wired):
controller, manager = wired
seen = []
controller._activate_on_demand = MagicMock(
side_effect=lambda r: seen.append(r['mode']))
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 10_000
threads_n, each = 8, 50
barrier = threading.Barrier(threads_n)
def ask(n):
plugin = _plugin(f'p{n}', manager)
barrier.wait()
for i in range(each):
assert plugin.request_on_demand(mode=f'{n}:{i}')
threads = [threading.Thread(target=ask, args=(n,)) for n in range(threads_n)]
for t in threads:
t.start()
# Drain while they ask, as the render thread would.
while any(t.is_alive() for t in threads):
controller._drain_control_commands()
for t in threads:
t.join()
controller._drain_control_commands()
assert len(seen) == threads_n * each
for n in range(threads_n):
mine = [int(m.split(':')[1]) for m in seen if m.startswith(f'{n}:')]
assert mine == list(range(each))
def test_a_full_queue_refuses(self, wired, caplog):
controller, manager = wired
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 2
plugin = _plugin('p', manager)
assert plugin.request_on_demand()
assert plugin.request_on_demand()
assert plugin.request_on_demand() is None
assert 'queue full' in caplog.text
controller._poll_on_demand_requests()
assert controller._activate_on_demand.call_count == 2
assert plugin.request_on_demand() # room again
class TestStop:
def test_a_plugin_ends_its_own_session(self, wired):
controller, manager = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'on-air'
controller._clear_on_demand = MagicMock()
assert _plugin('on-air', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
controller.cache_manager.delete.assert_not_called()
def test_a_plugin_cannot_end_another_plugins_session(self, wired):
controller, manager = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'clock' # the user started it
controller.on_demand_request_id = 'user'
controller._clear_on_demand = MagicMock()
_plugin('pomodoro-timer', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_not_called()
assert controller.on_demand_request_id == 'user'
def test_a_stop_with_no_session_does_nothing(self, wired):
controller, manager = wired
controller.on_demand_status = 'error'
controller._clear_on_demand = MagicMock()
_plugin('on-air', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_not_called()
def test_a_mailbox_stop_still_ends_any_session(self, wired):
controller, _ = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'clock'
controller._clear_on_demand = MagicMock()
controller.mailbox['value'] = {'request_id': 's', 'action': 'stop',
'plugin_id': 'on-air'}
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
controller, manager = wired
def activate(request):
controller.on_demand_active = True
controller.on_demand_plugin_id = request['plugin_id']
controller._activate_on_demand = MagicMock(side_effect=activate)
controller._clear_on_demand = MagicMock()
plugin = _plugin('pomodoro-timer', manager)
plugin.request_on_demand(mode='pomodoro', pinned=True)
plugin.end_on_demand()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
class TestNoDisplay:
"""None is a plugin's cue to write the mailbox instead."""
def test_a_manager_with_no_handler_answers_none(self):
plugin = _plugin('p', _manager())
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_no_plugin_manager_answers_none(self):
plugin = _plugin('p', None)
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_an_old_cores_plugin_manager_answers_none(self):
class OldManager:
plugin_manifests = {}
plugin = _plugin('p', OldManager())
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_a_handler_that_raises_answers_none(self):
def broken(request):
raise RuntimeError('boom')
plugin = _plugin('p', _manager(broken))
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_a_handler_that_refuses_answers_none(self):
plugin = _plugin('p', _manager(lambda request: False))
assert plugin.request_on_demand() is None
def test_a_controller_built_without_init_refuses(self):
from src.display_controller import DisplayController
bare = DisplayController.__new__(DisplayController)
assert bare.submit_plugin_on_demand({'action': 'start'}) is False
assert bare._plugin_on_demand_pending() is False
bare._drain_plugin_on_demand() # nothing to do, no error
def test_the_feature_detection_pattern(self):
"""The hasattr pattern from docs/PLUGIN_API_REFERENCE.md."""
writes = []
class OldCorePlugin: # an older core's BasePlugin has no such method
pass
for plugin, expect_mailbox in ((OldCorePlugin(), True),
(_plugin('p', _manager()), True),
(_plugin('p', _manager(lambda r: True)), False)):
writes.clear()
if not (hasattr(plugin, 'request_on_demand')
and plugin.request_on_demand(mode='m')):
writes.append('mailbox')
assert (writes == ['mailbox']) is expect_mailbox
class TestArguments:
def test_the_manager_shapes_the_request(self):
got = []
plugin = _plugin('p', _manager(lambda r: got.append(r) or True))
plugin.request_on_demand()
plugin.end_on_demand()
start, stop = got
assert start['plugin_id'] == 'p' and start['mode'] is None
assert start['duration'] is None and start['pinned'] is False
assert start['source'] == 'plugin' and start['timestamp'] > 0
assert stop == {'action': 'stop', 'plugin_id': 'p', 'request_id': stop['request_id'],
'timestamp': stop['timestamp'], 'source': 'plugin'}
assert start['request_id'] != stop['request_id']
@pytest.mark.parametrize('duration', [0, -5, float('inf'), float('nan')])
def test_no_positive_duration_means_no_limit(self, duration):
got = []
_plugin('p', _manager(lambda r: got.append(r) or True)).request_on_demand(
duration=duration)
assert got[0]['duration'] is None
@pytest.mark.parametrize('kwargs', [{'mode': 5}, {'mode': ''}, {'duration': '30'},
{'duration': True}])
def test_bad_arguments_raise(self, kwargs):
plugin = _plugin('p', _manager(lambda r: True))
with pytest.raises(ValueError):
plugin.request_on_demand(**kwargs)
class TestMockManagers:
def test_a_magicmock_manager_reads_as_not_taken(self):
"""A plugin's test with a MagicMock manager keeps its mailbox path."""
plugin = _plugin('p', MagicMock())
assert plugin.request_on_demand(mode='m') is None
assert plugin.end_on_demand() is None
plugin.plugin_manager.request_on_demand.assert_called_once_with(
'p', mode='m', duration=None, pinned=False)
plugin.plugin_manager.end_on_demand.assert_called_once_with('p')
def test_a_mocked_id_is_passed_through(self):
manager = MagicMock()
manager.request_on_demand.return_value = 'rid'
manager.end_on_demand.return_value = 'rid2'
plugin = _plugin('p', manager)
assert plugin.request_on_demand() == 'rid'
assert plugin.end_on_demand() == 'rid2'