diff --git a/plugins.json b/plugins.json index 410f4ea8..8c6413a6 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-10-04", + "last_updated": "2026-10-05", "plugins": [ { "id": "cricket-scoreboard", @@ -436,12 +436,11 @@ "plugin_path": "plugins/birdnet-go", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.2.8", - "ledmatrix_min_version": "2.0.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "latest_version": "1.2.9", + "ledmatrix_min_version": "2.0.0" }, { "id": "leaderboard", @@ -574,12 +573,11 @@ "plugin_path": "plugins/mqtt-notifications", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.2.8", - "ledmatrix_min_version": "2.0.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "latest_version": "1.2.9", + "ledmatrix_min_version": "2.0.0" }, { "id": "music", @@ -654,13 +652,12 @@ "plugin_path": "plugins/on-air", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.2.14", + "latest_version": "1.2.15", "icon": "fa-circle-dot", - "ledmatrix_min_version": "2.0.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "ledmatrix_min_version": "2.0.0" }, { "id": "nfl-draft", @@ -1289,13 +1286,12 @@ "plugin_path": "plugins/pomodoro-timer", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.3.10", + "latest_version": "1.3.11", "icon": "fa-hourglass-half", - "ledmatrix_min_version": "2.0.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "ledmatrix_min_version": "2.0.0" }, { "id": "sleeper-fantasy", diff --git a/plugins/birdnet-go/manager.py b/plugins/birdnet-go/manager.py index 504a3f1e..195da9fa 100644 --- a/plugins/birdnet-go/manager.py +++ b/plugins/birdnet-go/manager.py @@ -448,21 +448,40 @@ def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-arg def _trigger_on_demand(self, detection: Dict[str, Any]) -> None: try: - request_payload = { - 'request_id': str(uuid.uuid4()), - 'action': 'start', - 'plugin_id': self.plugin_id, - 'mode': DETECTION_MODE, - 'duration': self.interrupt_duration, - 'pinned': False, - 'timestamp': time.time(), - } self._on_demand_until = time.time() + self.interrupt_duration - self.cache_manager.set('display_on_demand_request', request_payload) + if not self._request_on_demand_in_process(): + # A core without request_on_demand() (3.8.0 and older), or no + # display in this process: the mailbox, as before. + self.cache_manager.set('display_on_demand_request', { + 'request_id': str(uuid.uuid4()), + 'action': 'start', + 'plugin_id': self.plugin_id, + 'mode': DETECTION_MODE, + 'duration': self.interrupt_duration, + 'pinned': False, + 'timestamp': time.time(), + }) self.logger.info("Triggered on-demand display for %s", detection['common_name']) except Exception as e: self.logger.error("Error triggering on-demand display: %s", e, exc_info=True) + def _request_on_demand_in_process(self) -> bool: + """Ask the core for the screen directly, where it has the API. + + BasePlugin.request_on_demand() reaches the display within a frame, + from this (MQTT or poll) thread; the file mailbox is read once a + second and is going away. False on a core without it, or when it + answers None (no display in this process), so the caller writes the + mailbox. + """ + if hasattr(self, 'request_on_demand'): + try: + return bool(self.request_on_demand( + mode=DETECTION_MODE, duration=self.interrupt_duration, pinned=False)) + except Exception as e: + self.logger.debug("In-process on-demand request failed: %s", e) + return False + def _connect_mqtt(self) -> bool: try: # _mqtt_loop tears the previous client down (_teardown_client) diff --git a/plugins/birdnet-go/manifest.json b/plugins/birdnet-go/manifest.json index f4ddcc38..e1637349 100644 --- a/plugins/birdnet-go/manifest.json +++ b/plugins/birdnet-go/manifest.json @@ -1,7 +1,7 @@ { "id": "birdnet-go", "name": "BirdNET-Go", - "version": "1.2.8", + "version": "1.2.9", "author": "ChuckBuilds", "description": "Show what BirdNET-Go is hearing. One screen cycles a different species each turn \u2014 name, confidence, how many times it has been heard today, and a photo \u2014 and a second shows today's stats: species count, total detections and the most-heard species. Polls the BirdNET-Go REST API, with optional MQTT for instant pop-ups.", "category": "integration", @@ -21,6 +21,12 @@ "entry_point": "manager.py", "class_name": "BirdNetGoPlugin", "versions": [ + { + "released": "2026-10-05", + "version": "1.2.9", + "changelog": "A detection reaches the panel within a frame on LEDMatrix cores that have BasePlugin.request_on_demand(): the plugin asks the display directly instead of writing the display_on_demand_request cache file, which the display reads only once a second while its control socket is up and which a future core removes. On older cores, or when no display answers, it writes the file as before.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-10-02", "version": "1.2.8", diff --git a/plugins/birdnet-go/test_on_demand_api.py b/plugins/birdnet-go/test_on_demand_api.py new file mode 100644 index 00000000..b179cb14 --- /dev/null +++ b/plugins/birdnet-go/test_on_demand_api.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""A detection takes the screen through the core's in-process API when the +core has one, and through the file mailbox otherwise. + +BasePlugin.request_on_demand() reaches the display within a frame; the +``display_on_demand_request`` mailbox is read once a second and is going +away. The plugin writes the mailbox on a core without the method, and when +it answers None (no display in this process, e.g. the web interface). + +Run with the core on PYTHONPATH: + PYTHONPATH=/path/to/LEDMatrix python +""" +import logging +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +try: + from src.plugin_system.plugin_manager import PluginManager +except ImportError: + print("SKIP: LEDMatrix core not on PYTHONPATH") + sys.exit(2) +from manager import BirdNetGoPlugin, DETECTION_MODE # noqa: E402 + +failures = [] + + +def check(label, ok): + print(("PASS " if ok else "FAIL ") + label) + if not ok: + failures.append(label) + + +class Cache: + def __init__(self): + self.store = {} + + def set(self, key, data, ttl=None): + self.store[key] = data + + +def display_process_manager(seen): + """The core's PluginManager as the display wires it; None on a core + without the in-process API.""" + if not hasattr(PluginManager, "set_on_demand_handler"): + return None + manager = PluginManager.__new__(PluginManager) + manager.logger = logging.getLogger("test-plugin-manager") + manager.set_on_demand_handler(lambda request: seen.append(request) or True) + return manager + + +def make(plugin_manager): + p = object.__new__(BirdNetGoPlugin) + p.plugin_id = "birdnet-go" + p.logger = logging.getLogger("test-birdnet-go") + p.cache_manager = Cache() + p.plugin_manager = plugin_manager + p.interrupt_duration = 12.0 + p._on_demand_until = 0.0 + return p + + +DETECTION = {"common_name": "American Robin"} + +# No display in this process, or a core without the API: the mailbox. +p = make(None) +p._trigger_on_demand(DETECTION) +request = p.cache_manager.store.get("display_on_demand_request") +check("without a display to ask, the start goes to the mailbox", + isinstance(request, dict) and request.get("action") == "start" + and request.get("plugin_id") == "birdnet-go" + and request.get("mode") == DETECTION_MODE and request.get("duration") == 12.0) +check("the interrupt window is set", p._on_demand_until > 0) + +seen = [] +manager = display_process_manager(seen) +if manager is None: + print("SKIP-PART: this core has no BasePlugin.request_on_demand()") +else: + p = make(manager) + p._trigger_on_demand(DETECTION) + check("with the core API, no mailbox write", + "display_on_demand_request" not in p.cache_manager.store) + check("the start reaches the display", + len(seen) == 1 and seen[0]["action"] == "start" + and seen[0]["plugin_id"] == "birdnet-go" and seen[0]["mode"] == DETECTION_MODE + and seen[0]["duration"] == 12.0 and seen[0]["pinned"] is False + and seen[0]["source"] == "plugin") + check("the interrupt window is set", p._on_demand_until > 0) + + # A display whose queue is full answers None: the mailbox again. + manager.set_on_demand_handler(lambda request: False) + p = make(manager) + p._trigger_on_demand(DETECTION) + check("a refused request falls back to the mailbox", + (p.cache_manager.store.get("display_on_demand_request") or {}).get("action") + == "start") + +print("%d failed" % len(failures)) +sys.exit(1 if failures else 0) diff --git a/plugins/mqtt-notifications/manager.py b/plugins/mqtt-notifications/manager.py index 81ddacc0..84ec0a74 100644 --- a/plugins/mqtt-notifications/manager.py +++ b/plugins/mqtt-notifications/manager.py @@ -333,30 +333,49 @@ def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-arg self.logger.error(f"Error processing MQTT message: {e}", exc_info=True) def _trigger_on_demand_display(self, message: Dict[str, Any]): - """Trigger on-demand display via cache manager.""" + """Store the message, then ask the core to show it now.""" try: - request_id = str(uuid.uuid4()) - request_payload = { - 'request_id': request_id, - 'action': 'start', - 'plugin_id': self.plugin_id, - 'mode': 'mqtt_notification', - 'duration': message.get('duration', self.default_duration), - 'pinned': False, - 'timestamp': time.time() - } - + duration = message.get('duration', self.default_duration) + # Store message in cache for display method self.cache_manager.set(f'{self.plugin_id}_current_message', message, ttl=3600) - - # Trigger on-demand display - self.cache_manager.set('display_on_demand_request', request_payload) + + if not self._request_on_demand_in_process(duration): + # A core without request_on_demand() (3.8.0 and older), or + # no display in this process: the mailbox, as before. + self.cache_manager.set('display_on_demand_request', { + 'request_id': str(uuid.uuid4()), + 'action': 'start', + 'plugin_id': self.plugin_id, + 'mode': 'mqtt_notification', + 'duration': duration, + 'pinned': False, + 'timestamp': time.time() + }) self.logger.info("Triggered on-demand display for %s notification", message['type']) except Exception as e: self.logger.error(f"Error triggering on-demand display: {e}", exc_info=True) + def _request_on_demand_in_process(self, duration: Any) -> bool: + """Ask the core for the screen directly, where it has the API. + + BasePlugin.request_on_demand() reaches the display within a frame, + from this MQTT thread; the file mailbox is read once a second and is + going away. False on a core without it, when it answers None (no + display in this process), or when it refuses the message's duration + (a string from the JSON, say, which the mailbox path parses as it + always has), so the caller writes the mailbox. + """ + if hasattr(self, 'request_on_demand'): + try: + return bool(self.request_on_demand( + mode='mqtt_notification', duration=duration, pinned=False)) + except Exception as e: + self.logger.debug("In-process on-demand request failed: %s", e) + return False + def _connect_mqtt(self): """Connect to MQTT broker.""" try: diff --git a/plugins/mqtt-notifications/manifest.json b/plugins/mqtt-notifications/manifest.json index 4e923648..ff6b1447 100644 --- a/plugins/mqtt-notifications/manifest.json +++ b/plugins/mqtt-notifications/manifest.json @@ -1,7 +1,7 @@ { "id": "mqtt-notifications", "name": "MQTT Notifications", - "version": "1.2.8", + "version": "1.2.9", "author": "ChuckBuilds", "description": "Display text or images from HomeAssistant via MQTT. Supports dynamic MQTT topics with wildcard support for flexible notification handling that interrupts the normal display rotation.", "category": "integration", @@ -18,6 +18,12 @@ "entry_point": "manager.py", "class_name": "MQTTNotificationsPlugin", "versions": [ + { + "released": "2026-10-05", + "version": "1.2.9", + "changelog": "A notification reaches the panel within a frame on LEDMatrix cores that have BasePlugin.request_on_demand(): the plugin asks the display directly from its MQTT thread instead of writing the display_on_demand_request cache file, which the display reads only once a second while its control socket is up and which a future core removes. On older cores, when no display answers, or for a duration the core refuses (a string), it writes the file as before.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-10-02", "version": "1.2.8", diff --git a/plugins/mqtt-notifications/test_on_demand_trigger.py b/plugins/mqtt-notifications/test_on_demand_trigger.py index 4c406606..f7ef0270 100644 --- a/plugins/mqtt-notifications/test_on_demand_trigger.py +++ b/plugins/mqtt-notifications/test_on_demand_trigger.py @@ -64,5 +64,42 @@ def set(self, *args, **kwargs): check("the message is stored for display()", p.cache_manager.store.get("mqtt-notifications_current_message", {}).get("text") == "Door open") + +# A core with BasePlugin.request_on_demand() takes the request in-process: it +# reaches the display within a frame, and nothing goes to the mailbox (read +# once a second, and going away). Without a display in this process (above, +# no plugin_manager) or on an older core, the mailbox, as before. +from src.plugin_system.plugin_manager import PluginManager # noqa: E402 + +if not hasattr(PluginManager, "set_on_demand_handler"): + print("SKIP-PART: this core has no BasePlugin.request_on_demand()") +else: + seen = [] + manager = PluginManager.__new__(PluginManager) + manager.logger = logging.getLogger("test-plugin-manager") + manager.set_on_demand_handler(lambda request: seen.append(request) or True) + p.cache_manager = SignatureCheckedCache() + p.plugin_manager = manager + p._trigger_on_demand_display({"type": "text", "text": "Door open", "duration": 7}) + check("with the core API, no mailbox write", + "display_on_demand_request" not in p.cache_manager.store) + check("the start reaches the display", + len(seen) == 1 and seen[0]["action"] == "start" + and seen[0]["plugin_id"] == "mqtt-notifications" + and seen[0]["mode"] == "mqtt_notification" and seen[0]["duration"] == 7.0 + and seen[0]["source"] == "plugin") + check("the message is still stored for display()", + p.cache_manager.store.get("mqtt-notifications_current_message", {}).get("text") + == "Door open") + + # A duration the API refuses (a string from the JSON) keeps the old path, + # which parses it as it always has. + seen.clear() + p.cache_manager = SignatureCheckedCache() + p._trigger_on_demand_display({"type": "text", "text": "Hi", "duration": "15"}) + check("a string duration falls back to the mailbox", + not seen and p.cache_manager.store.get("display_on_demand_request", {}) + .get("duration") == "15") + print("%d failed" % len(failures)) sys.exit(1 if failures else 0) diff --git a/plugins/on-air/manager.py b/plugins/on-air/manager.py index be7ae1e7..2ee4299c 100644 --- a/plugins/on-air/manager.py +++ b/plugins/on-air/manager.py @@ -538,6 +538,10 @@ def _parse_payload(self, raw: bytes): return None, None, None, None # unrecognised plain string — ignore def _trigger_display(self, on: bool) -> None: + if self._trigger_display_in_process(on): + return + # A core without request_on_demand() (3.8.0 and older), or no display + # in this process: the mailbox, as before. req: Dict[str, Any] = { 'request_id': str(uuid.uuid4()), 'plugin_id': self.plugin_id, @@ -548,6 +552,28 @@ def _trigger_display(self, on: bool) -> None: req.update({'mode': 'on_air', 'duration': None, 'pinned': True}) self.cache_manager.set('display_on_demand_request', req) + def _trigger_display_in_process(self, on: bool) -> bool: + """Take or give back the screen directly, where the core has the API. + + BasePlugin.request_on_demand() / end_on_demand() reach the display + within a frame, from this MQTT thread; the file mailbox is read once + a second and is going away. end_on_demand() ends only this plugin's + own session, so turning the sign off no longer stops something the + user put on screen meanwhile. False on a core without them, or when + they answer None (no display in this process), so the caller writes + the mailbox. + """ + try: + if on: + if hasattr(self, 'request_on_demand'): + return bool(self.request_on_demand(mode='on_air', duration=None, + pinned=True)) + elif hasattr(self, 'end_on_demand'): + return bool(self.end_on_demand()) + except Exception as e: + self.logger.debug("In-process on-demand request failed: %s", e) + return False + def _publish_state(self, on: bool) -> None: if not self.mqtt_client or not self.mqtt_connected: return diff --git a/plugins/on-air/manifest.json b/plugins/on-air/manifest.json index 06e7bb3f..82a485ac 100644 --- a/plugins/on-air/manifest.json +++ b/plugins/on-air/manifest.json @@ -1,7 +1,7 @@ { "id": "on-air", "name": "On Air Light", - "version": "1.2.14", + "version": "1.2.15", "author": "ChuckBuilds", "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live \u2014 stays on until you turn it off.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.2.15", + "released": "2026-10-05", + "ledmatrix_min_version": "2.0.0", + "notes": "ON and OFF reach the panel within a frame on LEDMatrix cores that have BasePlugin.request_on_demand() / end_on_demand(): the plugin asks the display directly from its MQTT thread instead of writing the display_on_demand_request cache file, which the display reads only once a second while its control socket is up and which a future core removes. On those cores OFF ends only the sign's own on-demand session, so it no longer stops something else the user put on screen meanwhile. On older cores, or when no display answers, it writes the file as before." + }, { "version": "1.2.14", "released": "2026-10-02", diff --git a/plugins/on-air/test_on_demand_api.py b/plugins/on-air/test_on_demand_api.py new file mode 100644 index 00000000..fbf0fe78 --- /dev/null +++ b/plugins/on-air/test_on_demand_api.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""The sign takes and gives back the screen through the core's in-process +API when the core has one, and through the file mailbox otherwise. + +BasePlugin.request_on_demand() / end_on_demand() reach the display within a +frame; the ``display_on_demand_request`` mailbox is read once a second and +is going away. The plugin writes the mailbox on a core without the methods, +and when they answer None (no display in this process, e.g. the web +interface). + +Run with the core on PYTHONPATH: + PYTHONPATH=/path/to/LEDMatrix python +""" +import logging +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +try: + from src.plugin_system.plugin_manager import PluginManager +except ImportError: + print("SKIP: LEDMatrix core not on PYTHONPATH") + sys.exit(2) +from manager import OnAirPlugin # noqa: E402 + +failures = [] + + +def check(label, ok): + print(("PASS " if ok else "FAIL ") + label) + if not ok: + failures.append(label) + + +class Cache: + def __init__(self): + self.store = {} + + def set(self, key, data, ttl=None): + self.store[key] = data + + +def display_process_manager(seen): + """The core's PluginManager as the display wires it; None on a core + without the in-process API.""" + if not hasattr(PluginManager, "set_on_demand_handler"): + return None + manager = PluginManager.__new__(PluginManager) + manager.logger = logging.getLogger("test-plugin-manager") + manager.set_on_demand_handler(lambda request: seen.append(request) or True) + return manager + + +def make(plugin_manager): + p = object.__new__(OnAirPlugin) + p.plugin_id = "on-air" + p.logger = logging.getLogger("test-on-air") + p.cache_manager = Cache() + p.plugin_manager = plugin_manager + return p + + +def mailbox(p): + return p.cache_manager.store.get("display_on_demand_request") or {} + + +# No display in this process, or a core without the API: the mailbox. +p = make(None) +p._trigger_display(True) +check("without a display to ask, ON goes to the mailbox", + mailbox(p).get("action") == "start" and mailbox(p).get("mode") == "on_air" + and mailbox(p).get("pinned") is True and mailbox(p).get("duration") is None) +p._trigger_display(False) +check("and OFF too", mailbox(p).get("action") == "stop" + and mailbox(p).get("plugin_id") == "on-air") + +seen = [] +manager = display_process_manager(seen) +if manager is None: + print("SKIP-PART: this core has no BasePlugin.request_on_demand()") +else: + p = make(manager) + p._trigger_display(True) + p._trigger_display(False) + check("with the core API, no mailbox write", + "display_on_demand_request" not in p.cache_manager.store) + check("ON pins the sign's mode, with no time limit", + len(seen) == 2 and seen[0]["action"] == "start" + and seen[0]["plugin_id"] == "on-air" and seen[0]["mode"] == "on_air" + and seen[0]["pinned"] is True and seen[0]["duration"] is None + and seen[0]["source"] == "plugin") + check("OFF gives the screen back (the plugin's own session only)", + len(seen) == 2 and seen[1]["action"] == "stop" + and seen[1]["plugin_id"] == "on-air" and seen[1]["source"] == "plugin") + + # A display whose queue is full answers None: the mailbox again. + manager.set_on_demand_handler(lambda request: False) + p = make(manager) + p._trigger_display(True) + check("a refused ON falls back to the mailbox", mailbox(p).get("action") == "start") + p._trigger_display(False) + check("a refused OFF falls back to the mailbox", mailbox(p).get("action") == "stop") + +print("%d failed" % len(failures)) +sys.exit(1 if failures else 0) diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index da3cc0c1..d32ec6cd 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -1367,6 +1367,10 @@ def _sync_pin(self) -> None: if want == self._pinned: return self._pinned = want + if self._sync_pin_in_process(want): + return + # A core without request_on_demand() (3.8.0 and older), or no + # display in this process: the mailbox, as before. request: Dict[str, Any] = { "request_id": str(uuid.uuid4()), "plugin_id": self.plugin_id, @@ -1381,6 +1385,30 @@ def _sync_pin(self) -> None: self.logger.debug("On-demand display request failed: %s", e) self._pinned = not want # let the next tick retry + def _sync_pin_in_process(self, want: bool) -> bool: + """Take or give back the panel directly, where the core has the API. + + BasePlugin.request_on_demand() / end_on_demand() reach the display + within a frame, from whichever thread called _sync_pin; the file + mailbox is read once a second and is going away. end_on_demand() + ends only this plugin's own session, so a timer that finishes no + longer stops something the user put on screen meanwhile. Both only + queue, so calling them under state_lock keeps the requests in order + without waiting on the display. False on a core without them, or + when they answer None (no display in this process, or its queue is + full), so the caller writes the mailbox. + """ + try: + if want: + if hasattr(self, "request_on_demand"): + return bool(self.request_on_demand(mode="pomodoro", duration=None, + pinned=True)) + elif hasattr(self, "end_on_demand"): + return bool(self.end_on_demand()) + except Exception as e: + self.logger.debug("In-process on-demand request failed: %s", e) + return False + def _timer_loop(self) -> None: """Drive the state machine independently of the render loop, so the timer keeps counting while another plugin owns the panel. diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index 9eac2f44..46bebf0b 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.3.10", + "version": "1.3.11", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT \u2014 with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.3.11", + "released": "2026-10-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Pinning and releasing the panel reaches the display within a frame on LEDMatrix cores that have BasePlugin.request_on_demand() / end_on_demand(): the plugin asks the display directly instead of writing the display_on_demand_request cache file, which the display reads only once a second while its control socket is up and which a future core removes. On those cores a timer that stops or finishes ends only its own on-demand session, so it no longer stops something else the user put on screen meanwhile. On older cores, or when no display answers, it writes the file as before." + }, { "version": "1.3.10", "released": "2026-10-02", diff --git a/plugins/pomodoro-timer/test_pomodoro_timer.py b/plugins/pomodoro-timer/test_pomodoro_timer.py index c01c4ff5..45a9eec6 100644 --- a/plugins/pomodoro-timer/test_pomodoro_timer.py +++ b/plugins/pomodoro-timer/test_pomodoro_timer.py @@ -313,6 +313,32 @@ def test_pin_request_follows_the_timer(): assert p.cache_manager.get("display_on_demand_request")["action"] == "stop" +def test_pin_uses_the_cores_in_process_api_when_it_has_one(): + """A core with BasePlugin.request_on_demand() gets no mailbox write.""" + p = make_plugin(pin_while_running=True) + calls = [] + p.request_on_demand = lambda **kw: calls.append(("start", kw)) or "rid-start" + p.end_on_demand = lambda: calls.append(("stop", {})) or "rid-stop" + p._apply_command("START", {}) + p._apply_command("STOP", {}) + assert calls == [("start", {"mode": "pomodoro", "duration": None, "pinned": True}), + ("stop", {})] + assert p.cache_manager.get("display_on_demand_request") is None + + +def test_pin_falls_back_to_the_mailbox_when_the_core_cannot_take_it(): + """None (no display in this process) or an error: the mailbox, as before.""" + for answer in (lambda **kw: None, lambda **kw: (_ for _ in ()).throw(RuntimeError("x"))): + p = make_plugin(pin_while_running=True) + p.request_on_demand = answer + p.end_on_demand = lambda: None + p._apply_command("START", {}) + request = p.cache_manager.get("display_on_demand_request") + assert request["action"] == "start" and request["pinned"] is True + p._apply_command("STOP", {}) + assert p.cache_manager.get("display_on_demand_request")["action"] == "stop" + + def test_pin_is_not_requested_when_disabled(): p = make_plugin(pin_while_running=False, alert_seconds=0) p._apply_command("START", {})