From 0c6c9f9f84fff50d0ec956cbc2e86d248ac55ed5 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 12:41:39 +0200 Subject: [PATCH] aircon rig: both units, both firmwares, and the catches that blocked control The panel now carries one wfrac node per unit, polling as well as commanding, so a command can be checked against the unit's own answer rather than assumed. Three things kept a panel from driving the old unit. The seeded commands catch is off by default, which is the usual reason a fresh panel looks dead; AIRCON_COMMANDS=1 arms it at seed time. A unit that is off names no mode, and a flow redelivers every bound port on each run, so that rejected value blocked every command including power-on. And the old firmware serves one connection at a time. --- ROADMAP.md | 12 + scripts/seed_aircon_control.py | 527 +++++++++++++++++++-------------- 2 files changed, 323 insertions(+), 216 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5e0b06d..5abbfb3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,6 +101,18 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend – M that gets updated costs one retry rather than a reconfiguration. The payload is identical across both, so only the transport moved. Verified against one unit of each generation, read and command +- [x] The aircon rig covers both units and both firmware generations: one + `wfrac` node each in `aircon_control`, polling as well as commanding, so + the panel shows what a unit answers next to what was asked of it. Three + things had to be fixed before a panel could actually drive one. The + seeded `commands` catch is off, which is the usual reason a fresh panel + looks dead — `AIRCON_COMMANDS=1` arms it at seed time. A unit that is off + names no mode and reports `unknown`, and a flow redelivers every bound + port on each run, so rejecting it made an idle unit impossible to start; + it now travels through untouched. And the old firmware serves one + connection at a time, answering an overlapping request with 501 — the + connector serialises per adapter and retries a busy one, which it must, + because other clients on the network are outside that lock - [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 index c94cd99..e4130cb 100644 --- a/scripts/seed_aircon_control.py +++ b/scripts/seed_aircon_control.py @@ -1,34 +1,40 @@ #!/usr/bin/env python -"""Seed the aircon write-path rig: one flow, one dashboard, one heat pump. +"""Seed the aircon write-path rig: one flow, one dashboard, two heat pumps. -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. +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:: - aircon_control inputs -> wfrac (commands off) tells the unit what to be - aircon (existing) wfrac -> ... says what it is + old WF-RAC wireless firmware 010, plain HTTP on port 51443 + new WF-RAC-HTTPS wireless firmware 025, TLS on the same port -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. +The connector negotiates that itself, so the two nodes differ only by address. -**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. +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. -Run it against a stack that is already up:: +**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 + make -C app seed-aircon # commands off, logs only + AIRCON_COMMANDS=1 make -C app seed-aircon # commands armed -Environment (the Makefile passes these): - API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD, AIRCON_HOST +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 @@ -37,170 +43,260 @@ 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") +#: Off builds the command and logs it. See the module docstring. +COMMANDS = os.environ.get("AIRCON_COMMANDS", "") == "1" + 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"] +#: 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", +} -def msg(name: str, flow: str = FLOW) -> str: - return f"{flow}.{name}" - - -NODES = [ +UNITS = [ { - "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"}, - ], + "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"), }, ] -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 +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() ], - }, - }, - { - "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", - }, + "provides": [ + {"name": rep(u["id"], port), "port": port, "dtype": dtype} + for port, dtype in REPORTED.items() ], - "history": {"points": 360}, - "unit": " °C", - "y_label": "°C", + } + 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": "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" - ) + { + "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) + 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}, @@ -220,39 +316,19 @@ class Api: pass -def current_state() -> dict[str, Any]: - """What the unit is doing now, as the flow's initial values. +def current_state(host: str) -> dict[str, Any]: + """What a 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. + 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(_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", - }, + sys.path.insert( + 0, str(Path(__file__).resolve().parents[2] / "connectors/wfrac/src") ) - 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, - } + from fluksio_connector_wfrac import WfRacSensor - -def _connector_source(): - from pathlib import Path - - return Path(__file__).resolve().parents[2] / "connectors/wfrac/src" + stat = WfRacSensor(name="seed", params={"host": host, "poll_interval": 0})._read() + return {port: getattr(stat, port) for port in COMMANDED} def main() -> int: @@ -260,28 +336,29 @@ def main() -> int: 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() - ] + 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() @@ -292,14 +369,15 @@ def main() -> int: { "name": FLOW, "title": "Aircon control (evaluation)", - "nodes": NODES, + "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") + 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}") @@ -313,11 +391,25 @@ def main() -> int: { **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": "main", "widgets": WIDGETS}], + "sections": [ + *( + { + "id": f"unit_{u['id']}", + "title": u["title"], + "widgets": controls(u["id"]), + } + for u in UNITS + ), + {"id": "shared", "title": "", "widgets": shared()}, + ], } ], }, @@ -326,8 +418,11 @@ def main() -> int: 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.") + 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