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