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:
@@ -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())
|
||||
Reference in New Issue
Block a user