Files
app/scripts/seed_aircon_control.py
T
stroblmeandClaude Opus 5 e8a818a50b Hold a new dashboard back until someone publishes it
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
2026-08-21 14:32:57 +02:00

336 lines
11 KiB
Python

#!/usr/bin/env python
"""Seed the aircon write-path rig: one flow, one dashboard, one heat pump.
The reading half already exists — the `aircon` flow polls the unit and
publishes what it says. This adds the other direction: a `wfrac` node with
input ports, driven by four widgets.
aircon_control inputs -> wfrac (commands off) tells the unit what to be
aircon (existing) wfrac -> ... says what it is
A WF-RAC command carries the *whole* state, so the node reads the unit and
applies the change on top. That is also why the flow's initial values are read
off the unit when this script runs: starting the flow publishes them once, and
"what it is already doing" is the only starting point that commands nothing.
**Two safety catches, both on by default.** The flow is seeded stopped, and the
node's `commands` setting is off — it builds the command and logs what it would
set. Turn `commands` on in the node panel when someone is watching the unit.
Run it against a stack that is already up::
make -C app seed-aircon
Environment (the Makefile passes these):
API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD, AIRCON_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", "")
HOST = os.environ.get("AIRCON_HOST", "192.168.1.22")
FLOW = "aircon_control"
PANEL = "aircon_control"
#: The flow that already reads this unit; its messages are what the panel shows.
READS = "aircon"
MODES = ["cooling", "heating", "fan", "dry"]
FAN_SPEEDS = ["auto", "1", "2", "3", "4"]
def msg(name: str, flow: str = FLOW) -> str:
return f"{flow}.{name}"
NODES = [
{
"id": "set",
"type": "wfrac",
"title": "Living room unit",
"params": {
"host": HOST,
# Never polls: the `aircon` flow already reads this unit, and a
# command reads it once itself anyway.
"poll_interval": 0,
# The catch. Off builds the command and logs it.
"commands": False,
},
"requires": [
{"name": "operation", "dtype": "bool"},
{"name": "mode", "dtype": "str"},
{"name": "preset_temp", "dtype": "float"},
{"name": "fan_speed", "dtype": "str"},
],
},
]
WIDGETS = [
{
"id": "power",
"type": "switch",
"title": "Power",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
"config": {"target": msg("operation"), "dtype": "bool", "style": "button"},
},
{
"id": "mode",
"type": "dropdown",
"title": "Mode",
"layout": {"lg": {"x": 3, "y": 0, "w": 4, "h": 2}},
"config": {
"target": msg("mode"),
"dtype": "str",
"style": "segmented",
"options": [{"label": m.title(), "value": m} for m in MODES],
},
},
{
"id": "fan",
"type": "dropdown",
"title": "Fan speed",
"layout": {"lg": {"x": 7, "y": 0, "w": 5, "h": 2}},
"config": {
"target": msg("fan_speed"),
"dtype": "str",
"style": "segmented",
"options": [
{"label": "Auto" if s == "auto" else s, "value": s} for s in FAN_SPEEDS
],
},
},
{
"id": "setpoint",
"type": "slider",
"title": "Setpoint",
"layout": {"lg": {"x": 0, "y": 2, "w": 6, "h": 2}},
"config": {
"target": msg("preset_temp"),
"dtype": "float",
"min": 16,
"max": 30,
"step": 0.5,
"unit": "°C",
},
},
{
"id": "running",
"type": "stat",
"title": "Unit reports",
"layout": {"lg": {"x": 6, "y": 2, "w": 3, "h": 2}},
"config": {"message": msg("mode", READS), "dtype": "str"},
},
{
"id": "reported_setpoint",
"type": "stat",
"title": "Setpoint it took",
"layout": {"lg": {"x": 9, "y": 2, "w": 3, "h": 2}},
"config": {
"message": msg("preset_temp", READS),
"dtype": "float",
"precision": 1,
"unit": "°C",
},
},
{
"id": "temps",
"type": "chart",
"title": "Indoor and outdoor",
"layout": {"lg": {"x": 6, "y": 4, "w": 6, "h": 5}},
"config": {
"series": [
{
"message": msg("indoor_temp", READS),
"dtype": "float",
"label": "Indoor",
},
{
"message": msg("outdoor_temp", READS),
"dtype": "float",
"label": "Outdoor",
},
],
"history": {"points": 360},
"unit": " °C",
"y_label": "°C",
},
},
{
"id": "protocol",
"type": "markdown",
"title": "Test protocol",
"layout": {"lg": {"x": 0, "y": 4, "w": 6, "h": 5}},
"config": {
"content": (
"## Two catches, both on\n"
"- The flow is seeded stopped.\n"
"- The node's `commands` setting is off: it builds the command"
" and logs what it would set. Turn it on in the node panel"
" when you are watching the unit.\n"
"## What to try\n"
"- With `commands` still off: start the flow, move a control,"
" and read the logs panel. It names exactly what it would set.\n"
"- Then turn `commands` on and change the setpoint by half a"
" degree. The two stats beside this come from the `aircon`"
" flow's own poll, so they are the unit's answer, not ours.\n"
"- Power off, then back on. Mode last, since it is the one that"
" starts a compressor.\n"
"## Worth knowing\n"
"- The initial values were read off the unit when this was"
" seeded, so starting the flow commands what it was already"
" doing. If someone has changed it since, the first command"
" puts it back.\n"
"- A command carries the whole state, so the node reads the"
" unit first and changes only what these controls name.\n"
"- Fan mode has no setpoint of its own; the unit pins it at 25.\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 current_state() -> dict[str, Any]:
"""What the unit is doing now, as the flow's initial values.
Read straight from the adapter rather than from the `aircon` flow, so this
works whether or not that flow is running.
"""
sys.path.insert(0, str(_connector_source()))
from fluksio_connector_wfrac.protocol import decode, status_request
response = httpx.post(
f"http://{HOST}:51443/beaver/command/getAirconStat",
json=status_request(),
timeout=6,
headers={
"Content-Type": "application/json;charset=UTF-8",
"Connection": "close",
"accept": "application/json",
},
)
response.raise_for_status()
stat = decode(response.json()["contents"]["airconStat"])
return {
"operation": stat.operation,
"mode": stat.mode,
"preset_temp": stat.preset_temp,
"fan_speed": stat.fan_speed,
}
def _connector_source():
from pathlib import Path
return Path(__file__).resolve().parents[2] / "connectors/wfrac/src"
def main() -> int:
if not EMAIL or not PASSWORD:
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
return 1
try:
state = current_state()
except Exception as exc:
print(f"Cannot read the unit at {HOST}: {exc}", file=sys.stderr)
print(
"The initial values have to come from the unit, or starting the "
"flow would command it to something it was not doing.",
file=sys.stderr,
)
return 1
print(f" unit at {HOST} is: {state}")
dtypes = {
"operation": "bool",
"mode": "str",
"preset_temp": "float",
"fan_speed": "str",
}
inputs = [
{"spec": {"name": name, "dtype": dtypes[name]}, "initial": value}
for name, value in state.items()
]
api = Api()
api.drop(f"/flows/{FLOW}")
api(
"PUT",
f"/flows/{FLOW}",
{
"name": FLOW,
"title": "Aircon control (evaluation)",
"nodes": NODES,
"inputs": inputs,
},
)
version = api("GET", f"/flows/{FLOW}")["definition"]["version"]
api("POST", f"/flows/{FLOW}/publish", {"version": version})
api("POST", f"/flows/{FLOW}/stop")
print(f" {FLOW}: published, stopped, commands off")
for issue in api("POST", f"/flows/{FLOW}/validate")["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": "Aircon control",
"pages": [
{
"id": "main",
"title": "Write path",
"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}. Commands stay off until")
print(f"you switch them on in the '{FLOW}.set' node panel.")
return 0
if __name__ == "__main__":
raise SystemExit(main())