From f88dcf81c060d8f64b2ac1afc86fab99ae89fdbd Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 21:57:27 +0200 Subject: [PATCH] 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. --- Makefile | 5 +- NOTEPAD.md | 26 +- ROADMAP.md | 12 +- backend/app/flow/connector.py | 26 +- backend/app/flow/nodes/mqtt.py | 6 +- backend/tests/flow/test_connector.py | 28 ++ backend/tests/flow/test_senders.py | 25 ++ scripts/seed_house_control.py | 459 +++++++++++++++++++++++++++ 8 files changed, 575 insertions(+), 12 deletions(-) create mode 100644 scripts/seed_house_control.py diff --git a/Makefile b/Makefile index aee1a87..79cc62e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # The workspace root delegates to these (see ../Makefile). .PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \ - generate-client seed-example seed-demo seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \ + generate-client seed-example seed-demo seed-house seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \ lint-frontend umami clean help COMPOSE_ROOT := $(CURDIR) @@ -97,6 +97,9 @@ seed-example: ## Seed the querying-chart example (needs a running stack + Influ seed-demo: ## Seed the training-run example: a batch flow and its dashboard cd backend && uv run python ../scripts/seed_demo_training.py +seed-house: ## Seed the house write-path rig (needs the real broker reachable) + cd backend && uv run python ../scripts/seed_house_control.py + # Operators of the hosted demo only — NOT part of any deployment, and nothing a # self-hosted instance needs. It wipes and recreates its three flows and its # dashboard, so re-running it is how the public demo is reset. diff --git a/NOTEPAD.md b/NOTEPAD.md index b4d2911..0f8dc42 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -60,9 +60,33 @@ Postgres + Redis + git-files split stays; the actionable part is durability. Needs someone watching the real hardware, so it is not a background task. This is what M4 still waits on, together with porting the flows. -- FEAT/NODE: the connectors only read. Enable the write paths with someone watching: WF-RAC `setAirconStat` (needs an operatorId registered with the unit first, which is itself a write) and Art-Net `transmit`. +Art-Net can write now: `ConnectorNode.write` carries a node's input ports, a +per-port `channels` map places each on its own DMX channel, and `transmit` +still gates the socket. Verified on the wire against a listener (channel 33 = +255, channel 31 = 60, nothing else set) and the MQTT half was driven end to +end against the house broker. The rig is the `house_control` flow and its +dashboard, seeded by `make -C app seed-house`. + +- FEAT/NODE: Art-Net against the real fixtures is still untried. The house's own dmxnet sender re-emits universe 1 every 1000 ms, so fluksio and Node-RED overwrite each other; the test needs Node-RED's Art-Net sender stopped, and while it is stopped every channel fluksio does not set is dark. +- FEAT/NODE: the WF-RAC write path stays gated. `setAirconStat` needs an operatorId registered with the unit first, which is itself a write, and the aircon is not on the safe-to-control list. - FEAT/NODE: the second WF-RAC unit (the one Node-RED addresses with operatorId "0") closes the connection on an anonymous read. It likely wants an account registered; the first unit answers without one. - CHORE/NODE: `wfrac` reports `mode` as "unknown" while the unit is off, because the mode bits hold a value outside the known set. Faithful to the reference decoder, but "off" would read better. +- PERF/NODE: `ArtNetOut.write` sends one frame per input port, so a node with two ports emits two frames per run. The last one carries both channels, so the end state is right; folding them into one send would halve the traffic. +- CHORE/NODE: the Art-Net node starts from an all-zero universe and has no way to learn what the fixtures are currently at — Art-Net has no read-back. Taking over a universe therefore blanks everything the flow does not drive. A baseline setting, or driving every channel, is what a real cutover needs. + +### Porting the Node-RED flows + +What the reference actually does, extracted while building the write-path rig. +One `Art-Net Out` node in 865 covers every physical device: `mqtt in ` +-> `change` (msg.topic = DMX channel) -> an `nCH` encoder function -> Art-Net, +to 192.168.1.12 universe 1. Payloads are bare: `ON`/`OFF`, a number, `[h,s,v]`, +`UP`/`DOWN`. Nothing is retained, so state lives only in Node-RED globals and +is lost on its restart. + +- CHORE/FLOW: three DMX channel collisions in the reference — ch 9 (`light/bathRoomLight` vs `light/bathRoomSinkLight`), ch 28 (`actor/windowOpenerStorage` 28-29 vs `light/traverseAmbientLight` 28-30), ch 129 (`actor/canopy` 129-130 vs an orphaned 1CH mapping). Decide these deliberately rather than porting them. +- CHORE/FLOW: 1CH values are not scaled. `light/traverseSpotLight` and `light/kitchenDirectLight` receive 0-100 and that number lands on DMX as-is, so those fixtures never go above 100/255. The 4CH "A" channel gets `v` raw for the same reason. Faithful is ugly; deliberate is better. +- BUG/FLOW: the reference's `function 9`/`function 10` publish the string `"undefined"` for unselected zones (`var a, b, c = [0,0,0]` only initialises `c`), which reaches the 3CH/4CH encoders and produces `NaN` DMX values. A port should emit an explicit `OFF`. +- CHORE/FLOW: dead in the reference and not worth porting — the `AmbientModeToLight` chain, the four Dashboard toggle chains, `light/generalLight` (written, no consumer), `light/outdoorPavillonLight` (no wiring), `Color Adapt`. ### Bugs found while building the screens diff --git a/ROADMAP.md b/ROADMAP.md index 16fccf4..c98826c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,9 +73,15 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend – M `connector-skeleton/`. The registry follows later - [x] First real connectors written against that contract from outside the engine: WF-RAC aircon, calendar, UniFi presence and Art-Net, in `connectors/`. - Built by `make connectors` and installed into the image. Reading only for - now — the aircon package cannot produce a command and Art-Net keeps its - packets off the wire until `transmit` is switched on + Built by `make connectors` and installed into the image. Three of the four + read only; the aircon package still cannot produce a command +- [x] The other direction of the contract: `ConnectorNode.write` receives the + node's input ports, so a connector can command something rather than only + read it. Additive, so `CONTRACT_VERSION` stays at 1 — before this the base + class discarded every message reaching a connector, which made `artnet`'s + packet builder unreachable. Art-Net now sends: a per-port `channels` map + puts each input on its own DMX channel, `transmit` still gates the socket, + and one node owns one universe because a frame carries all 512 levels - [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 diff --git a/backend/app/flow/connector.py b/backend/app/flow/connector.py index 2bd0a05..4f1a59d 100644 --- a/backend/app/flow/connector.py +++ b/backend/app/flow/connector.py @@ -14,7 +14,10 @@ What a connector gets from the base class: * :meth:`Node.report_health`, so a connection problem shows on the node rather than only in the log; * the lifecycle hooks the controller drives, so nothing device-specific has to - be known by the engine. + be known by the engine; +* :meth:`ConnectorNode.write`, the other direction — values arriving on the + node's input ports, for a connector that commands something rather than only + reading it. The message schemas and the parameter model are the rest of the contract, and they are the same ones the built-in nodes use. See ``docs/connectors/`` for the @@ -81,16 +84,15 @@ class ConnectorNode(Node): __slots__ = ("config", "_poll_task", "_stop_event", "_last_published") def __init__(self, **kwargs: Any) -> None: - super().__init__(f=self._unused, **kwargs) + super().__init__(f=self._dispatch, **kwargs) self.config = type(self).Params(**self.params) self._poll_task: asyncio.Task[None] | None = None self._stop_event: asyncio.Event | None = None self._last_published: dict[str, Any] = {} - @staticmethod - def _unused(**_: Any) -> None: - """A connector publishes from its own loop, not from the scheduler.""" - return None + def _dispatch(self, params: dict[str, Any], **ports: Any) -> dict[str, Any] | None: + """The scheduler's entry point. Settings are already on ``self.config``.""" + return self.write(**ports) # ------------------------------------------------------------------------- # What a connector implements @@ -104,6 +106,18 @@ class ConnectorNode(Node): """ return None + def write(self, **ports: Any) -> dict[str, Any] | None: + """Send incoming values to the device. Values arrive keyed by input port. + + A connector that only reads leaves this alone — the default discards + whatever reaches it, which is what a node with no inputs gets anyway. + Return ``None`` unless the device answers something worth publishing, + in which case return it keyed by output port like :meth:`poll` does. + + This runs on the scheduler's thread, so it must not block for long. + """ + return None + # ------------------------------------------------------------------------- # What the engine drives # ------------------------------------------------------------------------- diff --git a/backend/app/flow/nodes/mqtt.py b/backend/app/flow/nodes/mqtt.py index 8cf2505..fa154ed 100644 --- a/backend/app/flow/nodes/mqtt.py +++ b/backend/app/flow/nodes/mqtt.py @@ -379,7 +379,11 @@ class MqttNode(Node): ) continue - payload = json.dumps(value) + # A string goes on the wire as it stands. Devices on a shared + # broker expect bare values, and the subscriber below already + # falls back to the raw text when it is not JSON, so a + # fluksio-to-fluksio round trip is unaffected. + payload = value if isinstance(value, str) else json.dumps(value) await client.publish( topic, payload=payload, diff --git a/backend/tests/flow/test_connector.py b/backend/tests/flow/test_connector.py index 18c4691..38822df 100644 --- a/backend/tests/flow/test_connector.py +++ b/backend/tests/flow/test_connector.py @@ -100,6 +100,34 @@ def test_a_failing_poll_reports_down_and_keeps_going(): assert health[-1][0] == "ok" +class Actuator(ConnectorNode): + """A connector that commands something instead of reading it.""" + + contract = CONTRACT_VERSION + title = "Test actuator" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.commands: list[dict[str, Any]] = [] + + def write(self, **ports: Any) -> None: + self.commands.append(ports) + return None + + +def test_an_incoming_message_reaches_a_connector_that_writes(): + node = Actuator(requires=[MessageSpec(name="level", dtype=DType.INT)]) + node.assign_flow("demo", "actuator") + + assert node.execute({"demo.level": 255}) is None + assert node.commands == [{"level": 255}] + + +def test_a_read_only_connector_ignores_what_reaches_it(): + node = a_sensor([]) + assert node.execute({}) is None + + def test_a_credential_param_is_marked_for_the_editor(): schema = Sensor.Params.model_json_schema() assert schema["properties"]["poll_interval"]["default"] == 0 diff --git a/backend/tests/flow/test_senders.py b/backend/tests/flow/test_senders.py index cc593d7..ce2c40a 100644 --- a/backend/tests/flow/test_senders.py +++ b/backend/tests/flow/test_senders.py @@ -64,3 +64,28 @@ def test_a_full_publish_queue_drops_the_oldest(): assert health == [("degraded", "publish queue full")] asyncio.run(scenario()) + + +def test_a_string_goes_on_the_wire_bare(): + """Devices on a shared broker expect `ON`, not `"ON"`.""" + + class Recorder: + def __init__(self) -> None: + self.published: list[tuple[str, str]] = [] + + async def publish(self, topic, payload, **_): + self.published.append((topic, payload)) + + node = MqttNode( + requires=[ + MessageSpec(name="plug", port="plug", dtype=DType.STR), + MessageSpec(name="level", port="level", dtype=DType.INT), + ], + params={"topic": {"plug": "actor/plug", "level": "light/level"}}, + ) + node.assign_flow("house", "out") + client = Recorder() + + asyncio.run(node._publish_with(client, {"plug": "ON", "level": 60})) + + assert client.published == [("actor/plug", "ON"), ("light/level", "60")] diff --git a/scripts/seed_house_control.py b/scripts/seed_house_control.py new file mode 100644 index 0000000..0c20b47 --- /dev/null +++ b/scripts/seed_house_control.py @@ -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())