This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# The workspace root delegates to these (see ../Makefile).
|
# The workspace root delegates to these (see ../Makefile).
|
||||||
|
|
||||||
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \
|
.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
|
lint-frontend umami clean help
|
||||||
|
|
||||||
COMPOSE_ROOT := $(CURDIR)
|
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
|
seed-demo: ## Seed the training-run example: a batch flow and its dashboard
|
||||||
cd backend && uv run python ../scripts/seed_demo_training.py
|
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
|
# 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
|
# 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.
|
# dashboard, so re-running it is how the public demo is reset.
|
||||||
|
|||||||
+25
-1
@@ -61,9 +61,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
|
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.
|
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.
|
- 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.
|
- 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 <topic>`
|
||||||
|
-> `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
|
### Bugs found while building the screens
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -73,9 +73,15 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend – M
|
|||||||
`connector-skeleton/`. The registry follows later
|
`connector-skeleton/`. The registry follows later
|
||||||
- [x] First real connectors written against that contract from outside the engine:
|
- [x] First real connectors written against that contract from outside the engine:
|
||||||
WF-RAC aircon, calendar, UniFi presence and Art-Net, in `connectors/`.
|
WF-RAC aircon, calendar, UniFi presence and Art-Net, in `connectors/`.
|
||||||
Built by `make connectors` and installed into the image. Reading only for
|
Built by `make connectors` and installed into the image. Three of the four
|
||||||
now — the aircon package cannot produce a command and Art-Net keeps its
|
read only; the aircon package still cannot produce a command
|
||||||
packets off the wire until `transmit` is switched on
|
- [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`),
|
- [x] Node lifecycle as a protocol (`start`/`stop`/`report_health` on `Node`),
|
||||||
replacing the controller's per-type isinstance chains — the same hooks a
|
replacing the controller's per-type isinstance chains — the same hooks a
|
||||||
connector implements, validated on the built-in nodes first
|
connector implements, validated on the built-in nodes first
|
||||||
|
|||||||
@@ -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
|
* :meth:`Node.report_health`, so a connection problem shows on the node rather
|
||||||
than only in the log;
|
than only in the log;
|
||||||
* the lifecycle hooks the controller drives, so nothing device-specific has to
|
* 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
|
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
|
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")
|
__slots__ = ("config", "_poll_task", "_stop_event", "_last_published")
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
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.config = type(self).Params(**self.params)
|
||||||
self._poll_task: asyncio.Task[None] | None = None
|
self._poll_task: asyncio.Task[None] | None = None
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._last_published: dict[str, Any] = {}
|
self._last_published: dict[str, Any] = {}
|
||||||
|
|
||||||
@staticmethod
|
def _dispatch(self, params: dict[str, Any], **ports: Any) -> dict[str, Any] | None:
|
||||||
def _unused(**_: Any) -> None:
|
"""The scheduler's entry point. Settings are already on ``self.config``."""
|
||||||
"""A connector publishes from its own loop, not from the scheduler."""
|
return self.write(**ports)
|
||||||
return None
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# What a connector implements
|
# What a connector implements
|
||||||
@@ -104,6 +106,18 @@ class ConnectorNode(Node):
|
|||||||
"""
|
"""
|
||||||
return None
|
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
|
# What the engine drives
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -379,7 +379,11 @@ class MqttNode(Node):
|
|||||||
)
|
)
|
||||||
continue
|
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(
|
await client.publish(
|
||||||
topic,
|
topic,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
|||||||
@@ -100,6 +100,34 @@ def test_a_failing_poll_reports_down_and_keeps_going():
|
|||||||
assert health[-1][0] == "ok"
|
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():
|
def test_a_credential_param_is_marked_for_the_editor():
|
||||||
schema = Sensor.Params.model_json_schema()
|
schema = Sensor.Params.model_json_schema()
|
||||||
assert schema["properties"]["poll_interval"]["default"] == 0
|
assert schema["properties"]["poll_interval"]["default"] == 0
|
||||||
|
|||||||
@@ -64,3 +64,28 @@ def test_a_full_publish_queue_drops_the_oldest():
|
|||||||
assert health == [("degraded", "publish queue full")]
|
assert health == [("degraded", "publish queue full")]
|
||||||
|
|
||||||
asyncio.run(scenario())
|
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")]
|
||||||
|
|||||||
@@ -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