From 4b6b9348c6496419e2ad2d6bf05847f4d4421105 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 22:43:03 +0200 Subject: [PATCH] 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. --- Makefile | 5 +- NOTEPAD.md | 5 +- ROADMAP.md | 13 +- scripts/seed_aircon_control.py | 335 +++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 scripts/seed_aircon_control.py diff --git a/Makefile b/Makefile index 79cc62e..24effbc 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # The workspace root delegates to these (see ../Makefile). .PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \ - generate-client seed-example seed-demo seed-house seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \ + generate-client seed-example seed-demo seed-house seed-aircon seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \ lint-frontend umami clean help COMPOSE_ROOT := $(CURDIR) @@ -100,6 +100,9 @@ seed-demo: ## Seed the training-run example: a batch flow and its dashboard seed-house: ## Seed the house write-path rig (needs the real broker reachable) cd backend && uv run python ../scripts/seed_house_control.py +seed-aircon: ## Seed the aircon write-path rig (needs the unit reachable) + cd backend && uv run python ../scripts/seed_aircon_control.py + # Operators of the hosted demo only — NOT part of any deployment, and nothing a # self-hosted instance needs. It wipes and recreates its three flows and its # dashboard, so re-running it is how the public demo is reset. diff --git a/NOTEPAD.md b/NOTEPAD.md index 3cf348a..0625439 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -69,7 +69,10 @@ end against the house broker. The rig is the `house_control` flow and its dashboard, seeded by `make -C app seed-house`. - FEAT/NODE: Art-Net against the real fixtures is still untried. The house's own dmxnet sender re-emits universe 1 every 1000 ms, so fluksio and Node-RED overwrite each other; the test needs Node-RED's Art-Net sender stopped, and while it is stopped every channel fluksio does not set is dark. -- FEAT/NODE: the WF-RAC write path stays gated. `setAirconStat` needs an operatorId registered with the unit first, which is itself a write, and the aircon is not on the safe-to-control list. +- FEAT/NODE: the aircon has never been commanded for real. The encoder round-trips the unit's own live reading field for field and the dry run logs the right command, but nothing has been posted to it yet. `commands` is the switch. +- CHORE/NODE: the operatorId worry was unfounded — the reference Node-RED `setstat` node for this unit is configured with an empty operatorId and deviceId, so a command needs no registration. The second unit may still differ. +- PERF/NODE: a `wfrac` command is two round trips (read, then set) on the scheduler's thread, so at the default timeout a command can hold a cascade for several seconds. Fine for a person pressing a button; a flow commanding it on a schedule would want the work off that thread. +- CHORE/NODE: `wfrac` writes carry the unit's whole state, so two flows commanding one unit will each undo whatever the other set between their read and their write. One writer per unit, the same rule Art-Net has for a universe. - FEAT/NODE: the second WF-RAC unit (the one Node-RED addresses with operatorId "0") closes the connection on an anonymous read. It likely wants an account registered; the first unit answers without one. - CHORE/NODE: `wfrac` reports `mode` as "unknown" while the unit is off, because the mode bits hold a value outside the known set. Faithful to the reference decoder, but "off" would read better. - PERF/NODE: `ArtNetOut.write` sends one frame per input port, so a node with two ports emits two frames per run. The last one carries both channels, so the end state is right; folding them into one send would halve the traffic. diff --git a/ROADMAP.md b/ROADMAP.md index c98826c..06098a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,8 +73,9 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend – M `connector-skeleton/`. The registry follows later - [x] First real connectors written against that contract from outside the engine: WF-RAC aircon, calendar, UniFi presence and Art-Net, in `connectors/`. - Built by `make connectors` and installed into the image. Three of the four - read only; the aircon package still cannot produce a command + Built by `make connectors` and installed into the image. Calendar and + UniFi read only; Art-Net and the aircon write, each behind a setting that + starts off - [x] The other direction of the contract: `ConnectorNode.write` receives the node's input ports, so a connector can command something rather than only read it. Additive, so `CONTRACT_VERSION` stays at 1 — before this the base @@ -82,6 +83,14 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend – M packet builder unreachable. Art-Net now sends: a per-port `channels` map puts each input on its own DMX channel, `transmit` still gates the socket, and one node owns one universe because a frame carries all 512 levels +- [x] The aircon writes too: power, mode, setpoint and fan speed, behind a + `commands` setting that starts off. A WF-RAC command carries the whole + state, so the node reads the unit and applies the change on top — the + encoder is a port of the same reference the decoder came from, and it + round-trips the unit's own live reading field for field. No operatorId + registration turned out to be needed: the unit this instance talks to + accepts an anonymous command, which is what the reference Node-RED node + does as well - [x] Node lifecycle as a protocol (`start`/`stop`/`report_health` on `Node`), replacing the controller's per-type isinstance chains — the same hooks a connector implements, validated on the built-in nodes first diff --git a/scripts/seed_aircon_control.py b/scripts/seed_aircon_control.py new file mode 100644 index 0000000..250188e --- /dev/null +++ b/scripts/seed_aircon_control.py @@ -0,0 +1,335 @@ +#!/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())