diff --git a/docs/assets/mqtt-notifications/appearance.png b/docs/assets/mqtt-notifications/appearance.png new file mode 100644 index 00000000..3724f0ed Binary files /dev/null and b/docs/assets/mqtt-notifications/appearance.png differ diff --git a/docs/assets/mqtt-notifications/hero.png b/docs/assets/mqtt-notifications/hero.png new file mode 100644 index 00000000..6dd116b7 Binary files /dev/null and b/docs/assets/mqtt-notifications/hero.png differ diff --git a/docs/assets/mqtt-notifications/panel-sizes.png b/docs/assets/mqtt-notifications/panel-sizes.png new file mode 100644 index 00000000..606a053a Binary files /dev/null and b/docs/assets/mqtt-notifications/panel-sizes.png differ diff --git a/docs/assets/mqtt-notifications/scroll.png b/docs/assets/mqtt-notifications/scroll.png new file mode 100644 index 00000000..23409643 Binary files /dev/null and b/docs/assets/mqtt-notifications/scroll.png differ diff --git a/docs/assets/mqtt-notifications/shots.json b/docs/assets/mqtt-notifications/shots.json new file mode 100644 index 00000000..9f7f1c14 --- /dev/null +++ b/docs/assets/mqtt-notifications/shots.json @@ -0,0 +1,333 @@ +{ + "plugin": "mqtt-notifications", + "_comment": "display() reads self.current_message, a dict the MQTT handler builds with type, content, duration, priority, timestamp and topic; content carries text and/or image. The fixture seeds that dict directly so no broker is needed, and clears text_image_cache and image_cache because the handler clears them whenever a new message arrives. text.scroll is on by default, so the scrolling shots use frames -- frame 0 of a scroller is blank. Messages are invented.", + "defaults": { + "width": 128, + "height": 32, + "scale": 6, + "skip_update": true, + "freeze_time": "2026-09-04T12:00:00+00:00", + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + } + } + }, + "shots": [ + { + "name": "hero", + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Laundry finished in the garage" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "frames": 60 + }, + { + "name": "scroll-on", + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Laundry finished in the garage" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "frames": 60 + }, + { + "name": "scroll-off", + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Doorbell" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false + } + } + }, + { + "name": "colour", + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Doorbell" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false, + "text_color": [ + 0, + 220, + 255 + ] + } + } + }, + { + "name": "small-font", + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Laundry finished in the garage" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false + }, + "customization": { + "message_text": { + "font": "4x6-font.ttf", + "font_size": 6 + } + } + } + }, + { + "name": "size-64x32", + "width": 64, + "height": 32, + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Doorbell" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false + } + } + }, + { + "name": "size-128x32", + "width": 128, + "height": 32, + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Doorbell" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false + } + } + }, + { + "name": "size-128x64", + "width": 128, + "height": 64, + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Doorbell" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false + } + } + }, + { + "name": "size-256x32", + "width": 256, + "height": 32, + "standalone": false, + "attrs": { + "current_message": { + "type": "notification", + "content": { + "text": "Doorbell" + }, + "duration": 10, + "priority": "normal", + "timestamp": 9000000000.0, + "topic": "homeassistant/ledmatrix/notify" + }, + "text_image_cache": null, + "image_cache": null + }, + "config": { + "enabled": true, + "mqtt": { + "host": "localhost", + "port": 1883 + }, + "text": { + "scroll": false + } + } + } + ], + "composites": [ + { + "name": "scroll", + "columns": 1, + "cells": [ + { + "shot": "scroll-on", + "label": "text.scroll: true", + "sublabel": "the default; a long message scrolls past" + }, + { + "shot": "scroll-off", + "label": "text.scroll: false", + "sublabel": "the message is drawn once, centred" + } + ] + }, + { + "name": "appearance", + "columns": 1, + "cells": [ + { + "shot": "scroll-off", + "label": "defaults", + "sublabel": "PressStart2P at 8px, white on black" + }, + { + "shot": "colour", + "label": "text.text_color", + "sublabel": "any RGB triple" + }, + { + "shot": "small-font", + "label": "customization.message_text", + "sublabel": "4x6-font.ttf at 6px fits a longer message without scrolling" + } + ] + }, + { + "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" + } + ] + } + ] +} diff --git a/plugins.json b/plugins.json index f818927f..193a565e 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-09-03", + "last_updated": "2026-09-04", "plugins": [ { "id": "cricket-scoreboard", @@ -510,7 +510,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.2.1" }, { "id": "music", diff --git a/plugins/mqtt-notifications/README.md b/plugins/mqtt-notifications/README.md index 6948e8b0..b30111e3 100644 --- a/plugins/mqtt-notifications/README.md +++ b/plugins/mqtt-notifications/README.md @@ -72,6 +72,65 @@ Add the following to your `config/config.json`: } ``` +## What it looks like + +![A notification on a 128x32 panel](../../docs/assets/mqtt-notifications/hero.png) + +A message is drawn as a single line of text. Long messages scroll by default; +short ones fit as they are. + +![Scrolling on and off](../../docs/assets/mqtt-notifications/scroll.png) + +`text.scroll` is on by default. With it off the message is drawn once and +centred, which suits short alerts and costs no CPU between redraws. + +### Appearance + +![Default, recoloured, and a smaller face](../../docs/assets/mqtt-notifications/appearance.png) + +| Key | Default | What it does | +|---|---|---| +| `text.font_path` | `assets/fonts/PressStart2P-Regular.ttf` | Font file, TTF or BDF. Relative to the project root, or absolute. | +| `text.font_size` | `8` | Size in pixels. | +| `text.text_color` | `[255, 255, 255]` | RGB triple. | +| `text.background_color` | `[0, 0, 0]` | RGB triple. | +| `text.scroll` | `true` | Scroll the message rather than drawing it once. | +| `text.scroll_speed` | `30` | Pixels per second. Higher is faster. | +| `text.scroll_gap_width` | `32` | Pixels of gap between scroll loops. | +| `customization.message_text.font` | `PressStart2P-Regular.ttf` | Picked from a list in the web UI: `PressStart2P-Regular.ttf`, `4x6-font.ttf`, `5by7.regular.ttf`, `5x7.bdf`, `4x6.bdf`. Overrides `text.font_path` when set. | +| `customization.message_text.font_size` | `8` | Size for that face. | + +The two font routes exist because `text.font_path` takes any path while +`customization.message_text.font` offers a dropdown of the bundled faces. The +`customization` block wins when it loads successfully; colours come from `text` +either way, since `customization` carries no colour keys. + +### Panel sizes + +![The card at four panel sizes](../../docs/assets/mqtt-notifications/panel-sizes.png) + +The plugin passes the render-safety harness on every supported size. + +### Connection and timing settings + +| Key | Default | What it does | +|---|---|---| +| `enabled` | `false` | Master on/off switch. Off by default. | +| `mqtt.host` | `localhost` | Broker hostname or IP. | +| `mqtt.port` | `1883` | Broker port. | +| `mqtt.username` | `""` | Optional. | +| `mqtt.password` | `""` | Optional. | +| `mqtt.client_id` | `ledmatrix-mqtt-notifications` | Client id the plugin connects with. Change it if you run two boards against one broker, since a broker will disconnect a duplicate id. | +| `mqtt.keepalive` | `60` | Keepalive interval in seconds. | +| `mqtt.topics` | `["homeassistant/ledmatrix/+"]` | Topics to subscribe to. `+` matches one level, `#` matches the rest. | +| `display.default_duration` | `10` | How long a message holds the screen when its payload does not set `duration`. | +| `display_duration` | `10` | **Inert in this plugin.** The core reads this as a plugin's screen time, but this plugin overrides that accessor and returns `display.default_duration` instead, so setting the root key alone changes nothing. Set `display.default_duration`. | +| `update_interval` | `60` | How often the core calls the plugin's `update()`, which checks connection health. The MQTT client itself runs on its own thread, so this does not affect how quickly a message appears. | + +`mqtt`, `display`, `text`, `customization` and `customization.message_text` all +set `additionalProperties: false`, so a misspelled key is rejected rather than +quietly ignored. + ## Message Format Send JSON messages to the configured MQTT topics. The message format is: diff --git a/plugins/mqtt-notifications/manifest.json b/plugins/mqtt-notifications/manifest.json index 804e2989..8bf2a6c5 100644 --- a/plugins/mqtt-notifications/manifest.json +++ b/plugins/mqtt-notifications/manifest.json @@ -1,7 +1,7 @@ { "id": "mqtt-notifications", "name": "MQTT Notifications", - "version": "1.2.0", + "version": "1.2.1", "author": "ChuckBuilds", "description": "Display text or images from HomeAssistant via MQTT. Supports dynamic MQTT topics with wildcard support for flexible notification handling that interrupts the normal display rotation.", "category": "integration", @@ -18,6 +18,15 @@ "entry_point": "manager.py", "class_name": "MQTTNotificationsPlugin", "versions": [ + { + "version": "1.2.1", + "released": "2026-09-02", + "notes": "Load a bitmap font at its own pixel size, and drop one that has no font file. A .bdf exists at exactly one pixel size and FreeType rejects any other, so every .bdf in the font picker failed at the configured font_size and silently fell back to the default face -- choosing one appeared to do nothing. Bitmap faces are now retried at the size the file's PIXEL_SIZE header declares. cozette.bdf is removed from the picker: the file is shipped by neither the core nor the plugin, so it could only ever fall back. Same fix already landed for clock-simple, news and tide-display, which share this loader.", + "ledmatrix_min_version": "2.0.0", + "changes": [ + "Documented the five remaining settings and added rendered examples of the scroll toggle, the appearance options, and four panel sizes." + ] + }, { "version": "1.2.0", "released": "2026-09-02", diff --git a/scripts/docs_render_support/_docs_frame_runner.py b/scripts/docs_render_support/_docs_frame_runner.py index 6f85f52d..2ddc24eb 100644 --- a/scripts/docs_render_support/_docs_frame_runner.py +++ b/scripts/docs_render_support/_docs_frame_runner.py @@ -39,6 +39,10 @@ def main() -> int: "needed when the shot supplies its state directly") parser.add_argument("--frame-seconds", type=float, default=0.05, help="Frozen-clock seconds to advance between frames") + parser.add_argument("--display-mode", default=None, + help="Display mode to render, for plugins that declare more " + "than one. Matches render_plugin.py, so a scrolling shot " + "can name its mode the same way a still one does.") parser.add_argument("--output", required=True) args = parser.parse_args() @@ -92,7 +96,16 @@ def main() -> int: # force_clear only on the first frame, so a plugin that treats it as "start # over" does not restart its scroll on every step. for frame in range(max(1, args.frames)): - instance.display(force_clear=(frame == 0)) + if args.display_mode: + try: + instance.display(force_clear=(frame == 0), + display_mode=args.display_mode) + except TypeError: + # Same fallback render_plugin.py uses: a plugin whose display() + # takes no mode renders its default screen rather than failing. + instance.display(force_clear=(frame == 0)) + else: + instance.display(force_clear=(frame == 0)) if advance is not None: advance(args.frame_seconds) diff --git a/scripts/docs_render_support/sitecustomize.py b/scripts/docs_render_support/sitecustomize.py index 44eafa3a..64fbb438 100644 --- a/scripts/docs_render_support/sitecustomize.py +++ b/scripts/docs_render_support/sitecustomize.py @@ -89,12 +89,59 @@ def advance(seconds): _WANTED = _json.loads(_ATTRS) _TARGET = "src.plugin_system.plugin_loader" + def _resolve(instance, path): + """Walk a dotted path to the object that owns the final attribute. + + The sports scoreboards keep their per-mode state on sub-managers rather + than on the plugin -- self._managers["live"].current_game -- so a shot + that can only set top-level attributes cannot reach the game it wants + to draw. A segment is tried as a mapping key first, then as an + attribute, so both self._managers["live"] and self.live_manager work. + """ + parts = path.split(".") + target = instance + for part in parts[:-1]: + if hasattr(target, "get") and not hasattr(target, part): + nxt = target.get(part) + else: + nxt = getattr(target, part, None) + if nxt is None and hasattr(target, "get"): + nxt = target.get(part) + if nxt is None: + return None, None + target = nxt + return target, parts[-1] + + def _coerce(name, value): + """Turn JSON into the shapes the plugins actually hold. + + A shots file can only carry JSON, but plugin state is not all strings + and lists: colours are tuples throughout the core, and a logo field is + a pathlib.Path -- the sports renderers call logo_path.parent, so a + string there raises AttributeError and the card silently fails to draw. + """ + if isinstance(value, list) and len(value) == 3 and all( + isinstance(v, int) for v in value): + return tuple(value) # colours are tuples everywhere in the core + if isinstance(value, str) and name.endswith("_path") and value: + import pathlib as _pathlib + return _pathlib.Path(value) + if isinstance(value, dict): + return {k: _coerce(k, v) for k, v in value.items()} + if isinstance(value, list): + return [_coerce(name, v) for v in value] + return value + 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) + value = _coerce(name.rsplit(".", 1)[-1], value) + owner, attr = _resolve(instance, name) + if owner is None: + continue # the path does not exist on this plugin; leave it alone + if hasattr(owner, "__setitem__") and not hasattr(owner, attr): + owner[attr] = value + else: + setattr(owner, attr, value) return instance class _PatchingLoader: diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py index fa38f5f9..2ca204e6 100644 --- a/scripts/render_docs_assets.py +++ b/scripts/render_docs_assets.py @@ -268,6 +268,9 @@ def render_shot( mock_path = resolve_mock_data(mock_spec, shot_list_dir, tmpdir, name) if mock_path: cmd += ["--mock-data", str(mock_path)] + display_mode = shot.get("display_mode", defaults.get("display_mode")) + if display_mode: + cmd += ["--display-mode", str(display_mode)] if shot.get("skip_update", defaults.get("skip_update", False)): cmd.append("--skip-update")