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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | <a href="./plugins/on-air/"><img src="./docs/assets/on-air/hero.png" width="240" alt="on-air on an LED panel"></a> |

### Custom (3)

Expand Down
Binary file added docs/assets/on-air/color-vs-bg.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/on-air/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/on-air/on-off.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/on-air/panel-sizes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/on-air/presets.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 78 additions & 0 deletions docs/assets/on-air/shots.json
Original file line number Diff line number Diff line change
@@ -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"}
]
}
]
}
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
105 changes: 78 additions & 27 deletions plugins/on-air/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,23 +55,58 @@ mosquitto_pub -h <your-broker-ip> -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)

---

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 %}
Expand Down Expand Up @@ -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 %}
```

Expand All @@ -411,7 +462,7 @@ mosquitto_pub -h <broker-ip> -t ledmatrix/on-air/set \

# Turn on with label + amber color
mosquitto_pub -h <broker-ip> -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 <broker-ip> -t ledmatrix/on-air/state
Expand Down
51 changes: 35 additions & 16 deletions plugins/on-air/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ``<LEDMatrix>/plugin-repos/<id>``, 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()
Expand All @@ -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:
Expand All @@ -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):
Expand Down
10 changes: 8 additions & 2 deletions plugins/on-air/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading