#!/usr/bin/env python """Seed the aircon write-path rig: one flow, one dashboard, two heat pumps. Both WF-RAC firmware generations are on the rig, because the transport is the thing most likely to break and the panel should show it working:: old WF-RAC wireless firmware 010, plain HTTP on port 51443 new WF-RAC-HTTPS wireless firmware 025, TLS on the same port The connector negotiates that itself, so the two nodes differ only by address. Each unit gets one `wfrac` node that both polls and commands: the controls publish what the unit should be, the node's own poll publishes what it says it is, and the panel shows them side by side. A command that did not take is then visible rather than assumed. **The catch.** A command carries the whole state, so the node reads the unit and applies the change on top — and turning on a heat pump has a bill attached. So `commands` starts off: the node builds the command and logs what it *would* set, and nothing reaches the compressor. This is the reason a freshly seeded panel appears to do nothing. Arm it either in the node panel, or at seed time:: make -C app seed-aircon # commands off, logs only AIRCON_COMMANDS=1 make -C app seed-aircon # commands armed Run it against a stack that is already up. Environment (the Makefile passes the credentials): API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD AIRCON_HOST_OLD, AIRCON_HOST_NEW, AIRCON_COMMANDS """ from __future__ import annotations import os import sys from pathlib import Path 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", "") #: Off builds the command and logs it. See the module docstring. COMMANDS = os.environ.get("AIRCON_COMMANDS", "") == "1" FLOW = "aircon_control" PANEL = "aircon_control" MODES = ["cooling", "heating", "fan", "dry"] FAN_SPEEDS = ["auto", "1", "2", "3", "4"] #: What the controls set, and the type each carries. COMMANDED = { "operation": "bool", "mode": "str", "preset_temp": "float", "fan_speed": "str", } #: What each node's own poll publishes back, so a command can be checked. REPORTED = { "operation": "bool", "mode": "str", "preset_temp": "float", "indoor_temp": "float", "outdoor_temp": "float", } UNITS = [ { "id": "old", "title": "Living room — WF-RAC (plain HTTP)", "host": os.environ.get("AIRCON_HOST_OLD", "192.168.1.22"), }, { "id": "new", "title": "Second unit — WF-RAC-HTTPS (TLS)", "host": os.environ.get("AIRCON_HOST_NEW", "192.168.1.191"), }, ] def cmd(unit: str, port: str) -> str: """The message a control publishes on.""" return f"{unit}_set_{port}" def rep(unit: str, port: str) -> str: """The message that unit's own poll publishes on.""" return f"{unit}_{port}" def msg(name: str) -> str: return f"{FLOW}.{name}" def nodes() -> list[dict[str, Any]]: return [ { "id": f"set_{u['id']}", "type": "wfrac", "title": u["title"], "params": { "host": u["host"], # It polls as well as commands, so the panel can show the # unit's own answer next to what was asked of it. "poll_interval": 30, "commands": COMMANDS, }, "requires": [ {"name": cmd(u["id"], port), "port": port, "dtype": dtype} for port, dtype in COMMANDED.items() ], "provides": [ {"name": rep(u["id"], port), "port": port, "dtype": dtype} for port, dtype in REPORTED.items() ], } for u in UNITS ] def controls(unit: str) -> list[dict[str, Any]]: """The six tiles one unit gets: four to set it, two to read it back.""" return [ { "id": f"{unit}_power", "type": "switch", "title": "Power", "layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}}, "config": { "target": msg(cmd(unit, "operation")), "dtype": "bool", "style": "button", }, }, { "id": f"{unit}_mode", "type": "dropdown", "title": "Mode", "layout": {"lg": {"x": 3, "y": 0, "w": 4, "h": 2}}, "config": { "target": msg(cmd(unit, "mode")), "dtype": "str", "style": "segmented", "options": [{"label": m.title(), "value": m} for m in MODES], }, }, { "id": f"{unit}_fan", "type": "dropdown", "title": "Fan speed", "layout": {"lg": {"x": 7, "y": 0, "w": 5, "h": 2}}, "config": { "target": msg(cmd(unit, "fan_speed")), "dtype": "str", "style": "segmented", "options": [ {"label": "Auto" if s == "auto" else s, "value": s} for s in FAN_SPEEDS ], }, }, { "id": f"{unit}_setpoint", "type": "slider", "title": "Setpoint", "layout": {"lg": {"x": 0, "y": 2, "w": 6, "h": 2}}, "config": { "target": msg(cmd(unit, "preset_temp")), "dtype": "float", "min": 16, "max": 30, "step": 0.5, "unit": "°C", }, }, { "id": f"{unit}_reports_mode", "type": "stat", "title": "Unit reports", "layout": {"lg": {"x": 6, "y": 2, "w": 3, "h": 2}}, "config": {"message": msg(rep(unit, "mode")), "dtype": "str"}, }, { "id": f"{unit}_reports_setpoint", "type": "stat", "title": "Setpoint it took", "layout": {"lg": {"x": 9, "y": 2, "w": 3, "h": 2}}, "config": { "message": msg(rep(unit, "preset_temp")), "dtype": "float", "precision": 1, "unit": "°C", }, }, ] # The markdown widget renders headings and bullets and reads inline spans as # written, one paragraph per source line — so no bold, no backticks, and one # bullet per line however long it runs. _ARMED = [ "- Commands are ARMED: these controls reach both units.", "- Disarm in either node panel, or reseed without AIRCON_COMMANDS=1.", ] _DISARMED = [ "- Commands are OFF, which is why the controls appear to do nothing.", "- Off means the node builds the command and logs what it would have set." " Nothing reaches the compressor. This is the usual reason a freshly seeded" " panel looks dead.", "- Arm it in the set_old / set_new node panel, or reseed with" " AIRCON_COMMANDS=1 make -C app seed-aircon.", ] PROTOCOL = "\n".join( [ "## The catch, and why nothing may move", *(_ARMED if COMMANDS else _DISARMED), "", "## What to try", "- Move a control and read the logs panel. It names exactly what each" " node would set, per unit.", "- Change a setpoint by half a degree. The two stats beside each set of" " controls come from that unit's own poll, so they are its answer and" " not ours — give them a poll interval to catch up.", "- Power off, then back on. Mode last, since it is the one that starts a" " compressor.", "", "## Worth knowing", "- The two units run different firmware. The old one answers plain HTTP" " on port 51443; the new one wraps the same port in TLS with a" " self-signed certificate naming its MAC. The connector tries TLS, falls" " back, and remembers — so both rows above are the same node type with" " nothing but a different address.", "- The old firmware also serves one connection at a time and answers an" " overlapping request with 501. The connector serialises per adapter and" " retries a busy one, which matters because anything else on the network" " talking to the same unit is outside that lock.", "- The initial values were read off each unit when this was seeded, so" " starting the flow commands what it was already doing. If someone has" " changed a unit since, the first command puts it back.", "- A command carries the whole state, so the node reads the unit first" " and changes only what these controls name.", "- A unit that is off names no mode, and reports it as unknown. That" " travels through a command untouched rather than being rejected, or a" " unit could never be switched back on from here.", "- Fan mode has no setpoint of its own; the unit pins it at 25.", ] ) def shared() -> list[dict[str, Any]]: return [ { "id": "temps", "type": "chart", "title": "Both units", "layout": {"lg": {"x": 0, "y": 0, "w": 7, "h": 6}}, "config": { "series": [ { "message": msg(rep("old", "indoor_temp")), "dtype": "float", "label": "Old indoor", }, { "message": msg(rep("new", "indoor_temp")), "dtype": "float", "label": "New indoor", }, { "message": msg(rep("new", "outdoor_temp")), "dtype": "float", "label": "Outdoor", }, ], "history": {"points": 360}, "unit": " °C", "y_label": "°C", }, }, { "id": "protocol", "type": "markdown", "title": "Test protocol", "layout": {"lg": {"x": 7, "y": 0, "w": 5, "h": 6}}, "config": {"content": PROTOCOL}, }, ] class Api: def __init__(self) -> None: self.http = httpx.Client( base_url=f"{API}/api/v1", timeout=30, follow_redirects=True ) 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(host: str) -> dict[str, Any]: """What a unit is doing now, as the flow's initial values. Read through the connector rather than by hand, so this reaches whichever firmware the unit runs and works whether or not any flow is running. """ sys.path.insert( 0, str(Path(__file__).resolve().parents[2] / "connectors/wfrac/src") ) from fluksio_connector_wfrac import WfRacSensor stat = WfRacSensor(name="seed", params={"host": host, "poll_interval": 0})._read() return {port: getattr(stat, port) for port in COMMANDED} def main() -> int: if not EMAIL or not PASSWORD: print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr) return 1 inputs = [] for unit in UNITS: try: state = current_state(unit["host"]) except Exception as exc: print(f"Cannot read the unit at {unit['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['id']} at {unit['host']} is: {state}") inputs += [ { "spec": { "name": cmd(unit["id"], port), "dtype": COMMANDED[port], }, "initial": value, } for port, 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") armed = "commands ARMED" if COMMANDS else "commands off" print(f" {FLOW}: published, stopped, {armed}") 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", # Three sections stacked: two units and the shared read-back. The # panel scales this surface to whatever screen it hangs on, so the # arrangement wants the height rather than a second page. "canvas_height": 1700, "pages": [ { "id": "main", "title": "Write path", "sections": [ *( { "id": f"unit_{u['id']}", "title": u["title"], "widgets": controls(u["id"]), } for u in UNITS ), {"id": "shared", "title": "", "widgets": shared()}, ], } ], }, ) 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}.") if not COMMANDS: print("Commands are off: the nodes log what they would set and the") print("controls will appear to do nothing. Arm them in the node panel,") print("or reseed with AIRCON_COMMANDS=1.") return 0 if __name__ == "__main__": raise SystemExit(main())