feat(tools): MQTT bridge and Pixlet editor, ported onto the api_v3 split (#554)

* feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab

PR #544's change, ported onto the api_v3 package split (#553). Identical
behaviour; only the placement of the new code differs.

The original added 508 lines to web_interface/blueprints/api_v3.py, which #553
deletes, so every hunk of it would conflict irreconcilably. Ported by AST:
26 new top-level items sorted to where the split puts each kind --

  __init__.py   2 imports, 11 constants, 7 helpers
  starlark.py   4 routes  (/starlark/editor/{apps,status,start,stop})
  misc.py       2 routes  (/integrations/mqtt-bridge{,/config})

Everything outside api_v3.py -- the Tools partial, the installer scripts, the
JS tests -- applied unchanged.

Routes: 111 from the split plus these 6 = 117, and the url-map snapshot is
regenerated to match, which is exactly what test_api_v3_url_map.py is designed
to make you do when routes are added.

Full Python suite: 4,278 passed, 68 skipped, 0 failed. The JS tests this PR
ships could not be run here -- node is not installed on this machine -- so
test/js/dom/test_tools_sections.js is unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(starlark): don't crash the pixlet editor's start/stop routes, and honor an operator-set PIXLET_EDITOR_HOST

The AST-based port of #544 onto the api_v3 package split dropped `time`
from starlark.py's import list. start_pixlet_editor() and
stop_pixlet_editor() both call time.time()/time.sleep() directly, so
every start (NameError building `state['started_at']`) and every stop
that has to wait out the EXIT trap crashed with a 500. No test caught
it because the route's own tests mock subprocess.Popen but never
actually invoked it before now.

Also carries over #544's later fix that this port branched before:
env['PIXLET_EDITOR_HOST'] = '0.0.0.0' unconditionally overrode an
operator who had already pinned PIXLET_EDITOR_HOST to loopback,
forcing the unauthenticated `pixlet serve` process onto the LAN
regardless (CodeQL CWE-1188). Switched to env.setdefault(...), same as
api_v3.starlark.py's siblings already do for _pkg-owned names.

Both fixes route the shared _pkg.time reference the rest of the
package's route modules already use for anything a test might need to
patch, rather than a bare `import time` local to this file.

Ported the existing regression test from #544
(TestPixletEditorHostDefaultsButDoesNotOverride) onto this branch's
module layout (web_interface.blueprints.api_v3.starlark instead of the
old monolithic api_v3 module), which is what caught the NameError.

Full suite: 4330 passed, 62 skipped, 2 failed -- identical on this
branch and on origin/main (missing tzdata package breaks two
timezone-alias tests in test_onboarding_checklist.py, unrelated to
this change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api-v3): clear the six lint errors this rebase introduced

All six were introduced by rebasing this branch onto the merged blueprint
split, not by the split itself. Confirmed by diffing pyflakes output against
main with line numbers normalised -- everything else it reports is present on
main too and is the package's deliberate re-export pattern.

starlark.py used _STARLARK_APPS_DIR three times without importing it (F821).
The rebase resolved an import-list conflict as a union of both sides, and that
symbol was on neither side of the conflict hunk, so it was silently lost. It is
defined in __init__.py and is now imported like its neighbours. This was the
only one of the six that would fail at runtime rather than merely lint.

__init__.py imported contextlib twice (F811): the cherry-pick added one next to
the existing import. Removed the duplicate; the original at line 19 is used.

__init__.py imported signal purely to re-export it to starlark.py, so pyflakes
saw it as unused (F401). signal is stdlib and does not need routing through the
blueprint package, so starlark.py imports it directly and __init__.py no longer
does. contextlib stays re-exported because this module genuinely uses it.

_read_mqtt_bridge_config()'s local `config` shadowed the `config` submodule
this module imports at the bottom for its route side effects (F811). Renamed to
`settings`, with a comment saying why, since the name is otherwise the obvious
one to reach for.

Verified: pyflakes now reports nothing on this branch that main does not, the
package imports, all nine route modules load, and 117 routes register, matching
the pinned URL-map snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api-v3): reject MQTT bridge bodies the endpoint cannot apply

