mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab
Both shipped in #538 with no interface at all: the bridge could only be configured by editing integrations/mqtt_bridge/bridge_config.json over SSH, and the Pixlet editor was a script you had to know existed. Neither appeared anywhere in the web UI. They are now two sections in the Tools tab. **MQTT bridge.** Service state (installed / running / stopped), install, start, stop and restart, and a form for every setting: broker host and port, username, TLS, command topic, client id, API base, request timeout, on-demand duration and log level. The password is write-only. GET returns every other field plus a password_set flag, never the value -- this interface has no authentication, so anything it hands back is readable by anyone who can reach the port. Submitting a blank password keeps whatever is stored; there is an explicit Clear for removing one. The file is written through a temp file in the same directory and chmod 0600, so a crash mid-write cannot leave a config the bridge would refuse to load. Values are range-checked server-side (port, timeouts, log level, http(s) API base). **Pixlet editor.** Lists the apps on disk and starts a session per app, with a banner showing which app is open, a link to the editor, a countdown, and Stop. The banner survives a page reload because status is polled while a session runs, and Edit is disabled while one is open. Two changes to the script make that safe to drive from a browser: - It binds 0.0.0.0 by default (PIXLET_EDITOR_HOST overrides). Loopback-only meant the URL shown in a browser was unreachable and needed an SSH tunnel, which is the confusing path. The no-auth argument for loopback does not hold when web_interface already serves 0.0.0.0:5000 unauthenticated and can reconfigure everything. - The session ends by itself after PIXLET_EDITOR_TIMEOUT (default 30 min), enforced by `timeout` inside the script rather than by the caller. The display is stopped while editing, so a forgotten session would otherwise leave the panel dark and read as broken hardware -- the timeout is the whole reason a Start button is defensible. Two bugs found while testing that lifecycle, both of which would have bitten the Stop button: - GNU timeout runs its child in a NEW process group, so signalling the script's group killed only bash and orphaned pixlet with the port still bound. Now --foreground keeps one group, and the EXIT trap kills the recorded child too. - The liveness check treated a zombie as running: the script is a child of the web process and stays unreaped after exiting, and signal 0 succeeds against a zombie, so a finished session would have read as running forever. It now reaps non-blockingly and falls back to /proc state for a process that is not ours. Covered by test/js/dom/test_tools_sections.js (26 assertions, real DOM against the server-rendered partial): form prefill, the blank-means-unchanged password contract, the running-session banner and countdown, and that the editor link uses the host the page was loaded from. Also chmod +x on both scripts, which the docs tell you to run directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
This commit is contained in:
co-authored by
Claude Opus 5
parent
4423ec33d5
commit
c7eb09db1d
Regular → Executable
Regular → Executable
+66
-12
@@ -18,20 +18,33 @@
|
|||||||
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||||
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
||||||
#
|
#
|
||||||
# Binds loopback only, and there is deliberately no flag to change that:
|
# Binds the LAN by default, matching the web interface, which already serves
|
||||||
# `pixlet serve` has no authentication, and anything that can reach it can
|
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
|
||||||
# rewrite the app's config. To edit from another machine, forward the port --
|
# already reconfigure the display there. `pixlet serve` has no authentication
|
||||||
# which authenticates as SSH and leaves nothing listening on the LAN:
|
# either, so treat both the same way: fine on a home network, not on an open
|
||||||
|
# one. Override the bind and the session length with:
|
||||||
|
#
|
||||||
|
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
#
|
||||||
|
# For loopback-only editing from another machine, forward the port instead:
|
||||||
#
|
#
|
||||||
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||||
|
#
|
||||||
|
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
|
||||||
|
# (default 30 minutes). The display is stopped while editing, so a session
|
||||||
|
# left open would otherwise leave the panel dark indefinitely -- the timeout
|
||||||
|
# is what makes it safe to start one from the web interface.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
||||||
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
||||||
# Loopback only. See the header: pixlet serve is unauthenticated.
|
# LAN by default; see the header for why, and how to force loopback.
|
||||||
BIND_HOST="127.0.0.1"
|
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
|
||||||
|
# Hard stop, so the display cannot be left off by a forgotten session.
|
||||||
|
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
|
||||||
|
|
||||||
APP_ID="${1:-}"
|
APP_ID="${1:-}"
|
||||||
|
|
||||||
@@ -109,6 +122,19 @@ fi
|
|||||||
# failure worth guarding against.
|
# failure worth guarding against.
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
|
# Kill the serve child explicitly. `timeout` is started with --foreground so
|
||||||
|
# it shares this script's process group (without that it makes its own, and
|
||||||
|
# a group signal aimed at this script would orphan pixlet with the port
|
||||||
|
# still bound). Belt and braces: signal the recorded pid too, because a
|
||||||
|
# group signal only reaches it while the group is shared.
|
||||||
|
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$SERVE_PID" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$SERVE_PID" 2>/dev/null || break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
kill -KILL "$SERVE_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||||
echo "Restarting the display service..."
|
echo "Restarting the display service..."
|
||||||
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
||||||
@@ -122,22 +148,50 @@ if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
|||||||
sudo systemctl stop ledmatrix
|
sudo systemctl stop ledmatrix
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||||
|
REACH_HOST="$(hostname).local"
|
||||||
|
else
|
||||||
|
REACH_HOST="localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Editing: $APP_ID"
|
echo "Editing: $APP_ID"
|
||||||
echo "App file: $STAR_FILE"
|
echo "App file: $STAR_FILE"
|
||||||
echo "URL: http://localhost:$PORT/"
|
echo "URL: http://$REACH_HOST:$PORT/"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Listening on localhost only -- pixlet serve has no authentication."
|
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||||
echo "From another machine, forward the port:"
|
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
|
||||||
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
echo "same footing as the web interface on port 5000. Set"
|
||||||
|
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
|
||||||
|
else
|
||||||
|
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
|
||||||
|
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Changes save straight to the real config as you make them."
|
echo "Changes save straight to the real config as you make them."
|
||||||
echo "Press Ctrl+C when finished - the display restarts automatically."
|
echo "Press Ctrl+C when finished - the display restarts automatically."
|
||||||
|
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
"$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
# `timeout` owns the hard stop rather than the caller: the trap above restarts
|
||||||
|
# the display however this exits, so a session that outlives the person who
|
||||||
|
# started it still gives the panel back. Exit 124 is timeout's own code for
|
||||||
|
# "expired", which is a normal end here, not a failure.
|
||||||
|
# --foreground: stay in this script's process group so one signal reaches the
|
||||||
|
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
|
||||||
|
# is still alive; a foreground child would leave bash waiting on it instead.
|
||||||
|
timeout --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
||||||
--host "$BIND_HOST" \
|
--host "$BIND_HOST" \
|
||||||
--port "$PORT" \
|
--port "$PORT" \
|
||||||
--no-browser \
|
--no-browser \
|
||||||
--saveconfig "$CONFIG_FILE"
|
--saveconfig "$CONFIG_FILE" &
|
||||||
|
SERVE_PID=$!
|
||||||
|
|
||||||
|
status=0
|
||||||
|
wait "$SERVE_PID" || status=$?
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
|
||||||
|
status=0
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
|||||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||||
|
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||||
|
|
||||||
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
||||||
with two plugins installed will pass while exercising very little.
|
with two plugins installed will pass while exercising very little.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
|
||||||
|
// server-rendered /partials/tools, and the payloads are the real API's, so a
|
||||||
|
// renamed field or a changed shape fails this rather than passing quietly.
|
||||||
|
const http = require('http');
|
||||||
|
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
|
const get = p => new Promise((res, rej) =>
|
||||||
|
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const partial = await get('/partials/tools');
|
||||||
|
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
|
||||||
|
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
|
||||||
|
|
||||||
|
const errs = [];
|
||||||
|
const vc = new VirtualConsole();
|
||||||
|
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||||
|
|
||||||
|
// Controllable fetch: serve the real payloads, and let tests swap in others.
|
||||||
|
let editorStatus = { status: 'success', data: { running: false } };
|
||||||
|
let bridgePayload = bridge;
|
||||||
|
let onPut = null;
|
||||||
|
const stubFetch = (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
|
||||||
|
let body = { status: 'success', data: {} };
|
||||||
|
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
|
||||||
|
else if (u.includes('/starlark/editor/status')) body = editorStatus;
|
||||||
|
else if (u.includes('/starlark/editor/apps')) body = apps;
|
||||||
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// runScripts:'dangerously' so the partial's own <script> executes the way a
|
||||||
|
// browser runs it -- function declarations land on window. Evaluating the
|
||||||
|
// source by hand instead leaves helpers like escHtml off the global object
|
||||||
|
// and the page fails in ways it never would in a browser.
|
||||||
|
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
|
||||||
|
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
|
||||||
|
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
|
||||||
|
const { window } = dom;
|
||||||
|
|
||||||
|
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
await tick(300);
|
||||||
|
|
||||||
|
const $ = id => window.document.getElementById(id);
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||||
|
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
|
||||||
|
|
||||||
|
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
|
||||||
|
|
||||||
|
// ── MQTT bridge ────────────────────────────────────────────────────────
|
||||||
|
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
|
||||||
|
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
|
||||||
|
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
|
||||||
|
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
|
||||||
|
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
|
||||||
|
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
|
||||||
|
ok('password field is EMPTY', $('mqtt-password').value === '');
|
||||||
|
ok('password field is type=password', $('mqtt-password').type === 'password');
|
||||||
|
ok('no password value anywhere in the DOM',
|
||||||
|
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
|
||||||
|
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
|
||||||
|
$('mqtt-bridge-state').textContent);
|
||||||
|
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
|
||||||
|
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
|
||||||
|
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
|
||||||
|
|
||||||
|
// The save body must omit the password when the field is blank.
|
||||||
|
let sent = null;
|
||||||
|
onPut = body => { sent = body; };
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
|
||||||
|
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
|
||||||
|
|
||||||
|
$('mqtt-password').value = 'typed-secret';
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||||
|
onPut = null;
|
||||||
|
|
||||||
|
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||||
|
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||||
|
ok('editor lists the apps on disk',
|
||||||
|
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
|
||||||
|
ok('no session banner while idle', !$('pixlet-countdown'));
|
||||||
|
ok('warns that the display stops',
|
||||||
|
window.document.body.textContent.includes('display stops while a session is open'));
|
||||||
|
|
||||||
|
// ── Pixlet editor, running ─────────────────────────────────────────────
|
||||||
|
editorStatus = { status: 'success', data: {
|
||||||
|
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
|
||||||
|
timeout: 1800, host_bound: '0.0.0.0' } };
|
||||||
|
window.loadPixletEditor();
|
||||||
|
await tick(200);
|
||||||
|
|
||||||
|
ok('running session shows the banner', !!$('pixlet-countdown'));
|
||||||
|
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
|
||||||
|
$('pixlet-countdown') && $('pixlet-countdown').textContent);
|
||||||
|
ok('Stop button offered', !!$('btn-pixlet-stop'));
|
||||||
|
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
|
||||||
|
ok('editor link present', !!link);
|
||||||
|
ok('link uses this host, not localhost — no tunnel needed',
|
||||||
|
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
|
||||||
|
link && link.href);
|
||||||
|
ok('Edit buttons disabled while a session runs',
|
||||||
|
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
|
||||||
|
ok('banner names the app being edited',
|
||||||
|
$('pixlet-editor-body').textContent.includes('test-editor-app'));
|
||||||
|
|
||||||
|
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
|
||||||
+2
-1
@@ -15,7 +15,8 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
|
'dom/test_tools_sections.js'];
|
||||||
|
|
||||||
function reachable(url) {
|
function reachable(url) {
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from flask import Blueprint, request, jsonify, Response
|
from flask import Blueprint, request, jsonify, Response
|
||||||
|
import contextlib
|
||||||
|
import signal
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -2250,6 +2252,21 @@ def execute_system_action():
|
|||||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action in ('mqtt_bridge_start', 'mqtt_bridge_stop', 'mqtt_bridge_restart'):
|
||||||
|
verb = action.rsplit('_', 1)[1]
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', verb, _MQTT_BRIDGE_SERVICE],
|
||||||
|
capture_output=True, text=True, timeout=20)
|
||||||
|
elif action == 'mqtt_bridge_install':
|
||||||
|
# Runs the same installer the docs point at: it seeds
|
||||||
|
# bridge_config.json from the example at 0600 if absent, installs
|
||||||
|
# requirements, then enables and starts the unit.
|
||||||
|
installer = PROJECT_ROOT / 'scripts' / 'install' / 'install_mqtt_bridge.sh'
|
||||||
|
if not installer.is_file():
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'install_mqtt_bridge.sh not found'}), 404
|
||||||
|
result = subprocess.run(['/bin/bash', str(installer)], # nosec B603 - fixed path
|
||||||
|
cwd=str(PROJECT_ROOT), capture_output=True, text=True,
|
||||||
|
timeout=300)
|
||||||
elif action == 'install_base_requirements':
|
elif action == 'install_base_requirements':
|
||||||
# Base + web interface requirements: flask-compress and friends
|
# Base + web interface requirements: flask-compress and friends
|
||||||
# live in web_interface/requirements.txt, not the root file.
|
# live in web_interface/requirements.txt, not the root file.
|
||||||
@@ -9869,6 +9886,497 @@ def get_tronbyte_categories():
|
|||||||
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pixlet config editor sessions ───────────────────────────────────────────
|
||||||
|
# `pixlet serve` gives an app its own config form, executed for real, with a
|
||||||
|
# live render -- worth having for apps whose options only exist at runtime.
|
||||||
|
# It also stops the display for the duration, which is the whole risk: a
|
||||||
|
# session nobody closes leaves the panel dark and reads as broken hardware.
|
||||||
|
#
|
||||||
|
# The hard stop lives in the script (it runs pixlet under `timeout` and restarts
|
||||||
|
# the display from an EXIT trap), not here, so a session outlives neither its
|
||||||
|
# timeout nor this process. These routes only start, stop and report.
|
||||||
|
|
||||||
|
_PIXLET_EDITOR_SCRIPT = PROJECT_ROOT / 'scripts' / 'utils' / 'pixlet_config_editor.sh'
|
||||||
|
# Deliberately under /tmp: a session cannot survive a reboot, so neither should
|
||||||
|
# the record of one.
|
||||||
|
_PIXLET_EDITOR_STATE = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.json'
|
||||||
|
_PIXLET_EDITOR_DEFAULT_PORT = 8080
|
||||||
|
_PIXLET_EDITOR_DEFAULT_TIMEOUT = 1800
|
||||||
|
_PIXLET_EDITOR_MAX_TIMEOUT = 14400
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pixlet_editor_state() -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
if _PIXLET_EDITOR_STATE.is_file():
|
||||||
|
with open(_PIXLET_EDITOR_STATE, encoding='utf-8') as handle:
|
||||||
|
state = json.load(handle)
|
||||||
|
return state if isinstance(state, dict) else None
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
logger.debug('Unreadable pixlet editor state; treating as no session', exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_pixlet_editor_state() -> None:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
_PIXLET_EDITOR_STATE.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _pixlet_editor_alive(pid: Optional[int]) -> bool:
|
||||||
|
"""Is the recorded session still running?
|
||||||
|
|
||||||
|
os.kill(pid, 0) is not enough on its own: the script is a child of this
|
||||||
|
process, so once it exits it stays a zombie until reaped, and signal 0
|
||||||
|
succeeds against a zombie. Left at that, a finished session would read as
|
||||||
|
running forever and the UI would keep offering a Stop button for it.
|
||||||
|
"""
|
||||||
|
if not pid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Reap it if it is ours and already finished; harmless if it is not.
|
||||||
|
with contextlib.suppress(ChildProcessError, OSError):
|
||||||
|
reaped, _ = os.waitpid(pid, os.WNOHANG)
|
||||||
|
if reaped == pid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
# Exists but is not ours to signal, which still counts as running.
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Not our child (e.g. the web service restarted under a live session), so
|
||||||
|
# waitpid told us nothing -- ask /proc whether it is merely a zombie.
|
||||||
|
with contextlib.suppress(OSError, IndexError, ValueError):
|
||||||
|
with open(f'/proc/{pid}/stat', encoding='utf-8') as handle:
|
||||||
|
# The comm field can contain spaces and parens; everything after
|
||||||
|
# the final ')' is positional, and state is the first of those.
|
||||||
|
fields = handle.read().rsplit(')', 1)[1].split()
|
||||||
|
if fields and fields[0] == 'Z':
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _pixlet_editor_status() -> Dict[str, Any]:
|
||||||
|
"""Current session, reconciled against reality.
|
||||||
|
|
||||||
|
The state file records what we started; the process may have ended on its
|
||||||
|
own (its timeout, a crash, a manual kill). Anything stale is cleared here so
|
||||||
|
the UI never offers a Stop button for a session that is already over.
|
||||||
|
"""
|
||||||
|
state = _read_pixlet_editor_state()
|
||||||
|
if not state:
|
||||||
|
return {'running': False}
|
||||||
|
if not _pixlet_editor_alive(state.get('pid')):
|
||||||
|
_clear_pixlet_editor_state()
|
||||||
|
return {'running': False}
|
||||||
|
|
||||||
|
remaining = None
|
||||||
|
deadline = state.get('deadline')
|
||||||
|
if isinstance(deadline, (int, float)):
|
||||||
|
remaining = max(0, int(deadline - time.time()))
|
||||||
|
return {
|
||||||
|
'running': True,
|
||||||
|
'app_id': state.get('app_id'),
|
||||||
|
'port': state.get('port', _PIXLET_EDITOR_DEFAULT_PORT),
|
||||||
|
'pid': state.get('pid'),
|
||||||
|
'started_at': state.get('started_at'),
|
||||||
|
'timeout': state.get('timeout'),
|
||||||
|
'seconds_remaining': remaining,
|
||||||
|
'host_bound': state.get('host', '0.0.0.0'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/apps', methods=['GET'])
|
||||||
|
def list_pixlet_editor_apps():
|
||||||
|
"""Apps on disk that the editor can open.
|
||||||
|
|
||||||
|
Read from the directory rather than the loaded plugin: the editor works on
|
||||||
|
files, and the plugin may not be loaded in this process at all.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
apps = []
|
||||||
|
if _STARLARK_APPS_DIR.is_dir():
|
||||||
|
for entry in sorted(_STARLARK_APPS_DIR.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
star_files = sorted(entry.glob('*.star'))
|
||||||
|
name = entry.name
|
||||||
|
manifest = entry / 'manifest.json'
|
||||||
|
if manifest.is_file():
|
||||||
|
try:
|
||||||
|
with open(manifest, encoding='utf-8') as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
name = data.get('name') or name
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
apps.append({
|
||||||
|
'id': entry.name,
|
||||||
|
'name': name,
|
||||||
|
'editable': bool(star_files),
|
||||||
|
'has_config': (entry / 'config.json').is_file(),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': {
|
||||||
|
'apps': apps,
|
||||||
|
'apps_dir': str(_STARLARK_APPS_DIR),
|
||||||
|
'pixlet_available': _find_pixlet_binary() is not None,
|
||||||
|
}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error listing editor apps')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not list apps',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/status', methods=['GET'])
|
||||||
|
def get_pixlet_editor_status():
|
||||||
|
"""Whether a session is running, and how long it has left."""
|
||||||
|
try:
|
||||||
|
return jsonify({'status': 'success', 'data': _pixlet_editor_status()})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error reading editor status')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not read editor status',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/start', methods=['POST'])
|
||||||
|
def start_pixlet_editor():
|
||||||
|
"""Start an editing session for one app."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
app_id = data.get('app_id')
|
||||||
|
|
||||||
|
app_dir, err = _validate_starlark_app_path(app_id or '')
|
||||||
|
if err or not app_dir:
|
||||||
|
return jsonify({'status': 'error', 'message': err or 'Invalid app_id'}), 400
|
||||||
|
if not app_dir.is_dir():
|
||||||
|
return jsonify({'status': 'error', 'message': f'No such app: {app_id}'}), 404
|
||||||
|
if not any(app_dir.glob('*.star')):
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f'{app_id} has no .star file to edit'}), 400
|
||||||
|
if not _PIXLET_EDITOR_SCRIPT.is_file():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Editor script not found'}), 404
|
||||||
|
if _find_pixlet_binary() is None:
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'Pixlet is not installed - install it first'}), 503
|
||||||
|
|
||||||
|
current = _pixlet_editor_status()
|
||||||
|
if current.get('running'):
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f"An editor session for '{current.get('app_id')}' is "
|
||||||
|
f"already running; stop it first"}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout_s = int(data.get('timeout') or _PIXLET_EDITOR_DEFAULT_TIMEOUT)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'timeout must be a whole number'}), 400
|
||||||
|
if not 60 <= timeout_s <= _PIXLET_EDITOR_MAX_TIMEOUT:
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f'timeout must be between 60 and '
|
||||||
|
f'{_PIXLET_EDITOR_MAX_TIMEOUT} seconds'}), 400
|
||||||
|
try:
|
||||||
|
port = int(data.get('port') or _PIXLET_EDITOR_DEFAULT_PORT)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'port must be a whole number'}), 400
|
||||||
|
if not 1024 <= port <= 65535:
|
||||||
|
return jsonify({'status': 'error', 'message': 'port must be between 1024 and 65535'}), 400
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env['PIXLET_EDITOR_PORT'] = str(port)
|
||||||
|
env['PIXLET_EDITOR_TIMEOUT'] = str(timeout_s)
|
||||||
|
# A browser reaching this endpoint is remote by definition, so the
|
||||||
|
# session has to listen on more than loopback to be usable at all.
|
||||||
|
env['PIXLET_EDITOR_HOST'] = '0.0.0.0'
|
||||||
|
|
||||||
|
log_path = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.log'
|
||||||
|
log_handle = open(log_path, 'w', encoding='utf-8') # noqa: SIM115 - owned by the child
|
||||||
|
try:
|
||||||
|
# start_new_session so the script leads its own process group: the
|
||||||
|
# stop route signals the group, which is what lets the EXIT trap run
|
||||||
|
# and hand the display back.
|
||||||
|
process = subprocess.Popen( # nosec B603 - fixed script path, validated app_id
|
||||||
|
['/bin/bash', str(_PIXLET_EDITOR_SCRIPT), app_dir.name],
|
||||||
|
cwd=str(PROJECT_ROOT), env=env,
|
||||||
|
stdout=log_handle, stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True)
|
||||||
|
finally:
|
||||||
|
log_handle.close()
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
|
||||||
|
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
|
||||||
|
'host': '0.0.0.0', 'log': str(log_path)}
|
||||||
|
try:
|
||||||
|
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(state, handle)
|
||||||
|
except OSError as err:
|
||||||
|
logger.warning('Started an editor session but could not record it: %s', err)
|
||||||
|
|
||||||
|
logger.info('Pixlet editor started for %s on port %s (pid %s, %ss limit)',
|
||||||
|
app_dir.name, port, process.pid, timeout_s)
|
||||||
|
return jsonify({'status': 'success',
|
||||||
|
'message': f"Editing '{app_dir.name}'. The display is stopped until "
|
||||||
|
f"the session ends.",
|
||||||
|
'data': _pixlet_editor_status()})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error starting the pixlet editor')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not start the editor',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/stop', methods=['POST'])
|
||||||
|
def stop_pixlet_editor():
|
||||||
|
"""End the running session and give the display back."""
|
||||||
|
try:
|
||||||
|
state = _read_pixlet_editor_state()
|
||||||
|
if not state or not _pixlet_editor_alive(state.get('pid')):
|
||||||
|
_clear_pixlet_editor_state()
|
||||||
|
return jsonify({'status': 'success', 'message': 'No editor session was running.',
|
||||||
|
'data': {'running': False}})
|
||||||
|
|
||||||
|
pid = int(state['pid'])
|
||||||
|
# SIGTERM the group, not the pid: bash forwards nothing to `timeout` and
|
||||||
|
# its child on its own, and the trap needs to run to restart the display.
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError) as err:
|
||||||
|
logger.debug('Could not signal the editor process group: %s', err)
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
|
||||||
|
# Give the trap a moment to stop pixlet and restart ledmatrix.
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline and _pixlet_editor_alive(pid):
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
|
if _pixlet_editor_alive(pid):
|
||||||
|
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL. The display '
|
||||||
|
'may need a manual restart.', pid)
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||||
|
|
||||||
|
_clear_pixlet_editor_state()
|
||||||
|
return jsonify({'status': 'success',
|
||||||
|
'message': 'Editor stopped; the display is restarting.',
|
||||||
|
'data': {'running': False}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error stopping the pixlet editor')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not stop the editor',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ── Home Assistant MQTT bridge ──────────────────────────────────────────────
|
||||||
|
# The bridge is a standalone service (integrations/mqtt_bridge) with its own
|
||||||
|
# config file; these routes exist so the Tools tab can manage it without an
|
||||||
|
# SSH session. The password is deliberately never returned: this interface has
|
||||||
|
# no authentication, so anything it hands back is readable by anyone who can
|
||||||
|
# reach the port. Setting one is fine; reading it back is not.
|
||||||
|
|
||||||
|
_MQTT_BRIDGE_DIR = PROJECT_ROOT / 'integrations' / 'mqtt_bridge'
|
||||||
|
_MQTT_BRIDGE_CONFIG = _MQTT_BRIDGE_DIR / 'bridge_config.json'
|
||||||
|
_MQTT_BRIDGE_EXAMPLE = _MQTT_BRIDGE_DIR / 'bridge_config.example.json'
|
||||||
|
_MQTT_BRIDGE_SERVICE = 'ledmatrix-mqtt-bridge.service'
|
||||||
|
|
||||||
|
# Mirrors DEFAULTS in ledmatrix_mqtt_bridge.py. Duplicated rather than imported
|
||||||
|
# because that module pulls in paho-mqtt, which the web process does not need
|
||||||
|
# installed just to render a settings form.
|
||||||
|
_MQTT_BRIDGE_DEFAULTS = {
|
||||||
|
'mqtt_host': 'localhost',
|
||||||
|
'mqtt_port': 1883,
|
||||||
|
'mqtt_username': None,
|
||||||
|
'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',
|
||||||
|
}
|
||||||
|
_MQTT_BRIDGE_LOG_LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
|
||||||
|
|
||||||
|
|
||||||
|
def _mqtt_bridge_service_state() -> Dict[str, Any]:
|
||||||
|
"""installed / active / enabled for the bridge unit."""
|
||||||
|
state = {'installed': False, 'active': False, 'enabled': False}
|
||||||
|
try:
|
||||||
|
listed = _run_systemctl_command(
|
||||||
|
['systemctl', 'list-unit-files', _MQTT_BRIDGE_SERVICE, '--no-legend'])
|
||||||
|
state['installed'] = bool((listed.get('stdout') or '').strip())
|
||||||
|
state['active'] = _run_systemctl_command(
|
||||||
|
['systemctl', 'is-active', _MQTT_BRIDGE_SERVICE]).get('stdout', '').strip() == 'active'
|
||||||
|
state['enabled'] = _run_systemctl_command(
|
||||||
|
['systemctl', 'is-enabled', _MQTT_BRIDGE_SERVICE]).get('stdout', '').strip() == 'enabled'
|
||||||
|
except Exception:
|
||||||
|
logger.debug('Could not read %s state', _MQTT_BRIDGE_SERVICE, exc_info=True)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mqtt_bridge_config() -> Dict[str, Any]:
|
||||||
|
"""Stored config overlaid on the defaults. Missing file is not an error."""
|
||||||
|
config = dict(_MQTT_BRIDGE_DEFAULTS)
|
||||||
|
config['mqtt_password'] = None
|
||||||
|
try:
|
||||||
|
if _MQTT_BRIDGE_CONFIG.is_file():
|
||||||
|
with open(_MQTT_BRIDGE_CONFIG, encoding='utf-8') as handle:
|
||||||
|
stored = json.load(handle)
|
||||||
|
if isinstance(stored, dict):
|
||||||
|
config.update(stored)
|
||||||
|
except (OSError, json.JSONDecodeError) as err:
|
||||||
|
logger.warning('Could not read %s: %s', _MQTT_BRIDGE_CONFIG, err)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/integrations/mqtt-bridge', methods=['GET'])
|
||||||
|
def get_mqtt_bridge():
|
||||||
|
"""Bridge service state and its settings, minus the password."""
|
||||||
|
try:
|
||||||
|
config = _read_mqtt_bridge_config()
|
||||||
|
password = config.get('mqtt_password')
|
||||||
|
safe = {key: config.get(key, default)
|
||||||
|
for key, default in _MQTT_BRIDGE_DEFAULTS.items()}
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'service': _mqtt_bridge_service_state(),
|
||||||
|
'config_exists': _MQTT_BRIDGE_CONFIG.is_file(),
|
||||||
|
'config_path': str(_MQTT_BRIDGE_CONFIG),
|
||||||
|
'config': safe,
|
||||||
|
# Enough to render "a password is set" without disclosing it.
|
||||||
|
'password_set': bool(password),
|
||||||
|
'env_override_prefix': 'LEDMATRIX_MQTT_',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error reading MQTT bridge settings')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not read bridge settings',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_mqtt_bridge_value(key: str, raw: Any) -> Tuple[Any, Optional[str]]:
|
||||||
|
"""Validate one submitted field. Returns (value, error)."""
|
||||||
|
if key in ('mqtt_port',):
|
||||||
|
try:
|
||||||
|
port = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, 'Port must be a whole number'
|
||||||
|
if not 1 <= port <= 65535:
|
||||||
|
return None, 'Port must be between 1 and 65535'
|
||||||
|
return port, None
|
||||||
|
if key in ('request_timeout',):
|
||||||
|
try:
|
||||||
|
timeout = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, 'Request timeout must be a whole number'
|
||||||
|
if not 1 <= timeout <= 300:
|
||||||
|
return None, 'Request timeout must be between 1 and 300 seconds'
|
||||||
|
return timeout, None
|
||||||
|
if key == 'on_demand_duration':
|
||||||
|
if raw in (None, ''):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
duration = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, 'On-demand duration must be a whole number of seconds'
|
||||||
|
if not 1 <= duration <= 86400:
|
||||||
|
return None, 'On-demand duration must be between 1 and 86400 seconds'
|
||||||
|
return duration, None
|
||||||
|
if key in ('mqtt_tls', 'mqtt_tls_insecure'):
|
||||||
|
return bool(raw) if isinstance(raw, bool) else str(raw).lower() in ('1', 'true', 'yes', 'on'), None
|
||||||
|
if key == 'log_level':
|
||||||
|
level = str(raw or '').upper()
|
||||||
|
if level not in _MQTT_BRIDGE_LOG_LEVELS:
|
||||||
|
return None, f"Log level must be one of {', '.join(_MQTT_BRIDGE_LOG_LEVELS)}"
|
||||||
|
return level, None
|
||||||
|
if key == 'ledmatrix_api_base':
|
||||||
|
base = str(raw or '').strip().rstrip('/')
|
||||||
|
if not base.startswith(('http://', 'https://')):
|
||||||
|
return None, 'API base must start with http:// or https://'
|
||||||
|
if len(base) > 300:
|
||||||
|
return None, 'API base is too long'
|
||||||
|
return base, None
|
||||||
|
# Remaining keys are free text; empty means "unset" for the optional ones.
|
||||||
|
text = '' if raw is None else str(raw).strip()
|
||||||
|
if len(text) > 300:
|
||||||
|
return None, f'{key} is too long'
|
||||||
|
if key == 'mqtt_username' and not text:
|
||||||
|
return None, None
|
||||||
|
if key in ('mqtt_host', 'mqtt_client_id', 'mqtt_topic') and not text:
|
||||||
|
return None, f'{key.replace("_", " ")} cannot be empty'
|
||||||
|
return text, None
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/integrations/mqtt-bridge/config', methods=['PUT'])
|
||||||
|
def update_mqtt_bridge_config():
|
||||||
|
"""Write bridge_config.json.
|
||||||
|
|
||||||
|
The password is write-only: omit it to leave whatever is stored alone, send
|
||||||
|
a value to replace it, or send clear_password to remove it. It is never
|
||||||
|
returned by the GET above, so a form that round-tripped it would otherwise
|
||||||
|
have to blank it on every save.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400
|
||||||
|
|
||||||
|
config = _read_mqtt_bridge_config()
|
||||||
|
existing_password = config.get('mqtt_password')
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
for key in _MQTT_BRIDGE_DEFAULTS:
|
||||||
|
if key not in data:
|
||||||
|
continue
|
||||||
|
value, err = _coerce_mqtt_bridge_value(key, data[key])
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
updates[key] = value
|
||||||
|
|
||||||
|
config.update(updates)
|
||||||
|
|
||||||
|
if data.get('clear_password'):
|
||||||
|
config['mqtt_password'] = None
|
||||||
|
elif 'mqtt_password' in data and str(data['mqtt_password']) != '':
|
||||||
|
new_password = str(data['mqtt_password'])
|
||||||
|
if len(new_password) > 300:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Password is too long'}), 400
|
||||||
|
config['mqtt_password'] = new_password
|
||||||
|
else:
|
||||||
|
config['mqtt_password'] = existing_password
|
||||||
|
|
||||||
|
if config.get('mqtt_password') and not config.get('mqtt_tls'):
|
||||||
|
logger.warning('MQTT bridge: a password is set without TLS; '
|
||||||
|
'credentials will cross the network in cleartext')
|
||||||
|
|
||||||
|
_MQTT_BRIDGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Write via a temp file in the same directory so a crash mid-write
|
||||||
|
# cannot leave a half-written config the bridge would refuse to load.
|
||||||
|
fd, tmp_path = tempfile.mkstemp(dir=str(_MQTT_BRIDGE_DIR), prefix='.bridge_config.')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(config, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write('\n')
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
os.replace(tmp_path, _MQTT_BRIDGE_CONFIG)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
raise
|
||||||
|
|
||||||
|
service = _mqtt_bridge_service_state()
|
||||||
|
message = 'Bridge settings saved.'
|
||||||
|
if service['active']:
|
||||||
|
message += ' Restart the bridge for them to take effect.'
|
||||||
|
return jsonify({'status': 'success', 'message': message,
|
||||||
|
'data': {'password_set': bool(config.get('mqtt_password')),
|
||||||
|
'restart_required': service['active']}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error saving MQTT bridge settings')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not save bridge settings',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
def _starlark_virtual_plugins() -> list:
|
def _starlark_virtual_plugins() -> list:
|
||||||
"""Installed Starlark apps, shaped like plugin entries.
|
"""Installed Starlark apps, shaped like plugin entries.
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MQTT Bridge -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">MQTT Bridge</h2>
|
||||||
|
<span id="mqtt-bridge-state" class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">checking…</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Exposes the display to Home Assistant over MQTT — force a mode on demand, set
|
||||||
|
brightness, toggle power. Runs as <code class="bg-gray-100 px-1 rounded">ledmatrix-mqtt-bridge.service</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mqtt-bridge-body" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-500">Loading settings…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pixlet Config Editor -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Pixlet Config Editor</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Opens a Starlark app in Pixlet's own config form, which runs the app for real —
|
||||||
|
so dropdowns that build their options at runtime work, and you see the render update
|
||||||
|
as you type.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
The display stops while a session is open, and the editor has no password — the
|
||||||
|
same footing as this page. Sessions end by themselves after 30 minutes, so the
|
||||||
|
panel is never left dark.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pixlet-editor-body" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-500">Loading apps…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Power -->
|
<!-- System Power -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
@@ -962,5 +1002,339 @@
|
|||||||
|
|
||||||
// WiFi radio current state.
|
// WiFi radio current state.
|
||||||
window.loadWifiRadio();
|
window.loadWifiRadio();
|
||||||
|
|
||||||
|
// ── MQTT bridge ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MQTT_BRIDGE_URL = '/api/v3/integrations/mqtt-bridge';
|
||||||
|
|
||||||
|
function mqttBadge(service, configExists) {
|
||||||
|
if (!service.installed) return ['bg-gray-100 text-gray-600', 'not installed'];
|
||||||
|
if (service.active) return ['bg-green-100 text-green-700', 'running'];
|
||||||
|
if (!configExists) return ['bg-amber-100 text-amber-700', 'not configured'];
|
||||||
|
return ['bg-amber-100 text-amber-700', 'stopped'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMqttBridge(data) {
|
||||||
|
const badge = document.getElementById('mqtt-bridge-state');
|
||||||
|
const [cls, label] = mqttBadge(data.service, data.config_exists);
|
||||||
|
if (badge) {
|
||||||
|
badge.className = 'text-xs px-2 py-1 rounded-full ' + cls;
|
||||||
|
badge.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = data.config || {};
|
||||||
|
const field = (id, label, value, type, extra) => `
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">${escHtml(label)}</span>
|
||||||
|
<input id="${id}" type="${type || 'text'}" value="${value === null || value === undefined ? '' : escHtml(String(value))}"
|
||||||
|
${extra || ''}
|
||||||
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||||
|
</label>`;
|
||||||
|
|
||||||
|
document.getElementById('mqtt-bridge-body').innerHTML = `
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
${field('mqtt-host', 'Broker host', c.mqtt_host)}
|
||||||
|
${field('mqtt-port', 'Port', c.mqtt_port, 'number', 'min="1" max="65535"')}
|
||||||
|
${field('mqtt-username', 'Username (optional)', c.mqtt_username)}
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">Password</span>
|
||||||
|
<input id="mqtt-password" type="password" value=""
|
||||||
|
placeholder="${data.password_set ? 'unchanged — leave blank to keep' : 'none set'}"
|
||||||
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
Never sent back to the browser.
|
||||||
|
${data.password_set ? '<button type="button" id="mqtt-clear-password" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
|
||||||
|
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
|
||||||
|
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
|
||||||
|
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
|
||||||
|
${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">Log level</span>
|
||||||
|
<select id="mqtt-log-level" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md bg-white">
|
||||||
|
${['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l =>
|
||||||
|
`<option value="${l}" ${c.log_level === l ? 'selected' : ''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input id="mqtt-tls" type="checkbox" ${c.mqtt_tls ? 'checked' : ''}
|
||||||
|
class="rounded border-gray-300">
|
||||||
|
Use TLS
|
||||||
|
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4 pt-2">
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Saved to <code class="bg-gray-100 px-1 rounded">${escHtml(data.config_path)}</code>.
|
||||||
|
Any field can also be set as
|
||||||
|
<code class="bg-gray-100 px-1 rounded">${escHtml(data.env_override_prefix)}<KEY></code>,
|
||||||
|
which wins over the file.
|
||||||
|
</p>
|
||||||
|
<button id="btn-mqtt-save" onclick="saveMqttBridge()"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||||
|
<i class="fas fa-save mr-2"></i>Save settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-mqtt-save" class="hidden"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pt-4 border-t border-gray-100">
|
||||||
|
${data.service.installed ? `
|
||||||
|
<button id="btn-mqtt-restart" onclick="toolsAction('mqtt_bridge_restart','btn-mqtt-restart','result-mqtt-service')"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-sync-alt mr-2"></i>Restart
|
||||||
|
</button>
|
||||||
|
<button id="btn-mqtt-startstop"
|
||||||
|
onclick="toolsAction('${data.service.active ? 'mqtt_bridge_stop' : 'mqtt_bridge_start'}','btn-mqtt-startstop','result-mqtt-service')"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-${data.service.active ? 'stop' : 'play'} mr-2"></i>${data.service.active ? 'Stop' : 'Start'}
|
||||||
|
</button>` : `
|
||||||
|
<button id="btn-mqtt-install" onclick="installMqttBridge()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
|
<i class="fas fa-download mr-2"></i>Install & start bridge
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500">Installs dependencies, enables the unit and starts it.</span>`}
|
||||||
|
</div>
|
||||||
|
<div id="result-mqtt-service" class="hidden"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const clearBtn = document.getElementById('mqtt-clear-password');
|
||||||
|
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.loadMqttBridge = function() {
|
||||||
|
fetch(MQTT_BRIDGE_URL)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.status !== 'success') throw new Error(d.message || 'Failed');
|
||||||
|
renderMqttBridge(d.data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
document.getElementById('mqtt-bridge-body').innerHTML =
|
||||||
|
`<p class="text-sm text-red-600">Could not load bridge settings: ${escHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function mqttBody() {
|
||||||
|
const val = id => (document.getElementById(id) || {}).value;
|
||||||
|
const body = {
|
||||||
|
mqtt_host: val('mqtt-host'),
|
||||||
|
mqtt_port: val('mqtt-port'),
|
||||||
|
mqtt_username: val('mqtt-username'),
|
||||||
|
mqtt_topic: val('mqtt-topic'),
|
||||||
|
mqtt_client_id: val('mqtt-client-id'),
|
||||||
|
ledmatrix_api_base: val('mqtt-api-base'),
|
||||||
|
request_timeout: val('mqtt-timeout'),
|
||||||
|
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
||||||
|
log_level: val('mqtt-log-level'),
|
||||||
|
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
||||||
|
};
|
||||||
|
// Only send a password when one was typed; blank means "leave it alone".
|
||||||
|
const pw = val('mqtt-password');
|
||||||
|
if (pw) body.mqtt_password = pw;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.saveMqttBridge = function() {
|
||||||
|
setBusy('btn-mqtt-save', true);
|
||||||
|
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(mqttBody())
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||||
|
.then(({ok, d}) => {
|
||||||
|
showResult('result-mqtt-save', ok && d.status === 'success',
|
||||||
|
d.message || (ok ? 'Saved' : 'Failed'));
|
||||||
|
if (ok && d.status === 'success') loadMqttBridge();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy('btn-mqtt-save', false));
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearMqttPassword() {
|
||||||
|
if (!confirm('Remove the stored MQTT password?')) return;
|
||||||
|
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({clear_password: true})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
showResult('result-mqtt-save', d.status === 'success', d.message || 'Password cleared');
|
||||||
|
loadMqttBridge();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.installMqttBridge = function() {
|
||||||
|
// Installing pulls dependencies, so it is slower than the other actions
|
||||||
|
// and worth saying so rather than leaving a spinner unexplained.
|
||||||
|
showResult('result-mqtt-service', true, 'Installing — this pulls Python dependencies and can take a minute…');
|
||||||
|
setBusy('btn-mqtt-install', true);
|
||||||
|
fetch('/api/v3/system/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({action: 'mqtt_bridge_install'})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
showResult('result-mqtt-service', d.status === 'success',
|
||||||
|
d.message || (d.status === 'success' ? 'Installed' : 'Failed'), d.output || '');
|
||||||
|
loadMqttBridge();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-mqtt-service', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy('btn-mqtt-install', false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pixlet config editor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PIXLET_EDITOR_URL = '/api/v3/starlark/editor';
|
||||||
|
let pixletPollTimer = null;
|
||||||
|
|
||||||
|
function fmtRemaining(seconds) {
|
||||||
|
if (seconds === null || seconds === undefined) return '';
|
||||||
|
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorHost(port) {
|
||||||
|
// The session binds 0.0.0.0, so whatever host reached this page reaches
|
||||||
|
// the editor too -- no tunnel, and no guessing the device's address.
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:${port}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPixletEditor(status, apps, pixletAvailable, appsDir) {
|
||||||
|
const body = document.getElementById('pixlet-editor-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
if (!pixletAvailable) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Pixlet is not installed, so there is nothing to edit with. Install it from the
|
||||||
|
Starlark Apps section of the Plugins tab, or run
|
||||||
|
<code class="bg-gray-100 px-1 rounded">./scripts/download_pixlet.sh</code>.
|
||||||
|
</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = status.running ? `
|
||||||
|
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-amber-900">
|
||||||
|
<i class="fas fa-circle-notch fa-spin mr-1"></i>
|
||||||
|
Editing <code class="bg-amber-100 px-1 rounded">${escHtml(status.app_id)}</code> — the display is stopped
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-800 mt-1">
|
||||||
|
<a href="${editorHost(status.port)}" target="_blank" rel="noopener"
|
||||||
|
class="underline font-medium">Open the editor →</a>
|
||||||
|
<span class="ml-2">stops on its own in
|
||||||
|
<span id="pixlet-countdown">${fmtRemaining(status.seconds_remaining)}</span></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-pixlet-stop" onclick="stopPixletEditor()"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
|
<i class="fas fa-stop mr-2"></i>Stop now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const rows = apps.length ? apps.map(a => `
|
||||||
|
<div class="flex items-center justify-between gap-4 py-2 border-t border-gray-100 first:border-t-0">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">${escHtml(a.name)}</p>
|
||||||
|
<p class="text-xs text-gray-500 truncate">${escHtml(a.id)}${a.editable ? '' : ' — no .star file'}</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-pixlet-edit-${escHtml(a.id)}"
|
||||||
|
${(!a.editable || status.running) ? 'disabled' : ''}
|
||||||
|
onclick="startPixletEditor('${escHtml(a.id)}')"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<i class="fas fa-sliders-h mr-2"></i>Edit
|
||||||
|
</button>
|
||||||
|
</div>`).join('') : `
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
No Starlark apps installed. Install one from the Plugins tab first
|
||||||
|
(looked in <code class="bg-gray-100 px-1 rounded">${escHtml(appsDir)}</code>).
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
body.innerHTML = active + `<div class="mt-2">${rows}</div>
|
||||||
|
<div id="result-pixlet" class="hidden"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.loadPixletEditor = function() {
|
||||||
|
Promise.all([
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/status').then(r => r.json()),
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/apps').then(r => r.json())
|
||||||
|
])
|
||||||
|
.then(([s, a]) => {
|
||||||
|
const status = (s.data || {});
|
||||||
|
const apps = (a.data || {}).apps || [];
|
||||||
|
renderPixletEditor(status, apps, (a.data || {}).pixlet_available, (a.data || {}).apps_dir || '');
|
||||||
|
schedulePixletPoll(status.running);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
const body = document.getElementById('pixlet-editor-body');
|
||||||
|
if (body) body.innerHTML = `<p class="text-sm text-red-600">Could not load: ${escHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function schedulePixletPoll(running) {
|
||||||
|
// Poll only while a session is open: that is the state worth watching,
|
||||||
|
// and it is what makes the banner survive a page reload.
|
||||||
|
if (pixletPollTimer) { clearInterval(pixletPollTimer); pixletPollTimer = null; }
|
||||||
|
if (!running) return;
|
||||||
|
pixletPollTimer = setInterval(() => {
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const st = d.data || {};
|
||||||
|
if (!st.running) { loadPixletEditor(); return; }
|
||||||
|
const el = document.getElementById('pixlet-countdown');
|
||||||
|
if (el) el.textContent = fmtRemaining(st.seconds_remaining);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.startPixletEditor = function(appId) {
|
||||||
|
const btnId = 'btn-pixlet-edit-' + appId;
|
||||||
|
setBusy(btnId, true);
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({app_id: appId})
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||||
|
.then(({ok, d}) => {
|
||||||
|
if (!ok || d.status !== 'success') {
|
||||||
|
showResult('result-pixlet', false, d.message || 'Could not start the editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPixletEditor();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy(btnId, false));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.stopPixletEditor = function() {
|
||||||
|
setBusy('btn-pixlet-stop', true);
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/stop', {method: 'POST'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { showResult('result-pixlet', d.status === 'success', d.message || 'Stopped'); loadPixletEditor(); })
|
||||||
|
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy('btn-pixlet-stop', false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load for both sections. Down here rather than at the shared init
|
||||||
|
// site above, because these are assignments to window rather than hoisted
|
||||||
|
// declarations -- calling them earlier in the file throws.
|
||||||
|
loadMqttBridge();
|
||||||
|
loadPixletEditor();
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user