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.
This commit is contained in:
2026-08-20 22:43:03 +02:00
parent f4a55845ec
commit 4b6b9348c6
4 changed files with 354 additions and 4 deletions
+4 -1
View File
@@ -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.
+4 -1
View File
@@ -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.
+11 -2
View File
@@ -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
+335
View File
@@ -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())