diff --git a/plugins.json b/plugins.json
index c9d216bf..a49b2a14 100644
--- a/plugins.json
+++ b/plugins.json
@@ -362,6 +362,31 @@
"latest_version": "1.7.1",
"icon": "fas fa-baseball-ball"
},
+ {
+ "id": "birdnet-go",
+ "name": "BirdNET-Go",
+ "description": "Show what BirdNET-Go is hearing. One screen cycles a different species each turn — name, confidence, how many times it has been heard today, and a photo — 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.",
+ "author": "ChuckBuilds",
+ "category": "integration",
+ "tags": [
+ "birdnet",
+ "birds",
+ "nature",
+ "wildlife",
+ "stats",
+ "mqtt",
+ "homeassistant"
+ ],
+ "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
+ "branch": "main",
+ "plugin_path": "plugins/birdnet-go",
+ "stars": 0,
+ "downloads": 0,
+ "last_updated": "2026-08-05",
+ "verified": true,
+ "screenshot": "",
+ "latest_version": "1.2.0"
+ },
{
"id": "leaderboard",
"name": "Sports Leaderboard",
diff --git a/plugins/birdnet-go/LICENSE b/plugins/birdnet-go/LICENSE
new file mode 100644
index 00000000..e653a0c1
--- /dev/null
+++ b/plugins/birdnet-go/LICENSE
@@ -0,0 +1,17 @@
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright (C) 2025 LEDMatrix Team
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
diff --git a/plugins/birdnet-go/README.md b/plugins/birdnet-go/README.md
new file mode 100644
index 00000000..763f951f
--- /dev/null
+++ b/plugins/birdnet-go/README.md
@@ -0,0 +1,169 @@
+# BirdNET-Go Plugin
+
+Show what [BirdNET-Go](https://github.com/tphakala/birdnet-go) is hearing on your LED matrix, across two screens:
+
+- **`birdnet_go`** — the latest identified bird: common name, scientific name, confidence, time since detection, and a species photo
+- **`birdnet_stats`** — today's totals: species count, detection count, and the most-heard species with their counts
+
+Everything comes from BirdNET-Go's REST API by polling. MQTT is optional and only buys sub-second pop-ups.
+
+## Features
+
+- No broker required — point it at your BirdNET-Go URL and it works
+- Cycles a different species each turn, so a loud yard doesn't show the same bird every slot
+- Size-adaptive layouts: a third line and larger type on tall panels, graceful degradation down to 64x32
+- Species photos pulled on demand and cached for 30 days
+- Three detection behaviours: `rotation`, `interrupt`, or `both`
+- Configurable confidence threshold to filter noisy detections
+- Optional MQTT push with auto-reconnect and exponential backoff
+- Defensive payload parsing — tolerates `CommonName` / `commonName` / `common_name` and decimal-or-percent confidence
+- Optional field mapping for unusual BirdNET-Go forks
+
+## Quick start
+
+The only setting that matters is your BirdNET-Go address:
+
+```json
+"birdnet-go": {
+ "enabled": true,
+ "birdnet_api": {
+ "base_url": "http://birdnet-go.local:8080"
+ },
+ "display": {
+ "mode": "rotation",
+ "min_confidence": 0.6
+ }
+}
+```
+
+Then `sudo systemctl restart ledmatrix`. Both screens join the rotation.
+
+Find your base URL by opening the BirdNET-Go web UI and copying scheme, host and port — no path. Verify it from the LED matrix host:
+
+```bash
+curl "http://:/api/v2/health"
+```
+
+A healthy instance returns JSON containing `"status":"healthy"`.
+
+Full option reference: see `config_schema.json`.
+
+## Display modes
+
+Two rotation entries, enabled independently.
+
+`birdnet_go` shows the latest bird. Its behaviour is set by `display.mode`:
+
+- **`rotation`** — renders the last detected bird during its rotation slot; nothing pops up mid-rotation
+- **`interrupt`** — stays silent during rotation; new detections interrupt the current display for `interrupt_duration` seconds
+- **`both`** (default) — rotation slot *and* interrupt on new detections
+
+If more than `stale_after_minutes` pass without a detection, the slot shows "No recent birds".
+
+`birdnet_stats` shows today's numbers. Turn it off with `"stats": {"enabled": false}` and its rotation slot is skipped. `stats.top_n` caps how many species are kept; the panel renders as many as physically fit, so a short panel shows fewer without any config change.
+
+Interrupts only fire for a bird heard within the last couple of poll intervals. Polling surfaces old detections at startup, and popping those over the rotation is just noise.
+
+## Species cycling
+
+Showing "the latest bird" sounds right until you look at a real feed. A yard with 200 Fish Crow and 260 Blue Jay calls a day is one of those two almost every time you glance at the panel, and the other nine species never appear.
+
+So by default the `birdnet_go` screen cycles: one distinct species per rotation slot, moving to the next species on the next slot. Each card carries that species' count for the day, so you get the frequency ranking as you watch rather than only on the stats screen.
+
+```json
+"display": {
+ "unique_species": true,
+ "max_species": 8,
+ "species_order": "recent",
+ "show_today_count": true
+}
+```
+
+- **`species_order: "recent"`** (default) — most recently heard species first, so the panel reflects what's outside now
+- **`species_order: "frequency"`** — most-heard species first, a running top-N countdown
+- **`unique_species: false`** — the old behaviour: always show whatever called last
+
+The cycle is built from today's per-species analytics, not the raw detection stream — `/detections/recent` caps at ten rows, which on a busy feed is often a single species. That does mean the cycle only covers species heard *today*; early in the morning it's short, and it grows as the day goes on.
+
+Interrupt pop-ups always show the bird that just called, never the cycle's current card — an interrupt that showed some other species would defeat the point.
+
+## How data is fetched
+
+Every `birdnet_api.poll_interval` seconds (default 60) the plugin calls:
+
+| Endpoint | Used for |
+| --- | --- |
+| `/api/v2/detections/recent` | the latest bird above `min_confidence` |
+| `/api/v2/analytics/species/daily` | today's per-species counts |
+| `/api/v2/media/species-image?name=` | the species photo |
+
+Photos are cached in memory and on disk for 30 days, keyed by scientific name; failed lookups are remembered for the session so we don't hammer the API. If the image endpoint is unreachable the layout falls back to text-only.
+
+Polling keeps running even when MQTT is enabled, so a broker outage can't freeze the display.
+
+## Optional: MQTT for instant pop-ups
+
+Polling means a new bird appears within one `poll_interval`. If you want it on screen the moment it's heard, and BirdNET-Go is already publishing to a broker, enable MQTT. It supplements polling rather than replacing it.
+
+Most people running BirdNET-Go alongside Home Assistant already have the **Mosquitto broker** add-on installed.
+
+### 1. Create a dedicated MQTT user in Home Assistant
+
+1. **Settings → People → Users → Add User**
+2. Name it something like `ledmatrix` (not an admin, just a regular user)
+3. Set a password — you'll paste this into the plugin config
+
+The Mosquitto add-on authenticates against HA's user list by default, so no extra broker config is needed.
+
+### 2. Find your broker's address and port
+
+- **Host**: the LAN IP or hostname of the machine running Home Assistant (e.g. `192.168.1.10`). Don't use `localhost` unless the LED matrix runs on the HA host itself.
+- **Port**: `1883` (plain) is the default. TLS on 8883 is not supported, so stick with 1883 on your LAN.
+
+### 3. Configure BirdNET-Go to publish
+
+In BirdNET-Go's web UI (**Settings → Integrations → MQTT**):
+- Broker URL: `tcp://:1883`
+- Username / Password: the user you just made
+- Topic: `birdnet` (any topic works, it just has to match the plugin)
+- Enable the integration and restart BirdNET-Go
+
+Verify from any machine on the LAN:
+```bash
+mosquitto_sub -h -p 1883 -u ledmatrix -P -t 'birdnet/#' -v
+```
+
+### 4. Turn it on in the plugin
+
+```json
+"mqtt": {
+ "enabled": true,
+ "host": "192.168.1.10",
+ "port": 1883,
+ "username": "ledmatrix",
+ "password": "REPLACE_ME",
+ "topic": "birdnet"
+}
+```
+
+`paho-mqtt` must be installed (it's in `requirements.txt`). If it's missing, the plugin logs an error and falls back to polling rather than failing to load.
+
+### 5. Confirm it connected
+
+```bash
+sudo journalctl -u ledmatrix -f | grep -i birdnet
+```
+
+You should see `Connected to MQTT broker` and `Subscribed to topic: birdnet`.
+
+## Troubleshooting
+
+- **Nothing appears** — check `curl "/api/v2/health"` from the LED matrix host, and that `min_confidence` isn't set too high. Detections below it are logged at debug as "Dropping low-confidence detection".
+- **"No bird stats"** — the daily analytics call failed or returned nothing. Check `curl "/api/v2/analytics/species/daily"`; an empty array is normal before the first detection of the day.
+- **Name but no photo** — `curl "/api/v2/media/species-image?name=Cardinalis%20cardinalis"` should return an image.
+- **Time-ago looks wrong** — the plugin uses the detection's own timestamp when BirdNET-Go supplies a full ISO-8601 one, and falls back to arrival time otherwise.
+- **MQTT connects but nothing shows** — enable debug logging to see the raw payload, then override the relevant key under `field_mapping`.
+
+## Display modes supported
+
+`birdnet_go`, `birdnet_stats`
diff --git a/plugins/birdnet-go/config_schema.json b/plugins/birdnet-go/config_schema.json
new file mode 100644
index 00000000..015060a5
--- /dev/null
+++ b/plugins/birdnet-go/config_schema.json
@@ -0,0 +1,267 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "BirdNET-Go Plugin Configuration",
+ "description": "Configuration schema for the BirdNET-Go plugin - polls the BirdNET-Go REST API to display the most recent bird and today's detection stats on the LED matrix",
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable or disable the BirdNET-Go plugin"
+ },
+ "update_interval": {
+ "type": "integer",
+ "default": 30,
+ "minimum": 1,
+ "description": "How often (seconds) to run background update tasks (image warmup, connection health)"
+ },
+ "mqtt": {
+ "type": "object",
+ "description": "Optional. Only needed for sub-second interrupt pop-ups - the REST API alone drives both screens.",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Subscribe to an MQTT broker for pushed detections. Leave off to rely on REST polling alone."
+ },
+ "host": {
+ "type": "string",
+ "default": "",
+ "description": "MQTT broker hostname or IP address"
+ },
+ "port": {
+ "type": "integer",
+ "default": 1883,
+ "minimum": 1,
+ "maximum": 65535,
+ "description": "MQTT broker port"
+ },
+ "username": {
+ "type": "string",
+ "default": "",
+ "description": "MQTT broker username (optional)"
+ },
+ "password": {
+ "type": "string",
+ "default": "",
+ "description": "MQTT broker password (optional)"
+ },
+ "client_id": {
+ "type": "string",
+ "default": "ledmatrix-birdnet-go",
+ "description": "MQTT client ID"
+ },
+ "keepalive": {
+ "type": "integer",
+ "default": 60,
+ "minimum": 10,
+ "maximum": 300,
+ "description": "MQTT keepalive interval in seconds"
+ },
+ "topic": {
+ "type": "string",
+ "default": "birdnet/detections",
+ "description": "MQTT topic BirdNET-Go publishes detections to. Supports wildcards (+ and #)."
+ }
+ },
+ "additionalProperties": false
+ },
+ "birdnet_api": {
+ "type": "object",
+ "description": "The primary data source: detections, daily stats and species images all come from here.",
+ "properties": {
+ "base_url": {
+ "type": "string",
+ "default": "",
+ "description": "Base URL of the BirdNET-Go web server, e.g. http://birdnet-go.local:8080 or http://192.168.1.20:8084. Required: a self-hosted service has no useful default, and a placeholder host would just produce repeated connection warnings."
+ },
+ "request_timeout": {
+ "type": "number",
+ "default": 5.0,
+ "minimum": 1,
+ "maximum": 30,
+ "description": "HTTP timeout in seconds for API requests"
+ },
+ "poll_interval": {
+ "type": "number",
+ "default": 60,
+ "minimum": 5,
+ "description": "How often (seconds) to poll for new detections and refresh daily stats"
+ }
+ },
+ "required": ["base_url"],
+ "additionalProperties": false
+ },
+ "display": {
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["rotation", "interrupt", "both"],
+ "default": "both",
+ "description": "rotation: always shows most recent bird during plugin's rotation slot. interrupt: pops up only on new detections. both: rotation slot plus interrupt on new detections."
+ },
+ "interrupt_duration": {
+ "type": "number",
+ "default": 10,
+ "minimum": 1,
+ "maximum": 300,
+ "description": "Seconds to show a pop-up detection in interrupt mode"
+ },
+ "rotation_duration": {
+ "type": "number",
+ "default": 15,
+ "minimum": 1,
+ "maximum": 300,
+ "description": "Seconds to show the bird during the plugin's rotation slot"
+ },
+ "min_confidence": {
+ "type": "number",
+ "default": 0.5,
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Ignore detections with confidence below this threshold (0.0-1.0)"
+ },
+ "stale_after_minutes": {
+ "type": "integer",
+ "default": 120,
+ "minimum": 1,
+ "description": "If no detection arrives within this many minutes, show a 'no recent detection' frame instead"
+ },
+ "show_image": {
+ "type": "boolean",
+ "default": true,
+ "description": "Fetch and display the species image"
+ },
+ "show_confidence": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show the confidence percentage"
+ },
+ "show_time": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show how long ago the bird was detected"
+ },
+ "unique_species": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show a different species each rotation slot instead of repeating whatever called last. On a busy feed one or two loud species would otherwise fill nearly every slot."
+ },
+ "max_species": {
+ "type": "integer",
+ "default": 8,
+ "minimum": 1,
+ "maximum": 50,
+ "description": "How many of today's species to cycle through"
+ },
+ "species_order": {
+ "type": "string",
+ "enum": ["recent", "frequency"],
+ "default": "recent",
+ "description": "recent: most recently heard species first. frequency: most-heard species first."
+ },
+ "show_today_count": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show how many times the species has been heard today (e.g. x203)"
+ }
+ },
+ "additionalProperties": false
+ },
+ "stats": {
+ "type": "object",
+ "description": "The birdnet_stats screen: today's species count, total detections and most-heard species.",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show the stats screen. When off, the plugin's stats rotation slot is skipped."
+ },
+ "top_n": {
+ "type": "integer",
+ "default": 5,
+ "minimum": 1,
+ "maximum": 20,
+ "description": "How many of the most-heard species to keep. The panel shows as many as fit."
+ },
+ "rotation_duration": {
+ "type": "number",
+ "default": 15,
+ "minimum": 1,
+ "maximum": 300,
+ "description": "Seconds to show the stats screen during its rotation slot"
+ }
+ },
+ "additionalProperties": false
+ },
+ "text": {
+ "type": "object",
+ "properties": {
+ "font_path": {
+ "type": "string",
+ "default": "assets/fonts/PressStart2P-Regular.ttf",
+ "description": "Path to font file (TTF). Relative to project root or absolute path."
+ },
+ "font_size": {
+ "type": "number",
+ "default": 8,
+ "minimum": 4,
+ "maximum": 32,
+ "description": "Font size in pixels"
+ },
+ "text_color": {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 0, "maximum": 255},
+ "minItems": 3,
+ "maxItems": 3,
+ "default": [255, 255, 255],
+ "description": "RGB text color [R, G, B]"
+ },
+ "background_color": {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 0, "maximum": 255},
+ "minItems": 3,
+ "maxItems": 3,
+ "default": [0, 0, 0],
+ "description": "RGB background color [R, G, B]"
+ },
+ "accent_color": {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 0, "maximum": 255},
+ "minItems": 3,
+ "maxItems": 3,
+ "default": [255, 190, 0],
+ "description": "RGB accent color [R, G, B] for the stats header and per-species counts"
+ },
+ "scroll_speed": {
+ "type": "number",
+ "default": 30,
+ "minimum": 1,
+ "maximum": 200,
+ "description": "Scroll speed in pixels per second for long bird names"
+ },
+ "scroll_gap_width": {
+ "type": "number",
+ "default": 32,
+ "minimum": 0,
+ "description": "Gap width in pixels between scroll loops"
+ }
+ },
+ "additionalProperties": false
+ },
+ "field_mapping": {
+ "type": "object",
+ "description": "Override BirdNET-Go payload field names if your version uses different keys. Matching is case-insensitive with common variants tried automatically.",
+ "properties": {
+ "common_name": {"type": "string", "default": "CommonName"},
+ "scientific_name": {"type": "string", "default": "ScientificName"},
+ "confidence": {"type": "string", "default": "Confidence"},
+ "time": {"type": "string", "default": "Time"}
+ },
+ "additionalProperties": false
+ }
+ },
+ "required": ["enabled", "birdnet_api"],
+ "additionalProperties": false
+}
diff --git a/plugins/birdnet-go/manager.py b/plugins/birdnet-go/manager.py
new file mode 100644
index 00000000..95588953
--- /dev/null
+++ b/plugins/birdnet-go/manager.py
@@ -0,0 +1,1131 @@
+"""
+BirdNET-Go Plugin for LEDMatrix
+
+Shows what BirdNET-Go is hearing, in two display modes:
+
+ - ``birdnet_go`` the most recently identified bird — common name,
+ confidence, time since detection, and a species image
+ - ``birdnet_stats`` today's totals — species count, detection count, and the
+ most-heard species
+
+Data comes from BirdNET-Go's REST API by polling, which is all most setups
+need. MQTT is optional and only buys sub-second latency for the interrupt
+pop-up; without it, a new bird shows up on the next poll.
+
+API Version: 1.0.0
+"""
+
+import base64
+import json
+import logging
+import os
+import threading
+import time
+import uuid
+from collections import OrderedDict
+from datetime import datetime
+from io import BytesIO
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+from urllib.parse import quote
+
+import requests
+from PIL import Image, ImageDraw, ImageFont
+
+try:
+ import paho.mqtt.client as mqtt
+except ImportError:
+ mqtt = None
+
+from src.plugin_system.base_plugin import BasePlugin
+
+logger = logging.getLogger(__name__)
+
+DETECTION_MODE = 'birdnet_go'
+STATS_MODE = 'birdnet_stats'
+
+# A frame's delta-time feeds the scroll position. Rotation can park this plugin
+# for minutes at a time, so cap dt or the name jumps a screen-width on return.
+_MAX_FRAME_DT = 0.25
+
+# Image caches are bounded: a yard accumulates new species for months, and an
+# unbounded cache of decoded photos would grow for as long as the service runs.
+_MAX_SOURCE_IMAGES = 32
+_MAX_PANEL_IMAGES = 16
+
+# Case-insensitive fallback variants tried when a mapped field is missing.
+_FIELD_VARIANTS = {
+ 'common_name': ['CommonName', 'commonName', 'common_name', 'Common_Name'],
+ 'scientific_name': ['ScientificName', 'scientificName', 'scientific_name', 'Scientific_Name'],
+ 'confidence': ['Confidence', 'confidence'],
+ 'time': ['Timestamp', 'timestamp', 'Time', 'time', 'BeginTime', 'beginTime'],
+}
+
+_MEASURE_DRAW = ImageDraw.Draw(Image.new('RGB', (1, 1)))
+
+
+class BirdNetGoPlugin(BasePlugin):
+ """Display BirdNET-Go bird detections and daily stats on the LED matrix."""
+
+ def __init__(self, plugin_id: str, config: Dict[str, Any],
+ display_manager, cache_manager, plugin_manager):
+ super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
+
+ mqtt_config = config.get('mqtt', {}) or {}
+ self.mqtt_enabled = bool(mqtt_config.get('enabled', False))
+ self.mqtt_host = mqtt_config.get('host', '')
+ self.mqtt_port = int(mqtt_config.get('port', 1883))
+ self.mqtt_username = mqtt_config.get('username', '')
+ self.mqtt_password = mqtt_config.get('password', '')
+ self.mqtt_client_id = mqtt_config.get('client_id', 'ledmatrix-birdnet-go')
+ self.mqtt_keepalive = int(mqtt_config.get('keepalive', 60))
+ self.mqtt_topic = mqtt_config.get('topic', 'birdnet/detections')
+
+ if self.mqtt_enabled and mqtt is None:
+ self.logger.error(
+ "mqtt.enabled is set but paho-mqtt is not installed; falling back to REST "
+ "polling. Install with: pip install paho-mqtt")
+ self.mqtt_enabled = False
+
+ api_config = config.get('birdnet_api', {}) or {}
+ self.api_base_url = str(api_config.get('base_url', '') or '').rstrip('/')
+ self.api_timeout = float(api_config.get('request_timeout', 5.0))
+ self.poll_interval = float(api_config.get('poll_interval', 60))
+
+ display_config = config.get('display', {}) or {}
+ self.mode = display_config.get('mode', 'both')
+ self.interrupt_duration = float(display_config.get('interrupt_duration', 10))
+ self.rotation_duration = float(display_config.get('rotation_duration', 15))
+ self.min_confidence = float(display_config.get('min_confidence', 0.5))
+ self.stale_after_s = int(display_config.get('stale_after_minutes', 120)) * 60
+ self.show_image = bool(display_config.get('show_image', True))
+ self.show_confidence = bool(display_config.get('show_confidence', True))
+ self.show_time = bool(display_config.get('show_time', True))
+ self.unique_species = bool(display_config.get('unique_species', True))
+ self.max_species = max(1, int(display_config.get('max_species', 8)))
+ self.show_today_count = bool(display_config.get('show_today_count', True))
+ self.species_order = display_config.get('species_order', 'recent')
+
+ stats_config = config.get('stats', {}) or {}
+ self.stats_enabled = bool(stats_config.get('enabled', True))
+ self.stats_top_n = int(stats_config.get('top_n', 5))
+ self.stats_duration = float(stats_config.get('rotation_duration', 15))
+
+ text_config = config.get('text', {}) or {}
+ self.font_path = text_config.get('font_path', 'assets/fonts/PressStart2P-Regular.ttf')
+ self.font_size = int(text_config.get('font_size', 8))
+ self.text_color = tuple(int(c) for c in text_config.get('text_color', [255, 255, 255]))
+ self.bg_color = tuple(int(c) for c in text_config.get('background_color', [0, 0, 0]))
+ self.accent_color = tuple(int(c) for c in text_config.get('accent_color', [255, 190, 0]))
+ self.scroll_speed = float(text_config.get('scroll_speed', 30))
+ self.scroll_gap_width = int(text_config.get('scroll_gap_width', 32))
+
+ self.field_mapping = config.get('field_mapping', {}) or {}
+
+ # MQTT state
+ self.mqtt_client: Optional["mqtt.Client"] = None
+ self.mqtt_thread: Optional[threading.Thread] = None
+ self.mqtt_connected = False
+ self.mqtt_reconnect_delay = 1.0
+ self.mqtt_max_reconnect_delay = 60.0
+ self.mqtt_stop_event = threading.Event()
+
+ # Detection / stats state
+ self.state_lock = threading.Lock()
+ self.last_detection: Optional[Dict[str, Any]] = None
+ self._last_detection_key: Optional[str] = None
+ self._recent_species: List[Dict[str, Any]] = []
+ self._cycle_index = 0
+ self._cycle_started = 0.0
+ self._cycle_key: Optional[str] = None
+ self.daily_stats: Optional[Dict[str, Any]] = None
+ # species -> decoded source PIL, and (species, w, h) -> panel-sized frame.
+ self._species_img_cache: "OrderedDict[str, Image.Image]" = OrderedDict()
+ self._panel_img_cache: "OrderedDict[Tuple[str, int, int], Image.Image]" = OrderedDict()
+ self._species_img_failed: set = set() # species we already failed to fetch
+ self._pending_image_fetch: Optional[str] = None
+ self._scroll_pos = 0.0
+ self._scroll_cache: Optional[Image.Image] = None
+ self._scroll_cache_key: Optional[Tuple] = None
+ self._last_frame_time = time.time()
+ self._on_demand_until = 0.0
+ self._last_poll = 0.0
+ self._last_stats_poll = 0.0
+ self._last_rendered_mode = DETECTION_MODE
+
+ self._font_cache: Dict[int, Any] = {}
+ self._resolved_font_path = self._resolve_font_path()
+ self.font = self._font_for(self.font_size)
+
+ self.logger.info(
+ "BirdNET-Go plugin initialized (mode=%s, api=%s, mqtt=%s, stats=%s)",
+ self.mode, self.api_base_url or 'unset',
+ f"{self.mqtt_host}:{self.mqtt_port}" if self.mqtt_enabled else 'disabled',
+ 'on' if self.stats_enabled else 'off')
+
+ # ------------------------------------------------------------------ fonts
+
+ def _resolve_font_path(self) -> Optional[str]:
+ """Locate the configured font, searching cwd and the project root."""
+ font_path = self.font_path
+ if not font_path:
+ return None
+ if os.path.isabs(font_path):
+ return font_path if os.path.exists(font_path) else None
+ if os.path.exists(font_path):
+ return font_path
+ cwd_path = os.path.join(os.getcwd(), font_path)
+ if os.path.exists(cwd_path):
+ return cwd_path
+ project_path = Path(__file__).parent.parent.parent / font_path
+ if project_path.exists():
+ return str(project_path)
+ self.logger.warning("Font not found: %s, using default", self.font_path)
+ return None
+
+ def _font_for(self, size: int):
+ """Return the configured font at ``size``, cached per size."""
+ size = max(4, int(size))
+ cached = self._font_cache.get(size)
+ if cached is not None:
+ return cached
+ font = None
+ if self._resolved_font_path and self._resolved_font_path.lower().endswith('.ttf'):
+ try:
+ font = ImageFont.truetype(self._resolved_font_path, size)
+ except Exception as e:
+ self.logger.error("Failed to load font %s: %s", self._resolved_font_path, e)
+ if font is None:
+ font = ImageFont.load_default()
+ self._font_cache[size] = font
+ return font
+
+ @staticmethod
+ def _measure(text: str, font) -> Tuple[int, int, int]:
+ """Return (width, height, y_offset) for ``text`` in ``font``."""
+ bbox = _MEASURE_DRAW.textbbox((0, 0), text, font=font)
+ return bbox[2] - bbox[0], bbox[3] - bbox[1], bbox[1]
+
+ def _truncate(self, text: str, font, max_w: int) -> str:
+ """Trim ``text`` (with a trailing dot) until it fits ``max_w``."""
+ if max_w <= 0:
+ return ''
+ if self._measure(text, font)[0] <= max_w:
+ return text
+ while text and self._measure(text + '.', font)[0] > max_w:
+ text = text[:-1]
+ return (text + '.') if text else ''
+
+ def _first_fitting(self, options: List[str], font, max_w: int) -> str:
+ """Pick the richest phrasing that fits, rather than truncating the longest.
+
+ On a narrow panel "67% 46s ago" has to give something up; dropping the
+ age reads far better than chopping it to "67.".
+ """
+ for text in options:
+ if self._measure(text, font)[0] <= max_w:
+ return text
+ return self._truncate(options[-1], font, max_w) if options else ''
+
+ # ------------------------------------------------------- payload parsing
+
+ def _extract_field(self, payload: Dict[str, Any], field: str) -> Any:
+ mapped = self.field_mapping.get(field)
+ candidates = []
+ if mapped:
+ candidates.append(mapped)
+ candidates.extend(v for v in _FIELD_VARIANTS.get(field, []) if v not in candidates)
+ for key in candidates:
+ if key in payload:
+ return payload[key]
+ # Last resort: case-insensitive scan
+ lower_map = {k.lower(): k for k in payload.keys()}
+ for key in candidates:
+ actual = lower_map.get(key.lower())
+ if actual is not None:
+ return payload[actual]
+ return None
+
+ @staticmethod
+ def _parse_detection_time(value: Any) -> Optional[float]:
+ """Parse an ISO-8601 detection timestamp into epoch seconds.
+
+ BirdNET-Go's REST feed returns fully-qualified stamps like
+ ``2026-08-05T14:36:54-04:00``. Bare clock times ("14:36:54"), which some
+ MQTT payloads carry, are unparseable here and fall back to arrival time.
+ """
+ if not isinstance(value, str) or not value.strip():
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.astimezone()
+ return parsed.timestamp()
+
+ def _normalize_payload(self, raw: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ # Some BirdNET-Go versions wrap detection under a sub-object.
+ candidate = raw
+ for wrapper in ('detection', 'Detection', 'payload', 'data'):
+ inner = raw.get(wrapper)
+ if isinstance(inner, dict):
+ candidate = inner
+ break
+
+ common = self._extract_field(candidate, 'common_name')
+ sci = self._extract_field(candidate, 'scientific_name')
+ conf = self._extract_field(candidate, 'confidence')
+ ts = self._extract_field(candidate, 'time')
+
+ if common is None and sci is None:
+ self.logger.warning("Detection payload missing common/scientific name: %s",
+ list(candidate.keys())[:10])
+ return None
+
+ try:
+ conf_f = float(conf) if conf is not None else 0.0
+ except (TypeError, ValueError):
+ conf_f = 0.0
+ # Some feeds publish confidence as percent (0-100) rather than 0-1
+ if conf_f > 1.5:
+ conf_f = conf_f / 100.0
+
+ detected_at = self._parse_detection_time(ts)
+ common_str = str(common) if common else str(sci)
+ # Prefer the server's row id so re-polling the same bird is a no-op.
+ raw_id = candidate.get('id') or candidate.get('ID')
+ key = str(raw_id) if raw_id is not None else f"{common_str}|{ts}"
+
+ return {
+ 'common_name': common_str,
+ 'scientific_name': str(sci) if sci else '',
+ 'confidence': conf_f,
+ 'time_str': str(ts) if ts else '',
+ 'received_at': detected_at if detected_at is not None else time.time(),
+ 'key': key,
+ }
+
+ def _accept_detection(self, detection: Dict[str, Any], source: str) -> bool:
+ """Adopt a detection as current. Returns False if it's a duplicate."""
+ if detection['confidence'] < self.min_confidence:
+ self.logger.debug("Dropping low-confidence detection: %s @ %.2f",
+ detection['common_name'], detection['confidence'])
+ return False
+
+ with self.state_lock:
+ if detection.get('key') and detection['key'] == self._last_detection_key:
+ return False
+ self._last_detection_key = detection.get('key')
+ self.last_detection = detection
+ self._pending_image_fetch = (detection['scientific_name']
+ or detection['common_name'])
+ self._scroll_cache = None
+ self._scroll_cache_key = None
+ self._scroll_pos = 0.0
+
+ try:
+ self.cache_manager.set(f'{self.plugin_id}_last_detection', detection, ttl=86400)
+ except Exception as e:
+ self.logger.debug("Cache set failed: %s", e)
+
+ self.logger.info("Detection via %s: %s (%.0f%%)", source,
+ detection['common_name'], detection['confidence'] * 100)
+
+ # Only interrupt for a bird that was just heard. Polling surfaces old
+ # detections at startup, and popping those over the rotation is noise.
+ fresh_window = max(60.0, self.poll_interval * 2)
+ if (self.mode in ('interrupt', 'both')
+ and time.time() - detection['received_at'] <= fresh_window):
+ self._trigger_on_demand(detection)
+ return True
+
+ # ----------------------------------------------------------------- MQTT
+
+ def _on_mqtt_connect(self, client, userdata, flags, rc): # pylint: disable=unused-argument
+ if rc == 0:
+ self.mqtt_connected = True
+ self.mqtt_reconnect_delay = 1.0
+ self.logger.info("Connected to MQTT broker")
+ client.subscribe(self.mqtt_topic, qos=1)
+ self.logger.info("Subscribed to topic: %s", self.mqtt_topic)
+ else:
+ self.mqtt_connected = False
+ self.logger.error("Failed to connect to MQTT broker, rc=%s", rc)
+
+ def _on_mqtt_disconnect(self, client, userdata, rc): # pylint: disable=unused-argument
+ self.mqtt_connected = False
+ if rc != 0:
+ self.logger.warning("Unexpected MQTT disconnection, rc=%s", rc)
+
+ def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-argument
+ try:
+ payload = msg.payload.decode('utf-8', errors='replace')
+ self.logger.debug("MQTT message on %s: %s", msg.topic, payload[:200])
+ try:
+ raw = json.loads(payload)
+ except json.JSONDecodeError as e:
+ self.logger.error("Invalid JSON in MQTT message: %s", e)
+ return
+ if not isinstance(raw, dict):
+ self.logger.warning("Expected JSON object, got %s", type(raw).__name__)
+ return
+
+ detection = self._normalize_payload(raw)
+ if detection is not None:
+ self._accept_detection(detection, 'mqtt')
+ except Exception as e:
+ self.logger.error("Error handling MQTT message: %s", e, exc_info=True)
+
+ 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)
+ 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 _connect_mqtt(self) -> bool:
+ try:
+ # A broker drop only flips mqtt_connected; the old client keeps its
+ # network thread, socket and own reconnect loop. Without this
+ # teardown every drop leaks a thread and can double-deliver.
+ if self.mqtt_client is not None:
+ try:
+ self.mqtt_client.loop_stop()
+ self.mqtt_client.disconnect()
+ except Exception as e:
+ self.logger.debug("Error stopping previous MQTT client: %s", e)
+ self.mqtt_client = None
+ try:
+ self.mqtt_client = mqtt.Client(
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION1,
+ client_id=self.mqtt_client_id,
+ clean_session=True,
+ )
+ except (TypeError, AttributeError):
+ self.mqtt_client = mqtt.Client(client_id=self.mqtt_client_id, clean_session=True)
+
+ self.mqtt_client.on_connect = self._on_mqtt_connect
+ self.mqtt_client.on_disconnect = self._on_mqtt_disconnect
+ self.mqtt_client.on_message = self._on_mqtt_message
+
+ if self.mqtt_username:
+ self.mqtt_client.username_pw_set(self.mqtt_username, self.mqtt_password)
+
+ self.mqtt_client.connect(self.mqtt_host, self.mqtt_port, self.mqtt_keepalive)
+ return True
+ except Exception as e:
+ self.logger.error("Error connecting to MQTT broker: %s", e)
+ return False
+
+ def _mqtt_loop(self) -> None:
+ while not self.mqtt_stop_event.is_set():
+ try:
+ if not self.mqtt_connected:
+ if self._connect_mqtt():
+ self.mqtt_client.loop_start()
+ else:
+ wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay)
+ self.logger.info("Retrying MQTT connection in %.1fs", wait)
+ if self.mqtt_stop_event.wait(wait):
+ break
+ self.mqtt_reconnect_delay *= 2
+ else:
+ if self.mqtt_stop_event.wait(1.0):
+ break
+ self.mqtt_reconnect_delay = 1.0
+ except Exception as e:
+ self.logger.error("Error in MQTT loop: %s", e, exc_info=True)
+ self.mqtt_connected = False
+ if self.mqtt_client:
+ try:
+ self.mqtt_client.loop_stop()
+ self.mqtt_client.disconnect()
+ except Exception as stop_err:
+ self.logger.debug("Error stopping MQTT client: %s", stop_err)
+ self.mqtt_client = None
+ wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay)
+ if self.mqtt_stop_event.wait(wait):
+ break
+ self.mqtt_reconnect_delay *= 2
+
+ if self.mqtt_client:
+ try:
+ self.mqtt_client.loop_stop()
+ self.mqtt_client.disconnect()
+ except Exception as e:
+ self.logger.debug("Error stopping MQTT client on exit: %s", e)
+ self.mqtt_client = None
+ self.logger.info("MQTT loop thread stopped")
+
+ # ------------------------------------------------------------ REST feed
+
+ def _api_get(self, path: str) -> Optional[Any]:
+ if not self.api_base_url:
+ return None
+ url = f"{self.api_base_url}{path}"
+ try:
+ resp = requests.get(url, timeout=self.api_timeout)
+ if resp.status_code != 200:
+ self.logger.warning("BirdNET-Go HTTP %s for %s", resp.status_code, path)
+ return None
+ return resp.json()
+ except Exception as e:
+ self.logger.warning("Error calling BirdNET-Go %s: %s", path, e)
+ return None
+
+ @staticmethod
+ def _parse_heard_time(value: Any) -> Optional[float]:
+ """Parse the analytics feed's `latest_heard`, a bare clock time for today."""
+ parsed = BirdNetGoPlugin._parse_detection_time(value)
+ if parsed is not None:
+ return parsed
+ if not isinstance(value, str) or not value.strip():
+ return None
+ try:
+ clock = datetime.strptime(value.strip(), '%H:%M:%S').time()
+ except ValueError:
+ return None
+ return datetime.combine(datetime.now().date(), clock).timestamp()
+
+ def _poll_latest_detection(self) -> None:
+ """Adopt the newest detection worth showing.
+
+ The species cycle is built from the daily analytics feed instead of this
+ one: /detections/recent caps at ten rows, and on a busy feed those ten
+ are often a single loud species.
+ """
+ data = self._api_get('/api/v2/detections/recent?numResults=10')
+ if isinstance(data, dict):
+ data = data.get('data')
+ if not isinstance(data, list):
+ return
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ detection = self._normalize_payload(item)
+ if detection and detection['confidence'] >= self.min_confidence:
+ self._accept_detection(detection, 'api')
+ return
+
+ def _poll_daily_stats(self) -> None:
+ """Refresh today's per-species counts and rebuild the species cycle.
+
+ This endpoint is already one row per species, which makes it both the
+ stats source and a far better cycle source than the detection stream.
+ """
+ data = self._api_get('/api/v2/analytics/species/daily')
+ if isinstance(data, dict):
+ data = data.get('data')
+ if not isinstance(data, list):
+ return
+ rows: List[Dict[str, Any]] = []
+ cycle: List[Dict[str, Any]] = []
+ for entry in data:
+ if not isinstance(entry, dict):
+ continue
+ try:
+ count = int(entry.get('count', 0))
+ except (TypeError, ValueError):
+ count = 0
+ common = str(entry.get('common_name') or entry.get('commonName')
+ or entry.get('scientific_name') or '?')
+ rows.append({'name': common, 'count': count})
+
+ try:
+ confidence = float(entry.get('max_confidence', 0) or 0)
+ except (TypeError, ValueError):
+ confidence = 0.0
+ if confidence > 1.5:
+ confidence /= 100.0
+ if confidence < self.min_confidence:
+ continue
+ heard_at = self._parse_heard_time(entry.get('latest_heard'))
+ cycle.append({
+ 'common_name': common,
+ 'scientific_name': str(entry.get('scientific_name') or ''),
+ 'confidence': confidence,
+ 'time_str': str(entry.get('latest_heard') or ''),
+ 'received_at': heard_at if heard_at is not None else time.time(),
+ 'count': count,
+ 'key': common,
+ })
+
+ rows.sort(key=lambda r: r['count'], reverse=True)
+ if self.species_order == 'frequency':
+ cycle.sort(key=lambda c: c['count'], reverse=True)
+ else:
+ cycle.sort(key=lambda c: c['received_at'], reverse=True)
+
+ stats = {
+ 'species_today': len(rows),
+ 'detections_today': sum(r['count'] for r in rows),
+ 'top': rows[:max(1, self.stats_top_n)],
+ # Every species, not just the top slice — the detection card looks
+ # up its own count here regardless of where it ranks.
+ 'counts': {r['name']: r['count'] for r in rows},
+ 'fetched_at': time.time(),
+ }
+ with self.state_lock:
+ self.daily_stats = stats
+ self._recent_species = cycle[:self.max_species]
+ try:
+ self.cache_manager.set(f'{self.plugin_id}_daily_stats', stats, ttl=6 * 3600)
+ except Exception as e:
+ self.logger.debug("Stats cache set failed: %s", e)
+ self.logger.info("Daily stats: %d species, %d detections; cycling %d by %s",
+ stats['species_today'], stats['detections_today'],
+ len(cycle[:self.max_species]), self.species_order)
+
+ # ------------------------------------------------------------- images
+
+ def _matrix_dims(self) -> Tuple[int, int]:
+ width = getattr(self.display_manager, 'width', None)
+ height = getattr(self.display_manager, 'height', None)
+ if isinstance(width, int) and isinstance(height, int) and width > 0 and height > 0:
+ return width, height
+ matrix = getattr(self.display_manager, 'matrix', None)
+ if matrix is not None:
+ return matrix.width, matrix.height
+ image = getattr(self.display_manager, 'image', None)
+ if image is not None:
+ return image.width, image.height
+ return 128, 32
+
+ def _fetch_species_image(self, species: str) -> Optional[Image.Image]:
+ if not self.api_base_url or not species:
+ return None
+ if species in self._species_img_failed:
+ return None
+
+ # Disk cache via cache_manager (base64 of PNG bytes)
+ cache_key = f'{self.plugin_id}_img_{species}'
+ try:
+ cached_b64 = self.cache_manager.get(cache_key, max_age=30 * 86400)
+ if cached_b64:
+ img = Image.open(BytesIO(base64.b64decode(cached_b64)))
+ img.load()
+ return img
+ except Exception as e:
+ self.logger.debug("Image cache read failed: %s", e)
+
+ url = f"{self.api_base_url}/api/v2/media/species-image?name={quote(species)}"
+ try:
+ self.logger.info("Fetching species image: %s", url)
+ resp = requests.get(url, timeout=self.api_timeout)
+ if resp.status_code != 200:
+ self.logger.warning("Species image HTTP %s for %s", resp.status_code, species)
+ self._species_img_failed.add(species)
+ return None
+ img = Image.open(BytesIO(resp.content))
+ img.load()
+ try:
+ buf = BytesIO()
+ img.convert('RGB').save(buf, format='PNG')
+ self.cache_manager.set(cache_key,
+ base64.b64encode(buf.getvalue()).decode('ascii'),
+ ttl=30 * 86400)
+ except Exception as e:
+ self.logger.debug("Image cache write failed: %s", e)
+ return img
+ except Exception as e:
+ self.logger.warning("Error fetching species image for %s: %s", species, e)
+ self._species_img_failed.add(species)
+ return None
+
+ def _resize_image(self, img: Image.Image, box_w: int, box_h: int) -> Image.Image:
+ scale = min(box_w / img.width, box_h / img.height)
+ new_w = max(1, int(img.width * scale))
+ new_h = max(1, int(img.height * scale))
+ resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
+ frame = Image.new('RGB', (box_w, box_h), self.bg_color)
+ x = (box_w - new_w) // 2
+ y = (box_h - new_h) // 2
+ if resized.mode == 'RGBA':
+ frame.paste(resized, (x, y), resized)
+ else:
+ frame.paste(resized.convert('RGB'), (x, y))
+ return frame
+
+ def _cache_source_image(self, species: str, img: Image.Image) -> None:
+ self._species_img_cache[species] = img
+ self._species_img_cache.move_to_end(species)
+ while len(self._species_img_cache) > _MAX_SOURCE_IMAGES:
+ self._species_img_cache.popitem(last=False)
+
+ def _panel_image(self, species: str, img: Image.Image,
+ box_w: int, box_h: int) -> Image.Image:
+ """Panel-sized frame for a species, resized once instead of per frame.
+
+ Keyed by size as well as species: the core can hand a plugin a smaller
+ logical screen, and a frame cached at the previous size would paste wrong.
+ """
+ key = (species, box_w, box_h)
+ cached = self._panel_img_cache.get(key)
+ if cached is not None:
+ self._panel_img_cache.move_to_end(key)
+ return cached
+ cached = self._resize_image(img, box_w, box_h)
+ self._panel_img_cache[key] = cached
+ while len(self._panel_img_cache) > _MAX_PANEL_IMAGES:
+ self._panel_img_cache.popitem(last=False)
+ return cached
+
+ # ----------------------------------------------------------- rendering
+
+ def _format_age(self, received_at: float) -> str:
+ age = max(0, int(time.time() - received_at))
+ if age < 60:
+ return f"{age}s ago"
+ if age < 3600:
+ return f"{age // 60}m ago"
+ if age < 86400:
+ return f"{age // 3600}h ago"
+ return f"{age // 86400}d ago"
+
+ def _frame_dt(self) -> float:
+ now = time.time()
+ dt = min(_MAX_FRAME_DT, max(0.0, now - self._last_frame_time))
+ self._last_frame_time = now
+ return dt
+
+ @staticmethod
+ def _dim(color: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]:
+ return tuple(max(0, min(255, int(c * factor))) for c in color)
+
+ def _build_scroll_cache(self, text: str, height: int, font,
+ color: Tuple[int, int, int]) -> Image.Image:
+ text_w, text_h, y_off = self._measure(text, font)
+ cache = Image.new('RGB', (text_w + self.scroll_gap_width, height), self.bg_color)
+ draw = ImageDraw.Draw(cache)
+ draw.text((0, (height - text_h) // 2 - y_off), text, font=font, fill=color)
+ return cache
+
+ def _render_text_line(self, text: str, box_w: int, box_h: int,
+ dt: float, allow_scroll: bool, font=None,
+ align: str = 'center',
+ color: Optional[Tuple[int, int, int]] = None) -> Image.Image:
+ font = font or self.font
+ color = color or self.text_color
+ frame = Image.new('RGB', (box_w, box_h), self.bg_color)
+ draw = ImageDraw.Draw(frame)
+ text_w, text_h, y_off = self._measure(text, font)
+
+ if text_w <= box_w or not allow_scroll:
+ x = 0 if align == 'left' else max(0, (box_w - text_w) // 2)
+ draw.text((x, (box_h - text_h) // 2 - y_off), text, font=font, fill=color)
+ return frame
+
+ # Scroll. The cache is keyed by everything that changes its pixels, so a
+ # size or species change rebuilds it instead of scrolling stale content.
+ key = (text, box_h, getattr(font, 'size', 0), color)
+ if self._scroll_cache is None or self._scroll_cache_key != key:
+ self._scroll_cache = self._build_scroll_cache(text, box_h, font, color)
+ self._scroll_cache_key = key
+ self._scroll_pos = 0.0
+
+ self._scroll_pos = (self._scroll_pos + dt * self.scroll_speed) % self._scroll_cache.width
+ pos = int(self._scroll_pos)
+ cache_w = self._scroll_cache.width
+ if pos + box_w <= cache_w:
+ frame.paste(self._scroll_cache.crop((pos, 0, pos + box_w, box_h)), (0, 0))
+ else:
+ first_w = cache_w - pos
+ frame.paste(self._scroll_cache.crop((pos, 0, cache_w, box_h)), (0, 0))
+ frame.paste(self._scroll_cache.crop((0, 0, box_w - first_w, box_h)), (first_w, 0))
+ return frame
+
+ def _get_current_detection(self) -> Optional[Dict[str, Any]]:
+ with self.state_lock:
+ if self.last_detection:
+ return dict(self.last_detection)
+ try:
+ cached = self.cache_manager.get(f'{self.plugin_id}_last_detection', max_age=86400)
+ if cached:
+ return cached
+ except Exception as e:
+ self.logger.debug("Detection cache read failed: %s", e)
+ return None
+
+ def _get_daily_stats(self) -> Optional[Dict[str, Any]]:
+ with self.state_lock:
+ if self.daily_stats:
+ return dict(self.daily_stats)
+ try:
+ cached = self.cache_manager.get(f'{self.plugin_id}_daily_stats', max_age=6 * 3600)
+ if cached:
+ return cached
+ except Exception as e:
+ self.logger.debug("Stats cache read failed: %s", e)
+ return None
+
+ def _today_count(self, common_name: str) -> Optional[int]:
+ """How many times this species has been heard today, if stats are loaded."""
+ if not common_name:
+ return None
+ stats = self._get_daily_stats()
+ if not stats:
+ return None
+ return (stats.get('counts') or {}).get(common_name)
+
+ def _cycle_detection(self, force_clear: bool) -> Optional[Dict[str, Any]]:
+ """Pick this rotation slot's species, advancing one species per slot.
+
+ force_clear marks a fresh slot; the elapsed-time check is the fallback
+ for cores that don't set it, so the cycle can't stall on one bird.
+ """
+ with self.state_lock:
+ species_list = list(self._recent_species)
+ if not species_list:
+ return None
+
+ now = time.time()
+ slot = max(3.0, self.rotation_duration)
+ if self._cycle_started == 0.0:
+ self._cycle_started = now
+ elif force_clear or (now - self._cycle_started) >= slot:
+ self._cycle_index += 1
+ self._cycle_started = now
+
+ detection = species_list[self._cycle_index % len(species_list)]
+ key = detection['scientific_name'] or detection['common_name']
+ if key != self._cycle_key:
+ # New card — restart the name scroll rather than resuming mid-word.
+ self._cycle_key = key
+ self._scroll_cache = None
+ self._scroll_cache_key = None
+ self._scroll_pos = 0.0
+ return detection
+
+ def _render_centered(self, text: str, w: int, h: int) -> Image.Image:
+ img = Image.new('RGB', (w, h), self.bg_color)
+ draw = ImageDraw.Draw(img)
+ font = self._font_for(min(self.font_size, max(5, h // 3)))
+ text = self._truncate(text, font, w - 2)
+ tw, th, y_off = self._measure(text, font)
+ draw.text(((w - tw) // 2, (h - th) // 2 - y_off), text, font=font, fill=self.text_color)
+ return img
+
+ def _render_detection(self, det: Dict[str, Any], w: int, h: int) -> Image.Image:
+ dt = self._frame_dt()
+ frame = Image.new('RGB', (w, h), self.bg_color)
+
+ # Image on the left side
+ img_box_w = 0
+ if self.show_image:
+ species = det.get('scientific_name') or det.get('common_name')
+ pil_img = self._species_img_cache.get(species)
+ if pil_img is not None:
+ box = min(h, w // 3)
+ img_box_w = box
+ frame.paste(self._panel_image(species, pil_img, box, h), (0, 0))
+
+ pad = 2 if img_box_w else 1
+ text_x = img_box_w + pad
+ text_w = w - text_x - pad
+ if text_w < 8:
+ text_x, text_w = 0, w
+ # Centering reads well only when the text nearly fills the panel; beside
+ # a photo on a wide panel it strands the name in the middle.
+ align = 'left' if img_box_w else 'center'
+
+ name_line = det.get('common_name', '?')
+ sci_line = det.get('scientific_name') or ''
+ conf_str = f"{int(det.get('confidence', 0) * 100)}%" if self.show_confidence else ''
+ age_str = (self._format_age(det['received_at'])
+ if self.show_time and det.get('received_at') else '')
+ count_str = ''
+ if self.show_today_count:
+ # Cycle cards carry their own count; interrupt cards look theirs up.
+ count = det.get('count') or self._today_count(det.get('common_name', ''))
+ if count:
+ count_str = f"x{count}"
+
+ # Richest phrasing first; _first_fitting drops detail as width runs out.
+ meta_options: List[str] = []
+ for combo in ((conf_str, age_str, count_str), (conf_str, count_str),
+ (conf_str, age_str), (conf_str,), (age_str,)):
+ candidate = " ".join(part for part in combo if part)
+ if candidate and candidate not in meta_options:
+ meta_options.append(candidate)
+ meta_line = meta_options[0] if meta_options else ''
+
+ # Tall panels earn a third line for the scientific name. Short ones drop
+ # to name-only rather than squeezing in two unreadable rows.
+ if h >= 48 and sci_line and meta_line:
+ rows = [('name', 0.42, 22), ('sci', 0.28, 13), ('meta', 0.30, 16)]
+ elif h >= 24 and meta_line:
+ rows = [('name', 0.55, 18), ('meta', 0.45, 14)]
+ else:
+ rows = [('name', 1.0, 18)]
+
+ heights = [max(6, int(h * frac)) for _, frac, _ in rows]
+ heights[-1] = max(6, h - sum(heights[:-1]))
+
+ y = 0
+ for (kind, _, size_cap), row_h in zip(rows, heights):
+ row_h = min(row_h, h - y)
+ if row_h <= 0:
+ break
+ font = self._font_for(max(5, min(int(row_h * 0.78), size_cap)))
+ if kind == 'name':
+ text, color, scroll = name_line, self.text_color, True
+ elif kind == 'sci':
+ text = self._truncate(sci_line, font, text_w)
+ color, scroll = self._dim(self.text_color, 0.55), False
+ else:
+ text = self._first_fitting(meta_options, font, text_w)
+ color, scroll = self.accent_color, False
+ frame.paste(
+ self._render_text_line(text, text_w, row_h, dt, allow_scroll=scroll,
+ font=font, align=align, color=color),
+ (text_x, y))
+ y += row_h
+
+ return frame
+
+ def _stats_layout(self, h: int) -> Tuple[int, int, Any]:
+ """Pick a row count, row height and font that fill the panel height."""
+ if h >= 56:
+ rows = 5
+ elif h >= 40:
+ rows = 4
+ elif h >= 26:
+ rows = 3
+ else:
+ rows = 2
+ line_h = max(6, h // rows)
+ font = self._font_for(max(5, min(int(line_h * 0.75), 16)))
+ return rows, line_h, font
+
+ def _render_stats(self, stats: Dict[str, Any], w: int, h: int) -> Image.Image:
+ frame = Image.new('RGB', (w, h), self.bg_color)
+ draw = ImageDraw.Draw(frame)
+ rows, line_h, font = self._stats_layout(h)
+ pad = 1 if w < 96 else 2
+
+ # Header: species/detection totals, abbreviated when the panel is narrow.
+ species = stats.get('species_today', 0)
+ detections = stats.get('detections_today', 0)
+ header = f"TODAY {species} SPECIES {detections} CALLS"
+ if self._measure(header, font)[0] > w - pad * 2:
+ header = f"{species} SP {detections} CALLS"
+ if self._measure(header, font)[0] > w - pad * 2:
+ header = f"{species}sp {detections}"
+ header = self._truncate(header, font, w - pad * 2)
+ hw, hh, h_off = self._measure(header, font)
+ draw.text((max(pad, (w - hw) // 2), (line_h - hh) // 2 - h_off),
+ header, font=font, fill=self.accent_color)
+
+ # Body: most-heard species, count right-aligned.
+ top = stats.get('top') or []
+ for idx, entry in enumerate(top[:rows - 1]):
+ y = line_h * (idx + 1)
+ count_str = str(entry.get('count', 0))
+ cw, ch, c_off = self._measure(count_str, font)
+ name = self._truncate(str(entry.get('name', '?')), font,
+ w - cw - pad * 3)
+ _, nh, n_off = self._measure(name, font)
+ draw.text((pad, y + (line_h - nh) // 2 - n_off), name,
+ font=font, fill=self.text_color)
+ draw.text((w - pad - cw, y + (line_h - ch) // 2 - c_off), count_str,
+ font=font, fill=self.accent_color)
+
+ return frame
+
+ # ------------------------------------------------------- Plugin API
+
+ def update(self) -> None:
+ now = time.time()
+
+ # Poll the REST feed. MQTT, when enabled, pushes detections faster, but
+ # polling still runs so a broker outage can't freeze the display.
+ if self.api_base_url and now - self._last_poll >= self.poll_interval:
+ self._last_poll = now
+ try:
+ self._poll_latest_detection()
+ except Exception as e:
+ self.logger.error("Error polling detections: %s", e, exc_info=True)
+ # Also drives the species cycle, so it runs even with stats off.
+ if self.stats_enabled or self.unique_species:
+ try:
+ self._poll_daily_stats()
+ except Exception as e:
+ self.logger.error("Error polling stats: %s", e, exc_info=True)
+
+ if not self.show_image:
+ return
+
+ # Warm up images off the MQTT callback thread. Every species in the
+ # cycle needs one, not just the newest, or most cards render text-only.
+ wanted: List[str] = []
+ with self.state_lock:
+ if self._pending_image_fetch:
+ wanted.append(self._pending_image_fetch)
+ self._pending_image_fetch = None
+ wanted.extend(d['scientific_name'] or d['common_name']
+ for d in self._recent_species)
+
+ # Cap both the count and the wall-clock. The schema allows a 30s
+ # timeout, so three sequential fetches on top of two polls could
+ # otherwise block update() for minutes against a host that accepts
+ # connections but never answers. Whatever is missed resumes next tick.
+ fetched = 0
+ deadline = time.time() + max(2.0, self.api_timeout * 1.5)
+ for species in wanted:
+ if (not species or species in self._species_img_cache
+ or species in self._species_img_failed):
+ continue
+ if time.time() >= deadline:
+ self.logger.debug("Image warm-up budget spent; resuming next update")
+ break
+ img = self._fetch_species_image(species)
+ if img is not None:
+ self._cache_source_image(species, img)
+ fetched += 1
+ if fetched >= 3:
+ break
+
+ def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
+ w, h = self._matrix_dims()
+ mode = (display_mode or DETECTION_MODE).strip().lower()
+
+ if mode == STATS_MODE:
+ self._last_rendered_mode = STATS_MODE
+ if not self.stats_enabled:
+ return False
+ stats = self._get_daily_stats()
+ frame = (self._render_stats(stats, w, h) if stats
+ else self._render_centered("No bird stats", w, h))
+ else:
+ self._last_rendered_mode = DETECTION_MODE
+
+ # In pure interrupt mode the rotation slot stays empty.
+ interrupting = time.time() <= self._on_demand_until
+ if self.mode == 'interrupt' and not interrupting:
+ return False
+
+ # An interrupt is about the bird that just called, so it shows the
+ # newest detection rather than wherever the cycle happens to be.
+ det = None
+ if self.unique_species and not interrupting:
+ det = self._cycle_detection(force_clear)
+ if det is None:
+ det = self._get_current_detection()
+
+ if det is None or (self.stale_after_s > 0
+ and time.time() - det.get('received_at', 0) > self.stale_after_s
+ and self.mode != 'interrupt'):
+ frame = self._render_centered("No recent birds", w, h)
+ else:
+ frame = self._render_detection(det, w, h)
+
+ try:
+ self.display_manager.image = frame
+ self.display_manager.update_display()
+ return True
+ except Exception as e:
+ self.logger.error("Error updating display: %s", e, exc_info=True)
+ return False
+
+ def get_display_duration(self, display_mode: Optional[str] = None) -> float:
+ mode = (display_mode or self._last_rendered_mode or DETECTION_MODE).strip().lower()
+ if mode == STATS_MODE:
+ return self.stats_duration
+ if time.time() <= self._on_demand_until:
+ return self.interrupt_duration
+ return self.rotation_duration
+
+ def validate_config(self) -> bool:
+ if not super().validate_config():
+ return False
+ # Either data source is enough; the REST API alone is the common case.
+ if not self.api_base_url and not (self.mqtt_enabled and self.mqtt_host):
+ self.logger.error(
+ "Configure birdnet_api.base_url, or enable mqtt with a host — "
+ "the plugin has no way to reach BirdNET-Go otherwise")
+ return False
+ if self.mqtt_enabled and not self.mqtt_host:
+ self.logger.error("mqtt.enabled is set but mqtt.host is empty")
+ return False
+ if self.mode not in ('rotation', 'interrupt', 'both'):
+ self.logger.error("Invalid display.mode: %s", self.mode)
+ return False
+ if self.species_order not in ('recent', 'frequency'):
+ self.logger.error("Invalid display.species_order: %s", self.species_order)
+ return False
+ if self.poll_interval < 5:
+ self.logger.error("birdnet_api.poll_interval must be at least 5 seconds")
+ return False
+ for name, val in (("text_color", self.text_color),
+ ("background_color", self.bg_color),
+ ("accent_color", self.accent_color)):
+ if not isinstance(val, tuple) or len(val) != 3 or not all(0 <= c <= 255 for c in val):
+ self.logger.error("Invalid %s", name)
+ return False
+ return True
+
+ def on_enable(self) -> None:
+ super().on_enable()
+ if not self.mqtt_enabled:
+ self.logger.info("MQTT disabled; using REST polling every %.0fs", self.poll_interval)
+ return
+ if self.mqtt_thread is None or not self.mqtt_thread.is_alive():
+ self.mqtt_stop_event.clear()
+ self.mqtt_thread = threading.Thread(target=self._mqtt_loop, daemon=True)
+ self.mqtt_thread.start()
+ self.logger.info("MQTT client thread started")
+
+ def on_disable(self) -> None:
+ super().on_disable()
+ if self.mqtt_thread and self.mqtt_thread.is_alive():
+ self.mqtt_stop_event.set()
+ if self.mqtt_client:
+ try:
+ self.mqtt_client.loop_stop()
+ self.mqtt_client.disconnect()
+ except Exception as e:
+ self.logger.debug("Error stopping MQTT client on disable: %s", e)
+ self.mqtt_thread.join(timeout=5.0)
+ self.logger.info("MQTT client thread stopped")
+
+ def cleanup(self) -> None:
+ self.on_disable()
+ self._species_img_cache.clear()
+ self._panel_img_cache.clear()
+ self._species_img_failed.clear()
+ self._scroll_cache = None
+
+ def get_info(self) -> Dict[str, Any]:
+ info = super().get_info()
+ det = self._get_current_detection()
+ stats = self._get_daily_stats()
+ info.update({
+ 'api_base_url': self.api_base_url,
+ 'poll_interval': self.poll_interval,
+ 'mqtt_enabled': self.mqtt_enabled,
+ 'mqtt_connected': self.mqtt_connected,
+ 'mqtt_host': self.mqtt_host,
+ 'mqtt_port': self.mqtt_port,
+ 'mqtt_topic': self.mqtt_topic,
+ 'mode': self.mode,
+ 'last_species': det.get('common_name') if det else None,
+ 'last_confidence': det.get('confidence') if det else None,
+ 'last_received_at': det.get('received_at') if det else None,
+ 'species_today': stats.get('species_today') if stats else None,
+ 'detections_today': stats.get('detections_today') if stats else None,
+ 'unique_species': self.unique_species,
+ 'species_order': self.species_order,
+ 'species_cycle': [d['common_name'] for d in self._recent_species],
+ })
+ return info
diff --git a/plugins/birdnet-go/manifest.json b/plugins/birdnet-go/manifest.json
new file mode 100644
index 00000000..21b8c8ec
--- /dev/null
+++ b/plugins/birdnet-go/manifest.json
@@ -0,0 +1,53 @@
+{
+ "id": "birdnet-go",
+ "name": "BirdNET-Go",
+ "version": "1.2.0",
+ "author": "ChuckBuilds",
+ "description": "Show what BirdNET-Go is hearing. One screen cycles a different species each turn — name, confidence, how many times it has been heard today, and a photo — 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",
+ "tags": [
+ "birdnet",
+ "birds",
+ "nature",
+ "wildlife",
+ "stats",
+ "mqtt",
+ "homeassistant"
+ ],
+ "display_modes": [
+ "birdnet_go",
+ "birdnet_stats"
+ ],
+ "entry_point": "manager.py",
+ "class_name": "BirdNetGoPlugin",
+ "versions": [
+ {
+ "released": "2026-08-05",
+ "version": "1.2.0",
+ "ledmatrix_min_version": "2.0.0"
+ },
+ {
+ "released": "2026-08-05",
+ "version": "1.1.0",
+ "ledmatrix_min_version": "2.0.0"
+ },
+ {
+ "released": "2026-04-24",
+ "version": "1.0.1",
+ "ledmatrix_min_version": "2.0.0"
+ },
+ {
+ "released": "2026-04-24",
+ "version": "1.0.0",
+ "ledmatrix_min_version": "2.0.0"
+ }
+ ],
+ "last_updated": "2026-08-05",
+ "stars": 0,
+ "downloads": 0,
+ "verified": true,
+ "screenshot": "",
+ "compatible_versions": [
+ ">=2.0.0"
+ ]
+}
diff --git a/plugins/birdnet-go/requirements.txt b/plugins/birdnet-go/requirements.txt
new file mode 100644
index 00000000..dbc08cfc
--- /dev/null
+++ b/plugins/birdnet-go/requirements.txt
@@ -0,0 +1,3 @@
+paho-mqtt>=1.6.1
+requests>=2.33.0
+Pillow>=12.3.0