diff --git a/README.md b/README.md index 9fae8532..ad071555 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \ | Plugin | Description | Preview | |--------|-------------|---------| | [MQTT Notifications](./plugins/mqtt-notifications/) | HomeAssistant notifications via MQTT | | -| [On Air Light](./plugins/on-air/) | Broadcast ON AIR tally light, triggered remotely via MQTT / Home Assistant | | +| [On Air Light](./plugins/on-air/) | Broadcast ON AIR tally light, triggered remotely via MQTT / Home Assistant | on-air on an LED panel | ### Custom (3) diff --git a/docs/assets/on-air/color-vs-bg.png b/docs/assets/on-air/color-vs-bg.png new file mode 100644 index 00000000..7677eb79 Binary files /dev/null and b/docs/assets/on-air/color-vs-bg.png differ diff --git a/docs/assets/on-air/hero.png b/docs/assets/on-air/hero.png new file mode 100644 index 00000000..7221a309 Binary files /dev/null and b/docs/assets/on-air/hero.png differ diff --git a/docs/assets/on-air/on-off.png b/docs/assets/on-air/on-off.png new file mode 100644 index 00000000..b4f1cd83 Binary files /dev/null and b/docs/assets/on-air/on-off.png differ diff --git a/docs/assets/on-air/panel-sizes.png b/docs/assets/on-air/panel-sizes.png new file mode 100644 index 00000000..e14a01f6 Binary files /dev/null and b/docs/assets/on-air/panel-sizes.png differ diff --git a/docs/assets/on-air/presets.png b/docs/assets/on-air/presets.png new file mode 100644 index 00000000..559ac06f Binary files /dev/null and b/docs/assets/on-air/presets.png differ diff --git a/docs/assets/on-air/shots.json b/docs/assets/on-air/shots.json new file mode 100644 index 00000000..a5b6fc55 --- /dev/null +++ b/docs/assets/on-air/shots.json @@ -0,0 +1,78 @@ +{ + "plugin": "on-air", + "defaults": { + "width": 128, + "height": 32, + "scale": 6, + "config": { + "enabled": true, + "mqtt_host": "127.0.0.1", + "mqtt_port": 1 + }, + "attrs": { + "on_air": true + } + }, + "shots": [ + { "name": "hero" }, + { "name": "off", "attrs": { "on_air": false }, "standalone": false }, + { "name": "on", "standalone": false }, + + { "name": "size-64x32", "width": 64, "standalone": false }, + { "name": "size-128x32", "width": 128, "standalone": false }, + { "name": "size-128x64", "width": 128, "height": 64, "standalone": false }, + { "name": "size-256x32", "width": 256, "standalone": false }, + + { "name": "preset-onair", "standalone": false, + "attrs": { "on_air": true, "label": "ON AIR", "active_bg_color": [200, 10, 10] } }, + { "name": "preset-meeting", "standalone": false, + "attrs": { "on_air": true, "label": "IN MEETING", "active_bg_color": [255, 140, 0] } }, + { "name": "preset-live", "standalone": false, + "attrs": { "on_air": true, "label": "LIVE", "active_bg_color": [255, 0, 80] } }, + { "name": "preset-dnd", "standalone": false, + "attrs": { "on_air": true, "label": "DO NOT DISTURB", "active_bg_color": [180, 0, 180] } }, + + { "name": "color-only", "standalone": false, + "attrs": { "on_air": true, "label": "IN MEETING", "active_text_color": [255, 140, 0] } }, + { "name": "bg-only", "standalone": false, + "attrs": { "on_air": true, "label": "IN MEETING", "active_bg_color": [255, 140, 0] } } + ], + "composites": [ + { + "name": "panel-sizes", + "columns": 2, + "cells": [ + {"shot": "size-64x32", "label": "64 x 32"}, + {"shot": "size-128x32", "label": "128 x 32"}, + {"shot": "size-128x64", "label": "128 x 64"}, + {"shot": "size-256x32", "label": "256 x 32"} + ] + }, + { + "name": "presets", + "columns": 2, + "cells": [ + {"shot": "preset-onair", "label": "broadcast red", "sublabel": "bg [200, 10, 10]"}, + {"shot": "preset-meeting", "label": "amber", "sublabel": "bg [255, 140, 0]"}, + {"shot": "preset-live", "label": "hot pink", "sublabel": "bg [255, 0, 80]"}, + {"shot": "preset-dnd", "label": "purple", "sublabel": "bg [180, 0, 180]"} + ] + }, + { + "name": "color-vs-bg", + "columns": 1, + "cells": [ + {"shot": "color-only", "label": "\"color\": [255, 140, 0]", "sublabel": "sets the text only"}, + {"shot": "bg-only", "label": "\"bg\": [255, 140, 0]", "sublabel": "sets the sign"} + ] + }, + { + "name": "on-off", + "columns": 2, + "cells": [ + {"shot": "on", "label": "ON", "sublabel": "holds the display"}, + {"shot": "off", "label": "OFF", "sublabel": "a black frame, cycled past"} + ] + } + ] +} diff --git a/plugins.json b/plugins.json index f900d57c..7505947e 100644 --- a/plugins.json +++ b/plugins.json @@ -578,10 +578,10 @@ "plugin_path": "plugins/on-air", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.5", + "latest_version": "1.2.6", "icon": "fa-circle-dot" }, { diff --git a/plugins/on-air/README.md b/plugins/on-air/README.md index 2e9d4726..ccb64d68 100644 --- a/plugins/on-air/README.md +++ b/plugins/on-air/README.md @@ -2,6 +2,12 @@ A retro broadcast tally light for your LED matrix. Publish a single MQTT message to take over the display with a bold "ON AIR" sign — it holds until you send the off command, so it works as a persistent do-not-disturb signal during calls, recordings, or livestreams. Text, text color, and background color are all customizable. +![The ON AIR sign on a 128x32 panel: white PressStart2P lettering filling a +broadcast-red field](../../docs/assets/on-air/hero.png) + +*Every image in this README is real plugin output, rendered at the true panel +size and scaled up so the pixels stay pixels.* + --- ## Table of Contents @@ -49,23 +55,58 @@ mosquitto_pub -h -t ledmatrix/on-air/set -m OFF ## Plugin Configuration -| Field | Default | Description | -|---|---|---| -| **Sign Text** | `ON AIR` | Text shown when activated (max 32 chars). Overridable per-message via JSON. | -| **Text Color** | `[255, 255, 255]` | RGB color of the sign text. | -| **Background Color** | `[200, 10, 10]` | RGB background color — broadcast red by default. | -| **Font** | *(blank)* | Path to a TTF font relative to the LEDMatrix root (e.g. `assets/fonts/PressStart2P-Regular.ttf`). Blank uses the default font, auto-sized to 80% of display height. | -| **Font Size (px)** | `0` | Font height in pixels when a custom Font is set. `0` auto-sizes to 80% of display height. | -| **MQTT Broker Host** | `localhost` | IP or hostname of your MQTT broker. | -| **MQTT Port** | `1883` | Broker port (use 8883 for TLS). | -| **MQTT Username** | *(blank)* | Leave blank if no auth required. | -| **MQTT Password** | *(blank)* | Leave blank if no auth required. | -| **Command Topic** | `ledmatrix/on-air/set` | Topic the plugin **subscribes** to (publish `ON`/`OFF` or JSON). | -| **State Topic** | `ledmatrix/on-air/state` | Topic the plugin **publishes** to after each state change. | -| **Enable Home Assistant Auto-Discovery** | `true` | Announce to HA via MQTT discovery so it auto-creates the device (switch, label sensor, connectivity) — no `configuration.yaml` needed. | -| **HA Discovery Prefix** | `homeassistant` | MQTT topic prefix your HA MQTT integration listens on. | -| **Device Name in Home Assistant** | `LED Matrix — On Air` | How the device appears under Settings → Devices & Services → MQTT. | -| **Display Duration (seconds)** | `5` | How long the sign stays on screen each rotation cycle while active (1–60). | +Settings are edited in the plugin's tab in the LEDMatrix web UI. **Field** is the +label shown there; **Key** is the same setting as it appears in +`config/config.json` under `on-air`, which is what you need when editing the file +directly. The full schema is [`config_schema.json`](config_schema.json). + +| Field | Key | Default | Description | +|---|---|---|---| +| *(header toggle)* | `enabled` | `true` | Master switch, shown as the toggle in the tab header rather than a field in the form. | +| **Sign Text** | `default_label` | `ON AIR` | Text shown when activated (up to 32 characters). Overridable per-message via JSON. | +| **Text Color** | `text_color` | `[255, 255, 255]` | RGB color of the sign text. | +| **Background Color** | `background_color` | `[200, 10, 10]` | RGB background color — broadcast red by default. | +| **Font** | `font_path` | *(blank)* | Path to a TTF font relative to the LEDMatrix root (e.g. `assets/fonts/PressStart2P-Regular.ttf`). Blank auto-selects one from the LEDMatrix assets folder, sized to 80% of display height. | +| **Font Size (px)** | `font_size` | `0` | Font height in pixels when a custom Font is set. `0` auto-sizes to 80% of display height. | +| **MQTT Broker Host** | `mqtt_host` | `localhost` | IP or hostname of your MQTT broker. | +| **MQTT Port** | `mqtt_port` | `1883` | Broker port (use 8883 for TLS). | +| **MQTT Username** | `mqtt_username` | *(blank)* | Leave blank if no auth required. | +| **MQTT Password** | `mqtt_password` | *(blank)* | Leave blank if no auth required. Marked secret, so the web UI masks it. | +| **Command Topic** | `command_topic` | `ledmatrix/on-air/set` | Topic the plugin **subscribes** to (publish `ON`/`OFF` or JSON). | +| **State Topic** | `state_topic` | `ledmatrix/on-air/state` | Topic the plugin **publishes** to after each state change. | +| **Enable Home Assistant Auto-Discovery** | `ha_discovery` | `true` | Announce to HA via MQTT discovery so it auto-creates the device (switch, label sensor, connectivity) — no `configuration.yaml` needed. | +| **HA Discovery Prefix** | `discovery_prefix` | `homeassistant` | MQTT topic prefix your HA MQTT integration listens on. | +| **Device Name in Home Assistant** | `device_name` | `LED Matrix — On Air` | How the device appears under Settings → Devices & Services → MQTT. | +| **Display Duration (seconds)** | `display_duration` | `5` | How long the sign stays on screen each rotation cycle while active (1–60). | + +```json +{ + "on-air": { + "enabled": true, + "default_label": "ON AIR", + "background_color": [200, 10, 10], + "mqtt_host": "homeassistant.local" + } +} +``` + +### What the two states look like + +![The sign on and off](../../docs/assets/on-air/on-off.png) + +**ON** pins the display: the sign holds the panel and your other plugins do not +get a turn until an off command arrives. **OFF** renders a plain black frame +rather than dropping out of the rotation — the display duration cycles past it +almost instantly, and it avoids the display controller briefly falling back to +its "Initializing" screen while the stop request is processed. + +### Sizing + +The sign auto-sizes: the font is loaded at 80% of the panel height, then scaled +down if the text would be wider than 95% of the panel. A longer label therefore +comes out smaller rather than clipped. + +![The same sign on four panel sizes](../../docs/assets/on-air/panel-sizes.png) --- @@ -95,7 +136,7 @@ Case-insensitive. Any of these work. {"state": "on", "label": "LIVE"} ``` -The label (max 16 chars) replaces "ON AIR" on the display. On panels 128 px wide or larger, "ON AIR" appears as the header with your label as a subtitle below it. +The label replaces the sign text entirely — "ON AIR" is not kept as a header, and the label is not truncated. It is drawn on its own, centred, at whatever size fits the panel, so a long label simply comes out smaller. It lasts until the next command; an `ON` with no label restores the configured **Sign Text**. ### JSON — state + label + color @@ -114,6 +155,16 @@ The label (max 16 chars) replaces "ON AIR" on the display. On panels 128 px wide | Livestreaming | `[255, 0, 80]` — hot pink | | Do Not Disturb | `[180, 0, 180]` — purple | +![The four background presets](../../docs/assets/on-air/presets.png) + +> **`bg` sets the sign; `color` sets only the lettering.** They are easy to mix +> up, and the result is very different: +> +> ![The same label sent with color versus bg](../../docs/assets/on-air/color-vs-bg.png) +> +> If you want an amber sign, send `bg`. Sending `color` leaves the background at +> its configured red and just tints the text. + --- ## Home Assistant Setup @@ -333,13 +384,13 @@ action: payload: > {% set mode = trigger.to_state.state %} {% if mode == 'Recording' %} - {"state": "on", "label": "RECORDING", "color": [255, 20, 20]} + {"state": "on", "label": "RECORDING", "bg": [255, 20, 20]} {% elif mode == 'Meeting' %} - {"state": "on", "label": "IN MEETING", "color": [255, 140, 0]} + {"state": "on", "label": "IN MEETING", "bg": [255, 140, 0]} {% elif mode == 'Livestream' %} - {"state": "on", "label": "LIVE", "color": [255, 0, 80]} + {"state": "on", "label": "LIVE", "bg": [255, 0, 80]} {% elif mode == 'Focus' %} - {"state": "on", "label": "DO NOT DISTURB", "color": [180, 0, 180]} + {"state": "on", "label": "DO NOT DISTURB", "bg": [180, 0, 180]} {% else %} OFF {% endif %} @@ -379,13 +430,13 @@ action: {% if s == 'Off' %} OFF {% elif s == 'On Air' %} - {"state": "on", "label": "ON AIR", "color": [255, 20, 20]} + {"state": "on", "label": "ON AIR", "bg": [255, 20, 20]} {% elif s == 'Recording' %} - {"state": "on", "label": "RECORDING", "color": [255, 20, 20]} + {"state": "on", "label": "RECORDING", "bg": [255, 20, 20]} {% elif s == 'In Meeting' %} - {"state": "on", "label": "IN MEETING", "color": [255, 140, 0]} + {"state": "on", "label": "IN MEETING", "bg": [255, 140, 0]} {% elif s == 'Live' %} - {"state": "on", "label": "LIVE", "color": [255, 0, 80]} + {"state": "on", "label": "LIVE", "bg": [255, 0, 80]} {% endif %} ``` @@ -411,7 +462,7 @@ mosquitto_pub -h -t ledmatrix/on-air/set \ # Turn on with label + amber color mosquitto_pub -h -t ledmatrix/on-air/set \ - -m '{"state": "on", "label": "IN MEETING", "color": [255, 140, 0]}' + -m '{"state": "on", "label": "IN MEETING", "bg": [255, 140, 0]}' # Watch state feedback in another terminal mosquitto_sub -h -t ledmatrix/on-air/state diff --git a/plugins/on-air/manager.py b/plugins/on-air/manager.py index c40b1ad9..09228e74 100644 --- a/plugins/on-air/manager.py +++ b/plugins/on-air/manager.py @@ -25,7 +25,7 @@ import time import uuid from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont @@ -100,6 +100,29 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # ── Font helpers ──────────────────────────────────────────────────────────── + @staticmethod + def _project_roots() -> List[Path]: + """Directories that may hold the LEDMatrix ``assets/fonts`` folder. + + The plugin normally sits at ``/plugin-repos/``, so two + levels up is the root -- but the plugins directory is configurable, and + when it lives outside the core tree that guess finds nothing and the + sign silently drops to a built-in font a fraction of the intended size. + Ask the imported core where it is first, and keep the old guess and the + working directory as fallbacks. + """ + roots: List[Path] = [] + try: + import src # the LEDMatrix core package + if getattr(src, '__file__', None): + roots.append(Path(src.__file__).resolve().parent.parent) + except Exception: # nosec B110 - absence is expected off a real install + pass + roots.append(Path(__file__).resolve().parent.parent.parent) + roots.append(Path.cwd()) + seen = set() + return [r for r in roots if not (str(r) in seen or seen.add(str(r)))] + def _load_configured_font(self) -> Optional[ImageFont.FreeTypeFont]: """Load font from font_path at font_size; returns None to use built-ins.""" path = (self.font_path or '').strip() @@ -109,10 +132,7 @@ def _load_configured_font(self) -> Optional[ImageFont.FreeTypeFont]: # Resolve path: absolute → cwd-relative → project-root-relative candidates = [path] if not os.path.isabs(path): - candidates.append(os.path.join(os.getcwd(), path)) - plugin_dir = Path(__file__).parent - project_root = plugin_dir.parent.parent - candidates.append(str(project_root / path)) + candidates.extend(str(root / path) for root in self._project_roots()) for candidate in candidates: if os.path.exists(candidate): try: @@ -126,21 +146,20 @@ def _load_configured_font(self) -> Optional[ImageFont.FreeTypeFont]: def _find_system_ttf(self) -> Optional[str]: """Search the LEDMatrix assets folder for a usable TTF font file.""" - plugin_dir = Path(__file__).parent - project_root = plugin_dir.parent.parent preferred = [ 'assets/fonts/PressStart2P-Regular.ttf', 'assets/fonts/4x6-font.ttf', ] - for rel in preferred: - p = project_root / rel - if p.exists(): - return str(p) - # Fall back to any .ttf found in assets/fonts/ - fonts_dir = project_root / 'assets' / 'fonts' - if fonts_dir.is_dir(): - for p in sorted(fonts_dir.glob('*.ttf')): - return str(p) + for project_root in self._project_roots(): + for rel in preferred: + p = project_root / rel + if p.exists(): + return str(p) + # Fall back to any .ttf found in assets/fonts/ + fonts_dir = project_root / 'assets' / 'fonts' + if fonts_dir.is_dir(): + for p in sorted(fonts_dir.glob('*.ttf')): + return str(p) return None def _auto_font(self, dh: int): diff --git a/plugins/on-air/manifest.json b/plugins/on-air/manifest.json index cdfa8109..f28a8536 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.5", + "version": "1.2.6", "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.6", + "released": "2026-09-02", + "notes": "Font resolution and documentation. The sign looked for the LEDMatrix assets folder two levels above the plugin, which is only correct when the plugins directory sits inside the core tree; anywhere else it found nothing and silently fell back to a built-in font a fraction of the intended size. It now asks the imported core where it lives, keeping the old guess and the working directory as fallbacks. The README gains real rendered screenshots and the config.json key for every setting, corrects two claims about the label that the code does not implement, and fixes five automation examples that set 'color' where they meant 'bg'.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.2.5", "released": "2026-08-15", @@ -75,7 +81,7 @@ "license": "GPL-3.0", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/on-air", "config_schema": "config_schema.json", - "last_updated": "2026-07-31", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/scripts/docs_render_support/sitecustomize.py b/scripts/docs_render_support/sitecustomize.py index 20844d5e..90fe76c1 100644 --- a/scripts/docs_render_support/sitecustomize.py +++ b/scripts/docs_render_support/sitecustomize.py @@ -52,3 +52,65 @@ def today(cls): _docs_http_replay.install() except Exception: # never let doc tooling break the render it is measuring pass + + +# Some plugins hold their interesting state in memory, put there by an event +# the renderer cannot produce -- an MQTT message, a webhook, a button press. +# No configuration reaches that state, so a documentation render would only +# ever show the idle frame. LEDMATRIX_DOCS_ATTRS names attributes to set on the +# plugin instance once the core's loader has built it, which is the same thing +# the event would have done, without the plugin knowing it is being rendered. +_ATTRS = os.environ.get("LEDMATRIX_DOCS_ATTRS") +if _ATTRS: + import json as _json + import sys as _sys + + _WANTED = _json.loads(_ATTRS) + _TARGET = "src.plugin_system.plugin_loader" + + def _apply(instance): + for name, value in _WANTED.items(): + if isinstance(value, list) and len(value) == 3 and all( + isinstance(v, int) for v in value): + value = tuple(value) # colours are tuples everywhere in the core + setattr(instance, name, value) + return instance + + class _PatchingLoader: + """Wraps the real loader so the module is patched right after it runs.""" + + def __init__(self, inner): + self._inner = inner + + def create_module(self, spec): + return self._inner.create_module(spec) + + def exec_module(self, module): + self._inner.exec_module(module) + real = module.PluginLoader.load_plugin + + def load_plugin(self, *args, **kwargs): + instance, mod = real(self, *args, **kwargs) + return _apply(instance), mod + + module.PluginLoader.load_plugin = load_plugin + + def __getattr__(self, name): + return getattr(self._inner, name) + + class _AttrFinder: + """Hands back the real spec with its loader wrapped, once.""" + + def find_spec(self, name, path=None, target=None): + if name != _TARGET: + return None + index = _sys.meta_path.index(self) + for finder in _sys.meta_path[index + 1:]: + find = getattr(finder, "find_spec", None) + spec = find(name, path, target) if find else None + if spec is not None and spec.loader is not None: + spec.loader = _PatchingLoader(spec.loader) + return spec + return None + + _sys.meta_path.insert(0, _AttrFinder()) diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py index 42a1d93d..36c7bd4b 100644 --- a/scripts/render_docs_assets.py +++ b/scripts/render_docs_assets.py @@ -263,12 +263,16 @@ def render_shot( # clock, a countdown, or a "starts in 2h" line differs on every run. freeze_time = shot.get("freeze_time", defaults.get("freeze_time")) http_replay = shot.get("http_replay", defaults.get("http_replay")) - if freeze_time or http_replay: + # Runtime state that an event would normally have set; see the shim. + attrs = shot.get("attrs", defaults.get("attrs")) + if freeze_time or http_replay or attrs: support_dir = str(Path(__file__).resolve().parent / "docs_render_support") existing = env.get("PYTHONPATH") env["PYTHONPATH"] = f"{support_dir}{os.pathsep}{existing}" if existing else support_dir if freeze_time: env["LEDMATRIX_DOCS_FREEZE_TIME"] = str(freeze_time) + if attrs: + env["LEDMATRIX_DOCS_ATTRS"] = json.dumps(attrs) if http_replay: replay_path = (shot_list_dir / http_replay).resolve() if not replay_path.is_file():