Files
app/scripts/seed_house_control.py
T
stroblme f88dcf81c0
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
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Let a connector write, and publish strings bare
Two things stopped the engine commanding this house. ConnectorNode hardwired
its node function to a no-op, so an input message reaching a connector was
discarded and Art-Net's packet builder was unreachable; write() now carries
the input ports, which is additive so the contract version holds. And the MQTT
publisher JSON-encoded every payload, so "ON" went on the wire quoted and the
devices on a shared broker, which speak bare values, ignored it.

seed_house_control.py is the rig: a flow that drives the washing machine plug,
a dimmer and a colour fixture over MQTT, carries the same two as DMX on an
Art-Net node with transmit still off, and a dashboard to drive it by hand.
2026-08-20 21:57:27 +02:00

460 lines
14 KiB
Python

#!/usr/bin/env python
"""Seed the house write-path evaluation: one flow, one dashboard, real devices.
This is the M4 write-path test rig. Node-RED drives this house today; the flow
below speaks the same MQTT bus it does, and carries an Art-Net node aimed at
the same gateway so the Node-RED-free path can be tried on the same fixtures.
house_control inputs -> encoders -> mqtt out commands three fixtures
mqtt in -> meter what the house echoed
inputs -> dmx -> artnet the same two, direct
What the reference does, and what this reproduces:
actor/washingMachinePlug "ON" / "OFF" -> DMX ch 33 (1CH Actor)
light/traverseSpotLight 0-100 raw -> DMX ch 31 (1CH Light)
light/livingRoomAmbientLight "[h,s,v]" -> DMX ch 1-4 (4CH Light)
Nothing is retained on that broker, so the echo the flow subscribes to is the
only handshake there is: a value coming back is proof the broker took it.
**The flow is seeded stopped.** Starting it publishes the current input values
once, which is `OFF` for the plug and zero for both lights — start it with the
washing machine idle.
Run it against a stack that is already up::
make -C app seed-house
Environment (the Makefile passes these):
API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD
HOUSE_BROKER, HOUSE_BROKER_USER, ARTNET_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", "")
BROKER = os.environ.get("HOUSE_BROKER", "192.168.1.104")
BROKER_USER = os.environ.get("HOUSE_BROKER_USER", "fluksio")
ARTNET_HOST = os.environ.get("ARTNET_HOST", "192.168.1.12")
SECRET = "mqtt_thcomp_password"
FLOW = "house_control"
PANEL = "house_control"
PLUG_TOPIC = "actor/washingMachinePlug"
SPOT_TOPIC = "light/traverseSpotLight"
AMBIENT_TOPIC = "light/livingRoomAmbientLight"
POWER_TOPIC = "shellypm/status/pm1:0"
# Universe 1 on the gateway, the same channels the reference stamps in.
DMX_PLUG = 33
DMX_SPOT = 31
PLUG_SOURCE = '''"""A relay speaks ON and OFF. Anything else lands on DMX as a string."""
def process(wash):
return {"plug": "ON" if wash else "OFF"}
'''
SPOT_SOURCE = '''"""A single-channel dimmer takes the level raw.
The reference hands 0-100 straight to DMX without scaling to 255, so a
replacement that "fixes" that would be four times too bright.
"""
def process(spot):
return {"spot_level": max(0, min(100, int(spot)))}
'''
AMBIENT_SOURCE = '''"""The 4CH fixtures take "[h,s,v]" as text: hue 0-360, the rest 0-100."""
def process(hue, level):
return {"ambient": f"[{int(hue)},80,{max(0, min(100, int(level)))}]"}
'''
METER_SOURCE = '''"""House power, so a plug switching is visible as a step rather than a claim.
This is the whole house, not the one socket — the inventory has no per-plug
metering. A washing machine still moves it by hundreds of watts.
"""
def process(power_raw):
return {"power": float(power_raw.get("apower", 0.0))}
'''
DMX_SOURCE = '''"""The same two commands as DMX levels, for the Node-RED-free path.
Mirrors the reference's "1CH Actor" (ON -> 255) and "1CH Light" (level used
raw). One node owns the universe, so both channels are set every run.
"""
def process(wash, spot):
return {
"dmx_wash": 255 if wash else 0,
"dmx_spot": max(0, min(255, int(spot))),
}
'''
def broker_params(client_id: str, topic: dict[str, str]) -> dict[str, Any]:
"""One broker, one credential, a topic per port."""
return {
"topic": topic,
"broker_host": BROKER,
"broker_port": 1883,
"username": BROKER_USER,
"password": {"$secret": SECRET},
"client_id": client_id,
"qos": 0,
"retain": False,
}
# One publisher per fixture on purpose: a node is handed every input it
# requires on every run, so a single publisher would re-command the plug each
# time a light slider moved.
NODES = [
{
"id": "plug_cmd",
"type": "python",
"title": "Plug to ON/OFF",
"requires": [{"name": "wash", "dtype": "bool"}],
"provides": [{"name": "plug", "dtype": "str"}],
},
{
"id": "spot_cmd",
"type": "python",
"title": "Spot level",
"requires": [{"name": "spot", "dtype": "float"}],
"provides": [{"name": "spot_level", "dtype": "int"}],
},
{
"id": "ambient_cmd",
"type": "python",
"title": "Ambient colour",
"requires": [
{"name": "hue", "dtype": "float"},
{"name": "level", "dtype": "float"},
],
"provides": [{"name": "ambient", "dtype": "str"}],
},
{
"id": "plug_out",
"type": "mqtt",
"title": "Washing machine plug",
"params": broker_params("fluksio-house-plug", {"plug": PLUG_TOPIC}),
"requires": [{"name": "plug", "dtype": "str"}],
},
{
"id": "spot_out",
"type": "mqtt",
"title": "Traverse spot",
"params": broker_params("fluksio-house-spot", {"spot_level": SPOT_TOPIC}),
"requires": [{"name": "spot_level", "dtype": "int"}],
},
{
"id": "ambient_out",
"type": "mqtt",
"title": "Living room ambient",
"params": broker_params("fluksio-house-ambient", {"ambient": AMBIENT_TOPIC}),
"requires": [{"name": "ambient", "dtype": "str"}],
},
{
"id": "echo",
"type": "mqtt",
"title": "What the house said back",
"params": broker_params(
"fluksio-house-echo",
{
"plug_state": PLUG_TOPIC,
"spot_state": SPOT_TOPIC,
"power_raw": POWER_TOPIC,
},
),
"provides": [
{"name": "plug_state", "dtype": "str"},
{"name": "spot_state", "dtype": "float"},
{"name": "power_raw", "dtype": "json", "interval": 5.0},
],
},
{
"id": "meter",
"type": "python",
"title": "House power",
"requires": [{"name": "power_raw", "dtype": "json"}],
"provides": [{"name": "power", "dtype": "float"}],
},
{
"id": "dmx",
"type": "python",
"title": "The same two, as DMX levels",
"requires": [
{"name": "wash", "dtype": "bool"},
{"name": "spot", "dtype": "float"},
],
"provides": [
{"name": "dmx_wash", "dtype": "int"},
{"name": "dmx_spot", "dtype": "int"},
],
},
{
"id": "artnet_out",
"type": "artnet",
"title": "Art-Net universe 1 (off the wire)",
"params": {
"host": ARTNET_HOST,
"universe": 1,
"channels": {"dmx_wash": DMX_PLUG, "dmx_spot": DMX_SPOT},
# Stays off until Node-RED's own sender is stopped: a frame carries
# the whole universe, so two senders overwrite each other.
"transmit": False,
},
"requires": [
{"name": "dmx_wash", "dtype": "int"},
{"name": "dmx_spot", "dtype": "int"},
],
},
]
INPUTS = [
{"spec": {"name": "wash", "dtype": "bool"}, "initial": False},
{"spec": {"name": "spot", "dtype": "float"}, "initial": 0.0},
{"spec": {"name": "hue", "dtype": "float"}, "initial": 30.0},
{"spec": {"name": "level", "dtype": "float"}, "initial": 0.0},
]
SOURCES = {
"plug_cmd": PLUG_SOURCE,
"spot_cmd": SPOT_SOURCE,
"ambient_cmd": AMBIENT_SOURCE,
"meter": METER_SOURCE,
"dmx": DMX_SOURCE,
}
def msg(name: str) -> str:
return f"{FLOW}.{name}"
WIDGETS = [
{
"id": "wash",
"type": "switch",
"title": "Washing machine plug",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
"config": {"target": msg("wash"), "dtype": "bool", "style": "button"},
},
{
"id": "spot",
"type": "slider",
"title": "Traverse spot",
"layout": {"lg": {"x": 3, "y": 0, "w": 3, "h": 2}},
"config": {
"target": msg("spot"),
"dtype": "float",
"min": 0,
"max": 100,
"step": 5,
"unit": "%",
},
},
{
"id": "hue",
"type": "slider",
"title": "Ambient hue",
"layout": {"lg": {"x": 6, "y": 0, "w": 3, "h": 2}},
"config": {
"target": msg("hue"),
"dtype": "float",
"min": 0,
"max": 360,
"step": 10,
"unit": "°",
},
},
{
"id": "level",
"type": "slider",
"title": "Ambient brightness",
"layout": {"lg": {"x": 9, "y": 0, "w": 3, "h": 2}},
"config": {
"target": msg("level"),
"dtype": "float",
"min": 0,
"max": 100,
"step": 5,
"unit": "%",
},
},
{
"id": "plug_state",
"type": "stat",
"title": "Plug echoed back",
"layout": {"lg": {"x": 0, "y": 2, "w": 3, "h": 2}},
"config": {"message": msg("plug_state"), "dtype": "str"},
},
{
"id": "spot_state",
"type": "stat",
"title": "Spot echoed back",
"layout": {"lg": {"x": 3, "y": 2, "w": 3, "h": 2}},
"config": {
"message": msg("spot_state"),
"dtype": "float",
"precision": 0,
"unit": "%",
},
},
{
"id": "power",
"type": "chart",
"title": "House power",
"layout": {"lg": {"x": 6, "y": 2, "w": 6, "h": 5}},
"config": {
"series": [{"message": msg("power"), "dtype": "float", "label": "Total"}],
"history": {"points": 360},
"unit": " W",
"y_label": "W",
},
},
{
"id": "protocol",
"type": "markdown",
"title": "Test protocol",
"layout": {"lg": {"x": 0, "y": 4, "w": 6, "h": 5}},
"config": {
"content": (
"## Before you start\n"
"- The flow is seeded stopped. Starting it publishes the"
" current values once, which is OFF for the plug and zero for"
" both lights.\n"
"- Start it with the washing machine idle.\n"
"- Node-RED reacts to the plug going ON by scheduling an"
" automatic OFF at midnight. That is its logic, not a fault.\n"
"## What to try\n"
"- Plug: ON, then OFF. The stat beside it is the broker's"
" echo, and the chart should step by a few hundred watts.\n"
"- Traverse spot: 0, 60, 0. The number is used raw as the DMX"
" level, exactly as the reference does.\n"
"- Ambient: move brightness up, then hue. Back to zero when"
" done.\n"
"## Art-Net, once Node-RED is stopped\n"
"- The Art-Net node is on the canvas with transmit off. It"
" aims at the same gateway, universe 1, channels 33 and 31.\n"
"- A frame carries all 512 channels, so one sender owns a"
" universe. Stop Node-RED's Art-Net sender first, or the two"
" overwrite each other every second.\n"
"- Everything on that universe this node does not set goes"
" dark while it drives.\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 main() -> int:
if not EMAIL or not PASSWORD:
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
return 1
api = Api()
if SECRET not in api("GET", "/secrets/")["data"]:
print(
f"No secret named '{SECRET}'. Add the broker password under "
"Secrets, or point HOUSE_BROKER at a broker that takes anonymous "
"connections.",
file=sys.stderr,
)
return 1
api.drop(f"/flows/{FLOW}")
api(
"PUT",
f"/flows/{FLOW}",
{
"name": FLOW,
"title": "House control (evaluation)",
"nodes": NODES,
"inputs": INPUTS,
},
)
for node_id, code in SOURCES.items():
api("PUT", f"/flows/{FLOW}/nodes/{node_id}/source", {"code": code})
version = api("GET", f"/flows/{FLOW}")["definition"]["version"]
api("POST", f"/flows/{FLOW}/publish", {"version": version})
# Seeded stopped: nothing reaches the house until someone is watching.
api("POST", f"/flows/{FLOW}/stop")
print(f" {FLOW}: {len(NODES)} nodes, published, stopped")
issues = api("POST", f"/flows/{FLOW}/validate")["issues"]
for issue in 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": "House control",
"pages": [
{
"id": "main",
"title": "Write paths",
"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}.")
return 0
if __name__ == "__main__":
raise SystemExit(main())