mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-17 20:39:13 +00:00
Compare commits
2
Commits
793b988d33
...
968b953a51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968b953a51 | ||
|
|
e23f1f45d3 |
@@ -33,7 +33,7 @@ All endpoints return JSON responses with a standard format:
|
||||
|
||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||
> Flask app at `app.py:799-809`. There are 94 routes total — see
|
||||
> Flask app at `app.py:799-809`. There are 111 routes total — see
|
||||
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
||||
|
||||
---
|
||||
@@ -223,6 +223,56 @@ Get the current display state and preview image.
|
||||
}
|
||||
```
|
||||
|
||||
### List Display Modes
|
||||
|
||||
**GET** `/api/v3/display/modes`
|
||||
|
||||
Every display mode that can be requested on-demand, with the plugin that owns
|
||||
it. This is the list the force-display dialog offers.
|
||||
|
||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
||||
one) returns 404 there.
|
||||
|
||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||
opens the dashboard still gets the full list.
|
||||
|
||||
**Query Parameters**:
|
||||
- `include_disabled` (optional): `1` to include modes belonging to disabled
|
||||
plugins. They are still valid on-demand targets — the controller enables the
|
||||
plugin for the duration of the request — and are reported with
|
||||
`"enabled": false`.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"modes": [
|
||||
{
|
||||
"mode": "nfl_live",
|
||||
"plugin_id": "football-scoreboard",
|
||||
"plugin_name": "Football Scoreboard",
|
||||
"name": "nfl_live",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"mode": "clock-simple",
|
||||
"plugin_id": "clock-simple",
|
||||
"plugin_name": "Simple Clock",
|
||||
"name": "Simple Clock",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`name` is a label for a dropdown: a single-mode plugin's own name, or the raw
|
||||
mode string for a multi-mode plugin, since there is no per-mode name anywhere.
|
||||
|
||||
### On-Demand Display Status
|
||||
|
||||
**GET** `/api/v3/display/on-demand/status`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bridge_config.json
|
||||
@@ -0,0 +1,118 @@
|
||||
# Home Assistant MQTT Bridge
|
||||
|
||||
Control the matrix from Home Assistant: force any plugin or mode on demand,
|
||||
turn the display on and off, and set brightness — as real HA entities, not
|
||||
hand-written `mqtt.publish` calls.
|
||||
|
||||
The bridge owns no display logic. It subscribes to one command topic and
|
||||
turns each message into a call against the same `api_v3` routes the web UI
|
||||
uses, so behaviour lives in one place. It talks to the API over HTTP only —
|
||||
no filesystem access — so it can run on the Pi or anywhere that can reach
|
||||
the web interface.
|
||||
|
||||
## What appears in Home Assistant
|
||||
|
||||
On connect the bridge publishes [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
||||
config, so the matrix shows up under **Settings → Devices & Services → MQTT**
|
||||
with no YAML:
|
||||
|
||||
| Entity | Does |
|
||||
|---|---|
|
||||
| `select.ledmatrix_display_mode` | Every mode across enabled plugins. Choosing one force-displays it. |
|
||||
| `button.ledmatrix_stop_display` | Back to normal rotation. |
|
||||
| `switch.ledmatrix_power` | Starts/stops the display service. |
|
||||
| `number.ledmatrix_brightness` | 0–100. |
|
||||
|
||||
State is read back from the API every 30 seconds, so the entities also track
|
||||
changes made from the web UI or an on-demand window expiring on its own.
|
||||
All four share an availability topic that is the bridge's MQTT last will:
|
||||
if the bridge dies, HA greys the controls out rather than leaving them
|
||||
looking live but inert.
|
||||
|
||||
## Raw commands
|
||||
|
||||
For anything the entities do not cover, publish JSON to the command topic
|
||||
(`ledmatrix/command` by default):
|
||||
|
||||
```jsonc
|
||||
// Force a mode. plugin_id is optional — the bridge fills it in from
|
||||
// /api/v3/display/modes.
|
||||
{"action": "display", "mode": "nfl_live"}
|
||||
|
||||
// duration is seconds; pinned holds this one mode instead of rotating
|
||||
// through every mode the plugin owns. Pin Starlark apps, where each mode
|
||||
// is an unrelated widget; leave a sports plugin unpinned so live/recent/
|
||||
// upcoming still cycle.
|
||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||
"duration": 300, "pinned": true}
|
||||
|
||||
{"action": "stop_display"}
|
||||
{"action": "power", "state": "on"}
|
||||
{"action": "brightness", "value": 75}
|
||||
|
||||
// Re-read the mode list and re-publish discovery, after installing a plugin
|
||||
{"action": "refresh"}
|
||||
```
|
||||
|
||||
Every command publishes its outcome to `<command_topic>/status`, and current
|
||||
state to `<command_topic>/state`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A LEDMatrix install with its web interface reachable (default `http://localhost:5000`)
|
||||
- An MQTT broker that Home Assistant is also connected to
|
||||
- Python 3 with `paho-mqtt` 2.x and `requests`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
sudo ./scripts/install/install_mqtt_bridge.sh
|
||||
```
|
||||
|
||||
That copies `bridge_config.example.json` to `bridge_config.json` on first
|
||||
run, installs the dependencies, and enables `ledmatrix-mqtt-bridge.service`.
|
||||
Edit the config with your broker details and re-run it.
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt_host": "192.168.1.10",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_username": "ledmatrix",
|
||||
"mqtt_password": null,
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": true,
|
||||
"ledmatrix_api_base": "http://localhost:5000"
|
||||
}
|
||||
```
|
||||
|
||||
**TLS is on by default.** Without it the broker password and every display
|
||||
command cross the network in cleartext. If your broker only listens on plain
|
||||
1883 — which the Mosquitto add-on does out of the box — set `"mqtt_tls": false`
|
||||
and `"mqtt_port": 1883`. The bridge logs a warning at startup when a password
|
||||
is configured without TLS.
|
||||
|
||||
`bridge_config.json` is gitignored. Any key can also be supplied through the
|
||||
environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
|
||||
which keeps a broker password out of a file on disk — put it in a systemd
|
||||
drop-in with `Environment=` or `EnvironmentFile=` instead.
|
||||
|
||||
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
|
||||
certificate verification and exists only for a self-signed broker on a
|
||||
trusted LAN; it logs a warning when used.
|
||||
|
||||
To run it in the foreground while setting things up:
|
||||
|
||||
```bash
|
||||
python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/mqtt_bridge/bridge_config.json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one thing can be on-demand at a time — the same constraint the web UI has.
|
||||
- Forcing a mode restarts the display service, so the panel blanks for a moment.
|
||||
- The mode list comes from `/api/v3/display/modes`, which triggers plugin
|
||||
discovery itself. Discovery is lazy and normally happens because somebody
|
||||
opened the dashboard; without that endpoint a bridge that never does would
|
||||
see an empty list.
|
||||
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`.
|
||||
The display service picks it up on its next restart, not instantly.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mqtt_host": "192.168.1.10",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_username": "ledmatrix",
|
||||
"mqtt_password": null,
|
||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": true,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": null,
|
||||
"log_level": "INFO"
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Control a LEDMatrix display from Home Assistant over MQTT.
|
||||
|
||||
The bridge owns no display logic. It subscribes to one command topic and
|
||||
turns each message into a call against the same api_v3 routes the web UI
|
||||
uses, so behaviour stays in one place and this stays a translation layer.
|
||||
|
||||
On connect it publishes Home Assistant MQTT Discovery config, so a matrix
|
||||
appears in HA as real entities rather than something you drive with
|
||||
`mqtt.publish` by hand:
|
||||
|
||||
select.ledmatrix_display_mode every mode across enabled plugins;
|
||||
choosing one force-displays it
|
||||
button.ledmatrix_stop_display back to normal rotation
|
||||
switch.ledmatrix_power the display service, on or off
|
||||
number.ledmatrix_brightness 0-100
|
||||
|
||||
Anything the entities do not cover is still reachable by publishing JSON
|
||||
to the command topic:
|
||||
|
||||
{"action": "display", "mode": "nfl_live"}
|
||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||
"duration": 300, "pinned": true}
|
||||
{"action": "stop_display"}
|
||||
{"action": "power", "state": "on" | "off"}
|
||||
{"action": "brightness", "value": 75}
|
||||
{"action": "refresh"} re-publish discovery after installing a plugin
|
||||
|
||||
Every command publishes its result to <command_topic>/status.
|
||||
|
||||
Run it with `python3 ledmatrix_mqtt_bridge.py [--config PATH]`, or install
|
||||
ledmatrix-mqtt-bridge.service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("ledmatrix-mqtt-bridge")
|
||||
|
||||
DISCOVERY_PREFIX = "homeassistant"
|
||||
DEVICE_ID = "ledmatrix"
|
||||
DEVICE_INFO = {
|
||||
"identifiers": [DEVICE_ID],
|
||||
"name": "LEDMatrix",
|
||||
"manufacturer": "ChuckBuilds",
|
||||
"model": "LEDMatrix Display",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"mqtt_username": None,
|
||||
"mqtt_password": None, # nosec B105 - "no password configured", not a credential
|
||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": False,
|
||||
"mqtt_tls_insecure": False,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": None,
|
||||
"log_level": "INFO",
|
||||
}
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""The bridge cannot start with the configuration it was given."""
|
||||
|
||||
|
||||
def load_config(path: str) -> Dict[str, Any]:
|
||||
"""Read bridge_config.json, overlaid on DEFAULTS.
|
||||
|
||||
Every value may also come from the environment as LEDMATRIX_MQTT_<KEY>,
|
||||
which is how a password stays out of a file that has to be world-readable
|
||||
for the service user.
|
||||
"""
|
||||
config = dict(DEFAULTS)
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
try:
|
||||
loaded = json.load(handle)
|
||||
except json.JSONDecodeError as err:
|
||||
raise ConfigError(f"{path} is not valid JSON: {err}") from err
|
||||
if not isinstance(loaded, dict):
|
||||
raise ConfigError(f"{path} must contain a JSON object")
|
||||
config.update(loaded)
|
||||
else:
|
||||
logger.warning("No config file at %s - using defaults and environment", path)
|
||||
|
||||
for key in DEFAULTS:
|
||||
env_value = os.environ.get(f"LEDMATRIX_MQTT_{key.upper()}")
|
||||
if env_value is not None:
|
||||
config[key] = env_value
|
||||
|
||||
for key in ("mqtt_port", "request_timeout"):
|
||||
try:
|
||||
config[key] = int(config[key])
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ConfigError(f"{key} must be a whole number, got {config[key]!r}") from err
|
||||
for key in ("mqtt_tls", "mqtt_tls_insecure"):
|
||||
config[key] = str(config[key]).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
if config.get("mqtt_password") == "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD":
|
||||
raise ConfigError(
|
||||
"mqtt_password is still the example placeholder - set a real password, "
|
||||
"or remove the key if your broker allows anonymous connections")
|
||||
return config
|
||||
|
||||
|
||||
class LEDMatrixClient:
|
||||
"""The api_v3 calls the bridge needs, and nothing else.
|
||||
|
||||
Everything goes through the HTTP API rather than the filesystem, so the
|
||||
bridge does not have to live on the Pi, does not need read access to
|
||||
config.json, and cannot drift from the web UI's own behaviour.
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str, timeout: int = 15,
|
||||
session: Optional[requests.Session] = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
|
||||
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||
url = f"{self.api_base}/api/v3{path}"
|
||||
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
if response.status_code >= 400 or body.get("status") == "error":
|
||||
message = body.get("message") or f"HTTP {response.status_code}"
|
||||
raise RuntimeError(f"{method} {path} failed: {message}")
|
||||
return body.get("data", body)
|
||||
|
||||
def list_modes(self) -> List[Dict[str, Any]]:
|
||||
"""Every display mode that can be force-displayed, newest discovery.
|
||||
|
||||
/display/modes triggers plugin discovery itself, which matters because
|
||||
discovery is lazy: a bridge that never opens the dashboard would
|
||||
otherwise see nothing at all.
|
||||
"""
|
||||
return self._call("GET", "/display/modes").get("modes", [])
|
||||
|
||||
def display_status(self) -> Dict[str, Any]:
|
||||
return self._call("GET", "/display/on-demand/status")
|
||||
|
||||
def start_on_demand(self, mode: str, plugin_id: Optional[str] = None,
|
||||
duration: Optional[int] = None, pinned: bool = False) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"mode": mode, "pinned": pinned}
|
||||
if plugin_id:
|
||||
# find_plugin_for_mode only sees modes declared in a static
|
||||
# manifest, so a plugin whose modes are generated -- each installed
|
||||
# Starlark app is one -- 404s when plugin_id is omitted. Sending it
|
||||
# skips that lookup. /display/modes reports it for every mode.
|
||||
payload["plugin_id"] = plugin_id
|
||||
if duration:
|
||||
payload["duration"] = int(duration)
|
||||
return self._call("POST", "/display/on-demand/start", json=payload)
|
||||
|
||||
def stop_on_demand(self) -> Dict[str, Any]:
|
||||
return self._call("POST", "/display/on-demand/stop", json={})
|
||||
|
||||
def set_power(self, on: bool) -> Dict[str, Any]:
|
||||
action = "start_display" if on else "stop_display"
|
||||
return self._call("POST", "/system/action", json={"action": action})
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
config = self._call("GET", "/config/main")
|
||||
value = config.get("display", {}).get("hardware", {}).get("brightness")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def set_brightness(self, value: int) -> Dict[str, Any]:
|
||||
return self._call("POST", "/config/main", json={"brightness": int(value)})
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
"""Turns one decoded MQTT payload into one API call.
|
||||
|
||||
Kept free of MQTT so it can be tested against a fake client: the failure
|
||||
modes worth pinning are all in here (an unknown mode, an out-of-range
|
||||
brightness, a mode name that needs its plugin_id attached).
|
||||
"""
|
||||
|
||||
def __init__(self, client: LEDMatrixClient, default_duration: Optional[int] = None):
|
||||
self.client = client
|
||||
self.default_duration = default_duration
|
||||
self._modes_by_name: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def refresh_modes(self) -> List[Dict[str, Any]]:
|
||||
modes = self.client.list_modes()
|
||||
self._modes_by_name = {m["mode"]: m for m in modes}
|
||||
# Home Assistant's select shows labels, so accept them back as well --
|
||||
# otherwise picking "Simple Clock" in a dashboard is not a mode name.
|
||||
for entry in modes:
|
||||
self._modes_by_name.setdefault(entry.get("name") or entry["mode"], entry)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def known_modes(self) -> List[Dict[str, Any]]:
|
||||
return list({id(v): v for v in self._modes_by_name.values()}.values())
|
||||
|
||||
def handle(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = payload.get("action")
|
||||
handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||
"display": self._display,
|
||||
"stop_display": lambda _p: self._ok(self.client.stop_on_demand()),
|
||||
"power": self._power,
|
||||
"brightness": self._brightness,
|
||||
"refresh": lambda _p: self._ok({"modes": len(self.refresh_modes())}),
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return self._error(f"Unknown action {action!r}; expected one of "
|
||||
f"{', '.join(sorted(handlers))}")
|
||||
try:
|
||||
return handler(payload)
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.error("Command %s failed: %s", action, err)
|
||||
return self._error(str(err))
|
||||
|
||||
def _display(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mode = payload.get("mode")
|
||||
plugin_id = payload.get("plugin_id")
|
||||
if not mode and not plugin_id:
|
||||
return self._error("display requires 'mode' or 'plugin_id'")
|
||||
|
||||
known = self._modes_by_name.get(mode) if mode else None
|
||||
if known is None and mode and not plugin_id:
|
||||
# One retry against a fresh listing: a plugin installed since the
|
||||
# last refresh is the common reason a valid mode looks unknown.
|
||||
self.refresh_modes()
|
||||
known = self._modes_by_name.get(mode)
|
||||
if known is not None:
|
||||
mode = known["mode"]
|
||||
plugin_id = plugin_id or known.get("plugin_id")
|
||||
|
||||
duration = payload.get("duration", self.default_duration)
|
||||
pinned = bool(payload.get("pinned", False))
|
||||
result = self.client.start_on_demand(
|
||||
mode=mode, plugin_id=plugin_id, duration=duration, pinned=pinned)
|
||||
return self._ok(result, mode=mode, plugin_id=plugin_id)
|
||||
|
||||
def _power(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state = str(payload.get("state", "")).strip().lower()
|
||||
if state not in ("on", "off"):
|
||||
return self._error("power requires 'state' of 'on' or 'off'")
|
||||
return self._ok(self.client.set_power(state == "on"), state=state)
|
||||
|
||||
def _brightness(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = payload.get("value")
|
||||
try:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return self._error(f"brightness requires a number, got {raw!r}")
|
||||
if not 0 <= value <= 100:
|
||||
return self._error(f"brightness must be between 0 and 100, got {value}")
|
||||
return self._ok(self.client.set_brightness(value), value=value)
|
||||
|
||||
@staticmethod
|
||||
def _ok(result: Any, **extra) -> Dict[str, Any]:
|
||||
return {"status": "success", "result": result, **extra}
|
||||
|
||||
@staticmethod
|
||||
def _error(message: str) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": message}
|
||||
|
||||
|
||||
def discovery_messages(command_topic: str, state_topic: str, availability_topic: str,
|
||||
mode_labels: List[str]) -> List[Dict[str, Any]]:
|
||||
"""The retained MQTT Discovery configs, as {topic, payload} pairs.
|
||||
|
||||
Pure, so the entity shapes can be asserted without a broker. Every entity
|
||||
shares one availability topic, which is also the bridge's last will -- HA
|
||||
then shows the matrix as unavailable when the bridge dies, instead of
|
||||
leaving stale controls that silently do nothing.
|
||||
"""
|
||||
common = {
|
||||
"device": DEVICE_INFO,
|
||||
"availability_topic": availability_topic,
|
||||
"payload_available": "online",
|
||||
"payload_not_available": "offline",
|
||||
}
|
||||
return [
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/select/{DEVICE_ID}/display_mode/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Display Mode",
|
||||
"unique_id": f"{DEVICE_ID}_display_mode",
|
||||
"command_topic": command_topic,
|
||||
"command_template": '{"action": "display", "mode": "{{ value }}"}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json.mode }}",
|
||||
"options": mode_labels,
|
||||
"icon": "mdi:view-dashboard",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/button/{DEVICE_ID}/stop_display/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Stop Display",
|
||||
"unique_id": f"{DEVICE_ID}_stop_display",
|
||||
"command_topic": command_topic,
|
||||
"payload_press": '{"action": "stop_display"}',
|
||||
"icon": "mdi:stop",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/switch/{DEVICE_ID}/power/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Power",
|
||||
"unique_id": f"{DEVICE_ID}_power",
|
||||
"command_topic": command_topic,
|
||||
"payload_on": '{"action": "power", "state": "on"}',
|
||||
"payload_off": '{"action": "power", "state": "off"}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ 'ON' if value_json.power else 'OFF' }}",
|
||||
"state_on": "ON",
|
||||
"state_off": "OFF",
|
||||
"icon": "mdi:power",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/number/{DEVICE_ID}/brightness/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Brightness",
|
||||
"unique_id": f"{DEVICE_ID}_brightness",
|
||||
"command_topic": command_topic,
|
||||
"command_template": '{"action": "brightness", "value": {{ value }}}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json.brightness }}",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"icon": "mdi:brightness-6",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def warn_if_cleartext(config: Dict[str, Any]) -> bool:
|
||||
"""Say so, once, when a broker password is going over an unencrypted link.
|
||||
|
||||
The shipped example has TLS on, so reaching here means somebody turned it
|
||||
off deliberately -- which is legitimate (the Mosquitto add-on is plaintext
|
||||
on 1883) but should not be silent when there is a password to lose. Returns
|
||||
whether it warned, so the decision is testable without a broker.
|
||||
"""
|
||||
if config.get("mqtt_tls") or not config.get("mqtt_password"):
|
||||
return False
|
||||
logger.warning(
|
||||
'mqtt_tls is off and a password is set: the broker password and every '
|
||||
'command are sent unencrypted. Set "mqtt_tls": true (port 8883 on most '
|
||||
'brokers) unless this is a trusted, isolated network.')
|
||||
return True
|
||||
|
||||
|
||||
def read_state(client: LEDMatrixClient) -> Dict[str, Any]:
|
||||
"""The state every entity reads, so HA opens on real values.
|
||||
|
||||
Each field is fetched independently: a matrix with its display service
|
||||
stopped still has a brightness worth showing, and one unreachable field
|
||||
should not blank the rest.
|
||||
"""
|
||||
state: Dict[str, Any] = {"power": False, "mode": None, "brightness": None}
|
||||
try:
|
||||
status = client.display_status()
|
||||
state["power"] = bool(status.get("service", {}).get("active"))
|
||||
on_demand = status.get("state", {})
|
||||
if on_demand.get("active"):
|
||||
state["mode"] = on_demand.get("mode")
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.debug("Could not read display status: %s", err)
|
||||
try:
|
||||
state["brightness"] = client.get_brightness()
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.debug("Could not read brightness: %s", err)
|
||||
return state
|
||||
|
||||
|
||||
class Bridge:
|
||||
"""MQTT wiring around CommandHandler."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.command_topic = config["mqtt_topic"]
|
||||
self.status_topic = f"{self.command_topic}/status"
|
||||
self.state_topic = f"{self.command_topic}/state"
|
||||
self.availability_topic = f"{self.command_topic}/availability"
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
|
||||
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
||||
self._stop = threading.Event()
|
||||
self._mqtt = None
|
||||
|
||||
# -- MQTT callbacks (paho-mqtt 2.x VERSION2 signatures) ------------------
|
||||
|
||||
def _on_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
||||
if getattr(reason_code, "is_failure", reason_code != 0):
|
||||
logger.error("MQTT connection refused: %s", reason_code)
|
||||
return
|
||||
logger.info("Connected to MQTT broker; subscribing to %s", self.command_topic)
|
||||
client.subscribe(self.command_topic, qos=1)
|
||||
client.publish(self.availability_topic, "online", qos=1, retain=True)
|
||||
# Re-publish on every reconnect, not just the first connect: a broker
|
||||
# restart drops retained discovery configs, and HA would otherwise be
|
||||
# left with entities it can no longer describe.
|
||||
self.publish_discovery()
|
||||
self.publish_state()
|
||||
|
||||
def _on_message(self, _client, _userdata, message):
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
logger.warning("Ignoring unparseable message on %s: %s", message.topic, err)
|
||||
self._publish(self.status_topic, {"status": "error", "message": f"bad payload: {err}"})
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
self._publish(self.status_topic,
|
||||
{"status": "error", "message": "payload must be a JSON object"})
|
||||
return
|
||||
|
||||
logger.info("Command: %s", payload)
|
||||
result = self.handler.handle(payload)
|
||||
self._publish(self.status_topic, result)
|
||||
# The API applies changes asynchronously (the controller polls its
|
||||
# mailbox), so read state back rather than assuming the command took.
|
||||
self.publish_state()
|
||||
|
||||
# -- publishing ---------------------------------------------------------
|
||||
|
||||
def _publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
if self._mqtt is None:
|
||||
return
|
||||
body = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
self._mqtt.publish(topic, body, qos=1, retain=retain)
|
||||
|
||||
def publish_discovery(self) -> None:
|
||||
try:
|
||||
modes = self.handler.refresh_modes()
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.error("Could not list display modes: %s", err)
|
||||
modes = self.handler.known_modes
|
||||
labels = sorted({m.get("name") or m["mode"] for m in modes})
|
||||
for message in discovery_messages(self.command_topic, self.state_topic,
|
||||
self.availability_topic, labels):
|
||||
self._publish(message["topic"], message["payload"], retain=True)
|
||||
logger.info("Published discovery for %d display mode(s)", len(labels))
|
||||
|
||||
def publish_state(self) -> None:
|
||||
self._publish(self.state_topic, read_state(self.client), retain=True)
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
|
||||
def run(self) -> int:
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except ImportError:
|
||||
logger.error("paho-mqtt is not installed: pip install -r requirements.txt")
|
||||
return 1
|
||||
|
||||
# VERSION2 is the current callback API. The compatibility note in
|
||||
# CLAUDE.md is about code written against the v1 signatures; this file
|
||||
# is written against v2 and requires paho-mqtt >= 2.0.
|
||||
self._mqtt = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=self.config["mqtt_client_id"])
|
||||
if self.config.get("mqtt_username"):
|
||||
self._mqtt.username_pw_set(self.config["mqtt_username"],
|
||||
self.config.get("mqtt_password"))
|
||||
if self.config.get("mqtt_tls"):
|
||||
self._mqtt.tls_set()
|
||||
if self.config.get("mqtt_tls_insecure"):
|
||||
logger.warning("TLS certificate verification is disabled (mqtt_tls_insecure)")
|
||||
self._mqtt.tls_insecure_set(True)
|
||||
else:
|
||||
warn_if_cleartext(self.config)
|
||||
|
||||
self._mqtt.will_set(self.availability_topic, "offline", qos=1, retain=True)
|
||||
self._mqtt.on_connect = self._on_connect
|
||||
self._mqtt.on_message = self._on_message
|
||||
|
||||
logger.info("Connecting to %s:%s", self.config["mqtt_host"], self.config["mqtt_port"])
|
||||
try:
|
||||
self._mqtt.connect(self.config["mqtt_host"], self.config["mqtt_port"], keepalive=60)
|
||||
except OSError as err:
|
||||
logger.error("Could not reach the MQTT broker: %s", err)
|
||||
return 1
|
||||
|
||||
self._mqtt.loop_start()
|
||||
try:
|
||||
while not self._stop.wait(30):
|
||||
# HA is told the truth about state that changed outside the
|
||||
# bridge -- somebody using the web UI, or an on-demand window
|
||||
# expiring on its own.
|
||||
self.publish_state()
|
||||
finally:
|
||||
self._publish(self.availability_topic, "offline", retain=True)
|
||||
self._mqtt.loop_stop()
|
||||
self._mqtt.disconnect()
|
||||
return 0
|
||||
|
||||
def stop(self, *_args) -> None:
|
||||
logger.info("Shutting down")
|
||||
self._stop.set()
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge_config.json"),
|
||||
help="Path to bridge_config.json (default: alongside this script)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as err:
|
||||
logger.error("%s", err)
|
||||
return 1
|
||||
logging.getLogger().setLevel(str(config.get("log_level", "INFO")).upper())
|
||||
|
||||
bridge = Bridge(config)
|
||||
signal.signal(signal.SIGTERM, bridge.stop)
|
||||
signal.signal(signal.SIGINT, bridge.stop)
|
||||
return bridge.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
# Floors are security floors, not API floors. requests < 2.33.0 carries
|
||||
# CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645; matches the pin in the
|
||||
# project's own requirements.txt.
|
||||
paho-mqtt>=2.0.0,<3.0.0
|
||||
requests>=2.33.0,<3.0.0
|
||||
@@ -194,6 +194,15 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Each installed app becomes a dynamic display mode.
|
||||
"""
|
||||
|
||||
#: Starlark apps are animations: a .webp render carries per-frame delays
|
||||
#: and _display_frame advances at most one frame per call. The controller
|
||||
#: reads this attribute to decide whether a mode needs its high-FPS loop;
|
||||
#: without it display() was called once per rotation slot, so a multi-frame
|
||||
#: app showed a single frame and never moved. static-image is force-run at
|
||||
#: high FPS for the same reason (GIFs), but that plugin is special-cased by
|
||||
#: name in the controller and this one has to declare it.
|
||||
enable_scrolling = True
|
||||
|
||||
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
||||
display_manager, cache_manager, plugin_manager):
|
||||
"""Initialize the Starlark Apps plugin."""
|
||||
@@ -681,13 +690,20 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if app.is_enabled() and app.should_render(current_time):
|
||||
self._render_app(app, force=False)
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
|
||||
"""
|
||||
Display current Starlark app.
|
||||
|
||||
This method is called during the display rotation.
|
||||
Displays frames from the currently active app.
|
||||
|
||||
`display_mode` names the app to show when it matches an installed
|
||||
app_id. The controller passes the mode it is rotating to and inspects
|
||||
this signature to decide whether to, so accepting it is what lets a
|
||||
specific app be addressed -- including by an on-demand request pinned
|
||||
to one app. Anything else (the plugin id itself, when the plugin
|
||||
exposes no per-app modes) falls through to normal rotation.
|
||||
|
||||
Returns False when there is no app to show -- which is the state of
|
||||
every install without Pixlet, and of a fresh one before any app is
|
||||
added. The display controller only skips a mode on a boolean False
|
||||
@@ -698,8 +714,15 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# If no current app, try to select one
|
||||
if not self.current_app:
|
||||
if display_mode and display_mode in self.apps:
|
||||
self.current_app = self.apps[display_mode]
|
||||
elif force_clear or not self.current_app:
|
||||
# Advance on entry to the mode. _select_next_app only ran when
|
||||
# current_app was unset, so the first enabled app was picked
|
||||
# once and then shown forever -- every other installed app was
|
||||
# rendered on schedule and never displayed. force_clear is the
|
||||
# controller's "we just switched to you" signal (it is reset
|
||||
# immediately after this call), so one app gets each turn.
|
||||
self._select_next_app()
|
||||
|
||||
if not self.current_app:
|
||||
|
||||
@@ -264,10 +264,18 @@ class PixletRenderer:
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
# Validate value doesn't contain dangerous shell metacharacters
|
||||
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces
|
||||
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
|
||||
# Validate value doesn't contain dangerous shell metacharacters.
|
||||
# Kept as defence in depth only: cmd is a list and there is no
|
||||
# shell=True below, so nothing here is ever interpreted by a
|
||||
# shell. That made the list worth trimming rather than growing
|
||||
# -- "|" is a normal character inside a config value, and apps
|
||||
# do use it as a separator (a PennDOT sign id is
|
||||
# "I-476 North|175659"). Blocking it dropped the whole key
|
||||
# silently, and the app then rendered its own "not configured"
|
||||
# screen with nothing to say why.
|
||||
# Block: backticks, $(), redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces, pipes
|
||||
if re.search(r'[`$<>&;\x00]|\$\(', value_str):
|
||||
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
||||
continue
|
||||
|
||||
@@ -299,13 +307,21 @@ class PixletRenderer:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
if os.path.isfile(output_path):
|
||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||
return True, None
|
||||
else:
|
||||
if not os.path.isfile(output_path):
|
||||
error = "Rendering succeeded but output file not found"
|
||||
logger.error(error)
|
||||
return False, error
|
||||
# Pixlet exits 0 and writes a 0-byte file when the app renders
|
||||
# nothing -- an app whose config leaves it with no content to
|
||||
# show does exactly that. Treating existence alone as success
|
||||
# handed the caller a file with no frames in it, which reads
|
||||
# downstream as a working app that draws a black panel.
|
||||
if os.path.getsize(output_path) == 0:
|
||||
error = "Rendering produced an empty (0-byte) file - the app rendered no content"
|
||||
logger.error(error)
|
||||
return False, error
|
||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||
return True, None
|
||||
else:
|
||||
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
||||
logger.error(error)
|
||||
@@ -319,11 +335,76 @@ class PixletRenderer:
|
||||
logger.exception("Rendering exception")
|
||||
return False, "Rendering failed - see logs for details"
|
||||
|
||||
#: Schema extraction runs an app's own get_schema(), which may make a
|
||||
#: network call. Short enough that a hung app does not stall an upload,
|
||||
#: long enough for a real API round trip on a slow connection.
|
||||
SCHEMA_TIMEOUT = 20
|
||||
|
||||
def extract_schema_via_pixlet(self, star_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""Ask Pixlet itself for the app's schema, or None if it cannot say.
|
||||
|
||||
`pixlet schema` executes get_schema() instead of reading it, which is
|
||||
the only way to see options an app computes at runtime -- a dropdown
|
||||
whose choices come from a live API call has no option list anywhere in
|
||||
the source for the regex parser below to find, so that parser reports
|
||||
an empty dropdown and the config form offers nothing to pick.
|
||||
|
||||
Pixlet's own field keys are remapped to the ones the rest of this
|
||||
plugin and the config UI already use ("typeOf"/"desc"), so the two
|
||||
extractors return the same shape and callers cannot tell them apart.
|
||||
"""
|
||||
if not self.pixlet_binary:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.pixlet_binary, "schema", star_file],
|
||||
capture_output=True, text=True, timeout=self.SCHEMA_TIMEOUT,
|
||||
cwd=self._get_safe_working_directory(star_file),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"pixlet schema timed out after %ss for %s - get_schema() may be "
|
||||
"making a slow network call", self.SCHEMA_TIMEOUT, star_file)
|
||||
return None
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Could not run pixlet schema for {star_file}: {e}")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
# Not an error worth failing on: older Pixlet builds have no
|
||||
# `schema` subcommand at all, and the source parser still works.
|
||||
logger.debug(
|
||||
"pixlet schema exited %d for %s: %s",
|
||||
result.returncode, star_file, (result.stderr or '').strip()[:300])
|
||||
return None
|
||||
|
||||
try:
|
||||
schema = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning(f"pixlet schema returned unparseable output for {star_file}: {e}")
|
||||
return None
|
||||
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("schema"), list):
|
||||
logger.warning(f"pixlet schema returned an unexpected shape for {star_file}")
|
||||
return None
|
||||
|
||||
for field in schema["schema"]:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if "type" in field and "typeOf" not in field:
|
||||
field["typeOf"] = field.pop("type")
|
||||
if "description" in field and "desc" not in field:
|
||||
field["desc"] = field.pop("description")
|
||||
return schema
|
||||
|
||||
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Extract configuration schema from a .star file by parsing source code.
|
||||
Extract configuration schema from a .star file.
|
||||
|
||||
Supports:
|
||||
Prefers `pixlet schema`, which runs the app and therefore sees options
|
||||
it computes at runtime. Falls back to parsing the source when Pixlet is
|
||||
unavailable, too old to have the subcommand, or the app fails to run --
|
||||
that parser handles:
|
||||
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
||||
- Variable-referenced dropdown options
|
||||
- Graceful degradation for unsupported field types
|
||||
@@ -337,6 +418,13 @@ class PixletRenderer:
|
||||
if not os.path.isfile(star_file):
|
||||
return False, None, f"Star file not found: {star_file}"
|
||||
|
||||
schema = self.extract_schema_via_pixlet(star_file)
|
||||
if schema is not None:
|
||||
logger.debug(
|
||||
"Extracted schema with %d field(s) from %s via pixlet schema",
|
||||
len(schema.get('schema', [])), star_file)
|
||||
return True, schema, None
|
||||
|
||||
try:
|
||||
# Read .star file
|
||||
with open(star_file, 'r', encoding='utf-8') as f:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DNS single-request fix installation script.
|
||||
#
|
||||
# Optional. Install this only if plugins that call external APIs (Starlark
|
||||
# apps, weather, sports, music) are timing out or feel slow to first paint
|
||||
# while the network is otherwise fine. See the header of
|
||||
# scripts/utils/apply_dns_single_request.sh for what it changes and why.
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
SERVICE_NAME="ledmatrix-dns-fix"
|
||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
DROPIN_DIR="/etc/systemd/system/ledmatrix.service.d"
|
||||
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
SYSTEMCTL_CMD="systemctl"
|
||||
SUDO=""
|
||||
else
|
||||
SYSTEMCTL_CMD="sudo systemctl"
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing LED Matrix DNS fix service"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
|
||||
if [ ! -f "$UNIT_SRC" ]; then
|
||||
echo "✗ Missing unit file: $UNIT_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
||||
|
||||
echo "Installing $UNIT_DEST..."
|
||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||
|
||||
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
||||
# orders units already in the same transaction, so a plain
|
||||
# `systemctl restart ledmatrix` would not wait for it -- and since this fix is
|
||||
# opt-in, ledmatrix.service cannot carry the dependency in the repo.
|
||||
# Wants=, not Requires=: a DNS workaround failing should not stop the display.
|
||||
echo "Installing the ledmatrix.service ordering drop-in..."
|
||||
$SUDO mkdir -p "$DROPIN_DIR"
|
||||
printf '[Unit]\nWants=%s.service\nAfter=%s.service\n' "$SERVICE_NAME" "$SERVICE_NAME" \
|
||||
| $SUDO tee "$DROPIN_DIR/10-dns-fix.conf" > /dev/null
|
||||
|
||||
$SYSTEMCTL_CMD daemon-reload
|
||||
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
||||
|
||||
# Do not mask a failure here. The unit exits non-zero when it could not apply
|
||||
# the option -- a systemd-resolved host, an unwritable resolv.conf, a failed
|
||||
# `resolvconf -u` -- and reporting "installation complete" over that would
|
||||
# leave the operator believing a workaround is active when it is not.
|
||||
START_STATUS=0
|
||||
$SYSTEMCTL_CMD start "$SERVICE_NAME.service" || START_STATUS=$?
|
||||
|
||||
echo ""
|
||||
if grep -qs "^options single-request$" /etc/resolv.conf; then
|
||||
echo "✓ 'options single-request' is active in /etc/resolv.conf"
|
||||
elif [ "$START_STATUS" -ne 0 ]; then
|
||||
echo "✗ The DNS fix could not be applied on this host."
|
||||
echo " The service reported why:"
|
||||
echo " journalctl -u $SERVICE_NAME -n 20"
|
||||
echo ""
|
||||
echo " The unit is installed and will try again on the next boot. Nothing"
|
||||
echo " else about your install has changed."
|
||||
exit "$START_STATUS"
|
||||
else
|
||||
echo "⚠ 'options single-request' is not in /etc/resolv.conf yet."
|
||||
echo " Check what the service reported:"
|
||||
echo " journalctl -u $SERVICE_NAME -n 20"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "DNS fix installation complete."
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " sudo systemctl status $SERVICE_NAME # Check status"
|
||||
echo " sudo journalctl -u $SERVICE_NAME -n 50 # View logs"
|
||||
echo " sudo systemctl disable --now $SERVICE_NAME # Undo the service"
|
||||
echo " sudo rm $DROPIN_DIR/10-dns-fix.conf # Undo the ordering drop-in"
|
||||
echo " # then remove the 'options single-request' line from /etc/resolv.conf"
|
||||
echo ""
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Home Assistant MQTT bridge installation script.
|
||||
#
|
||||
# Optional. Installs integrations/mqtt_bridge as a service so Home Assistant
|
||||
# can force display modes, toggle power and set brightness over MQTT.
|
||||
# See integrations/mqtt_bridge/README.md.
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
||||
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
SYSTEMCTL_CMD="systemctl"
|
||||
SUDO=""
|
||||
else
|
||||
SYSTEMCTL_CMD="sudo systemctl"
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing LED Matrix MQTT bridge"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
|
||||
if [ ! -f "$BRIDGE_DIR/bridge_config.json" ]; then
|
||||
cp "$BRIDGE_DIR/bridge_config.example.json" "$BRIDGE_DIR/bridge_config.json"
|
||||
chmod 600 "$BRIDGE_DIR/bridge_config.json"
|
||||
echo ""
|
||||
echo "⚠ Created $BRIDGE_DIR/bridge_config.json from the example."
|
||||
echo " Edit it with your broker details, then re-run this script."
|
||||
echo " The service will refuse to start until the placeholder password is replaced."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Installing Python dependencies..."
|
||||
python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|
||||
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
||||
|
||||
echo "Installing $UNIT_DEST..."
|
||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||
|
||||
$SYSTEMCTL_CMD daemon-reload
|
||||
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
||||
$SYSTEMCTL_CMD restart "$SERVICE_NAME.service" || true
|
||||
|
||||
echo ""
|
||||
if $SYSTEMCTL_CMD is-active --quiet "$SERVICE_NAME.service" 2>/dev/null; then
|
||||
echo "✓ MQTT bridge is running"
|
||||
echo " The matrix should appear in Home Assistant under Settings > Devices > MQTT."
|
||||
else
|
||||
echo "⚠ MQTT bridge is not running. Check the logs:"
|
||||
echo " sudo journalctl -u $SERVICE_NAME -n 50"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " sudo systemctl status $SERVICE_NAME"
|
||||
echo " sudo journalctl -u $SERVICE_NAME -f"
|
||||
echo " sudo systemctl disable --now $SERVICE_NAME # Undo"
|
||||
echo ""
|
||||
@@ -123,6 +123,8 @@ def demo(hardware, target, seconds):
|
||||
"""Scroll text at the crisp speed nearest `target`."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
|
||||
choice = scroll_config.solve_crisp(target, hz)
|
||||
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
|
||||
@@ -137,7 +139,7 @@ def demo(hardware, target, seconds):
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
|
||||
):
|
||||
try:
|
||||
font = ImageFont.truetype(path, size)
|
||||
font = load_truetype(path, size)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,8 @@ This directory contains utility scripts for maintenance and system operations.
|
||||
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
||||
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
|
||||
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
|
||||
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
|
||||
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -25,3 +27,31 @@ This script is typically called by the systemd service (`ledmatrix-web.service`)
|
||||
### WiFi Monitor Daemon
|
||||
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
|
||||
|
||||
|
||||
### Pixlet Config Editor
|
||||
Run it when you want Pixlet's own config form for a Starlark app -- live
|
||||
render preview, cascading dropdowns -- rather than the LEDMatrix one.
|
||||
|
||||
```bash
|
||||
./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||
./scripts/utils/pixlet_config_editor.sh penndot_signs # edit, on localhost:8080
|
||||
```
|
||||
|
||||
Deliberately not a service. It stops the display for the length of the
|
||||
session and `pixlet serve` listens with no authentication, so it should only
|
||||
be running while you are actually editing. It backs the config up first and
|
||||
restarts the display on exit, however it exits.
|
||||
|
||||
It binds loopback only, with no flag to change that: anything that can reach
|
||||
`pixlet serve` can rewrite the app's config, and a printed warning is not
|
||||
access control. To edit from another machine, forward the port -- SSH does the
|
||||
authenticating and nothing is left listening on the LAN:
|
||||
|
||||
```bash
|
||||
ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||
```
|
||||
|
||||
### Apply DNS Single-Request Fix
|
||||
Installed and run by `ledmatrix-dns-fix.service`; see `systemd/README.md`.
|
||||
Safe to run by hand (`sudo ./scripts/utils/apply_dns_single_request.sh`) and
|
||||
idempotent.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Add `options single-request` to the system resolver configuration.
|
||||
#
|
||||
# glibc's getaddrinfo() sends the A and AAAA queries for a name in
|
||||
# parallel on one socket. Some routers answer the A query and drop the
|
||||
# AAAA one, so the resolver waits out its full timeout -- about five
|
||||
# seconds -- before returning an address that was already available.
|
||||
# Disabling IPv6 in the kernel does not help: the resolver still asks.
|
||||
#
|
||||
# `single-request` makes it send the two queries one after the other,
|
||||
# which those routers answer correctly. Anything on the matrix that
|
||||
# calls an external API pays that five seconds per lookup otherwise, and
|
||||
# a Starlark app with a render timeout will simply fail instead.
|
||||
#
|
||||
# Idempotent, and safe to run on a machine that does not need it. Run by
|
||||
# ledmatrix-dns-fix.service on every boot, because whatever manages
|
||||
# resolv.conf regenerates it and drops the option again.
|
||||
#
|
||||
# Usage: sudo ./scripts/utils/apply_dns_single_request.sh
|
||||
|
||||
set -eu
|
||||
|
||||
OPTION="options single-request"
|
||||
RESOLVCONF_TAIL="/etc/resolvconf/resolv.conf.d/tail"
|
||||
RESOLV_CONF="/etc/resolv.conf"
|
||||
|
||||
log() { echo "[dns-single-request] $*"; }
|
||||
|
||||
already_applied() {
|
||||
grep -qs "^${OPTION}\$" "$1"
|
||||
}
|
||||
|
||||
# resolvconf regenerates /etc/resolv.conf from these fragments, so the
|
||||
# tail file is the only place an addition survives. Prefer it when the
|
||||
# directory exists, whether or not resolvconf has run yet.
|
||||
if [ -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
||||
if already_applied "$RESOLVCONF_TAIL"; then
|
||||
log "already present in $RESOLVCONF_TAIL"
|
||||
else
|
||||
echo "$OPTION" >> "$RESOLVCONF_TAIL"
|
||||
log "added to $RESOLVCONF_TAIL"
|
||||
fi
|
||||
# Only a missing resolvconf is ignorable. If it is present and the
|
||||
# regeneration fails, /etc/resolv.conf still lacks the option, and
|
||||
# reporting success would be a lie.
|
||||
if command -v resolvconf >/dev/null 2>&1; then
|
||||
if ! resolvconf -u; then
|
||||
log "resolvconf -u failed; $RESOLV_CONF was not regenerated"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# systemd-resolved owns its stub file and rewrites anything appended to it,
|
||||
# and `single-request` is a glibc resolv.conf option with no resolved.conf
|
||||
# equivalent -- so there is nothing this script can do here. Exit non-zero:
|
||||
# the unit would otherwise record success while the workaround is inactive,
|
||||
# which is the failure mode this whole script exists to avoid.
|
||||
if [ -L "$RESOLV_CONF" ] && readlink -f "$RESOLV_CONF" | grep -q "systemd"; then
|
||||
log "$RESOLV_CONF is managed by systemd-resolved."
|
||||
log "'options single-request' is a glibc resolv.conf option and has no"
|
||||
log "resolved.conf equivalent, so it cannot be applied on this host."
|
||||
log "If external API calls are slow, the workaround is to stop using the"
|
||||
log "systemd-resolved stub (see 'man systemd-resolved', NSS/resolv.conf modes)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if already_applied "$RESOLV_CONF"; then
|
||||
log "already present in $RESOLV_CONF"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -w "$RESOLV_CONF" ] && [ -e "$RESOLV_CONF" ]; then
|
||||
log "cannot write $RESOLV_CONF (run with sudo?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A NetworkManager-generated resolv.conf is regenerated on every connection
|
||||
# change, not only at boot -- and this unit is oneshot with RemainAfterExit,
|
||||
# so it will not re-run within the same boot to put the option back. Say so
|
||||
# rather than implying the fix is permanent. Nothing is silently swallowed:
|
||||
# the append below still happens and still works until the next renewal.
|
||||
if grep -qs "Generated by NetworkManager" "$RESOLV_CONF" \
|
||||
&& [ ! -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
||||
log "NOTE: $RESOLV_CONF is generated by NetworkManager and has no"
|
||||
log "resolvconf tail directory to write to. The option is being added, but"
|
||||
log "NetworkManager will drop it on the next connection renewal, and this"
|
||||
log "unit does not run again until the next boot. If lookups go slow again"
|
||||
log "before a reboot, re-run this script."
|
||||
fi
|
||||
|
||||
echo "$OPTION" >> "$RESOLV_CONF"
|
||||
log "added to $RESOLV_CONF"
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Edit an installed Starlark app's config in Pixlet's own config UI.
|
||||
#
|
||||
# `pixlet serve` runs the app for real, so its form has working cascading
|
||||
# dropdowns and option lists fetched live -- useful for an app whose choices
|
||||
# only exist at runtime, or when you want to see the render change as you
|
||||
# type. The LEDMatrix config form now reads the same runtime schema (see
|
||||
# PixletRenderer.extract_schema_via_pixlet), so reach for this when you want
|
||||
# Pixlet's live preview, not because the normal form is missing options.
|
||||
#
|
||||
# Deliberately a script you run and then Ctrl+C, not a service: it stops the
|
||||
# display for the length of the session, and `pixlet serve` listens on a port
|
||||
# with no authentication. Nothing here should be listening when you are not
|
||||
# actually editing.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
||||
#
|
||||
# Binds loopback only, and there is deliberately no flag to change that:
|
||||
# `pixlet serve` has no authentication, and anything that can reach it can
|
||||
# rewrite the app's config. To edit from another machine, forward the port --
|
||||
# which authenticates as SSH and leaves nothing listening on the LAN:
|
||||
#
|
||||
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||
|
||||
set -eu
|
||||
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
||||
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
||||
# Loopback only. See the header: pixlet serve is unauthenticated.
|
||||
BIND_HOST="127.0.0.1"
|
||||
|
||||
APP_ID="${1:-}"
|
||||
|
||||
list_apps() {
|
||||
if [ -d "$APPS_DIR" ]; then
|
||||
find "$APPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf ' %f\n' 2>/dev/null | sort
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$APP_ID" ]; then
|
||||
echo "Usage: $0 <app_id>"
|
||||
echo ""
|
||||
echo "Installed apps:"
|
||||
list_apps || true
|
||||
[ -n "$(list_apps)" ] || echo " (none found in $APPS_DIR)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="$APPS_DIR/$APP_ID"
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
echo "No such app: $APP_ID"
|
||||
echo ""
|
||||
echo "Installed apps:"
|
||||
list_apps
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAR_FILE=$(find "$APP_DIR" -maxdepth 1 -iname "*.star" | head -1)
|
||||
if [ -z "$STAR_FILE" ]; then
|
||||
echo "No .star file found in $APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Same search order the plugin itself uses: the bundled binary for this
|
||||
# architecture first, then PATH -- so this works on an install that never put
|
||||
# pixlet on PATH.
|
||||
find_pixlet() {
|
||||
local arch bundled
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Linux-aarch64|Linux-arm64) arch="pixlet-linux-arm64" ;;
|
||||
Linux-x86_64|Linux-amd64) arch="pixlet-linux-amd64" ;;
|
||||
Darwin-arm64) arch="pixlet-darwin-arm64" ;;
|
||||
Darwin-x86_64) arch="pixlet-darwin-amd64" ;;
|
||||
*) arch="" ;;
|
||||
esac
|
||||
bundled="$PROJECT_ROOT_DIR/bin/pixlet/$arch"
|
||||
if [ -n "$arch" ] && [ -x "$bundled" ]; then
|
||||
echo "$bundled"
|
||||
return 0
|
||||
fi
|
||||
command -v pixlet 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
PIXLET_BIN=$(find_pixlet) || {
|
||||
echo "Pixlet not found. Install it with:"
|
||||
echo " ./scripts/download_pixlet.sh"
|
||||
exit 1
|
||||
}
|
||||
|
||||
CONFIG_FILE="$APP_DIR/config.json"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
|
||||
echo "Backed up existing config to $CONFIG_FILE.backup"
|
||||
else
|
||||
echo "{}" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
DISPLAY_WAS_RUNNING=false
|
||||
if systemctl is-active --quiet ledmatrix 2>/dev/null; then
|
||||
DISPLAY_WAS_RUNNING=true
|
||||
fi
|
||||
|
||||
# Restart the display however this exits -- Ctrl+C, an error, or pixlet
|
||||
# dying on its own. Leaving the panel dark because the editor crashed is the
|
||||
# failure worth guarding against.
|
||||
cleanup() {
|
||||
echo ""
|
||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||
echo "Restarting the display service..."
|
||||
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
||||
fi
|
||||
echo "Your config as it was before this session: $CONFIG_FILE.backup"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||
echo "Stopping the display service so it does not read config.json mid-write..."
|
||||
sudo systemctl stop ledmatrix
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Editing: $APP_ID"
|
||||
echo "App file: $STAR_FILE"
|
||||
echo "URL: http://localhost:$PORT/"
|
||||
echo ""
|
||||
echo "Listening on localhost only -- pixlet serve has no authentication."
|
||||
echo "From another machine, forward the port:"
|
||||
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
||||
echo ""
|
||||
echo "Changes save straight to the real config as you make them."
|
||||
echo "Press Ctrl+C when finished - the display restarts automatically."
|
||||
echo ""
|
||||
|
||||
cd "$APP_DIR"
|
||||
"$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
||||
--host "$BIND_HOST" \
|
||||
--port "$PORT" \
|
||||
--no-browser \
|
||||
--saveconfig "$CONFIG_FILE"
|
||||
@@ -27,6 +27,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||
|
||||
from src.common.font_layout import load_truetype # noqa: E402
|
||||
|
||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||
MODES = ("live", "recent", "upcoming")
|
||||
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||
@@ -52,12 +54,12 @@ class FixtureHost:
|
||||
try:
|
||||
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||
fonts['score'] = ImageFont.truetype(press, 10)
|
||||
fonts['time'] = ImageFont.truetype(press, 8)
|
||||
fonts['team'] = ImageFont.truetype(press, 8)
|
||||
fonts['status'] = ImageFont.truetype(small, 6)
|
||||
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||
fonts['rank'] = ImageFont.truetype(press, 10)
|
||||
fonts['score'] = load_truetype(press, 10)
|
||||
fonts['time'] = load_truetype(press, 8)
|
||||
fonts['team'] = load_truetype(press, 8)
|
||||
fonts['status'] = load_truetype(small, 6)
|
||||
fonts['detail'] = load_truetype(small, 6)
|
||||
fonts['rank'] = load_truetype(press, 10)
|
||||
except IOError:
|
||||
default = ImageFont.load_default()
|
||||
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import pytz
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
@@ -449,12 +450,12 @@ class SportsCore(ABC):
|
||||
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
|
||||
four_by_six = self._resolve_font_path("4x6-font.ttf")
|
||||
try:
|
||||
fonts['score'] = ImageFont.truetype(press_start, 10)
|
||||
fonts['time'] = ImageFont.truetype(press_start, 8)
|
||||
fonts['team'] = ImageFont.truetype(press_start, 8)
|
||||
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
|
||||
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
|
||||
fonts['rank'] = ImageFont.truetype(press_start, 10)
|
||||
fonts['score'] = load_truetype(press_start, 10)
|
||||
fonts['time'] = load_truetype(press_start, 8)
|
||||
fonts['team'] = load_truetype(press_start, 8)
|
||||
fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status
|
||||
fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font
|
||||
fonts['rank'] = load_truetype(press_start, 10)
|
||||
self.logger.info("Successfully loaded fonts")
|
||||
except OSError:
|
||||
# Name the directory we searched: the usual cause is an install
|
||||
@@ -1031,7 +1032,7 @@ class SportsCore(ABC):
|
||||
if os.path.exists(font_path):
|
||||
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
|
||||
if font_path.lower().endswith('.ttf'):
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
font = load_truetype(font_path, font_size)
|
||||
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
|
||||
self._font_cache[cache_key] = font
|
||||
return font
|
||||
@@ -1049,7 +1050,7 @@ class SportsCore(ABC):
|
||||
# correct one: the newer copies call truetype() on a BDF at
|
||||
# any size (which simply fails) or refuse BDF outright.
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
font = load_truetype(font_path, font_size)
|
||||
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
|
||||
self._font_cache[cache_key] = font
|
||||
return font
|
||||
@@ -1061,7 +1062,7 @@ class SportsCore(ABC):
|
||||
self._bdf_native_size_cache[font_path] = native_size
|
||||
if native_size and native_size != font_size:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, native_size)
|
||||
font = load_truetype(font_path, native_size)
|
||||
self.logger.debug(
|
||||
f"Loaded BDF font: {font_name} at its native size {native_size} "
|
||||
f"(requested {font_size} isn't a valid strike for this file)"
|
||||
@@ -1089,7 +1090,7 @@ class SportsCore(ABC):
|
||||
_resolve_font_family_alias(base_default))
|
||||
try:
|
||||
if os.path.exists(default_font_path):
|
||||
font = ImageFont.truetype(default_font_path, font_size)
|
||||
font = load_truetype(default_font_path, font_size)
|
||||
else:
|
||||
self.logger.warning("Default font not found, using PIL default")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""One text layout engine, everywhere.
|
||||
|
||||
``PIL.ImageFont.truetype`` picks its layout engine at load time: Raqm when the
|
||||
host Pillow was built with libraqm, Basic otherwise. The two disagree about
|
||||
fractional glyph advances, so the *same* Pillow version renders the *same*
|
||||
string differently depending on a build option of the host.
|
||||
|
||||
That is invisible for ``PressStart2P-Regular.ttf`` at 8px, whose advances are
|
||||
whole pixels either way — which is why most of the fleet's golden images
|
||||
matched on every machine. It is not invisible for ``4x6-font.ttf`` at 6px,
|
||||
where the advances are fractional: glyph positions drift cumulatively along a
|
||||
run, and the committed goldens for geochron, of-the-day, christmas-countdown
|
||||
and ledmatrix-weather's almanac passed on the machine that generated them and
|
||||
failed everywhere else (ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391).
|
||||
|
||||
Pinning the Basic engine makes a render depend on the font file and the size,
|
||||
and nothing else. Basic gives up complex-script shaping (Arabic, Indic) and
|
||||
kerning pairs; neither applies to the bitmap-grid faces this project draws
|
||||
with on an LED panel.
|
||||
|
||||
Use :func:`load_truetype` in place of ``ImageFont.truetype`` anywhere the
|
||||
result is drawn to a panel or compared against a golden image.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Union
|
||||
|
||||
from PIL import ImageFont
|
||||
|
||||
#: The engine every core font load pins. Named once so the reason above has a
|
||||
#: single referent, and so a future change is one line.
|
||||
LAYOUT_ENGINE = ImageFont.Layout.BASIC
|
||||
|
||||
|
||||
def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.FreeTypeFont:
|
||||
"""``ImageFont.truetype`` with the layout engine pinned.
|
||||
|
||||
Same signature and same exceptions as the PIL call it replaces, so it is a
|
||||
drop-in at every call site.
|
||||
"""
|
||||
kwargs.setdefault("layout_engine", LAYOUT_ENGINE)
|
||||
return ImageFont.truetype(font, size, **kwargs)
|
||||
@@ -86,6 +86,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
import pytz
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -712,11 +713,11 @@ class SportsCoreSharedMixin:
|
||||
while size > grid:
|
||||
if probe.textlength(
|
||||
self._SCORE_PROBE_TEXT,
|
||||
font=ImageFont.truetype(path, size)) <= budget:
|
||||
font=load_truetype(path, size)) <= budget:
|
||||
break
|
||||
size -= grid
|
||||
if size != getattr(fonts['score'], 'size', size):
|
||||
fonts['score'] = ImageFont.truetype(path, size)
|
||||
fonts['score'] = load_truetype(path, size)
|
||||
self._score_grew = True
|
||||
|
||||
if not self._score_grew and not self._user_chose_size('score_text') \
|
||||
@@ -736,7 +737,7 @@ class SportsCoreSharedMixin:
|
||||
if _size <= current:
|
||||
continue
|
||||
_path = _resolve_font_path(f"assets/fonts/{_name}")
|
||||
_candidate = ImageFont.truetype(_path, _size)
|
||||
_candidate = load_truetype(_path, _size)
|
||||
if probe.textlength(self._SCORE_PROBE_TEXT,
|
||||
font=_candidate) <= budget:
|
||||
fonts['score'] = _candidate
|
||||
@@ -751,7 +752,7 @@ class SportsCoreSharedMixin:
|
||||
if ceiling and size >= ceiling:
|
||||
size = max(grid, ceiling - grid)
|
||||
if size != getattr(fonts['time'], 'size', size):
|
||||
fonts['time'] = ImageFont.truetype(path, size)
|
||||
fonts['time'] = load_truetype(path, size)
|
||||
except Exception:
|
||||
self.logger.debug("Headline font scaling skipped", exc_info=True)
|
||||
return fonts
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
@@ -60,7 +61,7 @@ class TextHelper:
|
||||
size = config['size']
|
||||
|
||||
if font_path.exists():
|
||||
font = ImageFont.truetype(str(font_path), size)
|
||||
font = load_truetype(str(font_path), size)
|
||||
fonts[font_name] = font
|
||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||
else:
|
||||
|
||||
+154
-122
@@ -307,40 +307,8 @@ class DisplayController:
|
||||
|
||||
# Check for on-demand plugin filter from cache
|
||||
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
||||
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
||||
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
|
||||
|
||||
if on_demand_plugin_id:
|
||||
logger.info("On-demand mode detected during initialization: filtering to plugin '%s' only", on_demand_plugin_id)
|
||||
# Only load the on-demand plugin, but ensure it's enabled
|
||||
if on_demand_plugin_id not in discovered_plugins:
|
||||
error_msg = f"On-demand plugin '{on_demand_plugin_id}' not found in discovered plugins"
|
||||
logger.error(error_msg)
|
||||
logger.warning("Falling back to normal mode (all enabled plugins)")
|
||||
on_demand_plugin_id = None
|
||||
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
||||
else:
|
||||
plugin_config = self.config.get(on_demand_plugin_id, {})
|
||||
was_disabled = not plugin_config.get('enabled', False)
|
||||
if was_disabled:
|
||||
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
||||
if on_demand_plugin_id not in self.config:
|
||||
self.config[on_demand_plugin_id] = {}
|
||||
self.config[on_demand_plugin_id]['enabled'] = True
|
||||
enabled_plugins = [on_demand_plugin_id]
|
||||
# Set on-demand state from cached config
|
||||
self.on_demand_active = True
|
||||
self.on_demand_plugin_id = on_demand_plugin_id
|
||||
self.on_demand_mode = on_demand_config.get('mode')
|
||||
self.on_demand_duration = on_demand_config.get('duration')
|
||||
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
||||
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
||||
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
||||
self.on_demand_status = 'active'
|
||||
self.on_demand_schedule_override = True
|
||||
logger.info("On-demand mode: loading only plugin '%s'", on_demand_plugin_id)
|
||||
else:
|
||||
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
||||
|
||||
# Count enabled plugins for progress tracking
|
||||
enabled_count = len(enabled_plugins)
|
||||
logger.info("Loading %d enabled plugin(s) in parallel (max 4 concurrent)...", enabled_count)
|
||||
@@ -1279,6 +1247,91 @@ class DisplayController:
|
||||
#: perceptible, and it cuts the read rate by 30x.
|
||||
ON_DEMAND_POLL_INTERVAL = 0.25
|
||||
|
||||
def _select_startup_plugins(self, discovered_plugins: List[str],
|
||||
on_demand_config: Optional[Dict[str, Any]]) -> List[str]:
|
||||
"""Which plugins to load at startup, restoring on-demand state if any.
|
||||
|
||||
Every normally-enabled plugin loads, on-demand or not. Loading only the
|
||||
on-demand plugin left every other plugin unavailable for the rest of
|
||||
the process's life whenever the service was restarted while on-demand
|
||||
was still active -- and a restart during an on-demand session is
|
||||
routine, since that is how updates and config changes are applied. The
|
||||
panel came back cycling that one plugin's modes and nothing else, with
|
||||
no way out but clearing the on-demand cache by hand.
|
||||
|
||||
On-demand still resumes on its saved mode; this only widens what gets
|
||||
loaded, so normal rotation has somewhere to return to when it ends.
|
||||
A plugin that is disabled in config but named by the on-demand request
|
||||
is still enabled and added, since otherwise the mode being resumed
|
||||
would have nothing behind it.
|
||||
"""
|
||||
enabled_plugins = [p for p in discovered_plugins
|
||||
if self.config.get(p, {}).get('enabled', False)]
|
||||
|
||||
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
||||
if not on_demand_plugin_id:
|
||||
return enabled_plugins
|
||||
|
||||
if on_demand_plugin_id not in discovered_plugins:
|
||||
logger.error("On-demand plugin '%s' not found in discovered plugins",
|
||||
on_demand_plugin_id)
|
||||
logger.warning("Falling back to normal mode (all enabled plugins)")
|
||||
return enabled_plugins
|
||||
|
||||
if not self.config.get(on_demand_plugin_id, {}).get('enabled', False):
|
||||
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
||||
self.config.setdefault(on_demand_plugin_id, {})['enabled'] = True
|
||||
if on_demand_plugin_id not in enabled_plugins:
|
||||
enabled_plugins.append(on_demand_plugin_id)
|
||||
|
||||
# Restore on-demand state from the cached request so it resumes.
|
||||
self.on_demand_active = True
|
||||
self.on_demand_plugin_id = on_demand_plugin_id
|
||||
self.on_demand_mode = on_demand_config.get('mode')
|
||||
self.on_demand_duration = on_demand_config.get('duration')
|
||||
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
||||
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
||||
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
||||
self.on_demand_status = 'active'
|
||||
self.on_demand_schedule_override = True
|
||||
logger.info("On-demand mode detected during initialization: resuming on plugin '%s'; "
|
||||
"all %d enabled plugin(s) still load normally",
|
||||
on_demand_plugin_id, len(enabled_plugins))
|
||||
return enabled_plugins
|
||||
|
||||
def _consume_on_demand_request(self, request_id: str) -> None:
|
||||
"""Remove the request we just handled from the mailbox.
|
||||
|
||||
Leaving it on disk meant a restart replayed the previous request: the
|
||||
fresh controller read it, activated it and cached it, so the request
|
||||
the caller had just made was ignored and the panel silently showed the
|
||||
earlier plugin.
|
||||
|
||||
Compare before deleting. The web process can post a newer request
|
||||
between the read and this delete; an unconditional delete threw that
|
||||
one away and it was never processed -- the user's second click did
|
||||
nothing. Re-reading uncached and only deleting our own request_id
|
||||
leaves a newer request in the mailbox for the next poll instead.
|
||||
|
||||
This narrows the window rather than closing it: a request landing
|
||||
between the re-read and the delete is still lost. Closing it properly
|
||||
needs an atomic claim (a rename, or a compare-and-delete primitive)
|
||||
that the cache layer does not currently offer, so the honest fix is a
|
||||
smaller window plus this note, not a bigger lock. For start requests
|
||||
processed_id still guards against reprocessing if the delete fails.
|
||||
"""
|
||||
try:
|
||||
current = self.cache_manager.get('display_on_demand_request',
|
||||
max_age=3600, memory_ttl=0)
|
||||
if not current or current.get('request_id') == request_id:
|
||||
self.cache_manager.delete('display_on_demand_request')
|
||||
else:
|
||||
logger.debug("Newer on-demand request %s arrived while processing "
|
||||
"%s; leaving it in the mailbox",
|
||||
current.get('request_id'), request_id)
|
||||
except (OSError, AttributeError, KeyError) as err:
|
||||
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||
|
||||
def _poll_on_demand_requests(self) -> None:
|
||||
"""Poll cache for new on-demand requests from external controllers."""
|
||||
now = time.monotonic()
|
||||
@@ -1325,8 +1378,15 @@ class DisplayController:
|
||||
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
||||
# Still update request_id to acknowledge the request
|
||||
self.on_demand_request_id = request_id
|
||||
# Stop requests are deliberately exempt from the request_id/
|
||||
# processed_id guards above, so that a second click stops a mode
|
||||
# that a race left running. Consuming the mailbox is therefore the
|
||||
# only thing that ends the request: without it the same stop was
|
||||
# re-read and re-processed on every poll, forever, logging at
|
||||
# ON_DEMAND_POLL_INTERVAL for the life of the process.
|
||||
self._consume_on_demand_request(request_id)
|
||||
return
|
||||
|
||||
|
||||
# For start requests, check if already processed
|
||||
if request_id == self.on_demand_request_id:
|
||||
logger.debug("On-demand start request %s already processed (instance check)", request_id)
|
||||
@@ -1344,37 +1404,8 @@ class DisplayController:
|
||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||
self.on_demand_request_id = request_id
|
||||
# Consume the mailbox entry. Leaving it on disk meant a restart replayed
|
||||
# the previous request: the fresh controller read it, activated it and
|
||||
# cached it, so the request the caller had just made was ignored and the
|
||||
# panel silently showed the earlier plugin. processed_id still guards
|
||||
# against double-processing if this delete fails.
|
||||
try:
|
||||
# Compare before deleting. The web process can post a newer request
|
||||
# between the read above and this delete; an unconditional delete
|
||||
# threw that one away and it was never processed -- the user's
|
||||
# second click did nothing. Re-reading uncached and only deleting
|
||||
# our own request_id means a newer request is left in the mailbox
|
||||
# for the next poll instead.
|
||||
#
|
||||
# This narrows the window rather than closing it: a request landing
|
||||
# between this re-read and the delete is still lost. Closing it
|
||||
# properly needs an atomic claim (a rename, or a compare-and-delete
|
||||
# primitive) that the cache layer does not currently offer, so the
|
||||
# honest fix is a smaller window plus this note, not a bigger lock.
|
||||
current = self.cache_manager.get('display_on_demand_request',
|
||||
max_age=3600, memory_ttl=0)
|
||||
if not current or current.get('request_id') == request_id:
|
||||
self.cache_manager.delete('display_on_demand_request')
|
||||
else:
|
||||
logger.debug("Newer on-demand request %s arrived while processing "
|
||||
"%s; leaving it in the mailbox",
|
||||
current.get('request_id'), request_id)
|
||||
except (OSError, AttributeError, KeyError) as err:
|
||||
# Best-effort: processed_id still guards against reprocessing if the
|
||||
# mailbox cannot be cleared.
|
||||
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||
|
||||
self._consume_on_demand_request(request_id)
|
||||
|
||||
if action == 'start':
|
||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||
self._activate_on_demand(request)
|
||||
@@ -1416,34 +1447,28 @@ class DisplayController:
|
||||
return modes[0]
|
||||
return plugin_id
|
||||
|
||||
def _populate_on_demand_modes_from_plugin(self) -> None:
|
||||
def _on_demand_modes_for_plugin(self, plugin_id: str) -> List[str]:
|
||||
"""Every loaded display mode belonging to `plugin_id`, in rotation order.
|
||||
|
||||
Live modes that actually have content lead, then the rest, then live
|
||||
modes with nothing to show -- so an on-demand request for a sports
|
||||
plugin opens on a game in progress rather than an empty live screen.
|
||||
Returns an empty list when the plugin has no loaded modes.
|
||||
"""
|
||||
Populate on_demand_modes from the on-demand plugin's display modes.
|
||||
Called after plugin loading completes when on-demand state is restored from cache.
|
||||
"""
|
||||
if not self.on_demand_active or not self.on_demand_plugin_id:
|
||||
return
|
||||
|
||||
plugin_id = self.on_demand_plugin_id
|
||||
|
||||
# Get all modes for this plugin
|
||||
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
||||
if not plugin_modes:
|
||||
# Fallback: find all modes that belong to this plugin
|
||||
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == plugin_id]
|
||||
|
||||
|
||||
# Filter to only include modes that exist in plugin_modes
|
||||
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||
|
||||
if not available_plugin_modes:
|
||||
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
||||
self.on_demand_modes = []
|
||||
return
|
||||
|
||||
return []
|
||||
|
||||
# Prioritize live modes if they exist and have content
|
||||
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
|
||||
|
||||
|
||||
# Check if live modes have content
|
||||
live_with_content = []
|
||||
for live_mode in live_modes:
|
||||
@@ -1454,18 +1479,57 @@ class DisplayController:
|
||||
live_with_content.append(live_mode)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Build mode list: live modes with content first, then other modes, then live modes without content
|
||||
if live_with_content:
|
||||
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
|
||||
else:
|
||||
# No live content, skip live modes
|
||||
ordered_modes = other_modes
|
||||
|
||||
|
||||
if not ordered_modes:
|
||||
# Only live modes available but no content - use them anyway
|
||||
ordered_modes = live_modes
|
||||
|
||||
|
||||
return ordered_modes
|
||||
|
||||
def _apply_on_demand_pin(self, ordered_modes: List[str], resolved_mode: Optional[str],
|
||||
pinned: bool) -> List[str]:
|
||||
"""Narrow an on-demand rotation to the single requested mode when pinned.
|
||||
|
||||
`pinned` reaches the controller from the API and was stored and
|
||||
republished but never acted on, so a pinned request still rotated
|
||||
through every mode the resolved plugin owns. That is the right default
|
||||
for a sports plugin, whose modes are views of one subject
|
||||
(nfl_live/nfl_recent/nfl_upcoming), and the wrong one for a plugin
|
||||
whose modes are unrelated -- each Starlark app is its own widget, so
|
||||
asking for one and getting all of them is not what was requested.
|
||||
"""
|
||||
if not pinned or not resolved_mode or resolved_mode not in ordered_modes:
|
||||
return ordered_modes
|
||||
return [resolved_mode]
|
||||
|
||||
def _populate_on_demand_modes_from_plugin(self) -> None:
|
||||
"""
|
||||
Populate on_demand_modes from the on-demand plugin's display modes.
|
||||
Called after plugin loading completes when on-demand state is restored from cache.
|
||||
"""
|
||||
if not self.on_demand_active or not self.on_demand_plugin_id:
|
||||
return
|
||||
|
||||
plugin_id = self.on_demand_plugin_id
|
||||
|
||||
ordered_modes = self._on_demand_modes_for_plugin(plugin_id)
|
||||
if not ordered_modes:
|
||||
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
||||
self.on_demand_modes = []
|
||||
return
|
||||
|
||||
# A restart must not silently un-pin: the pin is part of the request
|
||||
# being resumed, and it is restored from the same cached config above.
|
||||
ordered_modes = self._apply_on_demand_pin(
|
||||
ordered_modes, self.on_demand_mode, self.on_demand_pinned)
|
||||
|
||||
self.on_demand_modes = ordered_modes
|
||||
# Set index to match the restored mode if available, otherwise start at 0
|
||||
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
|
||||
@@ -1520,46 +1584,14 @@ class DisplayController:
|
||||
if resolved_mode in self.available_modes:
|
||||
self.current_mode_index = self.available_modes.index(resolved_mode)
|
||||
|
||||
# Get all modes for this plugin
|
||||
plugin_modes = self.plugin_display_modes.get(resolved_plugin_id, [])
|
||||
if not plugin_modes:
|
||||
# Fallback: find all modes that belong to this plugin
|
||||
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == resolved_plugin_id]
|
||||
|
||||
# Filter to only include modes that exist in plugin_modes
|
||||
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||
|
||||
if not available_plugin_modes:
|
||||
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id)
|
||||
if not ordered_modes:
|
||||
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
||||
self._set_on_demand_error("no-modes")
|
||||
return
|
||||
|
||||
# Prioritize live modes if they exist and have content
|
||||
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
|
||||
|
||||
# Check if live modes have content
|
||||
live_with_content = []
|
||||
for live_mode in live_modes:
|
||||
plugin_instance = self.plugin_modes.get(live_mode)
|
||||
if plugin_instance and hasattr(plugin_instance, 'has_live_content'):
|
||||
try:
|
||||
if plugin_instance.has_live_content():
|
||||
live_with_content.append(live_mode)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build mode list: live modes with content first, then other modes, then live modes without content
|
||||
if live_with_content:
|
||||
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
|
||||
else:
|
||||
# No live content, skip live modes
|
||||
ordered_modes = other_modes
|
||||
|
||||
if not ordered_modes:
|
||||
# Only live modes available but no content - use them anyway
|
||||
ordered_modes = live_modes
|
||||
|
||||
|
||||
ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned)
|
||||
|
||||
self.on_demand_active = True
|
||||
self.on_demand_mode = resolved_mode # Keep for backward compatibility
|
||||
self.on_demand_modes = ordered_modes
|
||||
|
||||
+66
-9
@@ -34,6 +34,7 @@ else:
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
@@ -55,6 +56,31 @@ from src.common.permission_utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
|
||||
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
|
||||
#: size regardless, but a Face needs an active size before its metrics --
|
||||
#: and therefore get_font_height() -- report anything but 0.
|
||||
_CALENDAR_FONT_PX = 7
|
||||
|
||||
|
||||
def _bdf_native_size(face) -> int:
|
||||
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
||||
|
||||
Used only to rescue a Face that was built without ``set_char_size``, so a
|
||||
zero line height never reaches layout code.
|
||||
"""
|
||||
try:
|
||||
sizes = getattr(face, "available_sizes", None) or []
|
||||
if sizes:
|
||||
return int(getattr(sizes[0], "height", 0) or 0)
|
||||
except (AttributeError, IndexError, TypeError, ValueError) as exc:
|
||||
# This runs on the measurement path for a face the caller already
|
||||
# holds, so a malformed strike table must degrade to "unknown" rather
|
||||
# than take the display down. Say which face, so a font that is
|
||||
# actually broken is diagnosable rather than silently 8px.
|
||||
logger.debug("Could not read BDF strike size from %r: %s", face, exc)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
class _LogicalMatrix:
|
||||
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||
@@ -377,7 +403,7 @@ class DisplayManager:
|
||||
|
||||
# Initialize font with Press Start 2P
|
||||
try:
|
||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
logger.info("Initial Press Start 2P font loaded successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load initial font: {e}")
|
||||
@@ -557,19 +583,33 @@ class DisplayManager:
|
||||
pass
|
||||
|
||||
def _fitting_font(self, lines, width):
|
||||
"""The largest font from the usual ladder that fits every line."""
|
||||
"""The largest font from the usual ladder that fits every line.
|
||||
|
||||
The ladder ends at 4x6 at 5px because a full dotted-quad address --
|
||||
"255.255.255.255", the widest this screen ever shows -- is 66px at
|
||||
6px and a 64px panel has 62 to give it. That used to squeak in only
|
||||
because the measurement depended on which text layout engine the host
|
||||
Pillow had; with the engine pinned it does not, so the rung the
|
||||
worst case actually needs is here rather than implied.
|
||||
"""
|
||||
candidates = [self.font,
|
||||
("assets/fonts/4x6-font.ttf", 6)]
|
||||
("assets/fonts/4x6-font.ttf", 6),
|
||||
("assets/fonts/4x6-font.ttf", 5)]
|
||||
narrowest = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
font = candidate
|
||||
if isinstance(candidate, tuple):
|
||||
font = ImageFont.truetype(candidate[0], candidate[1])
|
||||
font = load_truetype(candidate[0], candidate[1])
|
||||
narrowest = font
|
||||
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
||||
return font
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
return self.font
|
||||
# Nothing fit. Return the smallest face that loaded, not self.font --
|
||||
# falling back to the widest option is how "Initializing" ran off the
|
||||
# side of a 64px panel in the first place.
|
||||
return narrowest or self.font
|
||||
|
||||
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
||||
"""Centre `lines` over whatever the test pattern already drew.
|
||||
@@ -933,11 +973,11 @@ class DisplayManager:
|
||||
self._text_width_cache.clear()
|
||||
try:
|
||||
# Load Press Start 2P font
|
||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
logger.info("Press Start 2P font loaded successfully")
|
||||
|
||||
# Use the same font for small text (currently same size; adjust size here if needed)
|
||||
self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
self.small_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
logger.info("Press Start 2P small font loaded successfully")
|
||||
|
||||
# Load 5x7 BDF font for calendar events
|
||||
@@ -950,6 +990,17 @@ class DisplayManager:
|
||||
|
||||
# Load with freetype for proper BDF handling
|
||||
face = freetype.Face(self.calendar_font_path)
|
||||
# A freshly constructed Face has no active size, so
|
||||
# face.size.height is 0 until set_char_size is called -- and
|
||||
# get_font_height() reads exactly that. Without this, every
|
||||
# caller measuring the 5x7 face got 0 and stacked rows on top
|
||||
# of one another; the "Calendar font size: 0 pixels" line
|
||||
# below has been printing the symptom on every start-up.
|
||||
# font_manager._load_bdf_font already does this; the two paths
|
||||
# disagreed about whether a Face was usable for measurement.
|
||||
# 5x7.bdf is a fixed strike, so FreeType renders 7px whatever
|
||||
# is asked for -- this sets the metrics, not the raster.
|
||||
face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72)
|
||||
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
||||
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
||||
|
||||
@@ -970,7 +1021,7 @@ class DisplayManager:
|
||||
try:
|
||||
font_path = "assets/fonts/4x6-font.ttf"
|
||||
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
|
||||
self.extra_small_font = ImageFont.truetype(font_path, 6)
|
||||
self.extra_small_font = load_truetype(font_path, 6)
|
||||
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
|
||||
except Exception as font_err:
|
||||
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
|
||||
@@ -1028,7 +1079,13 @@ class DisplayManager:
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
|
||||
return font.size.height >> 6
|
||||
height = font.size.height >> 6
|
||||
if height:
|
||||
return height
|
||||
# A Face constructed without set_char_size reports 0, and a
|
||||
# zero line height collapses every stacked row onto one line.
|
||||
# Fall back to the strike the file declares.
|
||||
return _bdf_native_size(font) or 8
|
||||
else:
|
||||
# For PIL TTF fonts, getmetrics() provides ascent and descent.
|
||||
# The line height is the sum of ascent and descent.
|
||||
|
||||
@@ -51,6 +51,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
try:
|
||||
import freetype
|
||||
@@ -155,7 +156,7 @@ def load_font(font_name: str, size: int) -> Any:
|
||||
face.set_char_size(size * 64, size * 64, 72, 72)
|
||||
font: Any = face
|
||||
else:
|
||||
font = ImageFont.truetype(path, size)
|
||||
font = load_truetype(path, size)
|
||||
except Exception as e:
|
||||
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
||||
path, size, e)
|
||||
@@ -174,7 +175,7 @@ def _load_fallback_font(size: int) -> Any:
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
font = ImageFont.truetype(path, size)
|
||||
font = load_truetype(path, size)
|
||||
_font_cache[cache_key] = font
|
||||
return font
|
||||
except Exception as e:
|
||||
|
||||
+3
-2
@@ -38,6 +38,7 @@ import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -479,7 +480,7 @@ class FontManager:
|
||||
if font_path.endswith('.bdf'):
|
||||
font = self._load_bdf_font(font_path, size_px)
|
||||
else:
|
||||
font = ImageFont.truetype(font_path, size_px)
|
||||
font = load_truetype(font_path, size_px)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading font {font_path}: {e}")
|
||||
self.performance_stats["failed_loads"] += 1
|
||||
@@ -859,7 +860,7 @@ class FontManager:
|
||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||
elif font_path.endswith('.ttf'):
|
||||
# Try to load TTF font
|
||||
ImageFont.truetype(font_path, 12)
|
||||
load_truetype(font_path, 12)
|
||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||
else:
|
||||
return {"valid": False, "error": "Unsupported font format"}
|
||||
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
@@ -747,7 +748,7 @@ class LogoDownloader:
|
||||
|
||||
# Try to load a font, fallback to default
|
||||
try:
|
||||
font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
||||
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
@@ -31,6 +31,7 @@ from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
@@ -141,8 +142,8 @@ class VisualTestDisplayManager:
|
||||
|
||||
# Press Start 2P — regular and small (both 8px)
|
||||
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
|
||||
self.regular_font = ImageFont.truetype(ttf_path, 8)
|
||||
self.small_font = ImageFont.truetype(ttf_path, 8)
|
||||
self.regular_font = load_truetype(ttf_path, 8)
|
||||
self.small_font = load_truetype(ttf_path, 8)
|
||||
self.font = self.regular_font # alias used by some code paths
|
||||
|
||||
# 5x7 BDF font via freetype
|
||||
@@ -162,7 +163,7 @@ class VisualTestDisplayManager:
|
||||
# 4x6 extra small TTF
|
||||
try:
|
||||
xs_path = str(fonts_dir / '4x6-font.ttf')
|
||||
self.extra_small_font = ImageFont.truetype(xs_path, 6)
|
||||
self.extra_small_font = load_truetype(xs_path, 6)
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
logger.debug("Extra small font not available, using fallback: %s", e)
|
||||
self.extra_small_font = self.small_font
|
||||
|
||||
@@ -19,12 +19,35 @@ This directory contains systemd service unit files for LEDMatrix services.
|
||||
- Automatically enables/disables access point mode
|
||||
- Uses `scripts/utils/wifi_monitor_daemon.py`
|
||||
|
||||
- **`ledmatrix-dns-fix.service`** - DNS single-request fix (optional)
|
||||
- Re-applies `options single-request` to the resolver on every boot,
|
||||
because whatever manages `resolv.conf` regenerates it and drops the
|
||||
option again
|
||||
- Works around glibc's parallel A/AAAA lookup stalling ~5s per name on
|
||||
routers that answer only the A query, which makes any plugin calling an
|
||||
external API slow or (for Starlark apps, which have a render timeout)
|
||||
fail outright
|
||||
- Uses `scripts/utils/apply_dns_single_request.sh`
|
||||
- Install only if external API calls are timing out; it is not part of a
|
||||
normal install
|
||||
|
||||
- **`ledmatrix-mqtt-bridge.service`** - Home Assistant MQTT bridge (optional)
|
||||
- Exposes the display to Home Assistant over MQTT Discovery: force a mode,
|
||||
stop on-demand, toggle power, set brightness
|
||||
- Uses `integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py`, which drives the
|
||||
web API rather than the display directly
|
||||
- Needs `integrations/mqtt_bridge/bridge_config.json`; see that directory's
|
||||
README
|
||||
|
||||
## Installation
|
||||
|
||||
These service files are installed by the installation scripts in `scripts/install/`:
|
||||
- `install_service.sh` installs `ledmatrix.service`
|
||||
- `install_web_service.sh` installs `ledmatrix-web.service`
|
||||
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.service`
|
||||
- `install_dns_fix.sh` installs `ledmatrix-dns-fix.service` (opt-in, not run
|
||||
by the normal installer)
|
||||
- `install_mqtt_bridge.sh` installs `ledmatrix-mqtt-bridge.service` (opt-in)
|
||||
|
||||
## Manual Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=LED Matrix DNS single-request fix (works around slow A/AAAA lookups)
|
||||
After=network-online.target NetworkManager.service
|
||||
Wants=network-online.target
|
||||
Before=ledmatrix.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=__PROJECT_ROOT_DIR__/scripts/utils/apply_dns_single_request.sh
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix-dns-fix
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=LED Matrix Home Assistant MQTT Bridge
|
||||
After=network-online.target ledmatrix-web.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix-mqtt-bridge
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,157 @@
|
||||
"""GET /api/v3/display/modes -- the list of modes an on-demand request can name.
|
||||
|
||||
Anything driving the display from outside the web UI needs two things that no
|
||||
existing endpoint gave it: the set of display modes, and which plugin owns each
|
||||
one. /plugins/installed carries neither, so callers read every plugin's
|
||||
manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
||||
api_v3_client, api_v3_module,
|
||||
)
|
||||
|
||||
|
||||
MANIFESTS = {
|
||||
'clock-simple': {'name': 'Simple Clock', 'display_modes': ['clock-simple']},
|
||||
'football-scoreboard': {
|
||||
'name': 'Football Scoreboard',
|
||||
'display_modes': ['nfl_live', 'nfl_recent', 'nfl_upcoming'],
|
||||
},
|
||||
'ledmatrix-weather': {'name': 'Weather', 'display_modes': ['weather']},
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
'clock-simple': {'enabled': True},
|
||||
'football-scoreboard': {'enabled': True},
|
||||
'ledmatrix-weather': {'enabled': False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_v3_module, api_v3_client):
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm.plugin_manifests = MANIFESTS
|
||||
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
||||
pm.get_plugin_display_modes = MagicMock(
|
||||
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value=CONFIG)
|
||||
return api_v3_client
|
||||
|
||||
|
||||
def _modes(response):
|
||||
return {m['mode']: m for m in response.get_json()['data']['modes']}
|
||||
|
||||
|
||||
class TestTheModeListing:
|
||||
def test_enabled_plugins_contribute_every_mode(self, client):
|
||||
modes = _modes(client.get('/api/v3/display/modes'))
|
||||
assert set(modes) == {'clock-simple', 'nfl_live', 'nfl_recent', 'nfl_upcoming'}
|
||||
|
||||
def test_each_mode_names_its_plugin(self, client):
|
||||
"""on-demand/start's find_plugin_for_mode fallback cannot see generated
|
||||
modes, so the caller has to send plugin_id -- it must come from here."""
|
||||
modes = _modes(client.get('/api/v3/display/modes'))
|
||||
assert modes['nfl_live']['plugin_id'] == 'football-scoreboard'
|
||||
|
||||
def test_disabled_plugins_are_left_out_by_default(self, client):
|
||||
modes = _modes(client.get('/api/v3/display/modes'))
|
||||
assert 'weather' not in modes
|
||||
|
||||
def test_disabled_plugins_can_be_asked_for(self, client):
|
||||
"""They are still valid on-demand targets: the controller enables them
|
||||
for the duration of the request."""
|
||||
modes = _modes(client.get('/api/v3/display/modes?include_disabled=1'))
|
||||
assert modes['weather']['enabled'] is False
|
||||
|
||||
def test_a_single_mode_plugin_is_labelled_with_its_own_name(self, client):
|
||||
modes = _modes(client.get('/api/v3/display/modes'))
|
||||
assert modes['clock-simple']['name'] == 'Simple Clock'
|
||||
|
||||
def test_a_multi_mode_plugin_labels_each_mode_distinctly(self, client):
|
||||
"""There is no per-mode name anywhere, and three modes all called
|
||||
"Football Scoreboard" are not a usable dropdown."""
|
||||
modes = _modes(client.get('/api/v3/display/modes'))
|
||||
assert modes['nfl_live']['name'] == 'nfl_live'
|
||||
assert modes['nfl_live']['plugin_name'] == 'Football Scoreboard'
|
||||
|
||||
|
||||
class TestItWorksForACallerThatNeverOpensTheDashboard:
|
||||
def test_discovery_is_triggered(self, client, api_v3_module):
|
||||
"""Discovery is lazy and normally runs because a person loaded the
|
||||
dashboard; a bridge or script would otherwise get an empty list."""
|
||||
client.get('/api/v3/display/modes')
|
||||
api_v3_module.api_v3.plugin_manager.discover_plugins.assert_called_once()
|
||||
|
||||
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
|
||||
api_v3_module.api_v3.plugin_manager = None
|
||||
response = api_v3_client.get('/api/v3/display/modes')
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()['status'] == 'error'
|
||||
|
||||
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
|
||||
"""Its mode is its own id -- the same fallback the controller uses."""
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
|
||||
pm.get_plugin_display_modes = MagicMock(return_value=[])
|
||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(
|
||||
return_value={'starlark-apps': {'enabled': True}})
|
||||
|
||||
modes = _modes(client.get('/api/v3/display/modes'))
|
||||
assert modes['starlark-apps']['plugin_id'] == 'starlark-apps'
|
||||
|
||||
|
||||
class TestOneBadConfigSectionDoesNotBlankTheList:
|
||||
"""config.json can hold a non-dict under a plugin id.
|
||||
|
||||
DisplayController guards the same shape, so it happens in practice. Here it
|
||||
used to raise AttributeError mid-loop and answer 500 with no modes at all --
|
||||
and the MQTT bridge builds every one of its entities from this list, so one
|
||||
hand-edited section would empty the Home Assistant dropdown.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_bad_section(self, api_v3_module, api_v3_client):
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm.plugin_manifests = MANIFESTS
|
||||
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
||||
pm.get_plugin_display_modes = MagicMock(
|
||||
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={
|
||||
'clock-simple': {'enabled': True},
|
||||
'football-scoreboard': "true", # a string, not an object
|
||||
'ledmatrix-weather': {'enabled': True},
|
||||
})
|
||||
return api_v3_client
|
||||
|
||||
def test_the_endpoint_still_answers(self, client_with_bad_section):
|
||||
assert client_with_bad_section.get('/api/v3/display/modes').status_code == 200
|
||||
|
||||
def test_the_healthy_plugins_are_still_listed(self, client_with_bad_section):
|
||||
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
||||
assert 'clock-simple' in modes and 'weather' in modes
|
||||
|
||||
def test_the_bad_section_is_treated_as_disabled(self, client_with_bad_section):
|
||||
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
||||
assert 'nfl_live' not in modes
|
||||
|
||||
def test_a_failure_is_reported_the_way_every_other_handler_reports_one(
|
||||
self, api_v3_module, api_v3_client):
|
||||
"""describe_exception, per test_web_error_detail's contract -- an
|
||||
opaque "see logs for details" is what that test exists to prevent."""
|
||||
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
|
||||
side_effect=RuntimeError("disk is gone"))
|
||||
resp = api_v3_client.get('/api/v3/display/modes')
|
||||
assert resp.status_code == 500
|
||||
assert 'disk is gone' in resp.get_json()['details']
|
||||
|
||||
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
||||
"""describe_exception is what makes returning detail safe."""
|
||||
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
|
||||
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
||||
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
||||
assert 'SEC123' not in json.dumps(body)
|
||||
@@ -0,0 +1,329 @@
|
||||
"""The Home Assistant MQTT bridge, without a broker or a matrix.
|
||||
|
||||
The bridge is a translation layer: one MQTT payload in, one api_v3 call out.
|
||||
Everything worth pinning is on that path -- attaching the plugin_id a mode
|
||||
needs, rejecting values HA can produce but the API cannot take, and the
|
||||
discovery configs HA reads once and caches -- so it is all reachable with a
|
||||
fake API client and a pure function.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
BRIDGE_PATH = (Path(__file__).resolve().parent.parent
|
||||
/ "integrations" / "mqtt_bridge" / "ledmatrix_mqtt_bridge.py")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bridge_module():
|
||||
if not BRIDGE_PATH.exists():
|
||||
pytest.skip("mqtt bridge is not present")
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("ledmatrix_mqtt_bridge", BRIDGE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except ImportError as e:
|
||||
pytest.skip(f"mqtt bridge dependencies are not installed here: {e}")
|
||||
finally:
|
||||
sys.modules.pop("ledmatrix_mqtt_bridge", None)
|
||||
|
||||
|
||||
MODES = [
|
||||
{"mode": "clock-simple", "plugin_id": "clock-simple",
|
||||
"plugin_name": "Simple Clock", "name": "Simple Clock", "enabled": True},
|
||||
{"mode": "nfl_live", "plugin_id": "football-scoreboard",
|
||||
"plugin_name": "Football Scoreboard", "name": "nfl_live", "enabled": True},
|
||||
{"mode": "aquarium", "plugin_id": "starlark-apps",
|
||||
"plugin_name": "Starlark Apps", "name": "aquarium", "enabled": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""A stand-in for LEDMatrixClient that records what it was asked to do."""
|
||||
fake = MagicMock()
|
||||
fake.list_modes.return_value = list(MODES)
|
||||
fake.start_on_demand.return_value = {"request_id": "r1"}
|
||||
fake.stop_on_demand.return_value = {}
|
||||
fake.set_power.return_value = {}
|
||||
fake.set_brightness.return_value = {}
|
||||
fake.get_brightness.return_value = 90
|
||||
fake.display_status.return_value = {
|
||||
"service": {"active": True},
|
||||
"state": {"active": True, "mode": "nfl_live"},
|
||||
}
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(bridge_module, client):
|
||||
h = bridge_module.CommandHandler(client)
|
||||
h.refresh_modes()
|
||||
return h
|
||||
|
||||
|
||||
class TestDisplayCommands:
|
||||
def test_a_mode_is_forced_on_demand(self, handler, client):
|
||||
result = handler.handle({"action": "display", "mode": "nfl_live"})
|
||||
assert result["status"] == "success"
|
||||
assert client.start_on_demand.call_args.kwargs["mode"] == "nfl_live"
|
||||
|
||||
def test_the_owning_plugin_is_sent_with_the_mode(self, handler, client):
|
||||
"""on-demand/start's find_plugin_for_mode fallback cannot resolve a
|
||||
generated mode, so an omitted plugin_id 404s for every Starlark app."""
|
||||
handler.handle({"action": "display", "mode": "aquarium"})
|
||||
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
|
||||
|
||||
def test_a_home_assistant_label_resolves_to_its_mode(self, handler, client):
|
||||
"""The select entity shows labels, so that is what comes back."""
|
||||
handler.handle({"action": "display", "mode": "Simple Clock"})
|
||||
assert client.start_on_demand.call_args.kwargs["mode"] == "clock-simple"
|
||||
|
||||
def test_an_unknown_mode_refreshes_before_giving_up(self, handler, client):
|
||||
"""A plugin installed since the last refresh is the usual reason."""
|
||||
client.list_modes.return_value = MODES + [
|
||||
{"mode": "new_app", "plugin_id": "starlark-apps", "name": "new_app"}]
|
||||
handler.handle({"action": "display", "mode": "new_app"})
|
||||
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
|
||||
|
||||
def test_an_explicit_plugin_id_is_respected(self, handler, client):
|
||||
handler.handle({"action": "display", "mode": "x", "plugin_id": "custom"})
|
||||
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "custom"
|
||||
|
||||
def test_duration_and_pinned_are_passed_through(self, handler, client):
|
||||
handler.handle({"action": "display", "mode": "aquarium",
|
||||
"duration": 300, "pinned": True})
|
||||
kwargs = client.start_on_demand.call_args.kwargs
|
||||
assert kwargs["duration"] == 300 and kwargs["pinned"] is True
|
||||
|
||||
def test_display_with_nothing_to_show_is_rejected(self, handler, client):
|
||||
result = handler.handle({"action": "display"})
|
||||
assert result["status"] == "error"
|
||||
client.start_on_demand.assert_not_called()
|
||||
|
||||
def test_stop_returns_to_normal_rotation(self, handler, client):
|
||||
assert handler.handle({"action": "stop_display"})["status"] == "success"
|
||||
client.stop_on_demand.assert_called_once()
|
||||
|
||||
|
||||
class TestPowerAndBrightness:
|
||||
@pytest.mark.parametrize("state,expected", [("on", True), ("OFF", False), (" On ", True)])
|
||||
def test_power_states_are_accepted(self, handler, client, state, expected):
|
||||
assert handler.handle({"action": "power", "state": state})["status"] == "success"
|
||||
client.set_power.assert_called_with(expected)
|
||||
|
||||
def test_an_unknown_power_state_is_rejected(self, handler, client):
|
||||
assert handler.handle({"action": "power", "state": "maybe"})["status"] == "error"
|
||||
client.set_power.assert_not_called()
|
||||
|
||||
def test_brightness_is_applied(self, handler, client):
|
||||
assert handler.handle({"action": "brightness", "value": 75})["status"] == "success"
|
||||
client.set_brightness.assert_called_with(75)
|
||||
|
||||
def test_a_float_from_home_assistant_is_accepted(self, handler, client):
|
||||
"""The number entity publishes "75.0"; int() would raise on that."""
|
||||
handler.handle({"action": "brightness", "value": "75.0"})
|
||||
client.set_brightness.assert_called_with(75)
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 101, "bright", None])
|
||||
def test_out_of_range_brightness_never_reaches_the_api(self, handler, client, value):
|
||||
assert handler.handle({"action": "brightness", "value": value})["status"] == "error"
|
||||
client.set_brightness.assert_not_called()
|
||||
|
||||
|
||||
class TestFailuresAreReportedNotRaised:
|
||||
"""A failed command must publish an error, not kill the MQTT loop."""
|
||||
|
||||
def test_an_api_error_becomes_an_error_result(self, handler, client):
|
||||
client.start_on_demand.side_effect = RuntimeError("Display service is not running")
|
||||
result = handler.handle({"action": "display", "mode": "nfl_live"})
|
||||
assert result["status"] == "error"
|
||||
assert "not running" in result["message"]
|
||||
|
||||
def test_an_unknown_action_lists_the_known_ones(self, handler):
|
||||
result = handler.handle({"action": "explode"})
|
||||
assert result["status"] == "error"
|
||||
assert "brightness" in result["message"]
|
||||
|
||||
def test_a_missing_action_is_an_error(self, handler):
|
||||
assert handler.handle({})["status"] == "error"
|
||||
|
||||
|
||||
class TestDiscoveryPayloads:
|
||||
"""HA reads these once and caches them; a wrong shape is a dead entity."""
|
||||
|
||||
@pytest.fixture
|
||||
def messages(self, bridge_module):
|
||||
return bridge_module.discovery_messages(
|
||||
"ledmatrix/command", "ledmatrix/command/state",
|
||||
"ledmatrix/command/availability", ["Simple Clock", "nfl_live"])
|
||||
|
||||
def test_every_entity_is_published(self, messages):
|
||||
components = {m["topic"].split("/")[1] for m in messages}
|
||||
assert components == {"select", "button", "switch", "number"}
|
||||
|
||||
def test_each_entity_has_a_stable_unique_id(self, messages):
|
||||
"""Without one HA cannot let a user rename or reassign the entity."""
|
||||
ids = [m["payload"]["unique_id"] for m in messages]
|
||||
assert len(ids) == len(set(ids)) and all(ids)
|
||||
|
||||
def test_the_select_offers_the_modes_it_was_given(self, messages):
|
||||
select = next(m for m in messages if "/select/" in m["topic"])
|
||||
assert select["payload"]["options"] == ["Simple Clock", "nfl_live"]
|
||||
|
||||
def test_every_entity_shares_the_availability_topic(self, messages):
|
||||
"""It is also the bridge's last will, so HA greys the controls out
|
||||
when the bridge dies rather than leaving them silently inert."""
|
||||
assert all(m["payload"]["availability_topic"] == "ledmatrix/command/availability"
|
||||
for m in messages)
|
||||
|
||||
def test_every_entity_belongs_to_one_device(self, messages):
|
||||
assert all(m["payload"]["device"]["identifiers"] == ["ledmatrix"] for m in messages)
|
||||
|
||||
def test_the_command_templates_are_valid_json_the_handler_accepts(self, messages, handler):
|
||||
"""A template that renders malformed JSON fails only at runtime, in HA."""
|
||||
select = next(m for m in messages if "/select/" in m["topic"])
|
||||
rendered = select["payload"]["command_template"].replace("{{ value }}", "nfl_live")
|
||||
assert handler.handle(json.loads(rendered))["status"] == "success"
|
||||
|
||||
number = next(m for m in messages if "/number/" in m["topic"])
|
||||
rendered = number["payload"]["command_template"].replace("{{ value }}", "40")
|
||||
assert handler.handle(json.loads(rendered))["status"] == "success"
|
||||
|
||||
def test_the_switch_payloads_are_valid_json_the_handler_accepts(self, messages, handler):
|
||||
switch = next(m for m in messages if "/switch/" in m["topic"])
|
||||
for key in ("payload_on", "payload_off"):
|
||||
assert handler.handle(json.loads(switch["payload"][key]))["status"] == "success"
|
||||
|
||||
def test_the_button_payload_is_valid_json_the_handler_accepts(self, messages, handler):
|
||||
button = next(m for m in messages if "/button/" in m["topic"])
|
||||
assert handler.handle(json.loads(button["payload"]["payload_press"]))["status"] == "success"
|
||||
|
||||
|
||||
class TestStateReporting:
|
||||
def test_state_reflects_the_running_display(self, bridge_module, client):
|
||||
state = bridge_module.read_state(client)
|
||||
assert state == {"power": True, "mode": "nfl_live", "brightness": 90}
|
||||
|
||||
def test_normal_rotation_reports_no_forced_mode(self, bridge_module, client):
|
||||
client.display_status.return_value = {
|
||||
"service": {"active": True}, "state": {"active": False}}
|
||||
assert bridge_module.read_state(client)["mode"] is None
|
||||
|
||||
def test_an_unreachable_field_does_not_blank_the_others(self, bridge_module, client):
|
||||
"""A stopped display service still has a brightness worth showing."""
|
||||
client.display_status.side_effect = RuntimeError("connection refused")
|
||||
state = bridge_module.read_state(client)
|
||||
assert state["brightness"] == 90 and state["power"] is False
|
||||
|
||||
|
||||
class TestConfigLoading:
|
||||
def test_defaults_apply_when_no_file_exists(self, bridge_module, tmp_path):
|
||||
config = bridge_module.load_config(str(tmp_path / "absent.json"))
|
||||
assert config["mqtt_topic"] == "ledmatrix/command"
|
||||
assert config["mqtt_port"] == 1883
|
||||
|
||||
def test_the_file_overrides_defaults(self, bridge_module, tmp_path):
|
||||
path = tmp_path / "bridge_config.json"
|
||||
path.write_text(json.dumps({"mqtt_host": "broker.local", "mqtt_port": "8883"}))
|
||||
config = bridge_module.load_config(str(path))
|
||||
assert config["mqtt_host"] == "broker.local"
|
||||
assert config["mqtt_port"] == 8883, "a port read from JSON must still be an int"
|
||||
|
||||
def test_the_environment_overrides_the_file(self, bridge_module, tmp_path, monkeypatch):
|
||||
"""So a password need not sit in a file the service user can read."""
|
||||
path = tmp_path / "bridge_config.json"
|
||||
path.write_text(json.dumps({"mqtt_password": "from-file"}))
|
||||
monkeypatch.setenv("LEDMATRIX_MQTT_MQTT_PASSWORD", "from-env")
|
||||
assert bridge_module.load_config(str(path))["mqtt_password"] == "from-env"
|
||||
|
||||
def test_the_example_placeholder_password_is_refused(self, bridge_module, tmp_path):
|
||||
"""Copying the example unedited must fail loudly, not silently fail to
|
||||
authenticate against the broker."""
|
||||
path = tmp_path / "bridge_config.json"
|
||||
path.write_text(json.dumps(
|
||||
{"mqtt_password": "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD"}))
|
||||
with pytest.raises(bridge_module.ConfigError):
|
||||
bridge_module.load_config(str(path))
|
||||
|
||||
def test_malformed_json_is_a_clear_error(self, bridge_module, tmp_path):
|
||||
path = tmp_path / "bridge_config.json"
|
||||
path.write_text("{not json")
|
||||
with pytest.raises(bridge_module.ConfigError):
|
||||
bridge_module.load_config(str(path))
|
||||
|
||||
def test_a_non_numeric_port_is_a_clear_error(self, bridge_module, tmp_path):
|
||||
path = tmp_path / "bridge_config.json"
|
||||
path.write_text(json.dumps({"mqtt_port": "not-a-port"}))
|
||||
with pytest.raises(bridge_module.ConfigError):
|
||||
bridge_module.load_config(str(path))
|
||||
|
||||
|
||||
class TestTheExampleConfigIsSecureByDefault:
|
||||
"""The installer copies bridge_config.example.json verbatim on first run.
|
||||
|
||||
Without TLS the broker password and every display command cross the network
|
||||
in cleartext, so the shipped default has to be the safe one -- a plaintext
|
||||
broker is a deliberate edit, not something you get by not reading.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def example(self):
|
||||
path = (Path(__file__).resolve().parent.parent
|
||||
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
|
||||
if not path.exists():
|
||||
pytest.skip("mqtt bridge example config is not present")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
def test_tls_is_on(self, example):
|
||||
assert example["mqtt_tls"] is True
|
||||
|
||||
def test_the_port_is_the_tls_one(self, example):
|
||||
"""1883 with mqtt_tls on would just fail to connect."""
|
||||
assert example["mqtt_port"] == 8883
|
||||
|
||||
def test_certificate_verification_is_not_disabled(self, example):
|
||||
assert example.get("mqtt_tls_insecure", False) is False
|
||||
|
||||
def test_no_password_ships_in_the_example(self, example):
|
||||
assert example["mqtt_password"] is None
|
||||
|
||||
def test_the_example_loads(self, bridge_module, tmp_path):
|
||||
"""It is copied verbatim, so it must survive load_config."""
|
||||
path = (Path(__file__).resolve().parent.parent
|
||||
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
|
||||
config = bridge_module.load_config(str(path))
|
||||
assert config["mqtt_tls"] is True and config["mqtt_port"] == 8883
|
||||
|
||||
|
||||
class TestCleartextIsCalledOut:
|
||||
"""Turning TLS off is allowed -- the Mosquitto add-on is plaintext on 1883 --
|
||||
but it should not be silent when a password is going over it."""
|
||||
|
||||
def test_a_password_without_tls_warns(self, bridge_module, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
warned = bridge_module.warn_if_cleartext(
|
||||
{"mqtt_tls": False, "mqtt_password": "hunter2"})
|
||||
assert warned is True
|
||||
assert "unencrypted" in caplog.text
|
||||
|
||||
def test_the_warning_does_not_repeat_the_password(self, bridge_module, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
bridge_module.warn_if_cleartext({"mqtt_tls": False, "mqtt_password": "hunter2"})
|
||||
assert "hunter2" not in caplog.text
|
||||
|
||||
def test_no_password_means_nothing_to_lose(self, bridge_module):
|
||||
assert bridge_module.warn_if_cleartext(
|
||||
{"mqtt_tls": False, "mqtt_password": None}) is False
|
||||
|
||||
def test_tls_on_does_not_warn(self, bridge_module):
|
||||
assert bridge_module.warn_if_cleartext(
|
||||
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
|
||||
@@ -0,0 +1,208 @@
|
||||
"""On-demand behaviour that a person can ask for but the controller ignored.
|
||||
|
||||
Three separate gaps, all reachable from the web UI's force-display dialog:
|
||||
|
||||
* `pinned` was accepted by the API, stored on the controller and published
|
||||
back in the status payload, but never narrowed the rotation -- a pinned
|
||||
request still cycled every mode its plugin owns;
|
||||
* restarting while on-demand was active loaded *only* the on-demand plugin,
|
||||
so normal rotation had nothing to return to for the life of the process;
|
||||
* a stop request was exempt from the duplicate guards on purpose and was
|
||||
never removed from the mailbox, so it was re-processed on every poll
|
||||
forever.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _plugin_with_modes(controller, plugin_id, modes):
|
||||
"""Register `modes` as loaded modes belonging to `plugin_id`."""
|
||||
controller.plugin_display_modes[plugin_id] = list(modes)
|
||||
for mode in modes:
|
||||
controller.plugin_modes[mode] = MagicMock(spec=[])
|
||||
controller.mode_to_plugin_id[mode] = plugin_id
|
||||
|
||||
|
||||
class TestPinnedNarrowsTheRotation:
|
||||
"""A pinned request shows the one mode that was asked for.
|
||||
|
||||
Unpinned stays the default: a sports plugin's modes are views of one
|
||||
subject, so rotating them is right. A Starlark plugin's modes are
|
||||
unrelated widgets, so it is not.
|
||||
"""
|
||||
|
||||
MODES = ['app_a', 'app_b', 'app_c']
|
||||
|
||||
def _activate(self, controller, pinned):
|
||||
_plugin_with_modes(controller, 'starlark-apps', self.MODES)
|
||||
controller.available_modes = list(self.MODES)
|
||||
controller._activate_on_demand({
|
||||
'request_id': 'r1',
|
||||
'action': 'start',
|
||||
'plugin_id': 'starlark-apps',
|
||||
'mode': 'app_b',
|
||||
'pinned': pinned,
|
||||
})
|
||||
|
||||
def test_pinned_rotation_holds_the_requested_mode(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._activate(c, pinned=True)
|
||||
assert c.on_demand_modes == ['app_b']
|
||||
|
||||
def test_unpinned_still_rotates_the_whole_plugin(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._activate(c, pinned=False)
|
||||
assert set(c.on_demand_modes) == set(self.MODES)
|
||||
|
||||
def test_pinned_still_starts_on_the_requested_mode(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._activate(c, pinned=True)
|
||||
assert c.current_display_mode == 'app_b'
|
||||
|
||||
def test_the_pin_is_recorded_for_the_status_payload(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._activate(c, pinned=True)
|
||||
assert c.on_demand_pinned is True
|
||||
|
||||
def test_an_unresolvable_pin_does_not_empty_the_rotation(self, test_display_controller):
|
||||
"""A pin naming a mode outside the plugin must not leave nothing to show."""
|
||||
c = test_display_controller
|
||||
_plugin_with_modes(c, 'starlark-apps', self.MODES)
|
||||
assert c._apply_on_demand_pin(list(self.MODES), 'not_a_mode', True) == self.MODES
|
||||
|
||||
|
||||
class TestPinSurvivesARestart:
|
||||
"""The pin is part of the request being resumed, not a per-session flag."""
|
||||
|
||||
def test_restored_pinned_state_narrows_the_rotation(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
|
||||
c.on_demand_active = True
|
||||
c.on_demand_plugin_id = 'starlark-apps'
|
||||
c.on_demand_mode = 'app_c'
|
||||
c.on_demand_pinned = True
|
||||
|
||||
c._populate_on_demand_modes_from_plugin()
|
||||
assert c.on_demand_modes == ['app_c']
|
||||
|
||||
def test_restored_unpinned_state_keeps_every_mode(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
|
||||
c.on_demand_active = True
|
||||
c.on_demand_plugin_id = 'starlark-apps'
|
||||
c.on_demand_mode = 'app_c'
|
||||
c.on_demand_pinned = False
|
||||
|
||||
c._populate_on_demand_modes_from_plugin()
|
||||
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
|
||||
|
||||
|
||||
class TestRestartDoesNotStarveTheOtherPlugins:
|
||||
"""Restarting mid-on-demand used to load only the on-demand plugin.
|
||||
|
||||
Restarts during an on-demand session are routine -- it is how an update or
|
||||
a config change is applied -- and the panel came back cycling one plugin's
|
||||
modes and nothing else until the on-demand cache was cleared by hand.
|
||||
"""
|
||||
|
||||
DISCOVERED = ['clock', 'weather', 'starlark-apps', 'disabled-one']
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.config.update({
|
||||
'clock': {'enabled': True},
|
||||
'weather': {'enabled': True},
|
||||
'starlark-apps': {'enabled': True},
|
||||
'disabled-one': {'enabled': False},
|
||||
})
|
||||
return c
|
||||
|
||||
def test_every_enabled_plugin_still_loads(self, controller):
|
||||
selected = controller._select_startup_plugins(
|
||||
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
|
||||
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
||||
|
||||
def test_disabled_plugins_are_still_left_out(self, controller):
|
||||
selected = controller._select_startup_plugins(
|
||||
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
|
||||
assert 'disabled-one' not in selected
|
||||
|
||||
def test_the_on_demand_state_is_still_restored(self, controller):
|
||||
controller._select_startup_plugins(
|
||||
self.DISCOVERED,
|
||||
{'plugin_id': 'starlark-apps', 'mode': 'app_a', 'pinned': True})
|
||||
assert controller.on_demand_active is True
|
||||
assert controller.on_demand_plugin_id == 'starlark-apps'
|
||||
assert controller.on_demand_mode == 'app_a'
|
||||
assert controller.on_demand_pinned is True
|
||||
|
||||
def test_a_disabled_on_demand_plugin_is_enabled_and_loaded(self, controller):
|
||||
"""Otherwise the mode being resumed has nothing behind it."""
|
||||
selected = controller._select_startup_plugins(
|
||||
self.DISCOVERED, {'plugin_id': 'disabled-one', 'mode': 'x'})
|
||||
assert 'disabled-one' in selected
|
||||
assert controller.config['disabled-one']['enabled'] is True
|
||||
|
||||
def test_an_unknown_on_demand_plugin_falls_back_to_normal(self, controller):
|
||||
selected = controller._select_startup_plugins(
|
||||
self.DISCOVERED, {'plugin_id': 'uninstalled', 'mode': 'x'})
|
||||
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
||||
assert controller.on_demand_active is False
|
||||
|
||||
def test_no_on_demand_config_is_a_normal_startup(self, controller):
|
||||
selected = controller._select_startup_plugins(self.DISCOVERED, None)
|
||||
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
|
||||
assert controller.on_demand_active is False
|
||||
|
||||
|
||||
class TestStopRequestsAreConsumed:
|
||||
"""A stop request is exempt from the duplicate guards, so the mailbox
|
||||
delete is the only thing that ends it."""
|
||||
|
||||
STOP = {'request_id': 'S1', 'action': 'stop'}
|
||||
|
||||
def _arrange(self, controller, active):
|
||||
controller.on_demand_active = active
|
||||
controller.on_demand_status = 'active' if active else 'idle'
|
||||
controller._last_on_demand_poll = None
|
||||
controller.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
self.STOP if key == 'display_on_demand_request' else None)
|
||||
controller.cache_manager.set = MagicMock()
|
||||
controller.cache_manager.delete = MagicMock()
|
||||
controller._clear_on_demand = MagicMock()
|
||||
|
||||
def test_a_handled_stop_is_removed_from_the_mailbox(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=True)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_a_stop_arriving_while_idle_is_also_removed(self, test_display_controller):
|
||||
"""Otherwise a stop sent to an idle display re-fires forever."""
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=False)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_the_stop_is_still_acted_on(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=True)
|
||||
c._poll_on_demand_requests()
|
||||
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
|
||||
def test_a_start_racing_in_behind_a_stop_is_not_discarded(self, test_display_controller):
|
||||
"""The compare-before-delete applies to stops too."""
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=True)
|
||||
newer = {'request_id': 'S2', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||
reads = iter([self.STOP, newer])
|
||||
c.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
next(reads, newer) if key == 'display_on_demand_request' else None)
|
||||
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.delete.call_count == 0
|
||||
@@ -0,0 +1,210 @@
|
||||
"""starlark-apps PixletRenderer: what reaches Pixlet, and what counts as a render.
|
||||
|
||||
Three things a Starlark app can do that the renderer got wrong:
|
||||
|
||||
* put a "|" in a config value -- a shell metacharacter filter dropped the
|
||||
whole key, though the command is a list and no shell is involved;
|
||||
* render nothing -- Pixlet exits 0 and writes a 0-byte file, which was
|
||||
reported as a successful render;
|
||||
* compute its schema at runtime -- the source parser can only read option
|
||||
lists that are written out literally, so a dropdown fed by a live API call
|
||||
came back empty.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def renderer_module():
|
||||
if not PLUGIN_DIR.exists():
|
||||
pytest.skip("starlark-apps plugin is not checked out")
|
||||
sys.path.insert(0, str(PLUGIN_DIR))
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"pixlet_renderer_under_test", PLUGIN_DIR / "pixlet_renderer.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception as e: # noqa: BLE001 - optional deps may be absent
|
||||
pytest.skip(f"pixlet_renderer is not importable here: {e}")
|
||||
finally:
|
||||
sys.path.remove(str(PLUGIN_DIR))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def renderer(renderer_module):
|
||||
"""A renderer with a known binary and __init__'s binary search bypassed."""
|
||||
r = renderer_module.PixletRenderer.__new__(renderer_module.PixletRenderer)
|
||||
r.timeout = 30
|
||||
r.pixlet_binary = "/usr/local/bin/pixlet"
|
||||
return r
|
||||
|
||||
|
||||
def _completed(returncode=0, stdout="", stderr=""):
|
||||
return subprocess.CompletedProcess(args=[], returncode=returncode,
|
||||
stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
class TestConfigValuesReachPixlet:
|
||||
"""cmd is a list and there is no shell=True, so nothing here is ever
|
||||
interpreted by a shell -- the filter is defence in depth, not a boundary."""
|
||||
|
||||
def _args_for(self, renderer, tmp_path, config):
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("# app", encoding="utf-8")
|
||||
out = tmp_path / "out.webp"
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
out.write_bytes(b"webp-bytes")
|
||||
fake_run.cmd = cmd
|
||||
return _completed()
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=fake_run):
|
||||
renderer.render(str(tmp_path / "app.star"), str(out), config=config)
|
||||
return fake_run.cmd
|
||||
|
||||
def test_a_pipe_in_a_value_is_passed_through(self, renderer, tmp_path):
|
||||
"""Real apps use "|" as a separator inside one config value."""
|
||||
args = self._args_for(renderer, tmp_path, {"sign_id": "I-476 North|175659"})
|
||||
assert "sign_id=I-476 North|175659" in args
|
||||
|
||||
def test_a_dropped_value_does_not_take_the_key_with_it(self, renderer, tmp_path):
|
||||
args = self._args_for(renderer, tmp_path, {"sign_id": "I-476 North|175659"})
|
||||
assert any(a.startswith("sign_id=") for a in args)
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"$(rm -rf /)",
|
||||
"`whoami`",
|
||||
"a;b",
|
||||
"a&b",
|
||||
"a>b",
|
||||
"a<b",
|
||||
])
|
||||
def test_actual_shell_metacharacters_are_still_dropped(self, renderer, tmp_path, value):
|
||||
args = self._args_for(renderer, tmp_path, {"k": value})
|
||||
assert not any(a.startswith("k=") for a in args)
|
||||
|
||||
def test_an_invalid_key_is_still_dropped(self, renderer, tmp_path):
|
||||
args = self._args_for(renderer, tmp_path, {"bad key!": "x"})
|
||||
assert not any("bad key!" in a for a in args)
|
||||
|
||||
|
||||
class TestAnEmptyRenderIsNotASuccess:
|
||||
"""Pixlet exits 0 and writes 0 bytes when an app renders no content."""
|
||||
|
||||
def _render(self, renderer, tmp_path, write_bytes):
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("# app", encoding="utf-8")
|
||||
out = tmp_path / "out.webp"
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
out.write_bytes(write_bytes)
|
||||
return _completed()
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=fake_run):
|
||||
return renderer.render(str(tmp_path / "app.star"), str(out))
|
||||
|
||||
def test_a_zero_byte_output_is_reported_as_a_failure(self, renderer, tmp_path):
|
||||
ok, error = self._render(renderer, tmp_path, b"")
|
||||
assert ok is False
|
||||
assert "empty" in (error or "").lower()
|
||||
|
||||
def test_a_real_output_still_succeeds(self, renderer, tmp_path):
|
||||
ok, error = self._render(renderer, tmp_path, b"RIFF....WEBP")
|
||||
assert (ok, error) == (True, None)
|
||||
|
||||
def test_a_missing_output_still_fails(self, renderer, tmp_path):
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("# app", encoding="utf-8")
|
||||
out = tmp_path / "out.webp"
|
||||
with patch.object(subprocess, "run", return_value=_completed()):
|
||||
ok, error = renderer.render(str(tmp_path / "app.star"), str(out))
|
||||
assert ok is False
|
||||
assert "not found" in (error or "").lower()
|
||||
|
||||
|
||||
class TestSchemaComesFromPixletWhenItCan:
|
||||
"""`pixlet schema` runs get_schema(); the source parser only reads it.
|
||||
|
||||
An app whose dropdown options come from a live API call has no option list
|
||||
anywhere in its source, so the parser reports an empty dropdown and the
|
||||
config form offers nothing to choose.
|
||||
"""
|
||||
|
||||
PIXLET_OUTPUT = json.dumps({
|
||||
"version": "1",
|
||||
"schema": [
|
||||
{"id": "roadway", "name": "Roadway", "type": "dropdown",
|
||||
"description": "Pick a road", "options": [{"display": "I-476", "value": "476"}]},
|
||||
],
|
||||
})
|
||||
|
||||
def test_the_pixlet_output_is_used(self, renderer, tmp_path):
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("# no schema literal anywhere\n", encoding="utf-8")
|
||||
with patch.object(subprocess, "run",
|
||||
return_value=_completed(stdout=self.PIXLET_OUTPUT)):
|
||||
ok, schema, error = renderer.extract_schema(str(star))
|
||||
assert ok is True and error is None
|
||||
assert schema["schema"][0]["options"][0]["value"] == "476"
|
||||
|
||||
def test_pixlet_field_keys_are_remapped_for_the_config_form(self, renderer, tmp_path):
|
||||
"""The UI reads typeOf/desc; Pixlet emits type/description."""
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("#\n", encoding="utf-8")
|
||||
with patch.object(subprocess, "run",
|
||||
return_value=_completed(stdout=self.PIXLET_OUTPUT)):
|
||||
_, schema, _ = renderer.extract_schema(str(star))
|
||||
field = schema["schema"][0]
|
||||
assert field["typeOf"] == "dropdown"
|
||||
assert field["desc"] == "Pick a road"
|
||||
assert "type" not in field and "description" not in field
|
||||
|
||||
@pytest.mark.parametrize("outcome", [
|
||||
{"return_value": _completed(returncode=1, stderr="unknown command \"schema\"")},
|
||||
{"return_value": _completed(stdout="not json at all")},
|
||||
{"side_effect": subprocess.TimeoutExpired(cmd="pixlet", timeout=20)},
|
||||
{"side_effect": OSError("no such binary")},
|
||||
])
|
||||
def test_it_falls_back_to_the_source_parser(self, renderer, tmp_path, outcome):
|
||||
"""Older Pixlet has no `schema` subcommand, and an app can fail to run.
|
||||
Neither should cost the schema an app does state literally."""
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text(
|
||||
'def get_schema():\n'
|
||||
' return schema.Schema(version = "1", fields = [\n'
|
||||
' schema.Text(id = "city", name = "City", desc = "Your city",'
|
||||
' icon = "gear"),\n'
|
||||
' ])\n',
|
||||
encoding="utf-8")
|
||||
with patch.object(subprocess, "run", **outcome):
|
||||
ok, parsed, error = renderer.extract_schema(str(star))
|
||||
assert ok is True and error is None
|
||||
assert [f["id"] for f in parsed["schema"]] == ["city"]
|
||||
|
||||
def test_no_pixlet_binary_skips_straight_to_the_parser(self, renderer, tmp_path):
|
||||
renderer.pixlet_binary = None
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("#\n", encoding="utf-8")
|
||||
assert renderer.extract_schema_via_pixlet(str(star)) is None
|
||||
|
||||
def test_a_missing_star_file_is_still_an_error(self, renderer, tmp_path):
|
||||
ok, schema, error = renderer.extract_schema(str(tmp_path / "gone.star"))
|
||||
assert ok is False and schema is None and "not found" in error
|
||||
|
||||
def test_an_unexpected_pixlet_shape_is_not_trusted(self, renderer, tmp_path):
|
||||
"""A bare list, or a dict with no schema array, must not reach the UI."""
|
||||
star = tmp_path / "app.star"
|
||||
star.write_text("#\n", encoding="utf-8")
|
||||
with patch.object(subprocess, "run",
|
||||
return_value=_completed(stdout='["not", "a", "schema"]')):
|
||||
assert renderer.extract_schema_via_pixlet(str(star)) is None
|
||||
@@ -7,6 +7,7 @@ of defect as a display() that returns None.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -20,6 +21,17 @@ def manager_module():
|
||||
if not PLUGIN_DIR.exists():
|
||||
pytest.skip("starlark-apps plugin is not checked out")
|
||||
sys.path.insert(0, str(PLUGIN_DIR))
|
||||
# fcntl is POSIX-only and the manager imports it at module scope for
|
||||
# manifest locking. None of the display paths under test touch it, so a
|
||||
# stub is what keeps these assertions running on a developer's machine
|
||||
# rather than skipping everywhere but the Pi and CI.
|
||||
injected_fcntl = "fcntl" not in sys.modules
|
||||
if injected_fcntl:
|
||||
stub = types.ModuleType("fcntl")
|
||||
stub.LOCK_EX = 2
|
||||
stub.LOCK_UN = 8
|
||||
stub.flock = lambda *a, **kw: None
|
||||
sys.modules["fcntl"] = stub
|
||||
try:
|
||||
import importlib
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
@@ -27,10 +39,12 @@ def manager_module():
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception as e: # noqa: BLE001 - optional deps (pixlet, fcntl) may be absent
|
||||
except Exception as e: # noqa: BLE001 - optional deps (Pillow, pixlet) may be absent
|
||||
pytest.skip(f"starlark-apps manager is not importable here: {e}")
|
||||
finally:
|
||||
sys.path.remove(str(PLUGIN_DIR))
|
||||
if injected_fcntl:
|
||||
sys.modules.pop("fcntl", None)
|
||||
|
||||
|
||||
def _plugin(manager_module):
|
||||
@@ -80,3 +94,112 @@ class TestDisplayFramePropagates:
|
||||
|
||||
assert result is False, "display() claimed success over a failed frame update"
|
||||
assert isinstance(result, bool), "the controller only skips on a real bool"
|
||||
|
||||
|
||||
class _SelectableApp(_App):
|
||||
"""An app that display() can pick, render and show."""
|
||||
|
||||
def __init__(self, app_id, enabled=True):
|
||||
super().__init__(frames=[("frame", 100)])
|
||||
self.app_id = app_id
|
||||
self._enabled = enabled
|
||||
|
||||
def is_enabled(self):
|
||||
return self._enabled
|
||||
|
||||
|
||||
def _plugin_with_apps(manager_module, *app_ids):
|
||||
p = _plugin(manager_module)
|
||||
p.apps = {app_id: _SelectableApp(app_id) for app_id in app_ids}
|
||||
p._display_frame = lambda: True
|
||||
return p
|
||||
|
||||
|
||||
class TestDisplayModeSelectsTheApp:
|
||||
"""The controller passes the mode it is rotating to; the plugin must use it.
|
||||
|
||||
It inspects display()'s signature to decide whether to pass display_mode at
|
||||
all, so the parameter is the whole mechanism -- without it there is no way
|
||||
to address one specific app, including from an on-demand request pinned to
|
||||
one.
|
||||
"""
|
||||
|
||||
def test_display_accepts_display_mode(self, manager_module):
|
||||
import inspect
|
||||
params = inspect.signature(manager_module.StarlarkAppsPlugin.display).parameters
|
||||
assert "display_mode" in params, \
|
||||
"the controller only passes display_mode to plugins whose signature accepts it"
|
||||
|
||||
def test_a_named_app_is_the_one_shown(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "aquarium", "printer", "nowplaying")
|
||||
assert p.display(display_mode="printer") is True
|
||||
assert p.current_app.app_id == "printer"
|
||||
|
||||
def test_a_named_app_wins_over_the_one_already_showing(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "aquarium", "printer")
|
||||
p.display(display_mode="aquarium")
|
||||
p.display(display_mode="printer")
|
||||
assert p.current_app.app_id == "printer"
|
||||
|
||||
def test_an_unknown_mode_falls_back_to_rotation(self, manager_module):
|
||||
"""The plugin id arrives here when no per-app modes are exposed."""
|
||||
p = _plugin_with_apps(manager_module, "aquarium")
|
||||
assert p.display(display_mode="starlark-apps") is True
|
||||
assert p.current_app.app_id == "aquarium"
|
||||
|
||||
def test_force_clear_does_not_override_a_named_app(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "aquarium", "printer")
|
||||
p.display(display_mode="printer", force_clear=True)
|
||||
assert p.current_app.app_id == "printer"
|
||||
|
||||
|
||||
class TestInstalledAppsTakeTurns:
|
||||
"""Every installed app gets shown, not just whichever was picked first.
|
||||
|
||||
_select_next_app ran only while current_app was unset, so the first enabled
|
||||
app was chosen once and displayed forever; the rest were rendered on
|
||||
schedule and never reached the panel.
|
||||
"""
|
||||
|
||||
def test_each_entry_to_the_mode_advances(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "a", "b", "c")
|
||||
seen = []
|
||||
for _ in range(3):
|
||||
p.display(force_clear=True) # force_clear = "you are up"
|
||||
seen.append(p.current_app.app_id)
|
||||
assert seen == ["a", "b", "c"]
|
||||
|
||||
def test_the_rotation_wraps(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "a", "b")
|
||||
seen = []
|
||||
for _ in range(4):
|
||||
p.display(force_clear=True)
|
||||
seen.append(p.current_app.app_id)
|
||||
assert seen == ["a", "b", "a", "b"]
|
||||
|
||||
def test_frames_within_one_slot_do_not_advance_the_app(self, manager_module):
|
||||
"""Only entry to the mode rotates -- the high-FPS loop must not."""
|
||||
p = _plugin_with_apps(manager_module, "a", "b", "c")
|
||||
p.display(force_clear=True)
|
||||
for _ in range(20):
|
||||
p.display(force_clear=False)
|
||||
assert p.current_app.app_id == "a"
|
||||
|
||||
def test_a_disabled_app_is_skipped(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "a", "b")
|
||||
p.apps["a"]._enabled = False
|
||||
p.display(force_clear=True)
|
||||
assert p.current_app.app_id == "b"
|
||||
|
||||
def test_no_enabled_apps_reports_nothing_displayed(self, manager_module):
|
||||
p = _plugin_with_apps(manager_module, "a")
|
||||
p.apps["a"]._enabled = False
|
||||
assert p.display(force_clear=True) is False
|
||||
|
||||
|
||||
class TestAnimationsRunAtFrameRate:
|
||||
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
|
||||
"""The controller reads this attribute; a multi-frame app is otherwise
|
||||
called once per rotation slot and never advances past frame one."""
|
||||
plugin = manager_module.StarlarkAppsPlugin
|
||||
assert getattr(plugin, "enable_scrolling", False) is True
|
||||
|
||||
@@ -2516,6 +2516,83 @@ def get_display_current():
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/display/modes', methods=['GET'])
|
||||
def get_display_modes():
|
||||
"""Every display mode that can be requested on-demand, with its plugin.
|
||||
|
||||
/plugins/installed carries no mode information, so anything driving the
|
||||
display from outside the web UI (the Home Assistant MQTT bridge, a script)
|
||||
had to read each plugin's manifest.json off disk and reimplement the
|
||||
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
|
||||
same list the force-display dialog offers, from the source that owns it.
|
||||
|
||||
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
||||
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
||||
lookup only sees modes declared in a static manifest -- a plugin whose
|
||||
modes are generated (each installed Starlark app is one) 404s there.
|
||||
Sending the plugin_id from this list skips the lookup entirely.
|
||||
|
||||
Query params:
|
||||
include_disabled: '1' to list modes of disabled plugins too. They can
|
||||
still be requested on-demand -- the controller enables the plugin
|
||||
for the duration -- so they are reported with enabled: false
|
||||
rather than omitted.
|
||||
"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
# Discovery is lazy and normally triggered by whichever endpoint runs
|
||||
# first, which is a person opening the dashboard. A caller that never
|
||||
# visits it would otherwise see an empty list.
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
|
||||
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||
|
||||
modes = []
|
||||
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
||||
# A hand-edited or migrated config.json can hold a non-dict under a
|
||||
# plugin id; DisplayController._reconcile guards the same shape, so
|
||||
# it happens in practice. Without this, .get() raises AttributeError,
|
||||
# the loop aborts and the endpoint answers 500 with no modes at all
|
||||
# -- one bad section would blank every entity the MQTT bridge builds
|
||||
# from this list.
|
||||
plugin_config = full_config.get(plugin_id)
|
||||
if not isinstance(plugin_config, dict):
|
||||
if plugin_config is not None:
|
||||
logger.warning(
|
||||
"Config for plugin %r is %s, not an object; treating it as disabled",
|
||||
plugin_id, type(plugin_config).__name__)
|
||||
plugin_config = {}
|
||||
enabled = bool(plugin_config.get('enabled', False))
|
||||
if not enabled and not include_disabled:
|
||||
continue
|
||||
plugin_name = (manifest or {}).get('name') or plugin_id
|
||||
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
||||
for mode in plugin_modes:
|
||||
# A single-mode plugin's mode is the plugin, so its own name is
|
||||
# the readable label. Multi-mode plugins have no per-mode name
|
||||
# anywhere, so the raw mode string is the only thing to show.
|
||||
modes.append({
|
||||
'mode': mode,
|
||||
'plugin_id': plugin_id,
|
||||
'plugin_name': plugin_name,
|
||||
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
||||
'enabled': enabled,
|
||||
})
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
||||
except Exception as exc:
|
||||
# describe_exception, not a bare message: test_web_error_detail.py
|
||||
# enforces that every handler here returns it, because a device whose
|
||||
# storage is failing otherwise answers "see logs for details" from the
|
||||
# log viewer too. It redacts credentials out of the exception text.
|
||||
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
||||
# it is the project's deliberate, reviewed trade-off.
|
||||
logger.error('Error in get_display_modes', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
|
||||
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||
def get_on_demand_status():
|
||||
"""Return the current on-demand display state."""
|
||||
|
||||
@@ -5945,7 +5945,10 @@ document.addEventListener('htmx:afterSettle', function() {
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center flex-wrap gap-1.5 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(app.name || app.id)}</h4>
|
||||
<!-- break-words: Starlark app names come from the
|
||||
community repo and some are long single tokens,
|
||||
which overflowed the card instead of wrapping. -->
|
||||
<h4 class="font-semibold text-gray-900 text-base break-words">${escapeHtml(app.name || app.id)}</h4>
|
||||
<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>
|
||||
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user