#!/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())