Two CodeRabbit findings on the bridge settings endpoint, both of which returned
200 while doing something other than what the caller asked.

`request.get_json(silent=True) or {}` turned a missing or unparseable body --
and the JSON literals null, [] and false -- into an empty dict, which then
satisfied the isinstance(data, dict) guard on the very next line. The guard was
there to reject exactly those bodies. Dropping the `or {}` lets None fail it.

The same `or {}` on /errors/clear is left alone: its docstring documents the
body as optional, so an absent body legitimately means "use the defaults". The
difference is that saving settings has nothing sensible to do with no body.

`if data.get('clear_password'):` accepted any truthy value, and the string
"false" is truthy in Python -- so a client echoing the field back as a string
wiped a password it meant to keep. Now coerced through the package's existing
_coerce_to_bool, which already maps 'true'/'on'/'1'/'yes' and nothing else.

test_mqtt_bridge_config_endpoint.py covers both: five unusable body shapes plus
a missing body, and clear_password across truthy and falsy spellings. Verified
against the unfixed code -- reverting the body guard fails 5, reverting the
coercion fails 3.

Not changed here: CodeRabbit also asks this endpoint to reject MQTT credentials
when TLS is off (CWE-319). That is a policy decision about the feature rather
than a defect -- unencrypted MQTT on a trusted LAN is common and often
deliberate -- so it is raised on the PR for a maintainer call instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: work through the remaining review findings on the editor and bridge

allow_insecure_mqtt (CWE-319, requested): a password with TLS disabled crosses
the network in cleartext. Refused now rather than merely warned about -- but
refused, not forbidden, because unencrypted MQTT on a trusted LAN is a normal
deliberate setup. allow_insecure_mqtt is the explicit acknowledgement, defaults
false, and is coerced like the other booleans so the string "false" cannot
switch the guard off.

starlark.py:796 -- the supported service runs Flask threaded, so two start
requests could each see running=False, each launch an editor, and the second
state write replace the first PID, orphaning a process that holds the display
down with nothing recording it. The check-launch-write sequence now takes a
module-level lock.

starlark.py:848 -- if the state write failed the route returned success with an
editor running and no PID recorded: status and stop both reported no session
while the display stayed down until the timeout expired. It now terminates the
process group and returns an error.

starlark.py:890 -- SIGKILL gives the script's EXIT trap no chance to run, so
nothing hands the display back, yet the response said "the display is
restarting". After an escalation the display is now restarted explicitly, and a
failure to do so returns an error naming the manual step instead of a success.

pixlet_config_editor.sh:184 -- find_pixlet supports Darwin but macOS ships no
timeout(1); GNU coreutils installs it as gtimeout. Resolved up front so the
failure lands before the display is stopped rather than after.

pixlet_config_editor.sh:154 -- wildcard, loopback and an explicit interface
address are three cases, not two. Collapsing the last two printed a URL saying
"localhost" whenever PIXLET_EDITOR_HOST named a LAN address.

tools.html:1254 -- escHtml does not encode single quotes, and the app id was
interpolated into an inline onclick="startPixletEditor('...')", so a directory
containing an apostrophe could break out of the JS string and run script. The
handler binds with addEventListener and reads the id from dataset, where it is
only ever parsed as an HTML attribute.

Tests: test_mqtt_bridge_config_endpoint.py grows to 23 cases covering the opt-in
in both directions. The tools DOM suite gains three guards asserting the edit
buttons carry no inline onclick and pass the id via dataset -- those need jsdom
and did not run here, so CI verifies them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api-v3): log the traceback on the editor state-write failure

The 848 fix answers 500 when the session state cannot be written, and logged
that at error level -- but without exc_info, so the traceback never reached the
log. test_web_error_detail.py guards exactly this: a handler returning 5xx must
write an error-level record *with* the traceback and return the sanitized
detail, because checking that merely something was logged is too weak.

