Merge branch 'main' of git.stroblme.de:Fluksio/app
Docs / docs (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

This commit is contained in:
2026-08-22 12:54:36 +02:00
2 changed files with 323 additions and 216 deletions
+12
View File
@@ -101,6 +101,18 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend M
that gets updated costs one retry rather than a reconfiguration. The that gets updated costs one retry rather than a reconfiguration. The
payload is identical across both, so only the transport moved. Verified payload is identical across both, so only the transport moved. Verified
against one unit of each generation, read and command 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`), - [x] Node lifecycle as a protocol (`start`/`stop`/`report_health` on `Node`),
replacing the controller's per-type isinstance chains — the same hooks a replacing the controller's per-type isinstance chains — the same hooks a
connector implements, validated on the built-in nodes first connector implements, validated on the built-in nodes first
+311 -216
View File
@@ -1,34 +1,40 @@
#!/usr/bin/env python #!/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 Both WF-RAC firmware generations are on the rig, because the transport is the
publishes what it says. This adds the other direction: a `wfrac` node with thing most likely to break and the panel should show it working::
input ports, driven by four widgets.
aircon_control inputs -> wfrac (commands off) tells the unit what to be old WF-RAC wireless firmware 010, plain HTTP on port 51443
aircon (existing) wfrac -> ... says what it is 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 The connector negotiates that itself, so the two nodes differ only by address.
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 Each unit gets one `wfrac` node that both polls and commands: the controls
node's `commands` setting is off — it builds the command and logs what it would publish what the unit should be, the node's own poll publishes what it says it
set. Turn `commands` on in the node panel when someone is watching the unit. 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): Run it against a stack that is already up.
API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD, AIRCON_HOST
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 from __future__ import annotations
import os import os
import sys import sys
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
@@ -37,170 +43,260 @@ API = os.environ.get("API_URL", "http://api.localhost")
EMAIL = os.environ.get("FIRST_SUPERUSER", "") EMAIL = os.environ.get("FIRST_SUPERUSER", "")
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "") 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" FLOW = "aircon_control"
PANEL = "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"] MODES = ["cooling", "heating", "fan", "dry"]
FAN_SPEEDS = ["auto", "1", "2", "3", "4"] 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: UNITS = [
return f"{flow}.{name}"
NODES = [
{ {
"id": "set", "id": "old",
"type": "wfrac", "title": "Living room — WF-RAC (plain HTTP)",
"title": "Living room unit", "host": os.environ.get("AIRCON_HOST_OLD", "192.168.1.22"),
"params": { },
"host": HOST, {
# Never polls: the `aircon` flow already reads this unit, and a "id": "new",
# command reads it once itself anyway. "title": "Second unit — WF-RAC-HTTPS (TLS)",
"poll_interval": 0, "host": os.environ.get("AIRCON_HOST_NEW", "192.168.1.191"),
# 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 = [ def cmd(unit: str, port: str) -> str:
{ """The message a control publishes on."""
"id": "power", return f"{unit}_set_{port}"
"type": "switch",
"title": "Power",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}}, def rep(unit: str, port: str) -> str:
"config": {"target": msg("operation"), "dtype": "bool", "style": "button"}, """The message that unit's own poll publishes on."""
}, return f"{unit}_{port}"
{
"id": "mode",
"type": "dropdown", def msg(name: str) -> str:
"title": "Mode", return f"{FLOW}.{name}"
"layout": {"lg": {"x": 3, "y": 0, "w": 4, "h": 2}},
"config": {
"target": msg("mode"), def nodes() -> list[dict[str, Any]]:
"dtype": "str", return [
"style": "segmented", {
"options": [{"label": m.title(), "value": m} for m in MODES], "id": f"set_{u['id']}",
}, "type": "wfrac",
}, "title": u["title"],
{ "params": {
"id": "fan", "host": u["host"],
"type": "dropdown", # It polls as well as commands, so the panel can show the
"title": "Fan speed", # unit's own answer next to what was asked of it.
"layout": {"lg": {"x": 7, "y": 0, "w": 5, "h": 2}}, "poll_interval": 30,
"config": { "commands": COMMANDS,
"target": msg("fan_speed"), },
"dtype": "str", "requires": [
"style": "segmented", {"name": cmd(u["id"], port), "port": port, "dtype": dtype}
"options": [ for port, dtype in COMMANDED.items()
{"label": "Auto" if s == "auto" else s, "value": s} for s in FAN_SPEEDS
], ],
}, "provides": [
}, {"name": rep(u["id"], port), "port": port, "dtype": dtype}
{ for port, dtype in REPORTED.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",
},
], ],
"history": {"points": 360}, }
"unit": " °C", for u in UNITS
"y_label": "°C", ]
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",
"id": "protocol", "type": "dropdown",
"type": "markdown", "title": "Mode",
"title": "Test protocol", "layout": {"lg": {"x": 3, "y": 0, "w": 4, "h": 2}},
"layout": {"lg": {"x": 0, "y": 4, "w": 6, "h": 5}}, "config": {
"config": { "target": msg(cmd(unit, "mode")),
"content": ( "dtype": "str",
"## Two catches, both on\n" "style": "segmented",
"- The flow is seeded stopped.\n" "options": [{"label": m.title(), "value": m} for m in MODES],
"- 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}_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: class Api:
def __init__(self) -> None: 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( token = self.http.post(
"/login/access-token", "/login/access-token",
data={"username": EMAIL, "password": PASSWORD}, data={"username": EMAIL, "password": PASSWORD},
@@ -220,39 +316,19 @@ class Api:
pass pass
def current_state() -> dict[str, Any]: def current_state(host: str) -> dict[str, Any]:
"""What the unit is doing now, as the flow's initial values. """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 Read through the connector rather than by hand, so this reaches whichever
works whether or not that flow is running. firmware the unit runs and works whether or not any flow is running.
""" """
sys.path.insert(0, str(_connector_source())) sys.path.insert(
from fluksio_connector_wfrac.protocol import decode, status_request 0, str(Path(__file__).resolve().parents[2] / "connectors/wfrac/src")
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() from fluksio_connector_wfrac import WfRacSensor
stat = decode(response.json()["contents"]["airconStat"])
return {
"operation": stat.operation,
"mode": stat.mode,
"preset_temp": stat.preset_temp,
"fan_speed": stat.fan_speed,
}
stat = WfRacSensor(name="seed", params={"host": host, "poll_interval": 0})._read()
def _connector_source(): return {port: getattr(stat, port) for port in COMMANDED}
from pathlib import Path
return Path(__file__).resolve().parents[2] / "connectors/wfrac/src"
def main() -> int: def main() -> int:
@@ -260,28 +336,29 @@ def main() -> int:
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr) print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
return 1 return 1
try: inputs = []
state = current_state() for unit in UNITS:
except Exception as exc: try:
print(f"Cannot read the unit at {HOST}: {exc}", file=sys.stderr) state = current_state(unit["host"])
print( except Exception as exc:
"The initial values have to come from the unit, or starting the " print(f"Cannot read the unit at {unit['host']}: {exc}", file=sys.stderr)
"flow would command it to something it was not doing.", print(
file=sys.stderr, "The initial values have to come from the unit, or starting the "
) "flow would command it to something it was not doing.",
return 1 file=sys.stderr,
print(f" unit at {HOST} is: {state}") )
return 1
dtypes = { print(f" {unit['id']} at {unit['host']} is: {state}")
"operation": "bool", inputs += [
"mode": "str", {
"preset_temp": "float", "spec": {
"fan_speed": "str", "name": cmd(unit["id"], port),
} "dtype": COMMANDED[port],
inputs = [ },
{"spec": {"name": name, "dtype": dtypes[name]}, "initial": value} "initial": value,
for name, value in state.items() }
] for port, value in state.items()
]
api = Api() api = Api()
@@ -292,14 +369,15 @@ def main() -> int:
{ {
"name": FLOW, "name": FLOW,
"title": "Aircon control (evaluation)", "title": "Aircon control (evaluation)",
"nodes": NODES, "nodes": nodes(),
"inputs": inputs, "inputs": inputs,
}, },
) )
version = api("GET", f"/flows/{FLOW}")["definition"]["version"] version = api("GET", f"/flows/{FLOW}")["definition"]["version"]
api("POST", f"/flows/{FLOW}/publish", {"version": version}) api("POST", f"/flows/{FLOW}/publish", {"version": version})
api("POST", f"/flows/{FLOW}/stop") 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"]: for issue in api("POST", f"/flows/{FLOW}/validate")["issues"]:
print(f" ! {issue}") print(f" ! {issue}")
@@ -313,11 +391,25 @@ def main() -> int:
{ {
**current, **current,
"title": "Aircon control", "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": [ "pages": [
{ {
"id": "main", "id": "main",
"title": "Write path", "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}) api("POST", f"/dashboards/{PANEL}/publish", {"version": version})
print(f" dashboard '{PANEL}': published") print(f" dashboard '{PANEL}': published")
print(f"\nStart the flow, then open /view/{PANEL}. Commands stay off until") print(f"\nStart the flow, then open /view/{PANEL}.")
print(f"you switch them on in the '{FLOW}.set' node 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 return 0