Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 13 additions & 17 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-10-04",
"last_updated": "2026-10-05",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 29 additions & 10 deletions plugins/birdnet-go/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion plugins/birdnet-go/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
101 changes: 101 additions & 0 deletions plugins/birdnet-go/test_on_demand_api.py
Original file line number Diff line number Diff line change
@@ -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 <thisfile>
"""
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)
49 changes: 34 additions & 15 deletions plugins/mqtt-notifications/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion plugins/mqtt-notifications/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions plugins/mqtt-notifications/test_on_demand_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading