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>
216 lines
6.7 KiB
Python
216 lines
6.7 KiB
Python
"""The two pieces of logic every actuator in the house needs.
|
|
|
|
Both are shared library nodes rather than copies: one arbiter fix reaches
|
|
fifteen actuators, and a rollershutter that learns to stop properly should not
|
|
have to learn it four times.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
ARBITER = '''"""Which value reaches an actuator: what the house decided, or what a person did.
|
|
|
|
Automation runs on a loop and a person does not. Without something between the
|
|
two, a switch someone presses is undone by the next evaluation — so a manual
|
|
value wins for a while, and the house takes over again once the hold expires.
|
|
|
|
The control on the dashboard binds to ``manual`` in both directions: this node
|
|
writes back whatever actually reached the actuator, so one tile shows the state
|
|
and sets it. ``force_at`` is a pulse that outranks a person — the nightly off,
|
|
and later the fire alarm — because a schedule that a forgotten override can
|
|
defeat is not a schedule.
|
|
"""
|
|
|
|
import time
|
|
|
|
|
|
def process(auto, manual, state=None, force_at=0.0, hold_s=14400.0, force_value=None):
|
|
state = dict(state or {})
|
|
now = time.time()
|
|
held_until = state.get("override_until", 0.0)
|
|
|
|
if not state:
|
|
# First run: adopt what is already there instead of reading the
|
|
# difference between two initial values as somebody pressing something.
|
|
decided = auto
|
|
held_until = 0.0
|
|
elif force_at != state.get("force_at", 0.0):
|
|
decided = auto if force_value is None else force_value
|
|
held_until = 0.0
|
|
elif manual != state.get("manual"):
|
|
# ponytail: a person setting the value it already holds is not seen,
|
|
# so it does not start a hold. Pressing what the screen already shows
|
|
# is not how anyone overrides anything; revisit if a control ever
|
|
# publishes a timestamp of its own.
|
|
decided = manual
|
|
held_until = -1.0 if hold_s <= 0 else now + hold_s
|
|
elif held_until < 0 or now < held_until:
|
|
decided = state.get("manual")
|
|
else:
|
|
decided = auto
|
|
|
|
return {
|
|
"command": decided,
|
|
"manual": decided,
|
|
"state": {
|
|
"manual": decided,
|
|
"force_at": force_at,
|
|
"override_until": held_until,
|
|
},
|
|
}
|
|
'''
|
|
|
|
MOTOR = '''"""A rollershutter with no position sensor: run for as long as it takes.
|
|
|
|
Time is the only feedback there is. The run-time per direction is a setting
|
|
because it was measured on this hardware and will drift — a motor that stops
|
|
half a turn early is a knob, not a rewrite.
|
|
|
|
What leaves here is the command and how long to hold it: a trigger node
|
|
downstream sends the STOP when the run is over, once, rather than the
|
|
reference's forever.
|
|
"""
|
|
|
|
import time
|
|
|
|
STOP = "STOP"
|
|
|
|
|
|
def process(cmd, state=None, up_s=26.0, down_s=28.0):
|
|
now = time.time()
|
|
state = dict(state or {})
|
|
# Nothing reports the end of a run, so it is over when it has had its time.
|
|
moving = state.get("moving", "") if now < state.get("moving_until", 0.0) else ""
|
|
position = state.get("position", "")
|
|
|
|
if cmd == STOP:
|
|
if not moving:
|
|
return None
|
|
run, run_for, target = STOP, 0.0, position
|
|
elif moving == cmd:
|
|
return None
|
|
elif moving:
|
|
# Reversing mid-travel drives both relays at once on the way past.
|
|
# Stop; the next command moves it.
|
|
run, run_for, target = STOP, 0.0, position
|
|
elif cmd == position:
|
|
return None
|
|
else:
|
|
run = cmd
|
|
run_for = up_s if cmd == "UP" else down_s
|
|
target = cmd
|
|
|
|
return {
|
|
"run": run,
|
|
"run_for": run_for,
|
|
"state": {
|
|
"moving": "" if run == STOP else run,
|
|
"moving_until": 0.0 if run == STOP else now + run_for,
|
|
"position": target,
|
|
},
|
|
}
|
|
'''
|
|
|
|
|
|
def arbiter(
|
|
node_id: str,
|
|
title: str,
|
|
dtype: str,
|
|
auto: str,
|
|
manual: str,
|
|
command: str,
|
|
state: str,
|
|
hold_s: float = 14400.0,
|
|
force_at: str = "",
|
|
force_value: Any = None,
|
|
shared: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""One actuator's arbiter, wired to the messages it decides between."""
|
|
params: dict[str, Any] = {"hold_s": hold_s}
|
|
if force_at:
|
|
params["force_value"] = force_value
|
|
requires = [
|
|
{"name": auto, "port": "auto", "dtype": dtype},
|
|
{"name": manual, "port": "manual", "dtype": dtype},
|
|
{"name": state, "port": "state", "dtype": "record", "trigger": False},
|
|
]
|
|
if force_at:
|
|
requires.append({"name": force_at, "port": "force_at", "dtype": "float"})
|
|
node = {
|
|
"id": node_id,
|
|
"type": "python",
|
|
"title": title,
|
|
"params": params,
|
|
"requires": requires,
|
|
"provides": [
|
|
{"name": command, "port": "command", "dtype": dtype},
|
|
{"name": manual, "port": "manual", "dtype": dtype},
|
|
{"name": state, "port": "state", "dtype": "record"},
|
|
],
|
|
}
|
|
if shared:
|
|
node["source_ref"] = "arbiter"
|
|
return node
|
|
|
|
|
|
def motor(
|
|
node_id: str,
|
|
title: str,
|
|
cmd: str,
|
|
run: str,
|
|
run_for: str,
|
|
state: str,
|
|
up_s: float,
|
|
down_s: float,
|
|
shared: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""One motor's run-time controller."""
|
|
node = {
|
|
"id": node_id,
|
|
"type": "python",
|
|
"title": title,
|
|
"params": {"up_s": up_s, "down_s": down_s},
|
|
"requires": [
|
|
{"name": cmd, "port": "cmd", "dtype": "str"},
|
|
{"name": state, "port": "state", "dtype": "record", "trigger": False},
|
|
],
|
|
"provides": [
|
|
{"name": run, "port": "run", "dtype": "str"},
|
|
{"name": run_for, "port": "run_for", "dtype": "float"},
|
|
{"name": state, "port": "state", "dtype": "record"},
|
|
],
|
|
}
|
|
if shared:
|
|
node["source_ref"] = "motor"
|
|
return node
|
|
|
|
|
|
def stopper(
|
|
node_id: str, title: str, run: str, run_for: str, cmd: str
|
|
) -> dict[str, Any]:
|
|
"""The trigger that sends a motor's STOP when its run is over.
|
|
|
|
Passes the command through immediately, then sends STOP once the run-time
|
|
the motor node worked out has elapsed. A run-time of zero — which is what a
|
|
STOP itself carries — sends nothing afterwards, so the relays are released
|
|
once and then left alone.
|
|
"""
|
|
return {
|
|
"id": node_id,
|
|
"type": "trigger",
|
|
"title": title,
|
|
"params": {
|
|
"then": "STOP",
|
|
"wait": 60.0,
|
|
"passthrough": True,
|
|
"wait_port": "run_for",
|
|
"extend": True,
|
|
},
|
|
"requires": [
|
|
{"name": run, "port": "run", "dtype": "str"},
|
|
{"name": run_for, "port": "run_for", "dtype": "float"},
|
|
],
|
|
"provides": [{"name": cmd, "port": "cmd", "dtype": "str"}],
|
|
}
|