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
336 lines
11 KiB
Python
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())
|