Caught by Core unit tests on the previous commit, not locally: the guard parses
every module under web_interface/blueprints/api_v3 as one source, so it only
fires once the whole package is read together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 11:32:12 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent a3d505384d
commit 5137e86d16
14 changed files with 1451 additions and 34 deletions
View File
+92 -12
View File
@@ -18,20 +18,33 @@
# ./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:
# Binds the LAN by default, matching the web interface, which already serves
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
# already reconfigure the display there. `pixlet serve` has no authentication
# 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
#
# 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
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"
# LAN by default; see the header for why, and how to force loopback.
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:-}"
@@ -91,6 +104,29 @@ PIXLET_BIN=$(find_pixlet) || {
exit 1
}
# find_pixlet supports Darwin, so this script has to as well. macOS ships no
# timeout(1); GNU coreutils installs it as gtimeout. Resolve whichever exists
# and fail here with instructions rather than at the invocation far below,
# where the failure would land after the display has already been stopped.
find_timeout() {
local candidate
for candidate in timeout gtimeout; do
if command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
return 0
fi
done
return 1
}
TIMEOUT_BIN=$(find_timeout) || {
echo "Neither 'timeout' nor 'gtimeout' was found on PATH."
echo "This script needs one to bound the editing session."
echo "On macOS, install GNU coreutils:"
echo " brew install coreutils"
exit 1
}
CONFIG_FILE="$APP_DIR/config.json"
if [ -f "$CONFIG_FILE" ]; then
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
@@ -109,6 +145,19 @@ fi
# failure worth guarding against.
cleanup() {
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
echo "Restarting the display service..."
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
@@ -122,22 +171,53 @@ if [ "$DISPLAY_WAS_RUNNING" = true ]; then
sudo systemctl stop ledmatrix
fi
# Wildcard, loopback and an explicit interface address are three different
# cases. Collapsing the last two into "localhost" printed a URL pointing at the
# user's own machine whenever PIXLET_EDITOR_HOST named a LAN address.
case "$BIND_HOST" in
0.0.0.0|::|"") REACH_HOST="$(hostname).local" ;;
127.0.0.1|::1|localhost) REACH_HOST="localhost" ;;
*) REACH_HOST="$BIND_HOST" ;;
esac
echo ""
echo "Editing: $APP_ID"
echo "App file: $STAR_FILE"
echo "URL: http://localhost:$PORT/"
echo "URL: http://$REACH_HOST:$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)"
if [ "$BIND_HOST" = "0.0.0.0" ]; then
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
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 "Changes save straight to the real config as you make them."
echo "Press Ctrl+C when finished - the display restarts automatically."
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
echo ""
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_BIN" --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
--host "$BIND_HOST" \
--port "$PORT" \
--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"
+51
View File
@@ -315,6 +315,23 @@
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge",
"api_v3.get_mqtt_bridge",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge/config",
"api_v3.update_mqtt_bridge_config",
[
"OPTIONS",
"PUT"
]
],
[
"/api/v3/logs",
"api_v3.get_logs",
@@ -747,6 +764,40 @@
"POST"
]
],
[
"/api/v3/starlark/editor/apps",
"api_v3.list_pixlet_editor_apps",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/start",
"api_v3.start_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/editor/status",
"api_v3.get_pixlet_editor_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/stop",
"api_v3.stop_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/install-pixlet",
"api_v3.install_pixlet",
+1
View File
@@ -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_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_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
with two plugins installed will pass while exercising very little.
+127
View File
@@ -0,0 +1,127 @@
// 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'));
// escHtml does not encode single quotes, so an app id interpolated into an
// inline onclick="startPixletEditor('...')" could break out of the JS string
// and run script. The id must reach the handler through dataset instead.
const editBtns = [...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')];
ok('edit buttons exist', editBtns.length > 0, editBtns.length);
ok('edit buttons carry no inline onclick',
editBtns.every(b => !b.getAttribute('onclick')),
editBtns.map(b => b.getAttribute('onclick')));
ok('edit buttons pass the app id via dataset',
editBtns.every(b => appIds.includes(b.dataset.appId)),
editBtns.map(b => b.dataset.appId));
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
View File
@@ -15,7 +15,8 @@ const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
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) {
return new Promise(res => {
+138
View File
@@ -0,0 +1,138 @@
"""The MQTT bridge settings endpoint must reject bodies it cannot apply.
Two defects CodeRabbit raised on #554, both of which reported success while
doing something other than what the caller asked:
* `request.get_json(silent=True) or {}` turned a missing or unparseable body --
and the JSON literals `null`, `[]` and `false` -- into an empty dict, which
then satisfied the `isinstance(data, dict)` guard directly below it. Malformed
JSON therefore returned 200 having applied nothing.
* `if data.get('clear_password'):` accepted any truthy value. The string
"false" is truthy in Python, so a client echoing the field back as a string
wiped a stored password it meant to keep.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
import web_interface.blueprints.api_v3 as pkg # noqa: E402
import web_interface.blueprints.api_v3.misc as misc # noqa: E402
URL = "/api/v3/integrations/mqtt-bridge/config"
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "bridge_config.json"
# Both modules: misc.py writes through its own imported copies of these
# names, while _read_mqtt_bridge_config() lives in the package __init__ and
# reads the one bound there. Patching only one leaves the read and the
# write pointing at different files.
for mod in (misc, pkg):
monkeypatch.setattr(mod, "_MQTT_BRIDGE_CONFIG", cfg, raising=False)
monkeypatch.setattr(mod, "_MQTT_BRIDGE_DIR", tmp_path, raising=False)
monkeypatch.setattr(api_v3, "config_manager", MagicMock(), raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
app.cfg_path = cfg
return app.test_client(), cfg
class TestABodyItCannotApplyIsRejected:
@pytest.mark.parametrize("raw", ["{ not json", "null", "[]", "false", '"a string"'])
def test_unusable_bodies_are_rejected(self, client, raw):
c, _ = client
r = c.put(URL, data=raw, content_type="application/json")
# The regression: every one of these returned 200 having applied nothing.
assert r.status_code == 400, f"{raw!r} was accepted"
assert "JSON object" in r.get_json()["message"]
def test_a_missing_body_is_rejected(self, client):
c, _ = client
assert c.put(URL).status_code == 400
def test_a_real_object_is_still_accepted(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_host": "192.168.1.20"})
assert r.status_code == 200, r.get_json()
class TestClearPasswordNeedsARealBoolean:
def _seed(self, cfg, password="hunter2"):
# TLS on so these cases exercise clear_password alone: a stored password
# with TLS off is refused by the CWE-319 guard, which is a separate test.
cfg.write_text(json.dumps({"mqtt_password": password, "mqtt_tls": True}),
encoding="utf-8")
def _stored(self, cfg):
return json.loads(cfg.read_text(encoding="utf-8")).get("mqtt_password")
def test_the_string_false_does_not_clear_it(self, client):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": "false"}).status_code == 200
# The regression: "false" is truthy, so this wiped the password.
assert self._stored(cfg) == "hunter2"
@pytest.mark.parametrize("falsy", [False, "no", "0", "", None])
def test_other_falsy_spellings_do_not_clear_it(self, client, falsy):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": falsy}).status_code == 200
assert self._stored(cfg) == "hunter2"
@pytest.mark.parametrize("truthy", [True, "true", "1", "yes", "on"])
def test_real_truthy_values_still_clear_it(self, client, truthy):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": truthy}).status_code == 200
assert self._stored(cfg) is None
class TestCleartextCredentialsNeedAnExplicitOptIn:
"""CWE-319. A password with TLS off crosses the network in the clear.
Refused rather than forbidden: unencrypted MQTT on a trusted LAN is a normal
deliberate setup, so allow_insecure_mqtt is the explicit acknowledgement.
"""
def test_a_password_without_tls_is_refused(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False})
assert r.status_code == 400
assert "allow_insecure_mqtt" in r.get_json()["message"]
def test_the_opt_in_allows_it(self, client):
c, cfg = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": True})
assert r.status_code == 200, r.get_json()
assert json.loads(cfg.read_text(encoding="utf-8"))["mqtt_password"] == "hunter2"
def test_tls_on_needs_no_opt_in(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": True})
assert r.status_code == 200, r.get_json()
def test_no_password_is_unaffected(self, client):
c, _ = client
assert c.put(URL, json={"mqtt_tls": False}).status_code == 200
def test_the_opt_in_is_a_real_boolean(self, client):
""""false" must not switch the guard off, same as clear_password."""
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": "false"})
assert r.status_code == 400
@@ -906,3 +906,66 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
client.get('/api/v3/starlark/repository/browse')
repo.assert_called_once_with(github_token='ghp_configured')
class TestPixletEditorHostDefaultsButDoesNotOverride:
"""PIXLET_EDITOR_HOST must default to 0.0.0.0, never force it.
A browser reaching the editor is remote by definition, so a session with
nothing configured has to bind more than loopback to be reachable at
all -- but an operator who has deliberately pinned PIXLET_EDITOR_HOST to
loopback (e.g. in the systemd unit's Environment=, to edit only over an
SSH tunnel) must keep that setting. The previous unconditional
``env['PIXLET_EDITOR_HOST'] = '0.0.0.0'`` overrode it every time,
always exposing the unauthenticated ``pixlet serve`` dev process on the
LAN regardless (CodeQL CWE-1188).
"""
@pytest.fixture
def app_dir(self, tmp_path):
d = tmp_path / "demo_app"
d.mkdir()
(d / "demo_app.star").write_text("def main():\n pass\n")
return d
def _start(self, client, app_dir, tmp_path, operator_host):
from web_interface.blueprints.api_v3 import starlark as mod
script = tmp_path / "pixlet_config_editor.sh"
script.write_text("#!/bin/bash\n")
state_file = tmp_path / "pixlet_editor_state.json"
captured = {}
class FakeProcess:
pid = 424242
def fake_popen(cmd, *args, env=None, **kwargs):
if env is not None:
captured['env'] = env
return FakeProcess()
with patch.object(mod, '_validate_starlark_app_path',
return_value=(app_dir, None)), \
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
patch.dict(os.environ):
if operator_host is None:
os.environ.pop('PIXLET_EDITOR_HOST', None)
else:
os.environ['PIXLET_EDITOR_HOST'] = operator_host
resp = client.post('/api/v3/starlark/editor/start',
json={'app_id': app_dir.name})
assert resp.status_code == 200, resp.get_json()
assert 'env' in captured, "subprocess.Popen was never called"
return captured['env']
def test_defaults_to_0_0_0_0_when_operator_set_nothing(self, client, app_dir, tmp_path):
env = self._start(client, app_dir, tmp_path, operator_host=None)
assert env['PIXLET_EDITOR_HOST'] == '0.0.0.0'
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1'
+208
View File
@@ -1835,6 +1835,214 @@ def _toggle_starlark_app(app_id: str, enabled: bool):
'enabled': enabled})
_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'),
}
_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,
# Opt-in acknowledgement that credentials may cross an untrusted
# network in cleartext. Off by default: the save is refused instead.
'allow_insecure_mqtt': 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.
Named `settings`, not `config`: this module imports a submodule called
`config` at the bottom for its route side effects, and a local of the same
name shadows it.
"""
settings = dict(_MQTT_BRIDGE_DEFAULTS)
settings['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):
settings.update(stored)
except (OSError, json.JSONDecodeError) as err:
logger.warning('Could not read %s: %s', _MQTT_BRIDGE_CONFIG, err)
return settings
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', 'allow_insecure_mqtt'):
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
# Imported last, and for their side effect: each registers its routes on
# api_v3. They import names from this module, so this module must be fully
# executed before they run.
+3 -3
View File
@@ -5,9 +5,9 @@ endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, Optional, PROJECT_ROOT, Path, _coerce_to_bool,
_redact_credentials, _validate_time_format, api_v3,
deep_merge, describe_exception, error_response, find_secret_fields, json,
jsonify, logger, logging, mask_all_secret_values, merge_secrets, os,
_redact_credentials, _validate_time_format, api_v3, deep_merge,
describe_exception, error_response, find_secret_fields, json, jsonify,
logger, logging, mask_all_secret_values, merge_secrets, os,
remove_empty_secrets, request, separate_secrets, strip_masked_values,
success_response,
)
+121 -3
View File
@@ -5,9 +5,13 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, Path, _JOURNALCTL, _SUDO, _get_display_service_status, api_v3,
describe_exception, error_response, get_error_aggregator, json, jsonify,
logger, os, request, subprocess, success_response,
_coerce_to_bool,
ErrorCode, Path, _JOURNALCTL, _MQTT_BRIDGE_CONFIG, _MQTT_BRIDGE_DEFAULTS,
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
_get_display_service_status, _mqtt_bridge_service_state,
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
error_response, get_error_aggregator, json, jsonify, logger, os, request,
subprocess, success_response, tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
@@ -396,3 +400,117 @@ def clear_old_errors():
message="Failed to clear old errors",
status_code=500
)
@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
@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:
# No `or {}` here: get_json(silent=True) returns None for a missing or
# unparseable body, and `None or {}` produced an empty dict that then
# satisfied the isinstance check below -- so malformed JSON, `null`,
# `[]` and `false` all reported success while applying nothing.
data = request.get_json(silent=True)
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)
# Coerced, not merely truthy: the string "false" is truthy in Python,
# so a client echoing the field back as a string would have wiped a
# stored password it meant to keep.
if _coerce_to_bool(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
# CWE-319: a password with TLS off is sent in the clear. On a trusted
# LAN that is a normal, deliberate setup, so this is refused rather
# than forbidden -- allow_insecure_mqtt is the explicit acknowledgement.
insecure = bool(config.get('mqtt_password')) and not config.get('mqtt_tls')
if insecure and not config.get('allow_insecure_mqtt'):
return jsonify({
'status': 'error',
'message': 'MQTT credentials would cross the network in cleartext '
'with TLS disabled. Enable mqtt_tls, or set '
'allow_insecure_mqtt to accept that on a trusted network.'
}), 400
if insecure:
logger.warning('MQTT bridge: a password is set without TLS and '
'allow_insecure_mqtt is on; 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
+12 -11
View File
@@ -5,17 +5,18 @@ endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, PROJECT_ROOT, Path, Response,
_CALENDAR_LIST_MAX_PAGES, _SKIP_FIELD,
_coerce_to_bool, _do_transactional_uninstall,
_enhance_schema_with_core_properties, _filter_config_by_schema,
_get_plugin_version, _get_schema_property, _installed_plugin_ids,
_is_plugin_update_available, _parse_form_value_with_schema,
_prune_credential_backups, _run_calendar_registration, _set_missing_booleans_to_false, _set_nested_value,
_starlark_virtual_plugins, _toggle_starlark_app, api_v3, datetime,
deep_merge, describe_exception, error_response, find_secret_fields,
hashlib, json, jsonify, logger, logging, merge_secrets, os, redact_text,
remove_empty_secrets, request, separate_secrets, shutil, stat, subprocess,
success_response, sys, tempfile, uuid, validate_request_json,
_CALENDAR_LIST_MAX_PAGES, _SKIP_FIELD, _coerce_to_bool,
_do_transactional_uninstall, _enhance_schema_with_core_properties,
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
_installed_plugin_ids, _is_plugin_update_available,
_parse_form_value_with_schema, _prune_credential_backups,
_run_calendar_registration, _set_missing_booleans_to_false,
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
api_v3, datetime, deep_merge, describe_exception, error_response,
find_secret_fields, hashlib, json, jsonify, logger, logging,
merge_secrets, os, redact_text, remove_empty_secrets, request,
separate_secrets, shutil, stat, subprocess, success_response, sys,
tempfile, uuid, validate_request_json,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
+250 -4
View File
@@ -3,11 +3,21 @@
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
import signal
import threading
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _find_pixlet_binary, _install_star_file, _standalone_render_starlark_app,
_starlark_github_token, _starlark_manifest_lock, _validate_and_sanitize_app_id,
_validate_starlark_app_path, _validate_timing_value, api_v3, describe_exception, json, jsonify,
logger, os, request, shutil, subprocess, tempfile,
PROJECT_ROOT, Path, _PIXLET_EDITOR_DEFAULT_PORT,
_PIXLET_EDITOR_DEFAULT_TIMEOUT, _PIXLET_EDITOR_MAX_TIMEOUT,
_PIXLET_EDITOR_SCRIPT, _PIXLET_EDITOR_STATE, _clear_pixlet_editor_state,
_find_pixlet_binary, _install_star_file, _pixlet_editor_alive,
_pixlet_editor_status, _read_pixlet_editor_state,
_STARLARK_APPS_DIR, _standalone_render_starlark_app,
_starlark_github_token, _starlark_manifest_lock,
_validate_and_sanitize_app_id,
_validate_starlark_app_path, _validate_timing_value, api_v3, contextlib,
describe_exception, json, jsonify, logger, os, request, shutil,
subprocess, tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
@@ -710,3 +720,239 @@ def get_tronbyte_categories():
except Exception as e:
logger.exception("[Starlark] get_tronbyte_categories failed")
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
@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
#: Flask runs threaded in the supported service, so two start requests can each
#: observe running=False, each launch an editor, and the second state write
#: replace the first PID -- orphaning a process that holds the display down with
#: nothing left recording it. The check-launch-write sequence takes this lock.
_EDITOR_START_LOCK = threading.Lock()
def _terminate_editor_process(pid, wait_s=5):
"""Signal an editor's process group and wait briefly for it to exit."""
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGTERM)
deadline = _pkg.time.time() + wait_s
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
_pkg.time.sleep(0.25)
if _pixlet_editor_alive(pid):
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
os.killpg(os.getpgid(pid), signal.SIGKILL)
@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
with _EDITOR_START_LOCK:
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 needs to listen on more than loopback to be usable at all --
# but only as a *default*. An operator who has already set
# PIXLET_EDITOR_HOST (e.g. to keep it loopback-only even from the web
# UI) must not have that overridden here.
env.setdefault('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 = _pkg.time.time()
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
'host': env['PIXLET_EDITOR_HOST'], 'log': str(log_path)}
try:
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
json.dump(state, handle)
except OSError as err:
# The process is up but nothing records its PID: status and stop
# would both report no session while the display stays down until
# the timeout expires. Take the editor with us instead.
# exc_info: this path answers 500, and the guard in
# test_web_error_detail.py requires the traceback to reach the log
# as well as the sanitized detail reaching the caller.
logger.error('Started an editor session but could not record it: %s',
err, exc_info=True)
_terminate_editor_process(process.pid)
return jsonify({'status': 'error',
'message': 'Could not record the editor session; '
'the editor was stopped.',
'details': describe_exception(err)}), 500
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 = _pkg.time.time() + 10
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
_pkg.time.sleep(0.25)
escalated = False
if _pixlet_editor_alive(pid):
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL.', pid)
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
os.killpg(os.getpgid(pid), signal.SIGKILL)
escalated = True
_clear_pixlet_editor_state()
if escalated:
# SIGKILL gives the script's EXIT trap no chance to run, so nothing
# has handed the display back. Reporting "the display is restarting"
# here was simply untrue: restart it, and if that fails say so
# rather than leave the panel dark behind a success response.
result = _run_systemctl_command(
['sudo', 'systemctl', 'start', 'ledmatrix.service'])
if result.get('returncode') != 0:
logger.error('Display restart after SIGKILL failed: %s',
(result.get('stderr') or '').strip())
return jsonify({
'status': 'error',
'message': 'Editor force-stopped, but the display could not be '
'restarted automatically - start it manually.',
'details': (result.get('stderr') or '').strip(),
'data': {'running': False}}), 500
return jsonify({'status': 'success',
'message': 'Editor force-stopped; the display has been '
'restarted.',
'data': {'running': False}})
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
@@ -233,6 +233,46 @@
</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&hellip;</span>
</div>
<p class="mt-1 text-sm text-gray-600">
Exposes the display to Home Assistant over MQTT &mdash; 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&hellip;</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 &mdash;
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 &mdash; the
same footing as this page. Sessions end by themselves after 30&nbsp;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&hellip;</p>
</div>
</div>
<!-- System Power -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
@@ -962,5 +1002,348 @@
// WiFi radio current state.
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)}&lt;KEY&gt;</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 &amp; 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 &rarr;</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' : ''}
data-pixlet-edit data-app-id="${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>`;
// Bound here rather than via an inline onclick: escHtml does not encode
// single quotes, and the id used to be interpolated into a single-quoted
// JS string, so an app directory containing an apostrophe could break
// out of it and run script. Through dataset the value is only ever
// parsed as an HTML attribute, never as JavaScript.
body.querySelectorAll('[data-pixlet-edit]').forEach(btn => {
btn.addEventListener('click', () => startPixletEditor(btn.dataset.appId));
});
}
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>