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
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
+228 -133
View File
@@ -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,84 +43,133 @@ 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,
"id": "old",
"title": "Living room — WF-RAC (plain HTTP)",
"host": os.environ.get("AIRCON_HOST_OLD", "192.168.1.22"),
},
"requires": [
{"name": "operation", "dtype": "bool"},
{"name": "mode", "dtype": "str"},
{"name": "preset_temp", "dtype": "float"},
{"name": "fan_speed", "dtype": "str"},
],
{
"id": "new",
"title": "Second unit — WF-RAC-HTTPS (TLS)",
"host": os.environ.get("AIRCON_HOST_NEW", "192.168.1.191"),
},
]
WIDGETS = [
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": "power",
"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("operation"), "dtype": "bool", "style": "button"},
"config": {
"target": msg(cmd(unit, "operation")),
"dtype": "bool",
"style": "button",
},
},
{
"id": "mode",
"id": f"{unit}_mode",
"type": "dropdown",
"title": "Mode",
"layout": {"lg": {"x": 3, "y": 0, "w": 4, "h": 2}},
"config": {
"target": msg("mode"),
"target": msg(cmd(unit, "mode")),
"dtype": "str",
"style": "segmented",
"options": [{"label": m.title(), "value": m} for m in MODES],
},
},
{
"id": "fan",
"id": f"{unit}_fan",
"type": "dropdown",
"title": "Fan speed",
"layout": {"lg": {"x": 7, "y": 0, "w": 5, "h": 2}},
"config": {
"target": msg("fan_speed"),
"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
{"label": "Auto" if s == "auto" else s, "value": s}
for s in FAN_SPEEDS
],
},
},
{
"id": "setpoint",
"id": f"{unit}_setpoint",
"type": "slider",
"title": "Setpoint",
"layout": {"lg": {"x": 0, "y": 2, "w": 6, "h": 2}},
"config": {
"target": msg("preset_temp"),
"target": msg(cmd(unit, "preset_temp")),
"dtype": "float",
"min": 16,
"max": 30,
@@ -123,38 +178,101 @@ WIDGETS = [
},
},
{
"id": "running",
"id": f"{unit}_reports_mode",
"type": "stat",
"title": "Unit reports",
"layout": {"lg": {"x": 6, "y": 2, "w": 3, "h": 2}},
"config": {"message": msg("mode", READS), "dtype": "str"},
"config": {"message": msg(rep(unit, "mode")), "dtype": "str"},
},
{
"id": "reported_setpoint",
"id": f"{unit}_reports_setpoint",
"type": "stat",
"title": "Setpoint it took",
"layout": {"lg": {"x": 9, "y": 2, "w": 3, "h": 2}},
"config": {
"message": msg("preset_temp", READS),
"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": "Indoor and outdoor",
"layout": {"lg": {"x": 6, "y": 4, "w": 6, "h": 5}},
"title": "Both units",
"layout": {"lg": {"x": 0, "y": 0, "w": 7, "h": 6}},
"config": {
"series": [
{
"message": msg("indoor_temp", READS),
"message": msg(rep("old", "indoor_temp")),
"dtype": "float",
"label": "Indoor",
"label": "Old indoor",
},
{
"message": msg("outdoor_temp", READS),
"message": msg(rep("new", "indoor_temp")),
"dtype": "float",
"label": "New indoor",
},
{
"message": msg(rep("new", "outdoor_temp")),
"dtype": "float",
"label": "Outdoor",
},
@@ -168,39 +286,17 @@ WIDGETS = [
"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"
)
},
"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,27 +336,28 @@ def main() -> int:
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
return 1
inputs = []
for unit in UNITS:
try:
state = current_state()
state = current_state(unit["host"])
except Exception as exc:
print(f"Cannot read the unit at {HOST}: {exc}", file=sys.stderr)
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 at {HOST} is: {state}")
dtypes = {
"operation": "bool",
"mode": "str",
"preset_temp": "float",
"fan_speed": "str",
print(f" {unit['id']} at {unit['host']} is: {state}")
inputs += [
{
"spec": {
"name": cmd(unit["id"], port),
"dtype": COMMANDED[port],
},
"initial": value,
}
inputs = [
{"spec": {"name": name, "dtype": dtypes[name]}, "initial": value}
for name, value in state.items()
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