diff --git a/README.md b/README.md index 62d07d33..550f0b3c 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \ | Plugin | Description | Preview | |--------|-------------|---------| -| [Web UI Info](./plugins/web-ui-info/) | Displays web UI URL for device access | | +| [Web UI Info](./plugins/web-ui-info/) | Displays web UI URL for device access | web-ui-info on an LED panel | ### Development (1) diff --git a/docs/assets/web-ui-info/hero.png b/docs/assets/web-ui-info/hero.png new file mode 100644 index 00000000..fc6a6977 Binary files /dev/null and b/docs/assets/web-ui-info/hero.png differ diff --git a/docs/assets/web-ui-info/hostname-length.png b/docs/assets/web-ui-info/hostname-length.png new file mode 100644 index 00000000..e52f4a82 Binary files /dev/null and b/docs/assets/web-ui-info/hostname-length.png differ diff --git a/docs/assets/web-ui-info/narrow-wrap.png b/docs/assets/web-ui-info/narrow-wrap.png new file mode 100644 index 00000000..4296231f Binary files /dev/null and b/docs/assets/web-ui-info/narrow-wrap.png differ diff --git a/docs/assets/web-ui-info/panel-sizes.png b/docs/assets/web-ui-info/panel-sizes.png new file mode 100644 index 00000000..49290dd7 Binary files /dev/null and b/docs/assets/web-ui-info/panel-sizes.png differ diff --git a/docs/assets/web-ui-info/shots.json b/docs/assets/web-ui-info/shots.json new file mode 100644 index 00000000..a2aa7983 --- /dev/null +++ b/docs/assets/web-ui-info/shots.json @@ -0,0 +1,121 @@ +{ + "plugin": "web-ui-info", + "defaults": { + "width": 128, + "height": 32, + "scale": 6, + "hostname": "ledmatrix", + "config": { + "enabled": true + } + }, + "shots": [ + { + "name": "hero", + "width": 128, + "height": 32 + }, + { + "name": "size-64x32", + "width": 64, + "height": 32, + "scale": 6, + "standalone": false + }, + { + "name": "size-128x32", + "width": 128, + "height": 32, + "scale": 6, + "standalone": false + }, + { + "name": "size-256x32", + "width": 256, + "height": 32, + "scale": 6, + "standalone": false + }, + { + "name": "size-128x64", + "width": 128, + "height": 64, + "scale": 6, + "standalone": false + }, + { + "name": "host-short", + "hostname": "pi", + "standalone": false + }, + { + "name": "host-long", + "hostname": "raspberrypi-livingroom", + "standalone": false + }, + { + "name": "host-long-64", + "width": 64, + "height": 32, + "scale": 6, + "hostname": "raspberrypi-livingroom", + "standalone": false + } + ], + "composites": [ + { + "name": "panel-sizes", + "columns": 2, + "cells": [ + { + "shot": "size-64x32", + "label": "64 x 32" + }, + { + "shot": "size-128x32", + "label": "128 x 32" + }, + { + "shot": "size-256x32", + "label": "256 x 32" + }, + { + "shot": "size-128x64", + "label": "128 x 64" + } + ] + }, + { + "name": "hostname-length", + "columns": 1, + "cells": [ + { + "shot": "host-short", + "label": "\"pi\" - \"at\" prefix fits" + }, + { + "shot": "hero", + "label": "\"ledmatrix\" - \"at\" prefix fits" + }, + { + "shot": "host-long", + "label": "\"raspberrypi-livingroom\" - prefix dropped" + } + ] + }, + { + "name": "narrow-wrap", + "columns": 1, + "cells": [ + { + "shot": "size-64x32", + "label": "\"ledmatrix\" on 64x32" + }, + { + "shot": "host-long-64", + "label": "long hostname wraps" + } + ] + } + ] +} diff --git a/plugins.json b/plugins.json index f8240000..fd023c79 100644 --- a/plugins.json +++ b/plugins.json @@ -952,10 +952,10 @@ "plugin_path": "plugins/web-ui-info", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.0.2" + "latest_version": "1.1.0" }, { "id": "youtube-stats", diff --git a/plugins/web-ui-info/README.md b/plugins/web-ui-info/README.md index 414864fc..8aef87d1 100644 --- a/plugins/web-ui-info/README.md +++ b/plugins/web-ui-info/README.md @@ -1,64 +1,228 @@ -# Web UI Info Plugin +# Web UI Info -A simple built-in plugin for LEDMatrix that displays the web UI URL for easy access. +A one-screen reminder of the address your LEDMatrix web interface is on. +It alternates between the device's hostname and its IP address, so you can +read the URL off the panel instead of hunting for the Pi on your network. -## Features +![Web UI Info on a 128x32 panel](../../docs/assets/web-ui-info/hero.png) -- Displays "visit web ui at http://[deviceID]:5000" on the LED matrix -- Automatically detects device hostname -- Easily enabled/disabled via web interface -- Configurable display duration +No network access, no API key, no dependencies beyond what LEDMatrix already +ships — the plugin only reads local system information. + +--- + +## What it shows + +Two centred lines of white text: + +```text +visit web ui +at ledmatrix:5000 +``` + +The address on the second line **alternates every 10 seconds** between: + +| Mode | Second line | Source | +|---|---|---| +| Hostname | `at ledmatrix:5000` | `socket.gethostname()` | +| IP address | `at 192.168.1.42:5000` | the first interface address that is not loopback | + +Both modes are laid out identically — only the address text differs. The port +is always `5000`, the port the LEDMatrix web interface listens on; it is not +configurable from this plugin. + +--- + +## Installation + +Web UI Info ships with the default Plugin Store: + +1. Open the web interface (`http://your-pi-ip:5000`) +2. Go to the **Plugin Manager** tab +3. Find **Web UI Info** under **Plugin Store** and click **Install** +4. Toggle it on, then click **Restart Display Service** on the **Overview** tab + +To install from source instead, copy the directory into your LEDMatrix +plugins directory (default `plugin-repos/`): + +```bash +cp -r plugins/web-ui-info ~/LEDMatrix/plugin-repos/ +sudo systemctl restart ledmatrix +``` + +--- ## Configuration -The plugin can be configured in `config/config.json`: +Settings live in the plugin's tab in the web UI, and are stored in +`config/config.json` under the `web-ui-info` key. The full schema is +[`config_schema.json`](config_schema.json). + +| Option | Type | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `true` | Master switch. When off, the plugin is skipped in the rotation. | +| `display_duration` | number | `10` | Seconds the panel holds the screen before the display rotates on (1–300). | +| `transition.enabled` | boolean | `true` | **Not implemented** — see below. | +| `transition.type` | string | `"redraw"` | **Not implemented** — see below. | +| `transition.speed` | integer | `2` | **Not implemented** — see below. | + +That is the whole surface: this plugin has no colours, fonts, or layout +options. The text is always white, always centred. ```json { "web-ui-info": { "enabled": true, - "display_duration": 10, - "transition": { - "type": "redraw", - "speed": 2, - "enabled": true - } + "display_duration": 10 } } ``` -### Configuration Options +### `display_duration` and the 10-second alternation + +The swap is driven by a wall clock, not by a per-turn counter: the plugin +changes address whenever 10 seconds have passed since the last change. Two +things follow from that. + +**Between turns.** The gap between one appearance in the rotation and the next +is almost always longer than 10 seconds, so the plugin swaps as it comes back +on screen. Consecutive turns alternate hostname, then IP, then hostname again — +even at the default `display_duration`. + +**Within a turn.** A screen held longer than 10 seconds swaps again while you +are looking at it. + +| `display_duration` | What you see | +|---|---| +| `5` – `10` (default) | One address per turn, alternating from turn to turn | +| `20` | One swap part way through the screen | +| `30` or more | A swap roughly every 10 seconds | + +The default is fine for most setups. Raise it to `20` if you want both +addresses in a single turn rather than across two. + +### The `transition` settings do nothing + +`transition.enabled`, `transition.type`, and `transition.speed` appear in the +configuration form, but `manager.py` never reads them and the LEDMatrix core +implements no display transitions. Changing them has no effect. They are +tracked for removal in +[issue #381](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/381) — +four other plugins declare the same dead block. + +--- + +## How the address is found -- `enabled` (boolean): Enable or disable the plugin (default: `true`) -- `display_duration` (number): How long to display the message in seconds (default: `10`, range: 1-300) -- `transition` (object): Transition settings for switching to/from this display +### Hostname -## Usage +`socket.gethostname()`, read once when the plugin loads. If that raises, the +plugin falls back to `localhost`. -The plugin will automatically be discovered and loaded when the LEDMatrix system starts. It will display the web UI URL during the normal rotation cycle. +### IP address -To enable/disable the plugin: -1. Open the web UI at `http://[your-device]:5000` -2. Open the **Plugin Manager** tab -3. Find **Web UI Info** in the **Installed Plugins** list and toggle it - on or off -4. Restart the display service from the **Overview** tab +Re-read every 30 seconds, so unplugging Ethernet or switching networks is +picked up without a restart. The plugin tries these in order and takes the +first answer that is neither loopback (`127.x`) nor the AP-mode address: -## Device ID +1. **AP mode check.** If `hostapd` is active, or `wlan0` already holds + `192.168.4.1`, the plugin reports `192.168.4.1` — the address the web UI is + on when the Pi is running as its own access point. +2. **`hostname -I`** — the fastest path, and the one that normally answers on + a Raspberry Pi. +3. **`ip -4 addr show`** — preferring wired interfaces (`eth*`, `enp*`), then + `wlan0`. +4. **A UDP socket to `8.8.8.8`** — no packet is actually sent; this just asks + the kernel which local address it would route from. Needs a default route. +5. **`socket.gethostbyname()`** on the hostname. -The plugin uses `socket.gethostname()` to determine the device hostname. If the hostname cannot be determined, it falls back to "localhost". +If every step fails, the second line reads `at localhost:5000`. -## Display +--- -The plugin displays two lines of text: -- Line 1: "visit web ui" -- Line 2: "at [deviceID]:5000" +## Panel sizes -Text is centered horizontally on the display and rendered in white. +The text is drawn in the 4x6 bitmap font at a fixed size, so what changes +across panels is how much room the two lines have, not how big they are. + +![The same screen on four panel sizes](../../docs/assets/web-ui-info/panel-sizes.png) + +The block is centred both horizontally and vertically, so it sits in the +middle of a tall panel rather than against the top edge. + +--- + +## Long hostnames + +A hostname long enough to overflow the panel used to be silently sliced off at +both ends. The plugin now shortens the line in stages instead: + +![The same panel with three hostname lengths](../../docs/assets/web-ui-info/hostname-length.png) + +1. `at
:5000` — the normal form +2. `
:5000` — the `at ` prefix is dropped when it no longer fits +3. the address wraps onto extra lines when even that is too wide + +On a 64x32 panel — where a 4x6 font gives you about 16 characters — both the +second and third stages come into play: + +![A long hostname wrapping on a 64x32 panel](../../docs/assets/web-ui-info/narrow-wrap.png) + +If you would rather see a short address than a wrapped one, rename the device +(`sudo hostnamectl set-hostname ledmatrix`) or wait out the 10-second swap and +read the IP instead. + +--- + +## Verifying it loaded + +Installed plugins appear under **Installed Plugins** in the **Plugin Manager** +tab. From SSH: + +```bash +sudo journalctl -u ledmatrix -f | grep web-ui-info +``` + +You should see the hostname and IP the plugin settled on at startup: + +```text +Web UI Info plugin initialized - Hostname: ledmatrix, IP: 192.168.1.42 +``` + +--- + +## Troubleshooting + +**The panel says `at localhost:5000`** +Every IP-detection step failed, which usually means no network interface is +up. Check `hostname -I` over SSH — if that prints nothing, the plugin has +nothing to show either. + +**The IP on the panel is out of date** +It is re-read every 30 seconds, but only while the plugin's `update()` runs. +If the display rotation is long, give it a full cycle before assuming it is +stuck. + +**It always shows `192.168.4.1`** +That is the AP-mode address, and it is reported whenever `hostapd` is running. +If the Pi is meant to be on your normal network, `hostapd` is still active: +`sudo systemctl stop hostapd`. + +**Changing `transition.*` does nothing** +Correct — see [above](#the-transition-settings-do-nothing). + +--- ## Notes -- This is a built-in plugin that comes with the LEDMatrix repository -- No external dependencies required -- The plugin does not fetch any external data - it only displays the local device information +- Ships with the LEDMatrix repository; no external dependencies. +- Makes no network requests. It reads the local hostname and interface + addresses only. +- The images in this README are produced by + [`scripts/render_docs_assets.py`](../../scripts/render_docs_assets.py) from + [`docs/assets/web-ui-info/shots.json`](../../docs/assets/web-ui-info/shots.json), + with the hostname pinned so they render the same on any machine. + +## License +GPL-3.0, same as the LEDMatrix project. diff --git a/plugins/web-ui-info/manager.py b/plugins/web-ui-info/manager.py index d473bb98..eba0a1a4 100644 --- a/plugins/web-ui-info/manager.py +++ b/plugins/web-ui-info/manager.py @@ -13,7 +13,7 @@ import subprocess import time from pathlib import Path -from typing import Dict, Any +from typing import Any, Dict, List from PIL import Image, ImageDraw, ImageFont from src.plugin_system.base_plugin import BasePlugin @@ -203,6 +203,33 @@ def update(self) -> None: self.device_ip = new_ip self.last_ip_refresh = current_time + def _fit_address(self, draw, font, width: int, address: str) -> List[str]: + """Lay ``address`` out in as few lines as fit ``width`` pixels. + + The 4x6 face is a fixed size, so a long hostname on a narrow panel + cannot be shrunk to fit. Drop the "at " prefix first, and only then + wrap the address itself -- a wrapped hostname is still readable, while + a clipped one is not. + """ + def text_width(text: str) -> int: + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + + if text_width(f"at {address}") <= width: + return [f"at {address}"] + if text_width(address) <= width: + return [address] + + lines: List[str] = [] + remaining = address + while remaining: + take = len(remaining) + while take > 1 and text_width(remaining[:take]) > width: + take -= 1 + lines.append(remaining[:take]) + remaining = remaining[take:] + return lines + def display(self, force_clear: bool = False) -> None: """ Display the web UI URL message. @@ -263,15 +290,17 @@ def display(self, force_clear: bool = False) -> None: else: address = self.device_id - # Prepare text to display - lines = [ - "visit web ui", - f"at {address}:5000" - ] - - # Calculate text positions (centered) - y_start = 5 + # Prepare text to display. PIL clips silently at draw time, so a + # line wider than the panel just loses its ends: on a 64x32 panel + # "at ledmatrix:5000" used to render as "t ledmatrix:500". + lines = ["visit web ui"] + self._fit_address( + draw, font_small, width, f"{address}:5000") + + # Centre the block vertically rather than pinning it 5px from the + # top, which left the text stranded at the top of a tall panel. line_height = 8 + block_height = (len(lines) - 1) * line_height + 6 + y_start = max(0, (height - block_height) // 2) # Draw each line for i, line in enumerate(lines): diff --git a/plugins/web-ui-info/manifest.json b/plugins/web-ui-info/manifest.json index 19eca646..4dd896ac 100644 --- a/plugins/web-ui-info/manifest.json +++ b/plugins/web-ui-info/manifest.json @@ -1,7 +1,7 @@ { "id": "web-ui-info", "name": "Web UI Info", - "version": "1.0.2", + "version": "1.1.0", "author": "LEDMatrix", "description": "Displays the web UI URL for easy access. Shows 'visit web ui at http://[deviceID]:5000'", "entry_point": "manager.py", @@ -19,6 +19,11 @@ "update_interval": 300, "default_duration": 10, "versions": [ + { + "version": "1.1.0", + "released": "2026-09-02", + "ledmatrix_min": "2.0.0" + }, { "version": "1.0.2", "released": "2026-07-31", @@ -35,7 +40,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-09-02", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/web-ui-info/test/harness.json b/plugins/web-ui-info/test/harness.json index 2684fbed..e623d75d 100644 --- a/plugins/web-ui-info/test/harness.json +++ b/plugins/web-ui-info/test/harness.json @@ -1,5 +1,5 @@ { - "_comment": "Harness config for the web-UI info panel. The rendered address line contains the host machine's IP, which differs per machine, so this plugin ships no golden images — the harness still exercises crash/overflow checks on every panel size deterministically enough (layout is fixed; only the digits vary).", + "_comment": "Harness config for the web-UI info panel. The rendered address line carries the host machine's own hostname (and, once it rotates, its IP), both of which differ per machine, so this plugin ships no golden images -- the harness still exercises crash and overflow checks on every panel size. Reproducible screenshots live in docs/assets/web-ui-info, where the renderer pins the hostname.", "config": { "enabled": true } diff --git a/scripts/docs_render_support/sitecustomize.py b/scripts/docs_render_support/sitecustomize.py index 9fb02048..44eafa3a 100644 --- a/scripts/docs_render_support/sitecustomize.py +++ b/scripts/docs_render_support/sitecustomize.py @@ -135,3 +135,14 @@ def find_spec(self, name, path=None, target=None): return None _sys.meta_path.insert(0, _AttrFinder()) + + +# A plugin that shows the device hostname would otherwise bake whoever ran the +# renderer into the committed image, and --check would then fail for everyone +# else. Pinning it keeps the screenshot generic and reproducible. +_HOSTNAME = os.environ.get("LEDMATRIX_DOCS_HOSTNAME") +if _HOSTNAME: + import socket as _socket + + _socket.gethostname = lambda: _HOSTNAME + _socket.getfqdn = lambda *_a: _HOSTNAME diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py index f515e426..fa38f5f9 100644 --- a/scripts/render_docs_assets.py +++ b/scripts/render_docs_assets.py @@ -56,7 +56,10 @@ ``freeze_time`` (ISO-8601 instant; pins "now" so the image is reproducible), ``http_replay`` (a recorded-responses file, for managers that fetch without reading the cache), ``frames`` (advance a scrolling plugin this many display -steps before snapshotting) and +steps before snapshotting), ``attrs`` (runtime state to set on the plugin +instance, for plugins whose interesting state arrives by event rather than by +configuration), ``hostname`` (pins ``socket.gethostname``, for plugins that +print the device name) and ``env`` (extra environment variables for the render subprocess). Anything omitted falls back to ``defaults``. Set ``"standalone": false`` on a shot that only exists to be pasted into a composite. @@ -277,7 +280,11 @@ def render_shot( http_replay = shot.get("http_replay", defaults.get("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: + # A plugin that prints the device hostname would otherwise bake whoever ran + # the renderer into the committed image, and --check would fail for anyone + # else. Pinning it keeps the screenshot generic and reproducible. + hostname = shot.get("hostname", defaults.get("hostname")) + if freeze_time or http_replay or attrs or hostname: 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 @@ -285,6 +292,8 @@ def render_shot( env["LEDMATRIX_DOCS_FREEZE_TIME"] = str(freeze_time) if attrs: env["LEDMATRIX_DOCS_ATTRS"] = json.dumps(attrs) + if hostname: + env["LEDMATRIX_DOCS_HOSTNAME"] = str(hostname) if http_replay: replay_path = (shot_list_dir / http_replay).resolve() if not replay_path.is_file():