Seed the TinyHouse: nineteen flows in place of eight hundred nodes
The Node-RED installation this replaces is 865 nodes across three tabs, and roughly a fifth of it is unreachable — the pellet stove's controller, the scene engine and the awning's logic were all disconnected from the heartbeat they ran on. What is here is the intent rather than the wiring: nineteen named flows, 109 nodes, and no heartbeat at all. A sensor value is the event. The device layer moves with it. `actor/*` and `light/*` were never a device interface — Node-RED subscribed to its own topics, stamped a DMX channel on each and encoded one Art-Net universe — so those topics retire with it and the encoders are five nodes in the `dmx` flow. Two shared library nodes carry what every actuator needs. `arbiter` answers the thing this design was missing: a value someone sets on a screen is not undone by the next evaluation. A manual value wins for a hold, the house takes over when it expires, and a schedule can force past both — so "off at two in the morning" still means off. The control binds to the message the arbiter writes back, so one tile shows what reached the fixture and setting it is the override. `motor` is why a stop is now commanded once. A rollershutter has no position sensor, so time is the only feedback: it says how long to run and a trigger sends the single STOP that ends it. The reference sent STOP forever. Everything is seeded stopped, the Art-Net node does not transmit and the heat pump does not accept commands until house.json says so. `--dry` checks the whole set without an installation: names nothing provides, loops, type disagreements, widgets bound to nothing, and every Python node run once on values of the shape it declared — including whether what it returns goes anywhere. That last one has already caught a typo that would have published into silence. house.json holds this installation's addresses, MAC addresses and DMX map and is git-ignored, as the Node-RED inventory is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
"""What the screens read: the charts behind them, and the kiosk still on MQTT.
|
||||
|
||||
`history` answers the chart widgets. The widget publishes the window it wants
|
||||
and draws the series that comes back, so it never learns which database
|
||||
answered — building the query and shaping the rows are ordinary Python nodes
|
||||
on either side of the InfluxDB node, which holds the connection and nothing
|
||||
else.
|
||||
|
||||
`kiosk` is the transition flow, and the one to delete. The e-ink dashboard on
|
||||
the wall speaks a bus that Node-RED used to answer; until a Fluksio panel
|
||||
hangs there instead, this keeps its side of that conversation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .api import Flow
|
||||
from .sensing import _broker
|
||||
|
||||
BUILD = '''"""A chart's window into Flux. The database-specific half, and the only one."""
|
||||
|
||||
BUCKET = "{bucket}"
|
||||
|
||||
|
||||
def process(chart_request, series=()):
|
||||
span = int(chart_request["range_s"])
|
||||
every = max(1, int(chart_request["interval_s"]))
|
||||
parts = []
|
||||
for measurement in series:
|
||||
parts.append(
|
||||
"\\n".join(
|
||||
[
|
||||
'from(bucket: "%s")' % BUCKET,
|
||||
" |> range(start: -%ds)" % span,
|
||||
' |> filter(fn: (r) => r["_measurement"] == "%s")' % measurement,
|
||||
' |> filter(fn: (r) => r["_field"] == "value")',
|
||||
" |> aggregateWindow(every: %ds, fn: mean, createEmpty: false)"
|
||||
% every,
|
||||
]
|
||||
)
|
||||
)
|
||||
return {
|
||||
"query": {
|
||||
"flux": "\\n".join(parts),
|
||||
"range_s": chart_request["range_s"],
|
||||
"interval_s": chart_request["interval_s"],
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
PARSE = '''"""Rows into the series a chart draws. Nothing here knows about InfluxDB."""
|
||||
|
||||
|
||||
def process(rows, labels=None):
|
||||
labels = labels or {}
|
||||
lines = {}
|
||||
for row in rows.get("rows", []):
|
||||
if row.get("ts") is None or row.get("value") is None:
|
||||
continue
|
||||
measurement = row.get("measurement", "")
|
||||
lines.setdefault(measurement, []).append([row["ts"], float(row["value"])])
|
||||
|
||||
return {
|
||||
"series": {
|
||||
# The echo the widget matches against what it asked for, so an
|
||||
# answer to an older question is ignored rather than drawn.
|
||||
"range_s": rows["range_s"],
|
||||
"interval_s": rows["interval_s"],
|
||||
"lines": [
|
||||
{"label": labels.get(name, name), "points": points}
|
||||
for name, points in sorted(lines.items())
|
||||
],
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
#: The three charts, and what each one asks the database for.
|
||||
CHARTS = {
|
||||
"power": (
|
||||
["ess/ac/out/power", "ess/dc/pv/power", "ess/ac/in/power"],
|
||||
{
|
||||
"ess/ac/out/power": "House",
|
||||
"ess/dc/pv/power": "Solar",
|
||||
"ess/ac/in/power": "Grid",
|
||||
},
|
||||
),
|
||||
"battery": (["ess/dc/battery/soc"], {"ess/dc/battery/soc": "Charge"}),
|
||||
"climate": (
|
||||
["environment/temperature/1", "environment/temperature/2"],
|
||||
{
|
||||
"environment/temperature/1": "Indoor",
|
||||
"environment/temperature/2": "Outdoor",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def history(h: dict[str, Any]) -> Flow:
|
||||
flow = Flow("history", "History")
|
||||
influx = h["influx"]
|
||||
build = BUILD.replace("{bucket}", influx["bucket"])
|
||||
|
||||
for name, (measurements, labels) in CHARTS.items():
|
||||
flow.add(
|
||||
{
|
||||
"id": f"{name}_build",
|
||||
"type": "python",
|
||||
"title": f"{name.title()}: the window into Flux",
|
||||
"params": {"series": measurements},
|
||||
"requires": [
|
||||
{
|
||||
"name": f"{name}_request",
|
||||
"port": "chart_request",
|
||||
"dtype": "record",
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
{"name": f"{name}_query", "port": "query", "dtype": "record"}
|
||||
],
|
||||
},
|
||||
build,
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": f"{name}_db",
|
||||
"type": "influxdb",
|
||||
"title": f"{name.title()}: ask",
|
||||
"params": {
|
||||
"url": influx["url"],
|
||||
"token": {"$secret": "influx_token"},
|
||||
"org": influx["org"],
|
||||
"bucket": influx["bucket"],
|
||||
},
|
||||
"requires": [
|
||||
{"name": f"{name}_query", "port": "query", "dtype": "record"}
|
||||
],
|
||||
"provides": [
|
||||
{"name": f"{name}_rows", "port": "rows", "dtype": "record"}
|
||||
],
|
||||
}
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": f"{name}_parse",
|
||||
"type": "python",
|
||||
"title": f"{name.title()}: rows to a series",
|
||||
"params": {"labels": labels},
|
||||
"requires": [
|
||||
{"name": f"{name}_rows", "port": "rows", "dtype": "record"}
|
||||
],
|
||||
"provides": [
|
||||
{"name": f"{name}_series", "port": "series", "dtype": "series"}
|
||||
],
|
||||
},
|
||||
PARSE,
|
||||
)
|
||||
flow.input(
|
||||
f"{name}_request",
|
||||
"record",
|
||||
{"range_s": 21600, "interval_s": 300},
|
||||
)
|
||||
return flow
|
||||
|
||||
|
||||
# ── the kiosk, until a panel hangs there instead ─────────────────────────
|
||||
|
||||
BRIGHTNESS = '''"""How bright the wall display should be.
|
||||
|
||||
Nobody in: off. Someone asleep: as low as it goes without being off. Otherwise
|
||||
it follows the daylight, which is what makes an e-ink panel readable at noon
|
||||
and not blinding at midnight.
|
||||
"""
|
||||
|
||||
|
||||
def process(state="home", light=0.0, bed_down=False, floor=6.0, gain=2.5):
|
||||
if state == "away":
|
||||
return {"brightness": 0}
|
||||
if bed_down:
|
||||
return {"brightness": 1}
|
||||
return {"brightness": int(round(min(floor + light * gain, 100)))}
|
||||
'''
|
||||
|
||||
ENV = '''"""The blob the e-ink dashboard reads, in the shape Node-RED sent it."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def process(
|
||||
indoor_temp=0.0,
|
||||
indoor_hum=0.0,
|
||||
indoor_dewpoint=0.0,
|
||||
outdoor_temp=0.0,
|
||||
outdoor_hum=0.0,
|
||||
outdoor_dewpoint=0.0,
|
||||
forecast=None,
|
||||
):
|
||||
return {
|
||||
"env": json.dumps(
|
||||
{
|
||||
"indoor": {
|
||||
"temp": indoor_temp,
|
||||
"hum": indoor_hum,
|
||||
"dp": indoor_dewpoint,
|
||||
},
|
||||
"outdoor": {
|
||||
"temp": outdoor_temp,
|
||||
"hum": outdoor_hum,
|
||||
"dp": outdoor_dewpoint,
|
||||
"daily": forecast or [],
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
'''
|
||||
|
||||
ZONES_IN = '''"""The e-ink dashboard's five zone buttons, back into one scene name.
|
||||
|
||||
It speaks zones and this house speaks scenes, so the nearest scene wins. Not a
|
||||
translation worth keeping: it exists so the wall keeps working through the
|
||||
changeover, and it goes when the wall does.
|
||||
"""
|
||||
|
||||
MATCHES = (
|
||||
(("bed", "bath"), "sleep"),
|
||||
(("kitchen", "workspace", "outside"), "night"),
|
||||
(("workspace", "outside"), "outside"),
|
||||
(("workspace",), "day"),
|
||||
)
|
||||
|
||||
|
||||
def process(bed="False", kitchen="False", bath="False", workspace="False", outside="False"):
|
||||
lit = {
|
||||
name
|
||||
for name, value in (
|
||||
("bed", bed),
|
||||
("kitchen", kitchen),
|
||||
("bath", bath),
|
||||
("workspace", workspace),
|
||||
("outside", outside),
|
||||
)
|
||||
if str(value).lower() in ("true", "1", "on")
|
||||
}
|
||||
if not lit:
|
||||
return {"scene": "off"}
|
||||
for zones, scene in MATCHES:
|
||||
if lit == set(zones):
|
||||
return {"scene": scene}
|
||||
return {"scene": "alarm" if len(lit) == 5 else "night"}
|
||||
'''
|
||||
|
||||
BOOLS = '''"""The e-ink dashboard sends the strings "True" and "False"."""
|
||||
|
||||
|
||||
def process(**controls):
|
||||
return {
|
||||
name: str(value).lower() in ("true", "1", "on")
|
||||
for name, value in controls.items()
|
||||
}
|
||||
'''
|
||||
|
||||
SHUTTER_WORDS = '''"""Its bed and door buttons are on/off; the motors speak up and down."""
|
||||
|
||||
|
||||
TRUTHY = ("true", "1", "on")
|
||||
|
||||
|
||||
def process(bed=None, door=None):
|
||||
out = {}
|
||||
if bed is not None:
|
||||
out["bed_cmd"] = "UP" if str(bed).lower() in TRUTHY else "DOWN"
|
||||
if door is not None:
|
||||
out["door_cmd"] = "UP" if str(door).lower() in TRUTHY else "DOWN"
|
||||
return out or None
|
||||
'''
|
||||
|
||||
|
||||
def kiosk(h: dict[str, Any]) -> Flow:
|
||||
"""Keeps the e-ink dashboard working until a Fluksio panel replaces it.
|
||||
|
||||
Delete this flow at the end of the changeover. Everything in it is a
|
||||
translation between two vocabularies, which is exactly the kind of node
|
||||
that should not outlive the reason for it.
|
||||
"""
|
||||
flow = Flow("kiosk", "Kiosk (transitional)")
|
||||
flow.add(
|
||||
{
|
||||
"id": "brightness",
|
||||
"type": "python",
|
||||
"title": "Display brightness",
|
||||
"requires": [
|
||||
{"name": "presence.state", "port": "state", "dtype": "str"},
|
||||
{
|
||||
"name": "weather.light",
|
||||
"port": "light",
|
||||
"dtype": "float",
|
||||
"trigger": False,
|
||||
},
|
||||
{"name": "shutters.bed_down", "port": "bed_down", "dtype": "bool"},
|
||||
],
|
||||
"provides": [{"name": "brightness", "dtype": "int"}],
|
||||
},
|
||||
BRIGHTNESS,
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": "env",
|
||||
"type": "python",
|
||||
"title": "The weather blob",
|
||||
"requires": [
|
||||
{
|
||||
"name": "weather.indoor_temp",
|
||||
"port": "indoor_temp",
|
||||
"dtype": "float",
|
||||
},
|
||||
{
|
||||
"name": "weather.indoor_hum",
|
||||
"port": "indoor_hum",
|
||||
"dtype": "float",
|
||||
"trigger": False,
|
||||
},
|
||||
{
|
||||
"name": "weather.indoor_dewpoint",
|
||||
"port": "indoor_dewpoint",
|
||||
"dtype": "float",
|
||||
"trigger": False,
|
||||
},
|
||||
{
|
||||
"name": "weather.outdoor_temp",
|
||||
"port": "outdoor_temp",
|
||||
"dtype": "float",
|
||||
},
|
||||
{
|
||||
"name": "weather.outdoor_hum",
|
||||
"port": "outdoor_hum",
|
||||
"dtype": "float",
|
||||
"trigger": False,
|
||||
},
|
||||
{
|
||||
"name": "weather.outdoor_dewpoint",
|
||||
"port": "outdoor_dewpoint",
|
||||
"dtype": "float",
|
||||
"trigger": False,
|
||||
},
|
||||
{
|
||||
"name": "weather.forecast",
|
||||
"port": "forecast",
|
||||
"dtype": "list",
|
||||
"item": "record",
|
||||
"trigger": False,
|
||||
},
|
||||
],
|
||||
"provides": [{"name": "env", "dtype": "str"}],
|
||||
},
|
||||
ENV,
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": "out",
|
||||
"type": "mqtt",
|
||||
"title": "What the wall reads",
|
||||
"params": {
|
||||
"topic": {
|
||||
"brightness": h["topics"]["kiosk_brightness"],
|
||||
"env": "dashboard/sensors/env",
|
||||
"pv": "dashboard/sensors/pv",
|
||||
"input": "dashboard/sensors/input",
|
||||
"output": "dashboard/sensors/output",
|
||||
"soc": "dashboard/sensors/soc",
|
||||
"notification": "dashboard/notifications",
|
||||
},
|
||||
**_broker(h, "fluksio-kiosk-out"),
|
||||
},
|
||||
"requires": [
|
||||
{"name": "brightness", "dtype": "int", "interval": 60.0},
|
||||
{"name": "env", "dtype": "str", "interval": 20.0},
|
||||
{
|
||||
"name": "power.pv_w",
|
||||
"port": "pv",
|
||||
"dtype": "float",
|
||||
"interval": 10.0,
|
||||
},
|
||||
{
|
||||
"name": "power.in_w",
|
||||
"port": "input",
|
||||
"dtype": "float",
|
||||
"interval": 10.0,
|
||||
},
|
||||
{
|
||||
"name": "power.out_w",
|
||||
"port": "output",
|
||||
"dtype": "float",
|
||||
"interval": 10.0,
|
||||
},
|
||||
{
|
||||
"name": "power.soc",
|
||||
"port": "soc",
|
||||
"dtype": "float",
|
||||
"interval": 10.0,
|
||||
},
|
||||
{
|
||||
"name": "power.alert",
|
||||
"port": "notification",
|
||||
"dtype": "record",
|
||||
"interval": 30.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": "controls_in",
|
||||
"type": "mqtt",
|
||||
"title": "What the wall presses",
|
||||
"params": {
|
||||
"topic": {
|
||||
"zone_bed": "dashboard/light-mode-bed",
|
||||
"zone_kitchen": "dashboard/light-mode-kitchen",
|
||||
"zone_bath": "dashboard/light-mode-bath",
|
||||
"zone_workspace": "dashboard/light-mode-workspace",
|
||||
"zone_outside": "dashboard/light-mode-outside",
|
||||
"wall_brightness": "dashboard/light-brightness",
|
||||
"wall_appliances": "dashboard/appliances",
|
||||
"wall_bed": "dashboard/bed",
|
||||
"wall_door": "dashboard/doorshutter",
|
||||
},
|
||||
**_broker(h, "fluksio-kiosk-in"),
|
||||
},
|
||||
"provides": [
|
||||
{"name": "zone_bed", "dtype": "str"},
|
||||
{"name": "zone_kitchen", "dtype": "str"},
|
||||
{"name": "zone_bath", "dtype": "str"},
|
||||
{"name": "zone_workspace", "dtype": "str"},
|
||||
{"name": "zone_outside", "dtype": "str"},
|
||||
{
|
||||
"name": "lights.brightness",
|
||||
"port": "wall_brightness",
|
||||
"dtype": "float",
|
||||
},
|
||||
{"name": "wall_appliances", "dtype": "str"},
|
||||
{"name": "wall_bed", "dtype": "str"},
|
||||
{"name": "wall_door", "dtype": "str"},
|
||||
],
|
||||
}
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": "zones",
|
||||
"type": "python",
|
||||
"title": "Zones to a scene",
|
||||
"requires": [
|
||||
{"name": "zone_bed", "port": "bed", "dtype": "str"},
|
||||
{"name": "zone_kitchen", "port": "kitchen", "dtype": "str"},
|
||||
{"name": "zone_bath", "port": "bath", "dtype": "str"},
|
||||
{"name": "zone_workspace", "port": "workspace", "dtype": "str"},
|
||||
{"name": "zone_outside", "port": "outside", "dtype": "str"},
|
||||
],
|
||||
"provides": [{"name": "lights.scene", "port": "scene", "dtype": "str"}],
|
||||
},
|
||||
ZONES_IN,
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": "switches",
|
||||
"type": "python",
|
||||
"title": "Its switches",
|
||||
"requires": [
|
||||
{"name": "wall_appliances", "port": "appliances_manual", "dtype": "str"}
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "appliances.appliances_manual",
|
||||
"port": "appliances_manual",
|
||||
"dtype": "bool",
|
||||
}
|
||||
],
|
||||
},
|
||||
BOOLS,
|
||||
)
|
||||
flow.add(
|
||||
{
|
||||
"id": "shutter_words",
|
||||
"type": "python",
|
||||
"title": "Its shutter buttons",
|
||||
"requires": [
|
||||
{"name": "wall_bed", "port": "bed", "dtype": "str"},
|
||||
{"name": "wall_door", "port": "door", "dtype": "str"},
|
||||
],
|
||||
"provides": [
|
||||
{"name": "shutters.bed_shutter_cmd", "port": "bed_cmd", "dtype": "str"},
|
||||
{
|
||||
"name": "shutters.door_shutter_cmd",
|
||||
"port": "door_cmd",
|
||||
"dtype": "str",
|
||||
},
|
||||
],
|
||||
},
|
||||
SHUTTER_WORDS,
|
||||
)
|
||||
return flow
|
||||
Reference in New Issue
Block a user