Let a connector write, and publish strings bare
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
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
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.
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user