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.
431 lines
15 KiB
Python
431 lines
15 KiB
Python
#!/usr/bin/env python
|
|
"""Seed the aircon write-path rig: one flow, one dashboard, two heat pumps.
|
|
|
|
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::
|
|
|
|
old WF-RAC wireless firmware 010, plain HTTP on port 51443
|
|
new WF-RAC-HTTPS wireless firmware 025, TLS on the same port
|
|
|
|
The connector negotiates that itself, so the two nodes differ only by address.
|
|
|
|
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.
|
|
|
|
**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 # commands off, logs only
|
|
AIRCON_COMMANDS=1 make -C app seed-aircon # commands armed
|
|
|
|
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
|
|
|
|
API = os.environ.get("API_URL", "http://api.localhost")
|
|
EMAIL = os.environ.get("FIRST_SUPERUSER", "")
|
|
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "")
|
|
|
|
#: Off builds the command and logs it. See the module docstring.
|
|
COMMANDS = os.environ.get("AIRCON_COMMANDS", "") == "1"
|
|
|
|
FLOW = "aircon_control"
|
|
PANEL = "aircon_control"
|
|
|
|
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",
|
|
}
|
|
|
|
UNITS = [
|
|
{
|
|
"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"),
|
|
},
|
|
]
|
|
|
|
|
|
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()
|
|
],
|
|
"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(cmd(unit, "operation")),
|
|
"dtype": "bool",
|
|
"style": "button",
|
|
},
|
|
},
|
|
{
|
|
"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, follow_redirects=True
|
|
)
|
|
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(host: str) -> dict[str, Any]:
|
|
"""What a unit is doing now, as the flow's initial values.
|
|
|
|
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(Path(__file__).resolve().parents[2] / "connectors/wfrac/src")
|
|
)
|
|
from fluksio_connector_wfrac import WfRacSensor
|
|
|
|
stat = WfRacSensor(name="seed", params={"host": host, "poll_interval": 0})._read()
|
|
return {port: getattr(stat, port) for port in COMMANDED}
|
|
|
|
|
|
def main() -> int:
|
|
if not EMAIL or not PASSWORD:
|
|
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
|
|
return 1
|
|
|
|
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()
|
|
|
|
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")
|
|
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}")
|
|
|
|
api.drop(f"/dashboards/{PANEL}")
|
|
api("POST", f"/dashboards/{PANEL}")
|
|
current = api("GET", f"/dashboards/{PANEL}?draft=true")
|
|
api(
|
|
"PUT",
|
|
f"/dashboards/{PANEL}",
|
|
{
|
|
**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": f"unit_{u['id']}",
|
|
"title": u["title"],
|
|
"widgets": controls(u["id"]),
|
|
}
|
|
for u in UNITS
|
|
),
|
|
{"id": "shared", "title": "", "widgets": shared()},
|
|
],
|
|
}
|
|
],
|
|
},
|
|
)
|
|
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}.")
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|