#!/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}?draft=true") 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())