A dashboard went live the moment it was created — an empty document straight to the panels — while a new flow starts as a draft. It now works the way flows do: published means `dashboard.json` exists, so every dashboard on every running installation is already published and nothing needs migrating. Only the ones created from here on start as drafts. Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard that had never been published unlinked its only file, and the read that followed raised out of a 200 handler. It answers 400 now, the way a flow does. Publishing all of them was 2N requests, because a publish has to name the version it expects and the summaries did not carry one. They do now — and so do the flow summaries, which had the same defect nobody had written down. A panel had no way to hear about any of this. A publish, or a change to which dashboards a panel carries, now puts one event on the bus and the screen refetches what changed: no reload, so a wall display never blanks or asks for its credential again. The subtle half is that a socket's message allowlist was computed once at handshake — a reassigned panel would have fetched its new document and then shown tiles that never updated. The panels dialog logged non-superusers out. Every write in it needs a superuser, not only the checkboxes the report mentioned, so the dialog is read-only for everyone else. The logout itself was `main.tsx` treating 403 as a dead session, against the contract deps.py spells out: only a 401 ends a session, and a 403 now says so rather than silently signing someone out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
460 lines
14 KiB
Python
460 lines
14 KiB
Python
#!/usr/bin/env python
|
|
"""Seed the house write-path evaluation: one flow, one dashboard, real devices.
|
|
|
|
This is the M4 write-path test rig. Node-RED drives this house today; the flow
|
|
below speaks the same MQTT bus it does, and carries an Art-Net node aimed at
|
|
the same gateway so the Node-RED-free path can be tried on the same fixtures.
|
|
|
|
house_control inputs -> encoders -> mqtt out commands three fixtures
|
|
mqtt in -> meter what the house echoed
|
|
inputs -> dmx -> artnet the same two, direct
|
|
|
|
What the reference does, and what this reproduces:
|
|
|
|
actor/washingMachinePlug "ON" / "OFF" -> DMX ch 33 (1CH Actor)
|
|
light/traverseSpotLight 0-100 raw -> DMX ch 31 (1CH Light)
|
|
light/livingRoomAmbientLight "[h,s,v]" -> DMX ch 1-4 (4CH Light)
|
|
|
|
Nothing is retained on that broker, so the echo the flow subscribes to is the
|
|
only handshake there is: a value coming back is proof the broker took it.
|
|
|
|
**The flow is seeded stopped.** Starting it publishes the current input values
|
|
once, which is `OFF` for the plug and zero for both lights — start it with the
|
|
washing machine idle.
|
|
|
|
Run it against a stack that is already up::
|
|
|
|
make -C app seed-house
|
|
|
|
Environment (the Makefile passes these):
|
|
API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD
|
|
HOUSE_BROKER, HOUSE_BROKER_USER, ARTNET_HOST
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
API = os.environ.get("API_URL", "http://api.localhost")
|
|
EMAIL = os.environ.get("FIRST_SUPERUSER", "")
|
|
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "")
|
|
|
|
BROKER = os.environ.get("HOUSE_BROKER", "192.168.1.104")
|
|
BROKER_USER = os.environ.get("HOUSE_BROKER_USER", "fluksio")
|
|
ARTNET_HOST = os.environ.get("ARTNET_HOST", "192.168.1.12")
|
|
|
|
SECRET = "mqtt_thcomp_password"
|
|
FLOW = "house_control"
|
|
PANEL = "house_control"
|
|
|
|
PLUG_TOPIC = "actor/washingMachinePlug"
|
|
SPOT_TOPIC = "light/traverseSpotLight"
|
|
AMBIENT_TOPIC = "light/livingRoomAmbientLight"
|
|
POWER_TOPIC = "shellypm/status/pm1:0"
|
|
|
|
# Universe 1 on the gateway, the same channels the reference stamps in.
|
|
DMX_PLUG = 33
|
|
DMX_SPOT = 31
|
|
|
|
PLUG_SOURCE = '''"""A relay speaks ON and OFF. Anything else lands on DMX as a string."""
|
|
|
|
|
|
def process(wash):
|
|
return {"plug": "ON" if wash else "OFF"}
|
|
'''
|
|
|
|
SPOT_SOURCE = '''"""A single-channel dimmer takes the level raw.
|
|
|
|
The reference hands 0-100 straight to DMX without scaling to 255, so a
|
|
replacement that "fixes" that would be four times too bright.
|
|
"""
|
|
|
|
|
|
def process(spot):
|
|
return {"spot_level": max(0, min(100, int(spot)))}
|
|
'''
|
|
|
|
AMBIENT_SOURCE = '''"""The 4CH fixtures take "[h,s,v]" as text: hue 0-360, the rest 0-100."""
|
|
|
|
|
|
def process(hue, level):
|
|
return {"ambient": f"[{int(hue)},80,{max(0, min(100, int(level)))}]"}
|
|
'''
|
|
|
|
METER_SOURCE = '''"""House power, so a plug switching is visible as a step rather than a claim.
|
|
|
|
This is the whole house, not the one socket — the inventory has no per-plug
|
|
metering. A washing machine still moves it by hundreds of watts.
|
|
"""
|
|
|
|
|
|
def process(power_raw):
|
|
return {"power": float(power_raw.get("apower", 0.0))}
|
|
'''
|
|
|
|
DMX_SOURCE = '''"""The same two commands as DMX levels, for the Node-RED-free path.
|
|
|
|
Mirrors the reference's "1CH Actor" (ON -> 255) and "1CH Light" (level used
|
|
raw). One node owns the universe, so both channels are set every run.
|
|
"""
|
|
|
|
|
|
def process(wash, spot):
|
|
return {
|
|
"dmx_wash": 255 if wash else 0,
|
|
"dmx_spot": max(0, min(255, int(spot))),
|
|
}
|
|
'''
|
|
|
|
|
|
def broker_params(client_id: str, topic: dict[str, str]) -> dict[str, Any]:
|
|
"""One broker, one credential, a topic per port."""
|
|
return {
|
|
"topic": topic,
|
|
"broker_host": BROKER,
|
|
"broker_port": 1883,
|
|
"username": BROKER_USER,
|
|
"password": {"$secret": SECRET},
|
|
"client_id": client_id,
|
|
"qos": 0,
|
|
"retain": False,
|
|
}
|
|
|
|
|
|
# One publisher per fixture on purpose: a node is handed every input it
|
|
# requires on every run, so a single publisher would re-command the plug each
|
|
# time a light slider moved.
|
|
NODES = [
|
|
{
|
|
"id": "plug_cmd",
|
|
"type": "python",
|
|
"title": "Plug to ON/OFF",
|
|
"requires": [{"name": "wash", "dtype": "bool"}],
|
|
"provides": [{"name": "plug", "dtype": "str"}],
|
|
},
|
|
{
|
|
"id": "spot_cmd",
|
|
"type": "python",
|
|
"title": "Spot level",
|
|
"requires": [{"name": "spot", "dtype": "float"}],
|
|
"provides": [{"name": "spot_level", "dtype": "int"}],
|
|
},
|
|
{
|
|
"id": "ambient_cmd",
|
|
"type": "python",
|
|
"title": "Ambient colour",
|
|
"requires": [
|
|
{"name": "hue", "dtype": "float"},
|
|
{"name": "level", "dtype": "float"},
|
|
],
|
|
"provides": [{"name": "ambient", "dtype": "str"}],
|
|
},
|
|
{
|
|
"id": "plug_out",
|
|
"type": "mqtt",
|
|
"title": "Washing machine plug",
|
|
"params": broker_params("fluksio-house-plug", {"plug": PLUG_TOPIC}),
|
|
"requires": [{"name": "plug", "dtype": "str"}],
|
|
},
|
|
{
|
|
"id": "spot_out",
|
|
"type": "mqtt",
|
|
"title": "Traverse spot",
|
|
"params": broker_params("fluksio-house-spot", {"spot_level": SPOT_TOPIC}),
|
|
"requires": [{"name": "spot_level", "dtype": "int"}],
|
|
},
|
|
{
|
|
"id": "ambient_out",
|
|
"type": "mqtt",
|
|
"title": "Living room ambient",
|
|
"params": broker_params("fluksio-house-ambient", {"ambient": AMBIENT_TOPIC}),
|
|
"requires": [{"name": "ambient", "dtype": "str"}],
|
|
},
|
|
{
|
|
"id": "echo",
|
|
"type": "mqtt",
|
|
"title": "What the house said back",
|
|
"params": broker_params(
|
|
"fluksio-house-echo",
|
|
{
|
|
"plug_state": PLUG_TOPIC,
|
|
"spot_state": SPOT_TOPIC,
|
|
"power_raw": POWER_TOPIC,
|
|
},
|
|
),
|
|
"provides": [
|
|
{"name": "plug_state", "dtype": "str"},
|
|
{"name": "spot_state", "dtype": "float"},
|
|
{"name": "power_raw", "dtype": "json", "interval": 5.0},
|
|
],
|
|
},
|
|
{
|
|
"id": "meter",
|
|
"type": "python",
|
|
"title": "House power",
|
|
"requires": [{"name": "power_raw", "dtype": "json"}],
|
|
"provides": [{"name": "power", "dtype": "float"}],
|
|
},
|
|
{
|
|
"id": "dmx",
|
|
"type": "python",
|
|
"title": "The same two, as DMX levels",
|
|
"requires": [
|
|
{"name": "wash", "dtype": "bool"},
|
|
{"name": "spot", "dtype": "float"},
|
|
],
|
|
"provides": [
|
|
{"name": "dmx_wash", "dtype": "int"},
|
|
{"name": "dmx_spot", "dtype": "int"},
|
|
],
|
|
},
|
|
{
|
|
"id": "artnet_out",
|
|
"type": "artnet",
|
|
"title": "Art-Net universe 1 (off the wire)",
|
|
"params": {
|
|
"host": ARTNET_HOST,
|
|
"universe": 1,
|
|
"channels": {"dmx_wash": DMX_PLUG, "dmx_spot": DMX_SPOT},
|
|
# Stays off until Node-RED's own sender is stopped: a frame carries
|
|
# the whole universe, so two senders overwrite each other.
|
|
"transmit": False,
|
|
},
|
|
"requires": [
|
|
{"name": "dmx_wash", "dtype": "int"},
|
|
{"name": "dmx_spot", "dtype": "int"},
|
|
],
|
|
},
|
|
]
|
|
|
|
INPUTS = [
|
|
{"spec": {"name": "wash", "dtype": "bool"}, "initial": False},
|
|
{"spec": {"name": "spot", "dtype": "float"}, "initial": 0.0},
|
|
{"spec": {"name": "hue", "dtype": "float"}, "initial": 30.0},
|
|
{"spec": {"name": "level", "dtype": "float"}, "initial": 0.0},
|
|
]
|
|
|
|
SOURCES = {
|
|
"plug_cmd": PLUG_SOURCE,
|
|
"spot_cmd": SPOT_SOURCE,
|
|
"ambient_cmd": AMBIENT_SOURCE,
|
|
"meter": METER_SOURCE,
|
|
"dmx": DMX_SOURCE,
|
|
}
|
|
|
|
|
|
def msg(name: str) -> str:
|
|
return f"{FLOW}.{name}"
|
|
|
|
|
|
WIDGETS = [
|
|
{
|
|
"id": "wash",
|
|
"type": "switch",
|
|
"title": "Washing machine plug",
|
|
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
|
|
"config": {"target": msg("wash"), "dtype": "bool", "style": "button"},
|
|
},
|
|
{
|
|
"id": "spot",
|
|
"type": "slider",
|
|
"title": "Traverse spot",
|
|
"layout": {"lg": {"x": 3, "y": 0, "w": 3, "h": 2}},
|
|
"config": {
|
|
"target": msg("spot"),
|
|
"dtype": "float",
|
|
"min": 0,
|
|
"max": 100,
|
|
"step": 5,
|
|
"unit": "%",
|
|
},
|
|
},
|
|
{
|
|
"id": "hue",
|
|
"type": "slider",
|
|
"title": "Ambient hue",
|
|
"layout": {"lg": {"x": 6, "y": 0, "w": 3, "h": 2}},
|
|
"config": {
|
|
"target": msg("hue"),
|
|
"dtype": "float",
|
|
"min": 0,
|
|
"max": 360,
|
|
"step": 10,
|
|
"unit": "°",
|
|
},
|
|
},
|
|
{
|
|
"id": "level",
|
|
"type": "slider",
|
|
"title": "Ambient brightness",
|
|
"layout": {"lg": {"x": 9, "y": 0, "w": 3, "h": 2}},
|
|
"config": {
|
|
"target": msg("level"),
|
|
"dtype": "float",
|
|
"min": 0,
|
|
"max": 100,
|
|
"step": 5,
|
|
"unit": "%",
|
|
},
|
|
},
|
|
{
|
|
"id": "plug_state",
|
|
"type": "stat",
|
|
"title": "Plug echoed back",
|
|
"layout": {"lg": {"x": 0, "y": 2, "w": 3, "h": 2}},
|
|
"config": {"message": msg("plug_state"), "dtype": "str"},
|
|
},
|
|
{
|
|
"id": "spot_state",
|
|
"type": "stat",
|
|
"title": "Spot echoed back",
|
|
"layout": {"lg": {"x": 3, "y": 2, "w": 3, "h": 2}},
|
|
"config": {
|
|
"message": msg("spot_state"),
|
|
"dtype": "float",
|
|
"precision": 0,
|
|
"unit": "%",
|
|
},
|
|
},
|
|
{
|
|
"id": "power",
|
|
"type": "chart",
|
|
"title": "House power",
|
|
"layout": {"lg": {"x": 6, "y": 2, "w": 6, "h": 5}},
|
|
"config": {
|
|
"series": [{"message": msg("power"), "dtype": "float", "label": "Total"}],
|
|
"history": {"points": 360},
|
|
"unit": " W",
|
|
"y_label": "W",
|
|
},
|
|
},
|
|
{
|
|
"id": "protocol",
|
|
"type": "markdown",
|
|
"title": "Test protocol",
|
|
"layout": {"lg": {"x": 0, "y": 4, "w": 6, "h": 5}},
|
|
"config": {
|
|
"content": (
|
|
"## Before you start\n"
|
|
"- The flow is seeded stopped. Starting it publishes the"
|
|
" current values once, which is OFF for the plug and zero for"
|
|
" both lights.\n"
|
|
"- Start it with the washing machine idle.\n"
|
|
"- Node-RED reacts to the plug going ON by scheduling an"
|
|
" automatic OFF at midnight. That is its logic, not a fault.\n"
|
|
"## What to try\n"
|
|
"- Plug: ON, then OFF. The stat beside it is the broker's"
|
|
" echo, and the chart should step by a few hundred watts.\n"
|
|
"- Traverse spot: 0, 60, 0. The number is used raw as the DMX"
|
|
" level, exactly as the reference does.\n"
|
|
"- Ambient: move brightness up, then hue. Back to zero when"
|
|
" done.\n"
|
|
"## Art-Net, once Node-RED is stopped\n"
|
|
"- The Art-Net node is on the canvas with transmit off. It"
|
|
" aims at the same gateway, universe 1, channels 33 and 31.\n"
|
|
"- A frame carries all 512 channels, so one sender owns a"
|
|
" universe. Stop Node-RED's Art-Net sender first, or the two"
|
|
" overwrite each other every second.\n"
|
|
"- Everything on that universe this node does not set goes"
|
|
" dark while it drives.\n"
|
|
)
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
class Api:
|
|
def __init__(self) -> None:
|
|
self.http = httpx.Client(base_url=f"{API}/api/v1", timeout=30)
|
|
token = self.http.post(
|
|
"/login/access-token",
|
|
data={"username": EMAIL, "password": PASSWORD},
|
|
).json()["access_token"]
|
|
self.http.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
def __call__(self, method: str, path: str, body: Any = None) -> Any:
|
|
response = self.http.request(method, path, json=body)
|
|
response.raise_for_status()
|
|
return response.json() if response.content else None
|
|
|
|
def drop(self, path: str) -> None:
|
|
"""Delete something that may not be there."""
|
|
try:
|
|
self("DELETE", path)
|
|
except httpx.HTTPStatusError:
|
|
pass
|
|
|
|
|
|
def main() -> int:
|
|
if not EMAIL or not PASSWORD:
|
|
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
|
|
return 1
|
|
|
|
api = Api()
|
|
|
|
if SECRET not in api("GET", "/secrets/")["data"]:
|
|
print(
|
|
f"No secret named '{SECRET}'. Add the broker password under "
|
|
"Secrets, or point HOUSE_BROKER at a broker that takes anonymous "
|
|
"connections.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
api.drop(f"/flows/{FLOW}")
|
|
api(
|
|
"PUT",
|
|
f"/flows/{FLOW}",
|
|
{
|
|
"name": FLOW,
|
|
"title": "House control (evaluation)",
|
|
"nodes": NODES,
|
|
"inputs": INPUTS,
|
|
},
|
|
)
|
|
for node_id, code in SOURCES.items():
|
|
api("PUT", f"/flows/{FLOW}/nodes/{node_id}/source", {"code": code})
|
|
version = api("GET", f"/flows/{FLOW}")["definition"]["version"]
|
|
api("POST", f"/flows/{FLOW}/publish", {"version": version})
|
|
|
|
# Seeded stopped: nothing reaches the house until someone is watching.
|
|
api("POST", f"/flows/{FLOW}/stop")
|
|
print(f" {FLOW}: {len(NODES)} nodes, published, stopped")
|
|
|
|
issues = api("POST", f"/flows/{FLOW}/validate")["issues"]
|
|
for issue in issues:
|
|
print(f" ! {issue}")
|
|
|
|
api.drop(f"/dashboards/{PANEL}")
|
|
api("POST", f"/dashboards/{PANEL}")
|
|
current = api("GET", f"/dashboards/{PANEL}?draft=true")
|
|
api(
|
|
"PUT",
|
|
f"/dashboards/{PANEL}",
|
|
{
|
|
**current,
|
|
"title": "House control",
|
|
"pages": [
|
|
{
|
|
"id": "main",
|
|
"title": "Write paths",
|
|
"sections": [{"id": "main", "widgets": WIDGETS}],
|
|
}
|
|
],
|
|
},
|
|
)
|
|
version = api("GET", f"/dashboards/{PANEL}?draft=true")["version"]
|
|
api("POST", f"/dashboards/{PANEL}/publish", {"version": version})
|
|
print(f" dashboard '{PANEL}': published")
|
|
|
|
print(f"\nStart the flow, then open /view/{PANEL}.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|