Files
app/scripts/seed_aircon_control.py
T
stroblme 4b6b9348c6 Seed the aircon write-path rig
Four controls and the unit's own answer beside them. Two catches, both on:
the flow is seeded stopped and the node's commands setting is off. The initial
values are read off the unit when the script runs, so starting the flow asks
for what it was already doing rather than commanding it to something else.
2026-08-20 22:43:03 +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}")
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())