From 436ca7e9af320bb40b665b1977329c80637d3bfa Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 14:44:14 +0200 Subject: [PATCH] Seed the TinyHouse: nineteen flows in place of eight hundred nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 13 +- scripts/seed_tinyhouse.py | 18 + scripts/tinyhouse/__init__.py | 0 scripts/tinyhouse/__main__.py | 423 ++++++++++ scripts/tinyhouse/actuators.py | 1241 +++++++++++++++++++++++++++++ scripts/tinyhouse/api.py | 166 ++++ scripts/tinyhouse/control.py | 985 +++++++++++++++++++++++ scripts/tinyhouse/dashboards.py | 358 +++++++++ scripts/tinyhouse/library.py | 215 +++++ scripts/tinyhouse/screens.py | 500 ++++++++++++ scripts/tinyhouse/sensing.py | 1096 +++++++++++++++++++++++++ scripts/tinyhouse/test_library.py | 144 ++++ 12 files changed, 5158 insertions(+), 1 deletion(-) create mode 100644 scripts/seed_tinyhouse.py create mode 100644 scripts/tinyhouse/__init__.py create mode 100644 scripts/tinyhouse/__main__.py create mode 100644 scripts/tinyhouse/actuators.py create mode 100644 scripts/tinyhouse/api.py create mode 100644 scripts/tinyhouse/control.py create mode 100644 scripts/tinyhouse/dashboards.py create mode 100644 scripts/tinyhouse/library.py create mode 100644 scripts/tinyhouse/screens.py create mode 100644 scripts/tinyhouse/sensing.py create mode 100644 scripts/tinyhouse/test_library.py diff --git a/Makefile b/Makefile index 6b4414e..50fff78 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # The workspace root delegates to these (see ../Makefile). .PHONY: dev-utils dev dev-local dev-lan up down update install dev-backend dev-frontend \ - generate-client seed-example seed-demo seed-house seed-aircon seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \ + generate-client seed-example seed-demo seed-house seed-aircon seed-tinyhouse seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \ lint-frontend format-frontend umami build docs docs-serve clean help COMPOSE_ROOT := $(CURDIR) @@ -112,6 +112,17 @@ seed-house: ## Seed the house write-path rig (needs the real broker reachable) seed-aircon: ## Seed the aircon write-path rig (needs the unit reachable) cd backend && uv run python ../scripts/seed_aircon_control.py +# The house itself. Reads ../house.json for this installation's addresses and +# DMX wiring; everything it creates is published and stopped. ARGS=--dry checks +# the whole set without an installation and pushes nothing. +seed-tinyhouse: ## Seed the TinyHouse flows (ARGS=--dry to check only) + @user=$$(grep -E '^FIRST_SUPERUSER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \ + pass=$$(grep -E '^FIRST_SUPERUSER_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \ + cd backend && \ + FIRST_SUPERUSER="$${FIRST_SUPERUSER:-$$user}" \ + FIRST_SUPERUSER_PASSWORD="$${FIRST_SUPERUSER_PASSWORD:-$$pass}" \ + uv run python ../scripts/seed_tinyhouse.py $(ARGS) + # 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/scripts/seed_tinyhouse.py b/scripts/seed_tinyhouse.py new file mode 100644 index 0000000..296d9d1 --- /dev/null +++ b/scripts/seed_tinyhouse.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +"""Seed the TinyHouse: the flows that replace the Node-RED installation. + + make -C app seed-tinyhouse + +See `scripts/tinyhouse/` for what it builds, and `house.json` at the workspace +root for this installation's own addresses and DMX wiring. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from tinyhouse.__main__ import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/tinyhouse/__init__.py b/scripts/tinyhouse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tinyhouse/__main__.py b/scripts/tinyhouse/__main__.py new file mode 100644 index 0000000..0a2c8ed --- /dev/null +++ b/scripts/tinyhouse/__main__.py @@ -0,0 +1,423 @@ +"""Seed this house into a Fluksio installation. + + make -C app seed-tinyhouse # check everything, then push + make -C app seed-tinyhouse ARGS=--dry # check everything, push nothing + +Everything is seeded **stopped** and nothing is armed: the Art-Net node does +not transmit and the heat pump does not accept commands until `house.json` +says so. Starting a flow is a decision someone makes while watching, which is +the whole of the cutover procedure. +""" + +from __future__ import annotations + +import sys +from typing import Any + +from . import actuators, control, dashboards, screens, sensing +from .api import Api, Flow, house, report +from .library import ARBITER, MOTOR + +#: Which secrets the flows reference. Nothing runs without them, and nothing +#: here can invent them. +SECRETS = { + "influx_token": "InfluxDB 2.x API token, for the history and the charts", + "unifi_password": "UniFi controller account (read-only is enough)", + "owm_appid": "OpenWeatherMap One Call 3.0 key", + "caldav_password": "Nextcloud app password for the shared calendar", +} + + +def build(h: dict[str, Any], commands: bool) -> list[Flow]: + """Every flow, in the order it is safe to start them.""" + return [ + sensing.clock(), + sensing.power(h), + sensing.weather(h), + sensing.presence(h), + sensing.calendar(h), + screens.history(h), + control.climate(), + control.hvac(h, commands), + control.oven(h), + control.boiler(h), + actuators.shutters(h), + actuators.window(h), + actuators.canopy(h), + actuators.lights(), + actuators.plugs(h), + actuators.appliances(), + actuators.outdoor(), + actuators.dmx(h), + screens.kiosk(h), + ] + + +# ── preflight ──────────────────────────────────────────────────────────── + + +def _qualify(flow: str, name: str) -> str: + return name if "." in name else f"{flow}.{name}" + + +def check(flows: list[Flow]) -> list[str]: + """Everything wrong that can be seen without an installation. + + The API validates too, and better — but it does it one flow at a time as + they are created, so a cross-flow name that nothing provides is only + reported once the last flow lands. Reading the whole set first means a + typo is a message here rather than nineteen flows and a broken house. + """ + provided: dict[str, list[str]] = {} + declared: set[str] = set() + problems: list[str] = [] + + for flow in flows: + for spec in flow.inputs: + declared.add(_qualify(flow.name, spec["spec"]["name"])) + for node in flow.nodes: + for spec in node.get("provides", []): + provided.setdefault(_qualify(flow.name, spec["name"]), []).append( + f"{flow.name}.{node['id']}" + ) + + known = set(provided) | declared + for flow in flows: + ids = [node["id"] for node in flow.nodes] + if len(set(ids)) != len(ids): + problems.append(f"{flow.name}: two nodes share an id") + for node in flow.nodes: + for spec in node.get("requires", []): + name = _qualify(flow.name, spec["name"]) + if name not in known: + problems.append( + f"{flow.name}.{node['id']} needs '{name}', " + "which nothing provides and no flow declares" + ) + ports = [s.get("port") or s["name"] for s in node.get("requires", [])] + if len(set(ports)) != len(ports): + problems.append(f"{flow.name}.{node['id']}: two inputs share a port") + settings = set(node.get("params", {})) + clash = settings & set(ports) + if clash: + problems.append( + f"{flow.name}.{node['id']}: setting and port share a name: " + + ", ".join(sorted(clash)) + ) + + problems += _check_types(flows) + problems += _check_cycles(flows, provided) + problems += _check_widgets(known) + problems += _check_sources(flows) + problems += _check_schemas(flows) + return problems + + +def _check_schemas(flows: list[Flow]) -> list[str]: + """Hand every node to the engine's own models before the API sees them. + + The API would say the same thing, one flow at a time, after creating the + ones before it. Importing the models is free and answers it here — a + misspelled setting on a connector is otherwise found halfway through a + house. + """ + try: + from fluksio.flow.controller import NODE_TYPES + from fluksio.flow.schemas import FlowDef + except ImportError: + return [] + + problems = [] + missing: set[str] = set() + for flow in flows: + try: + FlowDef.model_validate( + { + "name": flow.name, + "title": flow.title, + "nodes": flow.nodes, + "inputs": flow.inputs, + } + ) + except Exception as exc: # noqa: BLE001 - reported, not raised + problems.append(f"{flow.name}: {exc}") + continue + + for node in flow.nodes: + spec = NODE_TYPES.get(node["type"]) + if spec is None: + # Connectors are installed into the image, not into the venv + # this runs in, so their absence here says nothing. A type that + # is neither built in nor a connector this house uses is a + # typo, and that is what is worth saying. + if node["type"] not in CONNECTORS: + problems.append( + f"{flow.name}.{node['id']}: no node type '{node['type']}'" + ) + else: + missing.add(node["type"]) + continue + if spec.free_params: + continue + params = getattr(spec.cls, "Params", None) + if params is None: + continue + try: + params.model_validate(_without_secrets(node.get("params", {}))) + except Exception as exc: # noqa: BLE001 - reported, not raised + problems.append(f"{flow.name}.{node['id']} settings: {exc}") + + if missing: + print(f" (from the image, not checked here: {', '.join(sorted(missing))})") + return problems + + +def _without_secrets(params: Any) -> Any: + """A secret reference is an ordinary string by the time a node is built.""" + if isinstance(params, dict): + if set(params) == {"$secret"}: + return "from-the-store" + return {k: _without_secrets(v) for k, v in params.items()} + if isinstance(params, list): + return [_without_secrets(v) for v in params] + return params + + +#: Node types that come from connector packages. `make connectors` installs +#: them into the image, so a checkout does not have them and their absence +#: here is not a problem to report. +CONNECTORS = {"wfrac", "unifi_presence", "ical", "artnet"} + +#: Ports whose payload has a shape the node reads keys out of. Everything else +#: is a scalar or an object the node only ever asks politely. +SHAPES: dict[str, Any] = { + "chart_request": {"range_s": 3600, "interval_s": 60}, + "rows": {"rows": [], "range_s": 3600, "interval_s": 60}, + "limited": { + "operation": True, + "mode": "fan", + "preset_temp": 22.0, + "fan_speed": "auto", + }, +} + +#: Something of the right shape to hand a port, so a node can be called at all. +SAMPLE: dict[str, Any] = { + "float": 1.0, + "int": 1, + "str": "x", + "bool": True, + "json": {}, + "record": {}, + "list": [], + "series": {"lines": []}, +} + + +def _check_sources(flows: list[Flow]) -> list[str]: + """Run every Python node once, on values of the shape it declared. + + Not a test of what they decide — that is what the thresholds are for, and + they are settings. This catches the class of mistake that is otherwise + invisible until the house is running: a name that does not exist, an + argument the function does not take, and above all an output key that is + not a port, which publishes nothing at all and says nothing about it. + """ + problems = [] + for flow in flows: + for node in flow.nodes: + if node["type"] != "python": + continue + source = flow.sources.get(node["id"]) + if source is None: + continue + namespace: dict[str, Any] = {} + where = f"{flow.name}.{node['id']}" + try: + exec(compile(source, where, "exec"), namespace) + except Exception as exc: # noqa: BLE001 - reported, not raised + problems.append(f"{where}: will not load: {exc}") + continue + process = namespace.get("process") + if process is None: + problems.append(f"{where}: has no process()") + continue + + ports = {} + for spec in node.get("requires", []): + port = spec.get("port") or spec["name"] + ports[port] = SHAPES.get(port, SAMPLE.get(spec["dtype"])) + try: + result = process(**ports, **node.get("params", {})) + except Exception as exc: # noqa: BLE001 - reported, not raised + problems.append(f"{where}: {type(exc).__name__} when called: {exc}") + continue + + if result is None: + continue + if not isinstance(result, dict): + problems.append( + f"{where}: returned {type(result).__name__}, not a dict" + ) + continue + declared = { + spec.get("port") or spec["name"] for spec in node.get("provides", []) + } + stray = set(result) - declared + if stray: + problems.append( + f"{where}: returns {', '.join(sorted(stray))}, which " + "no output port carries — it would publish nothing" + ) + return problems + + +def _check_cycles(flows: list[Flow], provided: dict[str, list[str]]) -> list[str]: + """A waits for B and B waits for A, so neither of them ever runs. + + The same rule the engine applies: only a *triggering* input creates a + dependency, and a node reading a message it also writes is carrying state + rather than waiting for itself. Worth doing here because the engine can + only see it once every flow exists, and by then the house is half seeded. + """ + deps: dict[str, set[str]] = {} + for flow in flows: + for node in flow.nodes: + me = f"{flow.name}.{node['id']}" + mine = {_qualify(flow.name, s["name"]) for s in node.get("provides", [])} + deps[me] = { + producer + for spec in node.get("requires", []) + if spec.get("trigger", True) + for producer in provided.get(_qualify(flow.name, spec["name"]), []) + if producer != me and _qualify(flow.name, spec["name"]) not in mine + } + + # Kahn's algorithm; whatever is left over is in a loop. + pending = {node: set(edges) for node, edges in deps.items()} + while True: + ready = [node for node, edges in pending.items() if not edges] + if not ready: + break + for node in ready: + pending.pop(node) + for edges in pending.values(): + edges.difference_update(ready) + if pending: + return [ + "these depend on each other in a loop, so none of them can run: " + + ", ".join(sorted(pending)) + ] + return [] + + +def _check_types(flows: list[Flow]) -> list[str]: + """One message, one type — whoever writes it and whoever reads it.""" + types: dict[str, tuple[str, str]] = {} + problems = [] + for flow in flows: + for spec in flow.inputs: + types.setdefault( + _qualify(flow.name, spec["spec"]["name"]), + (spec["spec"]["dtype"], f"{flow.name} inputs"), + ) + for node in flow.nodes: + for spec in node.get("provides", []) + node.get("requires", []): + name = _qualify(flow.name, spec["name"]) + where = f"{flow.name}.{node['id']}" + seen = types.setdefault(name, (spec["dtype"], where)) + if seen[0] != spec["dtype"]: + problems.append( + f"'{name}' is {seen[0]} in {seen[1]} but " + f"{spec['dtype']} in {where}" + ) + return problems + + +def _check_widgets(known: set[str]) -> list[str]: + """A tile bound to a message nothing carries draws nothing, quietly.""" + problems = [] + for _, title, _, widgets in dashboards.SCREENS: + for widget in widgets: + config = widget["config"] + for key in ("message", "target", "request"): + name = config.get(key) + if name and name not in known: + problems.append( + f"{title}: '{widget['id']}' binds {key} to '{name}', " + "which no flow carries" + ) + return problems + + +# ── pushing it ─────────────────────────────────────────────────────────── + + +def push(api: Api, flows: list[Flow]) -> None: + """Create every flow, then share the two nodes the rest reference. + + Sharing happens after the flows exist because the library is written from + a node that already holds the code — so the first flow to use each carries + it in, and the others point at it. + """ + library = {"arbiter": ARBITER, "motor": MOTOR} + owners: dict[str, tuple[str, str]] = {} + for flow in flows: + for node in flow.nodes: + ref = node.get("source_ref") + if ref in library and ref not in owners: + owners[ref] = (flow.name, node["id"]) + # This one carries the code in; the rest reference it. + node.pop("source_ref") + flow.sources[node["id"]] = library[ref] + + for flow in flows: + report(flow.name, len(flow.nodes), api.put_flow(flow)) + + for ref, (flow_name, node_id) in owners.items(): + api.share(flow_name, node_id, ref) + print(f" library: '{ref}' shared from {flow_name}.{node_id}") + + for name, title, glyph, widgets in dashboards.SCREENS: + api.put_dashboard(name, title, glyph, widgets) + print(f" dashboard '{name}': {len(widgets)} widgets, published") + + +def main(argv: list[str]) -> int: + dry = "--dry" in argv + h = house() + flows = build(h, commands=bool(h["aircon"].get("commands", False))) + + problems = check(flows) + nodes = sum(len(f.nodes) for f in flows) + print(f"{len(flows)} flows, {nodes} nodes, {len(dashboards.SCREENS)} dashboards") + for problem in problems: + print(f" ! {problem}", file=sys.stderr) + if problems: + print(f"\n{len(problems)} problems; nothing was pushed.", file=sys.stderr) + return 1 + if dry: + print("\nEverything checks out. Nothing was pushed (--dry).") + return 0 + + api = Api() + have = set(api("GET", "/secrets/")["data"]) + missing = [name for name in SECRETS if name not in have] + if missing: + print("\nAdd these under Secrets first:", file=sys.stderr) + for name in missing: + print(f" {name} — {SECRETS[name]}", file=sys.stderr) + return 1 + + push(api, flows) + print( + "\nEvery flow is published and stopped. Start the reading ones first:\n" + " clock, power, weather, presence, calendar, history\n" + "Nothing moves until 'dmx' is started, and it only transmits once\n" + "house.json says so and Node-RED's own Art-Net sender is stopped." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/tinyhouse/actuators.py b/scripts/tinyhouse/actuators.py new file mode 100644 index 0000000..d7d8026 --- /dev/null +++ b/scripts/tinyhouse/actuators.py @@ -0,0 +1,1241 @@ +"""Everything that moves, and the one node that puts it on the wire. + +The device layer used to be Node-RED: twenty-four MQTT topics that only it +subscribed to, each stamped with a DMX channel and encoded into one Art-Net +universe. Those topics were never a device interface — they were its internal +wiring — so they retire with it, and the encoders live here instead. + +One node owns universe 1. A frame carries all 512 channels, so it has to be +one: two senders on one universe overwrite each other every time either of +them speaks. +""" + +from __future__ import annotations + +from typing import Any + +from .api import Flow +from .library import arbiter, motor, stopper + +# ── the encoders ───────────────────────────────────────────────────────── + +SWITCHES = '''"""A relay is a channel that is either off or all the way on.""" + + +def process(**fixtures): + return {f"dmx_{name}": 255 if value else 0 for name, value in fixtures.items()} +''' + +LEVELS = '''"""A dimmer channel takes its level raw. + +The reference hands 0-100 straight to DMX without scaling to 255, so the +fixtures on these channels have only ever seen the bottom 39% of their range. +Faithful rather than corrected: `scale` is here to fix it deliberately, on a +fixture someone has looked at, rather than by surprise on all of them at once. +""" + + +def process(scale=None, **fixtures): + scale = scale or {} + out = {} + for name, value in fixtures.items(): + level = float(value) * float(scale.get(name, 1.0)) + out[f"dmx_{name}"] = max(0, min(255, int(round(level)))) + return out +''' + +RGB = '''"""Hue, saturation and value into three channels of red, green and blue.""" + +import colorsys + + +def _rgb(value): + if not isinstance(value, (list, tuple)) or len(value) < 3: + return [0, 0, 0] + h, s, v = (float(x) for x in value[:3]) + r, g, b = colorsys.hsv_to_rgb((h % 360) / 360.0, s / 100.0, v / 100.0) + return [int(round(r * 255)), int(round(g * 255)), int(round(b * 255))] + + +def process(**fixtures): + return {f"dmx_{name}": _rgb(value) for name, value in fixtures.items()} +''' + +RGBW = '''"""The same, for a fixture whose first channel is its own white master. + +Channel order is A, B, G, R — the reference's, and the A channel takes `v` +raw on a 0-100 scale while the colour channels are 0-255. That asymmetry is +almost certainly a property of these fixtures rather than a mistake, so it is +reproduced deliberately and left adjustable. +""" + +import colorsys + + +def _argb(value, master_raw=True): + if not isinstance(value, (list, tuple)) or len(value) < 3: + return [0, 0, 0, 0] + h, s, v = (float(x) for x in value[:3]) + r, g, b = colorsys.hsv_to_rgb((h % 360) / 360.0, s / 100.0, v / 100.0) + master = v if master_raw else v * 2.55 + return [ + max(0, min(255, int(round(master)))), + int(round(b * 255)), + int(round(g * 255)), + int(round(r * 255)), + ] + + +def process(master_raw=True, **fixtures): + return {f"dmx_{name}": _argb(value, master_raw) for name, value in fixtures.items()} +''' + +MOTORS = '''"""Two channels per motor: one for each direction, neither for stop. + +Driving both at once is what a reversal without a stop in between would do, so +anything that is not a direction lands as a stop rather than as a guess. + +`inverted` is the bed shutter, whose two channels are wired the other way +round from the door's. That is the hardware, not a preference. +""" + +UP = [255, 0] +DOWN = [0, 255] +STOP = [0, 0] + + +def process(inverted=(), **fixtures): + out = {} + for name, value in fixtures.items(): + command = str(value).upper() + if command in ("UP", "0"): + levels = UP + elif command in ("DOWN", "100"): + levels = DOWN + else: + levels = STOP + if name in inverted and levels is not STOP: + levels = DOWN if levels is UP else UP + out[f"dmx_{name}"] = levels + return out +''' + +ENCODERS = { + "switch": ("switches", "Relays", SWITCHES, "bool", "int"), + "level": ("levels", "Dimmers", LEVELS, "float", "int"), + "rgb": ("rgb", "Colour fixtures (3 channels)", RGB, "list", "list"), + "rgbw": ("rgbw", "Colour fixtures (4 channels)", RGBW, "list", "list"), + "motor": ("motors", "Motors", MOTORS, "str", "list"), +} + +#: Which flow provides each fixture's value. The dmx flow reads them all and +#: owns none of them: it is a driver, not a decision. +SOURCE = { + "bath_plug": "plugs", + "bed_plug": "plugs", + "fridge_pump": "plugs", + "fridge_vent": "plugs", + "inlet_vent": "plugs", + "water_boiler": "boiler", + "kitchen_boiler": "boiler", + "appliances": "appliances", + "floor_heating": "oven", + "outdoor_plug": "outdoor", + "outdoor_ambient": "lights", + "outdoor_direct": "lights", + "canopy_light": "lights", + "kitchen_direct": "lights", + "traverse_spot": "lights", + "kitchen_light": "lights", + "bed_light": "lights", + "living_ambient": "lights", + "kitchen_ambient": "lights", + "bath_light": "lights", + "door_shutter": "shutters", + "bed_shutter": "shutters", + "window_opener": "window", + "canopy": "canopy", +} + + +def dmx(h: dict[str, Any]) -> Flow: + flow = Flow("dmx", "DMX universe") + fixtures = h["dmx"] + channels: dict[str, int] = {} + artnet_in: list[dict[str, Any]] = [] + + for kind, (node_id, title, source, in_type, out_type) in ENCODERS.items(): + mine = {n: f for n, f in fixtures.items() if f["kind"] == kind} + if not mine: + continue + params: dict[str, Any] = {} + if kind == "motor": + inverted = [n for n, f in mine.items() if f.get("inverted")] + if inverted: + params["inverted"] = inverted + if kind == "level": + scale = { + n: f["scale"] for n, f in mine.items() if f.get("scale", 1.0) != 1.0 + } + if scale: + params["scale"] = scale + + requires = [] + provides = [] + for name in sorted(mine): + spec: dict[str, Any] = { + "name": f"{SOURCE[name]}.{name}", + "port": name, + "dtype": in_type, + } + if in_type == "list": + spec["item"] = "float" + requires.append(spec) + out: dict[str, Any] = {"name": f"dmx_{name}", "dtype": out_type} + if out_type == "list": + out["item"] = "int" + provides.append(out) + channels[f"dmx_{name}"] = mine[name]["ch"] + artnet_in.append({**out, "port": f"dmx_{name}"}) + + flow.add( + { + "id": node_id, + "type": "python", + "title": title, + "params": params, + "requires": requires, + "provides": provides, + }, + source, + ) + + artnet = h["artnet"] + flow.add( + { + "id": "universe", + "type": "artnet", + "title": f"Art-Net universe {artnet['universe']}", + "params": { + "host": artnet["host"], + "port": artnet["port"], + "universe": artnet["universe"], + "channels": channels, + # Levels the house is already at, so the first frame does not + # darken every channel this node is not driving. Fill it in + # from what the fixtures are doing before switching over. + "baseline": artnet.get("baseline", {}), + "poll_interval": artnet.get("refresh_s", 0), + # Off until Node-RED's own sender is stopped. + "transmit": artnet.get("transmit", False), + }, + "requires": artnet_in, + } + ) + return flow + + +# ── the motors ─────────────────────────────────────────────────────────── + +POSITION = '''"""Which way a motor last went, and whether it is still going. + +The bed shutter being down is how the house knows someone is asleep, and the +door shutter moving is why the frost cable stands aside for a minute — both +read well outside the flow that moves them. +""" + +import time + + +def process(state): + return { + "down": state.get("position") == "DOWN", + "moving": time.time() < state.get("moving_until", 0.0), + } +''' + + +def _motor_chain( + flow: Flow, name: str, title: str, spec: dict[str, Any], cmd: str +) -> None: + """Command -> run-time -> the one STOP that ends it.""" + flow.add( + motor( + f"{name}_motor", + f"{title}: how long to run", + cmd=cmd, + run=f"{name}_run", + run_for=f"{name}_run_for", + state=f"{name}_state", + up_s=spec["up_s"], + down_s=spec["down_s"], + ) + ) + flow.add( + stopper( + f"{name}_stop", + f"{title}: stop when it is there", + run=f"{name}_run", + run_for=f"{name}_run_for", + cmd=name, + ) + ) + flow.input(f"{name}_state", "record", {}) + + +def shutters(h: dict[str, Any]) -> Flow: + """The two rollershutters, driven from the screen and nothing else.""" + flow = Flow("shutters", "Rollershutters") + for name, title in (("door_shutter", "Door"), ("bed_shutter", "Bed")): + _motor_chain(flow, name, title, h["dmx"][name], cmd=f"{name}_cmd") + flow.input(f"{name}_cmd", "str", "STOP") + for name, out in (("bed_shutter", "bed_down"), ("door_shutter", "door_down")): + flow.add( + { + "id": f"{out}_at", + "type": "python", + "title": f"Is the {name.split('_')[0]} shutter down?", + "requires": [ + {"name": f"{name}_state", "port": "state", "dtype": "record"} + ], + "provides": [ + {"name": out, "port": "down", "dtype": "bool"}, + { + "name": f"{name.split('_')[0]}_moving", + "port": "moving", + "dtype": "bool", + }, + ], + }, + POSITION, + ) + return flow + + +WINDOW = '''"""Whether the storage window should be open. + +The reference's rules, in the order it applied them. Two of them are about not +fighting something else — a stove or a heat pump running with a window open is +money out of the window, literally — and the rest is the one genuinely useful +idea in the whole installation: open when the air outside is *drier*, not just +cooler, because that is what actually takes damp out of a small house. + +100 closes and 0 opens, which is the DMX motor's own convention. +""" + +OPEN, CLOSED = "DOWN", "UP" + + +def process( + indoor, + t_max, + top=None, + outdoor=None, + humidity=None, + dewpoint_delta=None, + stratified=False, + oven_on=False, + hvac_on=False, + hvac_mode="fan", + sleeping=False, + high=25.0, + lift=0.0, + damp=65.0, + dry_enough=4.0, +): + if oven_on: + return {"want": CLOSED, "why": "the stove is lit"} + if hvac_on and hvac_mode != "fan": + return {"want": CLOSED, "why": "the heat pump is running"} + if sleeping: + return {"want": CLOSED, "why": "asleep"} + + t_high = high + lift + warm = top if top is not None else indoor + if indoor > t_high: + if outdoor is not None and outdoor < indoor: + return {"want": OPEN, "why": "cooler outside"} + return {"want": CLOSED, "why": "no cooler outside"} + + if ( + indoor < t_high - 1 + and humidity is not None + and humidity > damp + and dewpoint_delta is not None + and dewpoint_delta > dry_enough + ): + return {"want": OPEN, "why": "damp inside, drier outside"} + return {"want": CLOSED, "why": "nothing to gain"} +''' + + +def window(h: dict[str, Any]) -> Flow: + flow = Flow("window", "Window opener") + flow.add( + { + "id": "decide", + "type": "python", + "title": "Open or closed", + "requires": [ + {"name": "climate.indoor", "port": "indoor", "dtype": "float"}, + { + "name": "climate.t_max", + "port": "t_max", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.top", + "port": "top", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.lift", + "port": "lift", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.stratified", + "port": "stratified", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.outdoor_temp", + "port": "outdoor", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.indoor_hum", + "port": "humidity", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.dewpoint_delta", + "port": "dewpoint_delta", + "dtype": "float", + "trigger": False, + }, + { + "name": "oven.running", + "port": "oven_on", + "dtype": "bool", + "trigger": False, + }, + { + "name": "hvac.running", + "port": "hvac_on", + "dtype": "bool", + "trigger": False, + }, + { + "name": "hvac.reported_mode", + "port": "hvac_mode", + "dtype": "str", + "trigger": False, + }, + { + "name": "presence.sleeping", + "port": "sleeping", + "dtype": "bool", + "trigger": False, + }, + ], + "provides": [ + {"name": "want", "dtype": "str"}, + {"name": "why", "dtype": "str"}, + ], + }, + WINDOW, + ) + flow.add( + { + "id": "settle", + "type": "delay", + "title": "At most every ten minutes", + "params": {"interval": 600.0}, + "requires": [{"name": "want", "dtype": "str"}], + "provides": [{"name": "settled", "dtype": "str"}], + } + ) + flow.add( + arbiter( + "arbiter", + "Automation or the button", + "str", + auto="settled", + manual="window_manual", + command="window_cmd", + state="window_arbiter", + hold_s=7200.0, + ) + ) + _motor_chain( + flow, "window_opener", "Window", h["dmx"]["window_opener"], cmd="window_cmd" + ) + flow.input("window_manual", "str", "UP") + flow.input("window_arbiter", "record", {}) + return flow + + +CANOPY = '''"""Whether the awning should be out. + +An awning is the one thing in the house that the weather can destroy, so this +reads as a list of reasons to bring it in and one reason to put it out. Every +threshold is the reference's; the wind one especially is not a preference. + +Trust matters here more than anywhere else: a weather station that has gone +quiet is a reason to retract, not a reason to assume it is calm. +""" + +OUT, IN = "DOWN", "UP" + + +def process( + home=True, + door_down=False, + light=None, + humidity=None, + rainrate=0.0, + rain_soon=False, + outdoor=10.0, + wind=0.0, + station_trust=0.0, + min_trust=0.3, + max_wind=3.0, + max_humidity=96.0, + min_temp=1.0, + sun_temp=26.0, + sun_light=80.0, +): + if station_trust < min_trust: + return {"want": IN, "why": "no recent weather reading"} + if not home: + return {"want": IN, "why": "nobody in"} + if door_down: + return {"want": IN, "why": "the house is shut up"} + if wind > max_wind: + return {"want": IN, "why": "too windy"} + if rainrate > 0 or rain_soon: + return {"want": IN, "why": "rain"} + if humidity is not None and humidity > max_humidity: + return {"want": IN, "why": "wet"} + if outdoor < min_temp: + return {"want": IN, "why": "freezing"} + if light is not None and light < 0.1: + return {"want": IN, "why": "dark"} + if outdoor > sun_temp and light is not None and light > sun_light: + return {"want": OUT, "why": "sunny"} + return {"want": IN, "why": "nothing to shade"} +''' + + +def canopy(h: dict[str, Any]) -> Flow: + flow = Flow("canopy", "Awning") + flow.add( + { + "id": "decide", + "type": "python", + "title": "Out or in", + "requires": [ + { + "name": "weather.station_trust", + "port": "station_trust", + "dtype": "float", + }, + {"name": "presence.home", "port": "home", "dtype": "bool"}, + { + "name": "shutters.door_down", + "port": "door_down", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.light", + "port": "light", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.outdoor_hum", + "port": "humidity", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.rainrate", + "port": "rainrate", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.rain_soon", + "port": "rain_soon", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.outdoor_temp", + "port": "outdoor", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.wind", + "port": "wind", + "dtype": "float", + "trigger": False, + }, + ], + "provides": [ + {"name": "want", "dtype": "str"}, + {"name": "why", "dtype": "str"}, + ], + }, + CANOPY, + ) + flow.add( + { + "id": "settle", + "type": "delay", + "title": "At most every twenty minutes", + "params": {"interval": 1200.0}, + "requires": [{"name": "want", "dtype": "str"}], + "provides": [{"name": "settled", "dtype": "str"}], + } + ) + flow.add( + arbiter( + "arbiter", + "Automation or the button", + "str", + auto="settled", + manual="canopy_manual", + command="canopy_cmd", + state="canopy_arbiter", + # Shorter than the rest on purpose: an override that outlives the + # weather is how an awning ends up in a storm. + hold_s=3600.0, + ) + ) + _motor_chain(flow, "canopy", "Awning", h["dmx"]["canopy"], cmd="canopy_cmd") + flow.input("canopy_manual", "str", "UP") + flow.input("canopy_arbiter", "record", {}) + return flow + + +# ── plugs ──────────────────────────────────────────────────────────────── + +PLUGS = '''"""The four plugs whose rule is a sentence, decided together. + +They share one node because they share their inputs and each is two lines; +five nodes reading the same six messages would be the shape this framework +exists to avoid. + +The fridge pump is the odd one — it is a pulse rather than a state, so what it +gets here is permission, and a trigger downstream gives it its thirty seconds. +""" + + +def process( + home=True, + bed_down=False, + hour=12, + watch=0, + oven_on=False, + sleeping=False, + today_max=15.0, + day_from=10, + day_to=16, + morning_from=6, + evening_from=20, + vent_warm_c=16.0, +): + if watch: + # Nothing optional runs while the electricity is in trouble. + return {"bath": False, "bed_plug": bed_down, "inlet": 0.0, "fridge": 0.0} + + if day_from <= hour < day_to: + bath = True + elif home and (morning_from <= hour < day_from or hour >= evening_from): + bath = True + else: + bath = False + + # The fans: the reference's table, which is about how much the fridge and + # the inlet have to work rather than about comfort. + if not home: + inlet, fridge = 255.0, 255.0 + elif today_max > vent_warm_c: + inlet, fridge = 0.0, 150.0 + elif oven_on and not sleeping: + inlet, fridge = 255.0, 200.0 + elif sleeping: + inlet, fridge = 0.0, 175.0 + else: + inlet, fridge = 0.0, 0.0 + + return { + "bath": bath, + # The bedroom plug is the bed being down and nothing else. + "bed_plug": bool(bed_down), + "inlet": inlet, + "fridge": fridge, + } +''' + +PUMP_DUE = '''"""The fridge pump wants thirty seconds a day, while nobody is in. + +Standing water in a pump that never runs is what this is about, so it is once +a day rather than on any condition worth reasoning about. +""" + +import time + +DAY_S = 82800.0 + + +def process(home=True, hour=12, memory=None, after_hour=13, run_s=30.0): + memory = dict(memory or {}) + now = time.time() + last = memory.get("last", 0.0) + due = not home and hour >= after_hour and now - last > DAY_S + if not due: + return None + return {"pump": True, "pump_for": run_s, "pump_memory": {"last": now}} +''' + + +def plugs(h: dict[str, Any]) -> Flow: + flow = Flow("plugs", "Plugs") + flow.add( + { + "id": "decide", + "type": "python", + "title": "Bathroom, bedroom and the fans", + "requires": [ + {"name": "clock.hour", "port": "hour", "dtype": "int"}, + {"name": "presence.home", "port": "home", "dtype": "bool"}, + {"name": "presence.sleeping", "port": "sleeping", "dtype": "bool"}, + {"name": "shutters.bed_down", "port": "bed_down", "dtype": "bool"}, + { + "name": "power.watch", + "port": "watch", + "dtype": "int", + "trigger": False, + }, + { + "name": "oven.running", + "port": "oven_on", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.today_max", + "port": "today_max", + "dtype": "float", + "trigger": False, + }, + ], + "provides": [ + {"name": "bath_auto", "port": "bath", "dtype": "bool"}, + {"name": "bed_plug", "port": "bed_plug", "dtype": "bool"}, + {"name": "inlet_vent", "port": "inlet", "dtype": "float"}, + {"name": "fridge_vent", "port": "fridge", "dtype": "float"}, + ], + }, + PLUGS, + ) + flow.add( + arbiter( + "bath_arbiter", + "Bathroom plug: automation or the switch", + "bool", + auto="bath_auto", + manual="bath_manual", + command="bath_plug", + state="bath_arbiter", + hold_s=3600.0, + ) + ) + flow.add( + { + "id": "pump_due", + "type": "python", + "title": "Fridge pump: due?", + "requires": [ + {"name": "presence.home", "port": "home", "dtype": "bool"}, + {"name": "clock.hour", "port": "hour", "dtype": "int"}, + { + "name": "pump_memory", + "port": "memory", + "dtype": "record", + "trigger": False, + }, + ], + "provides": [ + {"name": "pump_start", "port": "pump", "dtype": "bool"}, + {"name": "pump_for", "port": "pump_for", "dtype": "float"}, + {"name": "pump_memory", "port": "pump_memory", "dtype": "record"}, + ], + }, + PUMP_DUE, + ) + flow.add( + { + "id": "pump_run", + "type": "trigger", + "title": "Fridge pump: thirty seconds", + "params": { + "first": True, + "then": False, + "wait": 30.0, + "wait_port": "pump_for", + "extend": False, + }, + "requires": [ + {"name": "pump_start", "port": "start", "dtype": "bool"}, + {"name": "pump_for", "port": "pump_for", "dtype": "float"}, + ], + "provides": [{"name": "fridge_pump", "dtype": "bool"}], + } + ) + flow.input("bath_manual", "bool", False) + flow.input("bath_arbiter", "record", {}) + flow.input("pump_memory", "record", {}) + # Nothing else provides it, and the plug has to start from somewhere. + flow.input("fridge_pump", "bool", False) + return flow + + +# ── appliances ─────────────────────────────────────────────────────────── + + +def appliances() -> Flow: + """One switch, and a promise that it is off by morning. + + The reference armed a delay of `24 - hour` hours whenever the plug went on, + which is the same intent expressed as arithmetic. A schedule says it + better: at two in the morning the plug goes off, and it goes off even if + somebody switched it on at midnight — which is exactly the case a hold + would otherwise defeat, so this is the one arbiter with a force behind it. + """ + flow = Flow("appliances", "Appliances") + flow.add( + { + "id": "nightly", + "type": "inject", + "title": "At two in the morning", + "params": {"cron": "0 2 * * *"}, + "provides": [{"name": "nightly_off", "dtype": "float"}], + } + ) + flow.add( + { + "id": "auto", + "type": "python", + "title": "Nothing to decide", + "requires": [{"name": "nightly_off", "port": "tick", "dtype": "float"}], + "provides": [{"name": "auto", "dtype": "bool"}], + }, + '''"""Nobody automates a washing machine; the schedule is the whole rule.""" + + +def process(tick): + return {"auto": False} +''', + ) + flow.add( + arbiter( + "arbiter", + "The switch, until two in the morning", + "bool", + auto="auto", + manual="appliances_manual", + command="appliances", + state="appliances_arbiter", + # Held until the schedule releases it, however long that is. + hold_s=0.0, + force_at="nightly_off", + force_value=False, + ) + ) + flow.input("appliances_manual", "bool", False) + flow.input("appliances_arbiter", "record", {}) + return flow + + +# ── the outdoor plug ───────────────────────────────────────────────────── + +OUTDOOR = '''"""One plug, two jobs, and the calendar decides which. + +In winter it is a frost-protection cable and in summer it is an irrigation +valve — the reference relabelled a dashboard button twice a year to say so. + +The cable is a duty cycle rather than a thermostat: there is no sensor in the +pipe, so it runs for part of each hour and for longer the colder it is. That is +what the reference expressed as a delay it computed in milliseconds; written +against the clock it is one line and it cannot leave the plug on because a +message went missing. + +Watering is the same idea with the season the other way round: five minutes at +eight and five at seven, on a day warm enough to be worth it. +""" + + +def process( + hour=12, + minute=0, + outdoor=10.0, + watering_season=False, + frost_season=False, + today_max=15.0, + out_w=0.0, + door_moving=False, + memory=None, + frost_on_c=0.2, + frost_off_c=1.2, + duty_min=15.0, + duty_max=59.0, + duty_from_c=4.0, + duty_to_c=-5.0, + overload_w=2000.0, + water_at=(8, 19), + water_min=5.0, + water_above_c=17.0, +): + memory = dict(memory or {}) + + if watering_season: + wanted = ( + today_max > water_above_c and hour in tuple(water_at) and minute < water_min + ) + return { + "want": bool(wanted), + "why": "watering" if wanted else "between waterings", + "outdoor_memory": memory, + } + + if not frost_season: + return {"want": False, "why": "neither season", "outdoor_memory": memory} + if out_w > overload_w: + return {"want": False, "why": "the inverter is loaded", "outdoor_memory": memory} + if door_moving: + # They share an inverter phase; the reference kept them apart and the + # cheapest way to keep doing that is to wait a minute. + return {"want": False, "why": "the door is moving", "outdoor_memory": memory} + + engaged = bool(memory.get("engaged")) + if outdoor < frost_on_c: + engaged = True + elif outdoor > frost_off_c: + engaged = False + + span = max(0.1, duty_from_c - duty_to_c) + duty = duty_min + (duty_max - duty_min) * (duty_from_c - outdoor) / span + duty = max(duty_min, min(duty_max, duty)) + + return { + "want": bool(engaged and minute < duty), + "why": f"frost cable, {round(duty)} min an hour" if engaged else "not freezing", + "outdoor_memory": {"engaged": engaged}, + } +''' + + +def outdoor() -> Flow: + flow = Flow("outdoor", "Outdoor plug") + flow.add( + { + "id": "decide", + "type": "python", + "title": "Frost cable or watering", + "requires": [ + {"name": "clock.minute", "port": "minute", "dtype": "int"}, + { + "name": "clock.hour", + "port": "hour", + "dtype": "int", + "trigger": False, + }, + { + "name": "weather.outdoor_temp", + "port": "outdoor", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.watering_season", + "port": "watering_season", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.frost_season", + "port": "frost_season", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.today_max", + "port": "today_max", + "dtype": "float", + "trigger": False, + }, + { + "name": "power.out_w", + "port": "out_w", + "dtype": "float", + "trigger": False, + }, + { + "name": "shutters.door_moving", + "port": "door_moving", + "dtype": "bool", + "trigger": False, + }, + { + "name": "outdoor_memory", + "port": "memory", + "dtype": "record", + "trigger": False, + }, + ], + "provides": [ + {"name": "want", "dtype": "bool"}, + {"name": "why", "dtype": "str"}, + {"name": "outdoor_memory", "port": "outdoor_memory", "dtype": "record"}, + ], + }, + OUTDOOR, + ) + flow.add( + arbiter( + "arbiter", + "Automation or the button", + "bool", + auto="want", + manual="outdoor_manual", + command="outdoor_plug", + state="outdoor_arbiter", + # A minute is enough to water something by hand, and an override + # that outlasts the frost cable's next hour is a frozen pipe. + hold_s=900.0, + ) + ) + flow.input("outdoor_manual", "bool", False) + flow.input("outdoor_arbiter", "record", {}) + return flow + + +# ── lights ─────────────────────────────────────────────────────────────── + +AUTO_SCENE = '''"""The scene the house would pick, left to itself. + +Only on a change of who is in or whether it is dark, because a scene that +re-asserts itself every minute is a scene nobody can override by hand — the +arbiter downstream would hold, but the house would keep asking, and the log +would be unreadable. +""" + + +def process(event=None, phase="day", door_down=False): + if event == "away": + return {"auto": "off"} + if event == "asleep": + return {"auto": "sleep"} + if event == "arrived": + return {"auto": "day" if phase == "day" else "outside"} + if event in ("home", "working"): + return {"auto": "day" if phase == "day" else "night"} + return None +''' + +SCENE = '''"""A scene is five zones and a brightness it starts from. + +The table is the reference's. What it does not do any more is decide the +brightness for good: the level it names is what the house *suggests*, and the +slider on the wall overrides it through the same arbiter every other actuator +uses — so picking "sleep" dims the house to twenty, and turning it back up +afterwards stays turned up. +""" + +ZONES = ("bed", "kitchen", "bath", "workspace", "outside") + +SCENES = { + "off": (0, ()), + "sleep": (20, ("bed", "bath")), + "day": (70, ("workspace",)), + "night": (40, ("kitchen", "workspace", "outside")), + "outside": (80, ("workspace", "outside")), + "alarm": (100, ZONES), +} + + +def process(scene="off"): + level, zones = SCENES.get(scene, SCENES["off"]) + return {"suggested": float(level), "zones": list(zones)} +''' + +FIXTURES = '''"""Zones and a brightness into one value for every fixture in the house. + +Every fixture is named in `DARK` before any zone is applied, which is the +whole point of writing it as a table: in the reference a zone that was not +selected left its fixtures holding the JavaScript value `undefined`, which +went onto the wire as an empty payload and reached the DMX encoder as a NaN. +Off is a value here, not an absence. + +Zones are applied in order and a later one wins — which is how the reference +behaved, and what makes `workspace` the zone that decides the living room when +both it and `kitchen` are lit. +""" + +DARK = { + "living_ambient": [0.0, 0.0, 0.0], + "kitchen_ambient": [0.0, 0.0, 0.0], + "bath_light": [0.0, 0.0, 0.0], + "kitchen_light": [0.0, 0.0, 0.0], + "bed_light": [0.0, 0.0, 0.0], + "kitchen_direct": 0.0, + "traverse_spot": 0.0, + "outdoor_ambient": False, + "outdoor_direct": False, + "canopy_light": False, +} + + +def process(zones, level=70.0, door_down=False, spot_above=20.0): + out = dict(DARK) + level = float(level) + half = level / 2.0 + + for zone in zones: + if zone == "bed": + out["bed_light"] = [10.0, 100.0, level] + out["kitchen_ambient"] = [10.0, 60.0, half] + elif zone == "kitchen": + out["kitchen_ambient"] = [0.0, 60.0, level] + out["living_ambient"] = [10.0, 100.0, half] + out["kitchen_direct"] = level + elif zone == "bath": + out["living_ambient"] = [10.0, 100.0, half] + out["bath_light"] = [0.0, 0.0, level] + elif zone == "workspace": + out["living_ambient"] = [0.0, 20.0, level] + out["traverse_spot"] = level if level > spot_above else 0.0 + out["kitchen_ambient"] = [0.0, 90.0, half] + elif zone == "outside" and not door_down: + # Shut up for the night: no point lighting the terrace. + out["outdoor_ambient"] = level > 10 + out["outdoor_direct"] = level > 70 + out["canopy_light"] = level > 30 + + out["lit"] = ", ".join(zones) if zones else "nothing" + return out +''' + + +def lights() -> Flow: + flow = Flow("lights", "Lights") + flow.add( + { + "id": "auto", + "type": "python", + "title": "What the house would pick", + "requires": [ + {"name": "presence.event", "port": "event", "dtype": "str"}, + { + "name": "weather.phase", + "port": "phase", + "dtype": "str", + "trigger": False, + }, + { + "name": "shutters.door_down", + "port": "door_down", + "dtype": "bool", + "trigger": False, + }, + ], + "provides": [{"name": "auto", "dtype": "str"}], + }, + AUTO_SCENE, + ) + flow.add( + arbiter( + "scene_arbiter", + "The scene: automation or the dial", + "str", + auto="auto", + manual="scene", + command="active_scene", + state="scene_state", + # Long: a scene someone chose should last the evening. + hold_s=14400.0, + ) + ) + flow.add( + { + "id": "scene", + "type": "python", + "title": "Scene to zones", + "requires": [{"name": "active_scene", "port": "scene", "dtype": "str"}], + "provides": [ + {"name": "suggested", "dtype": "float"}, + {"name": "zones", "dtype": "list", "item": "str"}, + ], + }, + SCENE, + ) + flow.add( + arbiter( + "brightness_arbiter", + "Brightness: the scene or the slider", + "float", + auto="suggested", + manual="brightness", + command="level", + state="brightness_state", + hold_s=14400.0, + ) + ) + flow.add( + { + "id": "fixtures", + "type": "python", + "title": "Zones to fixtures", + "requires": [ + {"name": "zones", "dtype": "list", "item": "str"}, + {"name": "level", "dtype": "float"}, + { + "name": "shutters.door_down", + "port": "door_down", + "dtype": "bool", + "trigger": False, + }, + ], + "provides": [ + {"name": "living_ambient", "dtype": "list", "item": "float"}, + {"name": "kitchen_ambient", "dtype": "list", "item": "float"}, + {"name": "bath_light", "dtype": "list", "item": "float"}, + {"name": "kitchen_light", "dtype": "list", "item": "float"}, + {"name": "bed_light", "dtype": "list", "item": "float"}, + {"name": "kitchen_direct", "dtype": "float"}, + {"name": "traverse_spot", "dtype": "float"}, + {"name": "outdoor_ambient", "dtype": "bool"}, + {"name": "outdoor_direct", "dtype": "bool"}, + {"name": "canopy_light", "dtype": "bool"}, + {"name": "lit", "dtype": "str"}, + ], + }, + FIXTURES, + ) + flow.input("scene", "str", "off") + flow.input("scene_state", "record", {}) + flow.input("brightness", "float", 70.0) + flow.input("brightness_state", "record", {}) + return flow diff --git a/scripts/tinyhouse/api.py b/scripts/tinyhouse/api.py new file mode 100644 index 0000000..22d4024 --- /dev/null +++ b/scripts/tinyhouse/api.py @@ -0,0 +1,166 @@ +"""Talking to the installation, and the constants this house is wired with. + +Addresses, MAC addresses and the DMX map live in ``house.json`` at the +workspace root rather than in this package: they are this installation's +private data, so they are git-ignored the way the Node-RED inventory is. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +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", "") + +HOUSE_FILE = Path( + os.environ.get("HOUSE_FILE", Path(__file__).resolve().parents[3] / "house.json") +) + + +def house() -> dict[str, Any]: + """The wiring of this particular house.""" + if not HOUSE_FILE.exists(): + raise SystemExit( + f"No {HOUSE_FILE}. It holds this installation's addresses and DMX " + "map; copy the one from the workspace root or write it from " + "docs/private/node-red-transition.md." + ) + data: dict[str, Any] = json.loads(HOUSE_FILE.read_text()) + return data + + +class Api: + """The REST API, logged in, raising on anything that is not a 2xx.""" + + def __init__(self) -> None: + if not EMAIL or not PASSWORD: + raise SystemExit("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.") + self.http = httpx.Client(base_url=f"{API}/api/v1", timeout=60) + answer = self.http.post( + "/login/access-token", + data={"username": EMAIL, "password": PASSWORD}, + ) + if answer.status_code != 200: + raise SystemExit(f"Could not log in to {API}: {answer.text}") + self.http.headers["Authorization"] = f"Bearer {answer.json()['access_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 + + # ── flows ──────────────────────────────────────────────────────────── + + def put_flow(self, flow: Flow) -> list[str]: + """Create the flow, write its node code, publish it, leave it stopped. + + Stopped is the only safe default here: publishing a flow that drives + the house would command every fixture it owns from whatever its inputs + happen to hold. Starting one is a decision someone makes while + watching. + """ + self.drop(f"/flows/{flow.name}") + self( + "PUT", + f"/flows/{flow.name}", + { + "name": flow.name, + "title": flow.title, + "nodes": flow.nodes, + "inputs": flow.inputs, + }, + ) + for node_id, code in flow.sources.items(): + self("PUT", f"/flows/{flow.name}/nodes/{node_id}/source", {"code": code}) + version = self("GET", f"/flows/{flow.name}")["definition"]["version"] + self("POST", f"/flows/{flow.name}/publish", {"version": version}) + self("POST", f"/flows/{flow.name}/stop") + issues = self("POST", f"/flows/{flow.name}/validate")["issues"] + return [_issue_line(i) for i in issues] + + def share(self, flow: str, node_id: str, lib_name: str) -> None: + """Move a node's code into the library, if it is not there already.""" + library = {node["name"] for node in self("GET", "/flows/library")} + if lib_name in library: + return + self("POST", f"/flows/{flow}/nodes/{node_id}/share", {"lib_name": lib_name}) + + # ── dashboards ─────────────────────────────────────────────────────── + + def put_dashboard(self, name: str, title: str, icon: str, widgets: list) -> None: + self.drop(f"/dashboards/{name}") + self("POST", f"/dashboards/{name}") + current = self("GET", f"/dashboards/{name}?draft=true") + self( + "PUT", + f"/dashboards/{name}", + { + **current, + "title": title, + "icon": icon, + "columns": 16, + "canvas_width": 2560, + "canvas_height": 1600, + "pages": [ + { + "id": "main", + "title": title, + "sections": [{"id": "main", "widgets": widgets}], + } + ], + }, + ) + version = self("GET", f"/dashboards/{name}?draft=true")["version"] + self("POST", f"/dashboards/{name}/publish", {"version": version}) + + +def _issue_line(issue: Any) -> str: + if isinstance(issue, dict): + return f"{issue.get('code')}: {issue.get('message')}" + return str(issue) + + +class Flow: + """One flow's definition, assembled before it is sent.""" + + def __init__(self, name: str, title: str) -> None: + self.name = name + self.title = title + self.nodes: list[dict[str, Any]] = [] + self.inputs: list[dict[str, Any]] = [] + self.sources: dict[str, str] = {} + + def add(self, node: dict[str, Any], source: str | None = None) -> dict[str, Any]: + self.nodes.append(node) + if source is not None: + self.sources[node["id"]] = source + return node + + def input(self, name: str, dtype: str, initial: Any, **spec: Any) -> None: + self.inputs.append( + {"spec": {"name": name, "dtype": dtype, **spec}, "initial": initial} + ) + + def msg(self, name: str) -> str: + """This flow's name for a message, as a dashboard widget spells it.""" + return f"{self.name}.{name}" + + +def report(name: str, nodes: int, issues: list[str]) -> None: + print(f" {name}: {nodes} nodes, published, stopped") + for issue in issues: + print(f" ! {issue}", file=sys.stderr) diff --git a/scripts/tinyhouse/control.py b/scripts/tinyhouse/control.py new file mode 100644 index 0000000..84335fa --- /dev/null +++ b/scripts/tinyhouse/control.py @@ -0,0 +1,985 @@ +"""What the house wants the temperature to be, and what it runs to get there. + +Four flows and one idea: `climate` works out the bands, and the three things +that can change the temperature — the heat pump, the pellet stove, the water +boilers competing for the same electricity — read them rather than each +deriving their own. The reference computed a winter factor in four places with +four different constants; here the calendar part is the clock's and the rest +is one node with the constants as settings. +""" + +from __future__ import annotations + +from typing import Any + +from .api import Flow +from .library import arbiter +from .sensing import _broker + +# ── climate ────────────────────────────────────────────────────────────── + +BANDS = '''"""The temperature bands, by who is in and what time of year it is. + +Every threshold below is the reference's, and every one of them is a setting, +because they are opinions about this house rather than physics. The winter +factor lifts the whole band as it gets colder outside: at -5 the house is +allowed to be warmer before it counts as too warm, which is what stops the +heating fighting the weather in January. + +`indoor` is deliberately not the mean of the two sensors. The bottom sensor is +where a person is and the top one is where the heat goes, so the bottom one +carries the weight — and when it is silent, which it is while the 433 MHz +station is dead, the top one is the whole reading rather than nothing at all. +""" + + +def process( + top, + bottom=None, + outdoor=10.0, + preset=21.0, + sleeping=False, + home=True, + winter_k=0.6, + winter_from=9.5, + night_max=23.0, + night_min=19.5, + away_max=20.0, + away_min=18.0, + band=1.5, + bottom_weight=0.75, +): + lift = winter_k * max(0.0, (winter_from - outdoor)) / winter_from + indoor = top if bottom is None else (1 - bottom_weight) * top + bottom_weight * bottom + + if not home: + low, high = away_min, away_max + elif sleeping: + low, high = night_min, night_max + else: + low, high = preset - band, preset + + return { + "indoor": round(indoor, 2), + "top": top, + "lift": round(lift, 2), + "setpoint": round(preset + lift, 1), + "t_min": round(low + lift, 2), + "t_max": round(high + lift, 2), + "stratified": bottom is not None and (top - bottom) > 6, + } +''' + + +def climate() -> Flow: + flow = Flow("climate", "Climate") + flow.add( + { + "id": "bands", + "type": "python", + "title": "Temperature bands", + "requires": [ + # The aircon reports the top of the room and is polled, so it + # is the one input that is always there. + {"name": "hvac.indoor_temp", "port": "top", "dtype": "float"}, + { + "name": "weather.indoor_temp", + "port": "bottom", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.outdoor_temp", + "port": "outdoor", + "dtype": "float", + "trigger": False, + }, + {"name": "preset", "dtype": "float", "trigger": False}, + {"name": "presence.sleeping", "port": "sleeping", "dtype": "bool"}, + {"name": "presence.home", "port": "home", "dtype": "bool"}, + ], + "provides": [ + {"name": "indoor", "dtype": "float"}, + {"name": "top", "dtype": "float"}, + {"name": "lift", "dtype": "float"}, + {"name": "setpoint", "dtype": "float"}, + {"name": "t_min", "dtype": "float"}, + {"name": "t_max", "dtype": "float"}, + {"name": "stratified", "dtype": "bool"}, + ], + }, + BANDS, + ) + flow.input("preset", "float", 21.0) + return flow + + +# ── heat pump ──────────────────────────────────────────────────────────── + +HVAC = '''"""What to ask of the heat pump, if anything. + +Ported from the reference's `HVAC Logic`, which is the most conditional thing +in that installation and the one worth keeping closest to what it was: every +branch is a bill, and the state of charge gates that appear arbitrary are the +difference between running the compressor off the sun and running it off the +battery at four in the morning. + +What changed: it decides in one place instead of reading nine globals, the +enable switch is honoured rather than being overwritten with true on the way +past, and it says why. Modes are the unit's own: 1 cool, 2 heat, 3 fan, 4 dry. +""" + +OFF = {"operation": False, "mode": "fan", "preset_temp": 22.0, "fan_speed": "auto"} + + +def process( + indoor, + top, + t_min, + t_max, + lift=0.0, + humidity=None, + stratified=False, + soc=100.0, + out_w=0.0, + in_v=230.0, + watch=0, + enabled=True, + home=True, + sleeping=False, + oven_on=False, + hot=29.0, + high=26.0, + cool_soc=60.0, + cool_soc_away=90.0, + fan_soc=45.0, + dry_soc=35.0, + dry_humidity=69.0, + dry_humidity_night=73.0, +): + if not enabled or watch: + return {"command": OFF, "why": "off: " + ("disabled" if not enabled else "power")} + + t_hot, t_high = hot + lift, high + lift + + def ask(mode, temp=None, fan="auto", why=""): + return { + "command": { + "operation": True, + "mode": mode, + "preset_temp": round(temp if temp is not None else t_max, 1), + "fan_speed": fan, + }, + "why": why, + } + + if not home: + # Nobody in: only ever to stop the house cooking, and only on the sun. + if top > t_hot and soc > cool_soc_away: + return ask("cool", t_hot, why="empty house is too hot") + if humidity is not None and humidity > 60 and soc > dry_soc: + return ask("dry", t_min + 3, why="empty house is damp") + return {"command": OFF, "why": "nobody in"} + + if stratified: + # Warm air at the ceiling and cold feet: move it rather than make more. + return ask("fan", fan="high" if oven_on else "low", why="stratified") + + limit = t_max if not sleeping else t_max + 1.5 + if top > limit: + if soc > (cool_soc if not sleeping else cool_soc_away): + return ask("cool", t_hot, why="too warm") + if soc > fan_soc: + return ask("fan", why="too warm, saving the battery") + return {"command": OFF, "why": "too warm, battery too low to help"} + + damp = dry_humidity_night if sleeping else dry_humidity + if humidity is not None and humidity > damp and in_v > 200: + return ask("dry", t_min + 3, fan="low", why="damp") + + if indoor < t_min and soc > dry_soc: + return ask("heat", t_min + 3, why="too cold") + + return {"command": OFF, "why": "within the band"} +''' + +HVAC_LIMIT = '''"""Never let the compressor be the thing that trips the inverter.""" + + +def process(command, out_w=0.0, overload_w=2000.0): + if command.get("mode") == "cool" and out_w > overload_w: + return {"limited": {**command, "mode": "fan"}} + return {"limited": command} +''' + +HVAC_PORTS = '''"""One command record into the four ports the connector writes.""" + + +def process(limited): + return { + "operation": bool(limited["operation"]), + "mode": str(limited["mode"]), + "preset_temp": float(limited["preset_temp"]), + "fan_speed": str(limited["fan_speed"]), + } +''' + + +def hvac(h: dict[str, Any], commands: bool) -> Flow: + flow = Flow("hvac", "Heat pump") + # Reading and commanding are two nodes on one adapter, not one node doing + # both. A node that did both would sit in its own cascade: the temperature + # it reports decides the bands, the bands decide the command, and the + # command comes back to it — a cycle the canvas would refuse to run. The + # connector already keeps two nodes on one unit from talking over each + # other, which is what makes the split safe rather than merely legal. + flow.add( + { + "id": "unit", + "type": "wfrac", + "title": "Aircon (reading)", + "params": { + "host": h["aircon"]["host"], + "poll_interval": 60.0, + "commands": False, + }, + "provides": [ + {"name": "indoor_temp", "dtype": "float"}, + {"name": "outdoor_temp", "dtype": "float"}, + {"name": "reported_temp", "port": "preset_temp", "dtype": "float"}, + {"name": "running", "port": "operation", "dtype": "bool"}, + {"name": "reported_mode", "port": "mode", "dtype": "str"}, + {"name": "electric", "dtype": "float"}, + ], + } + ) + flow.add( + { + "id": "writer", + "type": "wfrac", + "title": "Aircon (commanding)", + "params": { + "host": h["aircon"]["host"], + # Nothing to poll here; the reader above owns that. + "poll_interval": 0.0, + # Off until someone is watching: turning a heat pump on has a + # bill attached. + "commands": commands, + }, + "requires": [ + {"name": "operation", "dtype": "bool"}, + {"name": "mode", "dtype": "str"}, + {"name": "preset_temp", "dtype": "float"}, + {"name": "fan_speed", "dtype": "str"}, + ], + } + ) + flow.add( + { + "id": "second", + "type": "wfrac", + "title": "Aircon (bedroom, read only)", + "params": { + "host": h["aircon"]["second_host"], + "poll_interval": 300.0, + "commands": False, + }, + "provides": [ + {"name": "second_indoor", "port": "indoor_temp", "dtype": "float"}, + {"name": "second_running", "port": "operation", "dtype": "bool"}, + ], + } + ) + flow.add( + { + "id": "decide", + "type": "python", + "title": "What to ask of it", + "requires": [ + {"name": "climate.indoor", "port": "indoor", "dtype": "float"}, + { + "name": "climate.top", + "port": "top", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.t_min", + "port": "t_min", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.t_max", + "port": "t_max", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.lift", + "port": "lift", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.stratified", + "port": "stratified", + "dtype": "bool", + "trigger": False, + }, + { + "name": "weather.indoor_hum", + "port": "humidity", + "dtype": "float", + "trigger": False, + }, + { + "name": "power.soc", + "port": "soc", + "dtype": "float", + "trigger": False, + }, + { + "name": "power.out_w", + "port": "out_w", + "dtype": "float", + "trigger": False, + }, + { + "name": "power.in_v", + "port": "in_v", + "dtype": "float", + "trigger": False, + }, + {"name": "power.watch", "port": "watch", "dtype": "int"}, + { + "name": "presence.home", + "port": "home", + "dtype": "bool", + "trigger": False, + }, + { + "name": "presence.sleeping", + "port": "sleeping", + "dtype": "bool", + "trigger": False, + }, + { + "name": "oven.running", + "port": "oven_on", + "dtype": "bool", + "trigger": False, + }, + {"name": "enabled", "dtype": "bool", "trigger": False}, + ], + "provides": [ + {"name": "wanted", "port": "command", "dtype": "record"}, + {"name": "why", "dtype": "str"}, + ], + }, + HVAC, + ) + flow.add( + { + "id": "limit", + "type": "python", + "title": "Not while the inverter is loaded", + "requires": [ + {"name": "wanted", "port": "command", "dtype": "record"}, + { + "name": "power.out_w", + "port": "out_w", + "dtype": "float", + "trigger": False, + }, + ], + "provides": [{"name": "limited", "dtype": "record"}], + }, + HVAC_LIMIT, + ) + flow.add( + { + "id": "settle", + "type": "delay", + "title": "At most every fifteen minutes", + # A heat pump that is asked something new every minute never + # reaches anything. The reference used the same figure. + "params": {"interval": 900.0}, + "requires": [{"name": "limited", "dtype": "record"}], + "provides": [{"name": "settled", "dtype": "record"}], + } + ) + flow.add( + { + "id": "changed", + "type": "rbe", + "title": "Only when it changes", + "requires": [{"name": "settled", "port": "settled", "dtype": "record"}], + "provides": [{"name": "command", "port": "command", "dtype": "record"}], + } + ) + flow.add( + { + "id": "ports", + "type": "python", + "title": "Into the unit's ports", + "requires": [{"name": "command", "port": "limited", "dtype": "record"}], + "provides": [ + {"name": "operation", "dtype": "bool"}, + {"name": "mode", "dtype": "str"}, + {"name": "preset_temp", "dtype": "float"}, + {"name": "fan_speed", "dtype": "str"}, + ], + }, + HVAC_PORTS, + ) + flow.input("enabled", "bool", True) + return flow + + +# ── pellet stove ───────────────────────────────────────────────────────── + +OVEN = '''"""Whether the pellet stove should be burning. + +The reference decided this by pushing a vote into a twenty-sample array every +three seconds and firing when the mean crossed 0.9 — which is a way of +building hysteresis out of a heartbeat. With no heartbeat there is no window +to average over, so the hysteresis is written down instead: light it below the +bottom of the band, stop it above the top, and do nothing in between. That is +what the vote was approximating, and it is legible. + +None of this ran in the reference at all — the controller had been +disconnected from its heartbeat. It is reinstated here because heating the +house is what the stove is for. +""" + + +def process( + indoor, + t_min, + t_max, + running=False, + heating_season=True, + faulty=False, + watch=0, + margin=0.0, +): + if not heating_season: + return {"wanted": False, "why": "not the season"} + if faulty: + return {"wanted": False, "why": "the stove reports a fault"} + if watch >= 4: + return {"wanted": False, "why": "mains is down"} + + if not running and indoor <= t_min - margin: + return {"wanted": True, "why": "below the band"} + if running and indoor >= t_max + margin: + return {"wanted": False, "why": "above the band"} + return {"wanted": running, "why": "within the band"} +''' + +OVEN_STATE = '''"""What the stove says about itself, and whether to believe it. + +A stove that has claimed to be on for five hours without the room warming up +is stuck, and the reference used exactly that to stop the heat pump trying to +heat with it. Kept, including the five hours. +""" + +import time + + +def process(onoff=None, power_state=None, memory=None, faulty_after_s=18000.0): + state = str(onoff if onoff is not None else power_state or "").upper() + if not state: + return None + running = state in ("ON", "1", "TRUE") + + memory = dict(memory or {}) + now = time.time() + since = memory.get("since", now) + if running != memory.get("running"): + since = now + + return { + "running": running, + "faulty": bool(running and now - since > faulty_after_s), + "since": since, + "oven_memory": {"running": running, "since": since}, + } +''' + +OVEN_CMD = '''"""The stove takes ON to light and 'force' to shut down. Nothing else.""" + + +def process(command): + return {"oven_command": "ON" if command else "force"} +''' + +PUMP = '''"""The floor-heating pump follows the stove, late and then later. + +The plug labelled microwave in the reference is wired to the pumps that move +the water the stove heats. Running them the moment it lights pushes cold water +through a cold house, and stopping them the moment it goes out leaves the heat +in the stove — so the pump starts a quarter of an hour behind and runs three +hours past. Both are settings; they are properties of this plumbing. + +It reads the clock rather than scheduling anything, which is what makes a +stove that lights and goes straight back out harmless: there is no pending +message to arrive after the reason for it has gone. +""" + +import time + + +def process(running, since=0.0, tick=0, on_after_s=900.0, off_after_s=10800.0): + if not since: + return {"floor_heating": False} + elapsed = time.time() - since + if running: + return {"floor_heating": elapsed >= on_after_s} + return {"floor_heating": elapsed < off_after_s} +''' + + +def oven(h: dict[str, Any]) -> Flow: + flow = Flow("oven", "Pellet stove") + topics = h["topics"] + flow.add( + { + "id": "stove_in", + "type": "mqtt", + "title": "What the stove says", + "params": { + "topic": { + "onoff": topics["oven_state"], + "power_state": topics["oven_power_state"], + "ambient": topics["oven_ambient"], + "fume": topics["oven_fume"], + }, + **_broker(h, "fluksio-oven"), + }, + "provides": [ + {"name": "onoff", "dtype": "str"}, + {"name": "power_state", "dtype": "str"}, + {"name": "ambient", "dtype": "float"}, + {"name": "fume", "dtype": "float"}, + ], + } + ) + flow.add( + { + "id": "state", + "type": "python", + "title": "Running, and healthy?", + "requires": [ + {"name": "onoff", "dtype": "str"}, + {"name": "power_state", "dtype": "str", "trigger": False}, + { + "name": "oven_memory", + "port": "memory", + "dtype": "record", + "trigger": False, + }, + ], + "provides": [ + {"name": "running", "dtype": "bool"}, + {"name": "faulty", "dtype": "bool"}, + {"name": "since", "dtype": "float"}, + {"name": "oven_memory", "port": "oven_memory", "dtype": "record"}, + ], + }, + OVEN_STATE, + ) + flow.add( + { + "id": "decide", + "type": "python", + "title": "Should it be burning?", + "requires": [ + {"name": "climate.indoor", "port": "indoor", "dtype": "float"}, + { + "name": "climate.t_min", + "port": "t_min", + "dtype": "float", + "trigger": False, + }, + { + "name": "climate.t_max", + "port": "t_max", + "dtype": "float", + "trigger": False, + }, + {"name": "running", "dtype": "bool", "trigger": False}, + { + "name": "weather.heating_season", + "port": "heating_season", + "dtype": "bool", + "trigger": False, + }, + {"name": "faulty", "dtype": "bool", "trigger": False}, + { + "name": "power.watch", + "port": "watch", + "dtype": "int", + "trigger": False, + }, + ], + "provides": [ + {"name": "wanted", "dtype": "bool"}, + {"name": "why", "dtype": "str"}, + ], + }, + OVEN, + ) + flow.add( + arbiter( + "arbiter", + "Automation or the button", + "bool", + auto="wanted", + manual="oven_manual", + command="command", + state="oven_arbiter", + # Lighting a stove by hand is a decision that should stand for the + # evening, not be undone by the next reading. + hold_s=10800.0, + ) + ) + flow.add( + { + "id": "settle", + "type": "delay", + "title": "At most every half hour", + "params": {"interval": 1800.0}, + "requires": [{"name": "command", "dtype": "bool"}], + "provides": [{"name": "settled", "dtype": "bool"}], + } + ) + flow.add( + { + "id": "changed", + "type": "rbe", + "title": "Only when it changes", + "requires": [{"name": "settled", "port": "settled", "dtype": "bool"}], + "provides": [{"name": "to_send", "port": "to_send", "dtype": "bool"}], + } + ) + flow.add( + { + "id": "as_words", + "type": "python", + "title": "ON or force", + "requires": [{"name": "to_send", "port": "command", "dtype": "bool"}], + "provides": [{"name": "oven_command", "dtype": "str"}], + }, + OVEN_CMD, + ) + flow.add( + { + "id": "stove_out", + "type": "mqtt", + "title": "Tell the stove", + "params": { + "topic": {"oven_command": topics["oven_command"]}, + "qos": 1, + **_broker(h, "fluksio-oven-out"), + }, + "requires": [{"name": "oven_command", "dtype": "str"}], + } + ) + flow.add( + { + "id": "pump", + "type": "python", + "title": "Floor heating follows", + "requires": [ + {"name": "running", "port": "running", "dtype": "bool"}, + {"name": "since", "dtype": "float", "trigger": False}, + {"name": "clock.minute", "port": "tick", "dtype": "int"}, + ], + "provides": [{"name": "floor_heating", "dtype": "bool"}], + }, + PUMP, + ) + flow.input("oven_manual", "bool", False) + flow.input("oven_arbiter", "record", {}) + return flow + + +# ── water boilers ──────────────────────────────────────────────────────── + +HEATED = '''"""Has this boiler had its heating today? + +A boiler with no thermostat readback: the only evidence it got hot is that it +drew power for a while without the inverter being busy. The reference counted +ten samples of a three-second heartbeat, which is thirty seconds of drawing +below 600 W. Kept as thirty seconds of wall clock, which is the same statement +without the heartbeat. + +Ratcheting on purpose — once heated, heated, until it is reset at five in the +morning. A boiler that cools slightly should not send the house looking for +sun again at four in the afternoon. +""" + +import time + + +def process(on, out_w=0.0, hour=12, memory=None, quiet_w=600.0, needs_s=30.0, reset_hour=5): + memory = dict(memory or {}) + now = time.time() + + if hour == reset_hour and memory.get("day") != reset_hour: + memory = {"day": reset_hour} + elif hour != reset_hour: + memory["day"] = hour + + heating_since = memory.get("since", 0.0) + if on and out_w < quiet_w: + heating_since = heating_since or now + else: + heating_since = 0.0 + + heated = bool(memory.get("heated")) or bool( + heating_since and now - heating_since >= needs_s + ) + if memory.get("day") == reset_hour: + heated = False + + return { + "heated": heated, + "heated_memory": {"heated": heated, "since": heating_since, "day": memory.get("day", hour)}, + } +''' + +BOILER = '''"""Whether to put the immersion heater on, and on whose electricity. + +This is the reference's `Water Boiler Logic`, and the thing worth preserving +about it is the shape rather than any single number: by day it heats only on +surplus solar, and at night it falls back to the grid — but only if the day +did not manage it and tomorrow does not look better than today. + +Two boilers share the rules and the constants; the kitchen one is gated on the +main one being satisfied first, so they never draw together. + +The battery thresholds are in volts because that is what this bank reports, +and the turn-on point moves with the season: 50.4 V in June, 49.8 V in +December, because a winter battery that waits for a summer voltage waits all +day. +""" + + +def process( + batt_v, + heated=False, + out_w=0.0, + in_w=0.0, + in_v=230.0, + watch=0, + hour=12, + winter=0.0, + tomorrow_day=0.0, + tomorrow_clouds=100.0, + on=False, + day_from=9, + day_to=18, + night_from=1, + night_to=4, + min_batt_v=48.4, + on_batt_v=50.4, + winter_batt_drop=0.6, + overload_w=2800.0, + busy_w=2600.0, + low_grid_v=185.0, + better_tomorrow_c=25.0, + better_tomorrow_clouds=50.0, +): + if out_w > overload_w: + return {"want": False, "why": "the inverter is overloaded"} + if heated: + return {"want": False, "why": "already heated today"} + + day = day_from <= hour < day_to + night = night_from <= hour < night_to + if not day and not night: + return {"want": False, "why": "outside both windows"} + + if watch: + return {"want": False, "why": "power watch"} + if out_w > busy_w: + return {"want": False, "why": "the house is drawing too much"} + + if day: + if in_w > 0: + return {"want": False, "why": "importing rather than exporting"} + # Hysteresis: below the floor it stops, above the mark it starts, and + # in between it keeps doing whatever it was doing. + threshold = on_batt_v - winter_batt_drop * winter + if batt_v < min_batt_v: + return {"want": False, "why": "battery too low"} + if batt_v > threshold: + return {"want": True, "why": "surplus solar"} + return {"want": on, "why": "holding"} + + if tomorrow_day > better_tomorrow_c and tomorrow_clouds < better_tomorrow_clouds: + return {"want": False, "why": "tomorrow looks better than the grid"} + if in_v < 10: + threshold = on_batt_v - winter_batt_drop * winter + if batt_v > threshold: + return {"want": True, "why": "no mains, but the battery has it"} + return {"want": False, "why": "no mains and the battery is low"} + if in_v < low_grid_v: + return {"want": False, "why": "mains too weak"} + return {"want": True, "why": "the day did not manage it"} +''' + + +def boiler(h: dict[str, Any]) -> Flow: + """Two immersion heaters, one set of rules, one at a time.""" + flow = Flow("boiler", "Water boilers") + + for which, title, day_from, day_to, night_from, night_to, grid_v in ( + ("water", "Main boiler", 9, 18, 1, 4, 185.0), + ("kitchen", "Kitchen boiler", 9, 16, 2, 4, 180.0), + ): + state_msg = f"{which}_heated_memory" + flow.add( + { + "id": f"{which}_heated", + "type": "python", + "title": f"{title}: heated today?", + "requires": [ + { + "name": f"{which}_boiler", + "port": "on", + "dtype": "bool", + "trigger": False, + }, + {"name": "power.out_w", "port": "out_w", "dtype": "float"}, + { + "name": "clock.hour", + "port": "hour", + "dtype": "int", + "trigger": False, + }, + { + "name": state_msg, + "port": "memory", + "dtype": "record", + "trigger": False, + }, + ], + "provides": [ + {"name": f"{which}_heated", "port": "heated", "dtype": "bool"}, + {"name": state_msg, "port": "heated_memory", "dtype": "record"}, + ], + }, + HEATED, + ) + requires = [ + {"name": "power.batt_v", "port": "batt_v", "dtype": "float"}, + { + "name": f"{which}_heated", + "port": "heated", + "dtype": "bool", + "trigger": False, + }, + { + "name": "power.out_w", + "port": "out_w", + "dtype": "float", + "trigger": False, + }, + {"name": "power.in_w", "port": "in_w", "dtype": "float", "trigger": False}, + {"name": "power.in_v", "port": "in_v", "dtype": "float", "trigger": False}, + {"name": "power.watch", "port": "watch", "dtype": "int", "trigger": False}, + {"name": "clock.hour", "port": "hour", "dtype": "int"}, + { + "name": "clock.winter", + "port": "winter", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.tomorrow_day", + "port": "tomorrow_day", + "dtype": "float", + "trigger": False, + }, + { + "name": "weather.tomorrow_clouds", + "port": "tomorrow_clouds", + "dtype": "float", + "trigger": False, + }, + { + "name": f"{which}_boiler", + "port": "on", + "dtype": "bool", + "trigger": False, + }, + ] + if which == "kitchen": + # Only once the main one is satisfied: they share an inverter, and + # two immersion heaters is more than it has. + requires.append( + { + "name": "water_heated", + "port": "main_heated", + "dtype": "bool", + "trigger": False, + } + ) + flow.add( + { + "id": f"{which}_decide", + "type": "python", + "title": f"{title}: on?", + "params": { + "day_from": day_from, + "day_to": day_to, + "night_from": night_from, + "night_to": night_to, + "low_grid_v": grid_v, + }, + "requires": requires, + "provides": [ + {"name": f"{which}_want", "port": "want", "dtype": "bool"}, + {"name": f"{which}_why", "port": "why", "dtype": "str"}, + ], + }, + BOILER if which == "water" else KITCHEN_BOILER, + ) + flow.add( + { + "id": f"{which}_changed", + "type": "rbe", + "title": "Only when it changes", + "requires": [ + {"name": f"{which}_want", "port": "want", "dtype": "bool"} + ], + "provides": [ + {"name": f"{which}_boiler", "port": "on", "dtype": "bool"} + ], + } + ) + flow.input(state_msg, "record", {}) + flow.input(f"{which}_boiler", "bool", False) + return flow + + +KITCHEN_BOILER = BOILER.replace( + """def process( + batt_v,""", + """def process( + batt_v, + main_heated=False,""", + 1, +).replace( + """ if out_w > overload_w: + return {"want": False, "why": "the inverter is overloaded"}""", + """ if out_w > overload_w: + return {"want": False, "why": "the inverter is overloaded"} + if not main_heated: + return {"want": False, "why": "the main boiler comes first"}""", + 1, +) diff --git a/scripts/tinyhouse/dashboards.py b/scripts/tinyhouse/dashboards.py new file mode 100644 index 0000000..2558569 --- /dev/null +++ b/scripts/tinyhouse/dashboards.py @@ -0,0 +1,358 @@ +"""Three screens on one panel: the house, its comfort, and its electricity. + +One dashboard each rather than three pages of one, because a panel carries +several whole dashboards and switches between them on a rail — which is the +thing that exists, and pages are not. + +Every control binds to the message an arbiter both reads and writes, so a tile +shows what actually reached the fixture and setting it is what overrides the +automation. There is no second tile saying what the first one really did. +""" + +from __future__ import annotations + +from typing import Any + +COLUMNS = 16 + + +def _at(x: int, y: int, w: int, h: int) -> dict[str, Any]: + return {"lg": {"x": x, "y": y, "w": w, "h": h}} + + +def stat(id_, title, message, unit="", precision=1, dtype="float", **at): + return { + "id": id_, + "type": "stat", + "title": title, + "layout": _at(**at), + "config": { + "message": message, + "dtype": dtype, + "unit": unit, + "precision": precision, + }, + } + + +def switch(id_, title, target, **at): + return { + "id": id_, + "type": "switch", + "title": title, + "layout": _at(**at), + "config": {"target": target, "dtype": "bool", "style": "button"}, + } + + +def button(id_, title, target, value, **at): + return { + "id": id_, + "type": "button", + "title": title, + "layout": _at(**at), + "config": {"target": target, "dtype": "str", "value": value, "label": title}, + } + + +def slider(id_, title, target, lo, hi, step=1, unit="", **at): + return { + "id": id_, + "type": "slider", + "title": title, + "layout": _at(**at), + "config": { + "target": target, + "dtype": "float", + "min": lo, + "max": hi, + "step": step, + "unit": unit, + }, + } + + +def gauge(id_, title, message, lo, hi, unit="", **at): + return { + "id": id_, + "type": "gauge", + "title": title, + "layout": _at(**at), + "config": { + "message": message, + "dtype": "float", + "min": lo, + "max": hi, + "unit": unit, + "precision": 0, + }, + } + + +def chart(id_, title, request, series, unit="", **at): + return { + "id": id_, + "type": "chart", + "title": title, + "layout": _at(**at), + "config": { + "source": "query", + "request": request, + "request_dtype": "record", + "message": series, + "dtype": "series", + "range_s": 21600, + "refresh_s": 300, + "unit": unit, + }, + } + + +def icon(id_, title, message, rules, dtype="int", **at): + return { + "id": id_, + "type": "icon", + "title": title, + "layout": _at(**at), + "config": {"message": message, "dtype": dtype, "rules": rules}, + } + + +def dropdown(id_, title, target, options, **at): + return { + "id": id_, + "type": "dropdown", + "title": title, + "layout": _at(**at), + "config": { + "target": target, + "dtype": "str", + "style": "segmented", + "options": [{"label": label, "value": value} for label, value in options], + }, + } + + +def _cover(prefix, title, target, y): + """Three buttons, because a shutter with no position sensor has three states.""" + return [ + button(f"{prefix}_up", f"{title} up", target, "UP", x=0, y=y, w=2, h=2), + button(f"{prefix}_stop", "Stop", target, "STOP", x=2, y=y, w=2, h=2), + button(f"{prefix}_down", f"{title} down", target, "DOWN", x=4, y=y, w=2, h=2), + ] + + +HOME = [ + stat("indoor", "Inside", "climate.indoor", "°C", 1, x=0, y=0, w=3, h=2), + stat("outdoor", "Outside", "weather.outdoor_temp", "°C", 1, x=3, y=0, w=3, h=2), + stat("humidity", "Humidity", "weather.indoor_hum", "%", 0, x=6, y=0, w=3, h=2), + icon( + "power_state", + "Power", + "power.watch", + [ + {"at": 0, "icon": "check", "color": "success", "label": "Normal"}, + {"at": 1, "icon": "triangle-alert", "color": "warning", "label": "Watch"}, + {"at": 3, "icon": "zap-off", "color": "danger", "label": "Mains"}, + ], + x=9, + y=0, + w=3, + h=2, + ), + { + "id": "alert", + "type": "notification", + "title": "What is happening", + "layout": _at(x=12, y=0, w=4, h=4), + "config": {"message": "power.alert"}, + }, + dropdown( + "scene", + "Scene", + "lights.scene", + [ + ("Off", "off"), + ("Day", "day"), + ("Night", "night"), + ("Sleep", "sleep"), + ("Outside", "outside"), + ], + x=0, + y=2, + w=8, + h=2, + ), + slider( + "brightness", + "Brightness", + "lights.brightness", + 0, + 100, + 5, + "%", + x=8, + y=2, + w=4, + h=2, + ), + stat("lit", "Lit", "lights.lit", dtype="str", precision=0, x=0, y=4, w=4, h=2), + switch( + "appliances", "Appliances", "appliances.appliances_manual", x=4, y=4, w=3, h=2 + ), + switch("bath", "Bathroom plug", "plugs.bath_manual", x=7, y=4, w=3, h=2), + stat( + "floor", + "Floor heating", + "oven.floor_heating", + dtype="bool", + precision=0, + x=10, + y=4, + w=3, + h=2, + ), + stat( + "presence", + "Who is in", + "presence.state", + dtype="str", + precision=0, + x=13, + y=4, + w=3, + h=2, + ), + *_cover("door", "Door", "shutters.door_shutter_cmd", y=6), + *_cover("bed", "Bed", "shutters.bed_shutter_cmd", y=8), + { + "id": "load", + "type": "bar", + "title": "House draw", + "layout": _at(x=6, y=6, w=6, h=4), + "config": { + "message": "power.out_w", + "min": 0, + "max": 3000, + "unit": "W", + "precision": 0, + "inner": [{"message": "power.pv_w", "dtype": "float"}], + }, + }, + gauge("soc", "Battery", "power.soc", 0, 100, "%", x=12, y=6, w=4, h=4), +] + +COMFORT = [ + { + "id": "forecast", + "type": "forecast", + "title": "The next few days", + "layout": _at(x=0, y=0, w=8, h=3), + "config": {"message": "weather.forecast", "count": 4}, + }, + { + "id": "agenda", + "type": "agenda", + "title": "Coming up", + "layout": _at(x=8, y=0, w=8, h=3), + "config": {"message": "calendar.events", "count": 5}, + }, + slider("preset", "Wanted", "climate.preset", 16, 26, 0.5, "°C", x=0, y=3, w=5, h=2), + stat("setpoint", "Band", "climate.t_max", "°C", 1, x=5, y=3, w=3, h=2), + switch("ac", "Heat pump enabled", "hvac.enabled", x=8, y=3, w=4, h=2), + stat( + "hvac_why", + "Heat pump", + "hvac.why", + dtype="str", + precision=0, + x=12, + y=3, + w=4, + h=2, + ), + switch("oven", "Pellet stove", "oven.oven_manual", x=0, y=5, w=4, h=2), + stat("oven_why", "Stove", "oven.why", dtype="str", precision=0, x=4, y=5, w=4, h=2), + stat( + "window_why", + "Window", + "window.why", + dtype="str", + precision=0, + x=8, + y=5, + w=4, + h=2, + ), + stat( + "canopy_why", + "Awning", + "canopy.why", + dtype="str", + precision=0, + x=12, + y=5, + w=4, + h=2, + ), + *_cover("window", "Window", "window.window_manual", y=7), + *_cover("canopy", "Awning", "canopy.canopy_manual", y=9), + switch("outdoor", "Outdoor plug", "outdoor.outdoor_manual", x=6, y=7, w=4, h=2), + stat( + "outdoor_why", + "Outdoor plug", + "outdoor.why", + dtype="str", + precision=0, + x=10, + y=7, + w=6, + h=2, + ), + chart( + "climate_chart", + "Inside and out", + "history.climate_request", + "history.climate_series", + "°C", + x=6, + y=9, + w=10, + h=5, + ), +] + +ENERGY = [ + gauge("pv", "Solar", "power.pv_w", 0, 3000, "W", x=0, y=0, w=4, h=4), + gauge("draw", "House", "power.out_w", 0, 3000, "W", x=4, y=0, w=4, h=4), + gauge("grid", "Mains", "power.in_v", 0, 260, "V", x=8, y=0, w=4, h=4), + gauge("charge", "Battery", "power.soc", 0, 100, "%", x=12, y=0, w=4, h=4), + chart( + "power_chart", + "Where the power went", + "history.power_request", + "history.power_series", + "W", + x=0, + y=4, + w=16, + h=6, + ), + chart( + "battery_chart", + "State of charge", + "history.battery_request", + "history.battery_series", + "%", + x=0, + y=10, + w=16, + h=5, + ), +] + +SCREENS = ( + ("home", "Home", "house", HOME), + ("comfort", "Comfort", "thermometer", COMFORT), + ("energy", "Energy", "zap", ENERGY), +) diff --git a/scripts/tinyhouse/library.py b/scripts/tinyhouse/library.py new file mode 100644 index 0000000..e74fe7f --- /dev/null +++ b/scripts/tinyhouse/library.py @@ -0,0 +1,215 @@ +"""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"}], + } diff --git a/scripts/tinyhouse/screens.py b/scripts/tinyhouse/screens.py new file mode 100644 index 0000000..0abf30d --- /dev/null +++ b/scripts/tinyhouse/screens.py @@ -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 diff --git a/scripts/tinyhouse/sensing.py b/scripts/tinyhouse/sensing.py new file mode 100644 index 0000000..1347055 --- /dev/null +++ b/scripts/tinyhouse/sensing.py @@ -0,0 +1,1096 @@ +"""What the house knows: the time, the power, the weather, who is in. + +Nothing here commands anything. These flows are the ones to start first — +they can run beside Node-RED for as long as it takes to trust them, because +all they do is read. + +The heartbeat is gone. Node-RED published a timestamp every three seconds and +fourteen controllers re-evaluated everything on every tick; here a sensor +value *is* the event, and the only clock left is the one whose consumers +genuinely need to know what hour it is. +""" + +from __future__ import annotations + +from typing import Any + +from .api import Flow + +# ── clock ──────────────────────────────────────────────────────────────── + +CLOCK = '''"""The wall clock, for the rules that are about the time of day. + +A minute is as fine as any rule here needs. Consumers that act hourly put a +filter-on-change on `hour` and see one message an hour rather than sixty. +""" + +import time + + +def process(tick): + now = time.localtime() + month = now.tm_mon + return { + "hour": now.tm_hour, + "minute": now.tm_min, + "weekday": now.tm_wday, + "is_weekend": now.tm_wday >= 5, + "month": month, + # How far into winter we are, 0 in June and 1 in December. The + # reference spread this across four controllers as + # 0.7*(9.5-t_outdoor)/9.5 and friends; the part that is about the + # calendar rather than the thermometer belongs here, once. + "winter": abs(6 - month) / 6.0, + } +''' + + +def clock() -> Flow: + flow = Flow("clock", "Clock") + flow.add( + { + "id": "tick", + "type": "inject", + "title": "Every minute", + "params": {"interval": 60, "at_start": True}, + "provides": [{"name": "tick", "dtype": "float"}], + } + ) + flow.add( + { + "id": "clock", + "type": "python", + "title": "Time of day", + "requires": [{"name": "tick", "dtype": "float"}], + "provides": [ + {"name": "hour", "dtype": "int"}, + {"name": "minute", "dtype": "int"}, + {"name": "weekday", "dtype": "int"}, + {"name": "is_weekend", "dtype": "bool"}, + {"name": "month", "dtype": "int"}, + {"name": "winter", "dtype": "float"}, + ], + }, + CLOCK, + ) + return flow + + +# ── power ──────────────────────────────────────────────────────────────── + +METER = '''"""Two Shellys report the computer's own draw; unwrap what they send.""" + + +def process(system_raw=None, peripheral_raw=None): + out = {} + if isinstance(system_raw, dict): + out["system_w"] = float(system_raw.get("apower", 0.0)) + if isinstance(peripheral_raw, dict): + out["peripheral_w"] = float(peripheral_raw.get("apower", 0.0)) + return out or None +''' + +POWER_WATCH = '''"""How worried to be about the electricity, on one scale. + +Ported from the reference's `Power Watch`, which is the single most useful +thing in that installation: one number that every controller consults before +it switches anything on, and one sentence a person can act on. + +The ladder is ordered, and the first match wins. The thresholds are settings +because they are properties of this inverter and this battery bank. +""" + +LEVELS = { + 5: "Critical: mains is down and the battery is low.", + 4: "Attention: mains is off.", + 3: "Warning: mains voltage is below 180 volts.", + 2: "Warning: battery low, mains weak and not charging.", + 1: "Warning", + 0: "Power levels are normal.", +} + + +def process( + soc=100.0, + out_w=0.0, + in_w=0.0, + in_v=230.0, + pv_v=0.0, + hour=12, + low_soc=19.0, + overload_w=2900.0, + high_w=2500.0, +): + if in_v <= 20 and soc < low_soc: + level, why = 5, LEVELS[5] + elif in_v <= 20: + level, why = 4, LEVELS[4] + elif in_v <= 180: + level, why = 3, LEVELS[3] + elif soc < low_soc and in_w <= 10 and in_v < 190: + level, why = 2, LEVELS[2] + elif soc < low_soc and out_w <= 300: + level, why = 1, "Warning: battery low." + elif pv_v <= 1.0 and 10 <= hour < 16: + level, why = 1, "Warning: the solar system looks like it is not working." + elif out_w >= overload_w: + level, why = 1, "Warning: power overload." + elif out_w >= high_w: + level, why = 1, "Warning: power consumption is high." + else: + level, why = 0, LEVELS[0] + + return { + "watch": level, + # A record is what the notification widget draws, and what the ntfy + # node sends. One shape, both places. + "alert": { + "title": "Power" if level else "Power normal", + "body": why, + "severity": "error" if level >= 4 else "warning" if level else "info", + }, + "on_grid": in_v > 195, + } +''' + + +def power(h: dict[str, Any]) -> Flow: + """Victron over its own broker, the two Shellys over the house one.""" + flow = Flow("power", "Power") + victron = h["victron"] + prefix = f"N/{victron['portal_id']}" + + flow.add( + { + "id": "keepalive_tick", + "type": "inject", + "title": "Every 30 seconds", + "params": {"interval": 30, "at_start": True}, + "provides": [{"name": "keepalive", "dtype": "str"}], + } + ) + flow.add( + { + "id": "keepalive", + "type": "mqtt", + "title": "Cerbo keepalive", + "params": { + "topic": {"keepalive": f"R/{victron['portal_id']}/keepalive"}, + "broker_host": victron["host"], + "broker_port": victron["port"], + "client_id": "fluksio-cerbo-keepalive", + }, + "requires": [{"name": "keepalive", "dtype": "str"}], + } + ) + flow.add( + { + "id": "cerbo", + "type": "mqtt", + "title": "Victron Cerbo GX", + "params": { + "topic": { + "pv_v": f"{prefix}/solarcharger/279/Pv/V", + "pv_w": f"{prefix}/solarcharger/279/Yield/Power", + "out_w": f"{prefix}/vebus/276/Ac/Out/L1/P", + "in_w": f"{prefix}/vebus/276/Ac/ActiveIn/L1/P", + "in_v": f"{prefix}/vebus/276/Ac/ActiveIn/L1/V", + "batt_v": f"{prefix}/battery/512/Dc/0/Voltage", + "soc": f"{prefix}/battery/512/Soc", + }, + # Every Victron path wraps its reading in an object. + "json_key": "value", + "broker_host": victron["host"], + "broker_port": victron["port"], + "client_id": "fluksio-cerbo", + }, + "provides": [ + {"name": "pv_v", "dtype": "float", "interval": 5.0}, + {"name": "pv_w", "dtype": "float", "interval": 5.0}, + {"name": "out_w", "dtype": "float", "interval": 5.0}, + {"name": "in_w", "dtype": "float", "interval": 5.0}, + {"name": "in_v", "dtype": "float", "interval": 5.0}, + {"name": "batt_v", "dtype": "float", "interval": 10.0}, + {"name": "soc", "dtype": "float", "interval": 10.0}, + ], + } + ) + flow.add( + { + "id": "shellys", + "type": "mqtt", + "title": "The computer's own draw", + "params": { + "topic": { + "system_raw": h["topics"]["power_system"], + "peripheral_raw": h["topics"]["power_peripheral"], + }, + **_broker(h, "fluksio-power"), + }, + "provides": [ + {"name": "system_raw", "dtype": "json", "interval": 10.0}, + {"name": "peripheral_raw", "dtype": "json", "interval": 10.0}, + ], + } + ) + flow.add( + { + "id": "meter", + "type": "python", + "title": "Computer power", + "requires": [ + {"name": "system_raw", "dtype": "json"}, + {"name": "peripheral_raw", "dtype": "json"}, + ], + "provides": [ + {"name": "system_w", "dtype": "float"}, + {"name": "peripheral_w", "dtype": "float"}, + ], + }, + METER, + ) + flow.add( + { + "id": "watch", + "type": "python", + "title": "Power watch", + "requires": [ + {"name": "soc", "dtype": "float"}, + {"name": "out_w", "dtype": "float"}, + {"name": "in_w", "dtype": "float"}, + {"name": "in_v", "dtype": "float"}, + {"name": "pv_v", "dtype": "float"}, + {"name": "clock.hour", "port": "hour", "dtype": "int"}, + ], + "provides": [ + {"name": "watch", "dtype": "int"}, + {"name": "alert", "dtype": "record"}, + {"name": "on_grid", "dtype": "bool"}, + ], + }, + POWER_WATCH, + ) + flow.add( + { + "id": "watch_changed", + "type": "rbe", + "title": "Only when it changes", + "requires": [{"name": "alert", "port": "alert", "dtype": "record"}], + "provides": [ + {"name": "alert_changed", "port": "alert_changed", "dtype": "record"} + ], + } + ) + flow.add( + { + "id": "say", + "type": "python", + "title": "Worth a push?", + "requires": [{"name": "alert_changed", "port": "alert", "dtype": "record"}], + "provides": [{"name": "push", "dtype": "str"}], + }, + '''"""A level going back to normal is worth knowing; an info line is not.""" + + +def process(alert): + if alert.get("severity") == "info" and "normal" not in alert.get("body", ""): + return None + return {"push": alert.get("body", "")} +''', + ) + flow.add( + { + "id": "push", + "type": "ntfy", + "title": "Push", + "params": { + "server": h["ntfy"]["server"], + "topic": h["ntfy"]["topic"], + "title": "Power levels", + "priority": "high", + }, + "requires": [{"name": "push", "dtype": "str"}], + } + ) + flow.add( + { + "id": "history", + "type": "influxdb", + "title": "Keep it", + "params": { + "url": h["influx"]["url"], + "token": {"$secret": "influx_token"}, + "org": h["influx"]["org"], + "bucket": h["influx"]["bucket"], + "writes": { + "soc": _point("ess/dc/battery/soc"), + "batt_v": _point("ess/dc/battery/voltage"), + "out_w": _point("ess/ac/out/power"), + "in_w": _point("ess/ac/in/power"), + "in_v": _point("ess/ac/in/voltage"), + "pv_w": _point("ess/dc/pv/power"), + }, + }, + "requires": [ + {"name": "soc", "dtype": "float", "interval": 60.0}, + {"name": "batt_v", "dtype": "float", "interval": 60.0}, + {"name": "out_w", "dtype": "float", "interval": 60.0}, + {"name": "in_w", "dtype": "float", "interval": 60.0}, + {"name": "in_v", "dtype": "float", "interval": 60.0}, + {"name": "pv_w", "dtype": "float", "interval": 60.0}, + ], + } + ) + return flow + + +def _point(measurement: str) -> dict[str, Any]: + return {"measurement": measurement, "field": "value", "tags": {"name": "TinyHouse"}} + + +def _broker(h: dict[str, Any], client_id: str) -> dict[str, Any]: + """The house broker, with a credential only if this one wants one.""" + broker = h["broker"] + params: dict[str, Any] = { + "broker_host": broker["host"], + "broker_port": broker["port"], + "client_id": client_id, + "qos": 0, + "retain": False, + } + if broker.get("username"): + params["username"] = broker["username"] + if broker.get("password_secret"): + params["password"] = {"$secret": broker["password_secret"]} + return params + + +# ── weather ────────────────────────────────────────────────────────────── + +ESP = '''"""What the two 433 MHz stations say, and what follows from it. + +Dewpoint is the reference's approximation rather than Magnus: the number is +only ever used as a difference between inside and out, where the error mostly +cancels, and changing it would move a threshold that was tuned against it. + +Rain rate is a rolling window because the station reports a running total. +""" + +import time + +WINDOW = 10 + + +def process(indoor_raw=None, outdoor_raw=None, state=None): + state = dict(state or {}) + now = time.time() + out = {} + + if isinstance(indoor_raw, dict): + temp = float(indoor_raw.get("temp_c", 0.0)) + hum = float(indoor_raw.get("humidity", 0.0)) + out["indoor_temp"] = temp + out["indoor_hum"] = hum + out["indoor_dewpoint"] = round(temp - (100 - hum) / 5.0, 1) + out["indoor_battery_ok"] = bool(indoor_raw.get("battery_ok", 1)) + + if isinstance(outdoor_raw, dict): + temp = float(outdoor_raw.get("temp_c", 0.0)) + hum = float(outdoor_raw.get("humidity", 0.0)) + rain = float(outdoor_raw.get("rain", 0.0)) + out["station_temp"] = temp + out["outdoor_hum"] = hum + out["outdoor_dewpoint"] = round(temp - (100 - hum) / 5.0, 1) + out["wind"] = float(outdoor_raw.get("wind_avg", 0.0)) + out["light"] = float(outdoor_raw.get("light_klx", 0.0)) + out["outdoor_battery_ok"] = bool(outdoor_raw.get("battery_ok", 1)) + out["station_seen"] = now + + last_rain = state.get("rain") + last_seen = state.get("seen", now) + minutes = (now - last_seen) / 60.0 + history = [] + if last_rain is not None and minutes > 0: + history = [ + *(state.get("history") or []), + [max(0.0, rain - last_rain), minutes], + ][-WINDOW:] + fell = sum(step for step, _ in history) + over = sum(span for _, span in history) + out["rainrate"] = round(fell / over, 3) if over else 0.0 + state = {"rain": rain, "seen": now} + out["_history"] = history + + if "_history" in out: + # The window is a list, which a record may not hold: it rides in the + # json state message instead. + out["state"] = {**state, "history": out.pop("_history")} + return out or None +''' + +BLEND = '''"""Trust the thermometer while it is fresh, the forecast once it is not. + +The outdoor station is a battery-powered 433 MHz sender in a garden. It goes +quiet, and everything that decides whether to open a window reads what it +said. The reference's four-step ladder is kept as it stands, because the +thresholds were arrived at by watching this station rather than derived. +""" + +import time + +STEPS = ((20000, 0.95), (40000, 0.70), (60000, 0.35)) + + +def process(station_temp=None, forecast_temp=None, station_seen=0.0): + if forecast_temp is None: + return None + if station_temp is None or not station_seen: + return {"outdoor_temp": float(forecast_temp), "station_trust": 0.0} + + age = time.time() - station_seen + trust = 0.05 + for limit, weight in STEPS: + if age < limit: + trust = weight + break + + return { + "outdoor_temp": round((1 - trust) * forecast_temp + trust * station_temp, 2), + "station_trust": trust, + } +''' + +FORECAST = '''"""OpenWeatherMap's answer, in the shapes the rest of the house asks for. + +Three consumers, three shapes: a strip of icons for the screen, today's and +tomorrow's extremes for the rules that are about the season, and whether it is +about to rain for the one that decides about the awning. +""" + +ICONS = { + "Clear": ("sun", "warning"), + "Clouds": ("cloud", "muted"), + "Rain": ("cloud-rain", "primary"), + "Drizzle": ("cloud-rain", "primary"), + "Snow": ("snowflake", "primary"), + "Thunderstorm": ("zap", "danger"), +} + + +def _icon(main): + return ICONS.get(main, ("cloud", "muted")) + + +def process(weather, days=4): + daily = weather.get("daily") or [] + hourly = weather.get("hourly") or [] + current = weather.get("current") or {} + if not daily: + return None + + today, tomorrow = daily[0], daily[1] if len(daily) > 1 else daily[0] + strip = [] + for day in daily[:days]: + main = (day.get("weather") or [{}])[0].get("main", "") + icon, colour = _icon(main) + strip.append( + { + "label": main or "-", + "icon": icon, + "color": colour, + "value": f"{round(day['temp']['max'])}/{round(day['temp']['min'])}", + } + ) + + soon = hourly[:4] + rain_soon = any( + (hour.get("weather") or [{}])[0].get("main") in ("Rain", "Snow", "Thunderstorm") + for hour in soon + ) + + return { + "forecast": strip, + "forecast_temp": float(current.get("temp", today["temp"]["day"])), + "today_max": float(today["temp"]["max"]), + "today_min": float(today["temp"]["min"]), + "tomorrow_day": float(tomorrow["temp"]["day"]), + "tomorrow_clouds": float(tomorrow.get("clouds", 100)), + "rain_soon": rain_soon, + "sunrise": float(current.get("sunrise", 0)), + "sunset": float(current.get("sunset", 0)), + } +''' + +SEASON = '''"""Which half of the year the house is in, as the plugs and the stove see it. + +The reference decided this twice with the same two numbers — once to relabel a +dashboard button and once to pick which logic owned the outdoor plug. It is +one question, so it is answered once, here. +""" + + +def process(today_max=15.0, today_min=5.0, warm=15.0, cold=4.0, heating_below=17.0): + return { + "watering_season": today_max > warm and today_min > cold, + "frost_season": today_max < warm and today_min < cold, + "heating_season": today_max < heating_below or today_min < 9.5, + } +''' + +DAYLIGHT = '''"""Before sunrise, daylight, after sunset — from the forecast's own times.""" + +import time + + +def process(sunrise=0.0, sunset=0.0, tick=0.0): + now = time.time() + if not sunrise or not sunset: + return None + if now < sunrise: + return {"daylight": False, "phase": "before"} + if now < sunset: + return {"daylight": True, "phase": "day"} + return {"daylight": False, "phase": "after"} +''' + +BATTERIES = '''"""One line about whichever sensor battery is flat, at most once an hour.""" + + +def process(indoor_battery_ok=True, outdoor_battery_ok=True, th2_battery=100.0): + flat = [] + if not indoor_battery_ok: + flat.append("the indoor sensor") + if not outdoor_battery_ok: + flat.append("the outdoor sensor") + if th2_battery < 20: + flat.append("the sensor in TinyHouse 2") + if not flat: + return None + return {"battery_warning": "Battery low: " + ", ".join(flat) + "."} +''' + +TH2 = '''"""TinyHouse 2's Shelly H&T, which reports each reading on its own topic.""" + + +def process(temperature=None, humidity=None, power=None): + out = {} + if isinstance(temperature, dict): + out["th2_temp"] = float(temperature.get("tC", 0.0)) + if isinstance(humidity, dict): + out["th2_hum"] = float(humidity.get("rh", 0.0)) + if isinstance(power, dict): + out["th2_battery"] = float((power.get("battery") or {}).get("percent", 100)) + return out or None +''' + + +def weather(h: dict[str, Any]) -> Flow: + flow = Flow("weather", "Weather") + topics = h["topics"] + + flow.add( + { + "id": "stations", + "type": "mqtt", + "title": "433 MHz stations", + "params": { + "topic": { + "indoor_raw": topics["weather_indoor"], + "outdoor_raw": topics["weather_outdoor"], + }, + **_broker(h, "fluksio-weather"), + }, + "provides": [ + {"name": "indoor_raw", "dtype": "json"}, + {"name": "outdoor_raw", "dtype": "json"}, + ], + } + ) + flow.add( + { + "id": "esp", + "type": "python", + "title": "Decode the stations", + "requires": [ + {"name": "indoor_raw", "dtype": "json"}, + {"name": "outdoor_raw", "dtype": "json"}, + { + "name": "esp_state", + "port": "state", + "dtype": "json", + "trigger": False, + }, + ], + "provides": [ + {"name": "indoor_temp", "dtype": "float"}, + {"name": "indoor_hum", "dtype": "float"}, + {"name": "indoor_dewpoint", "dtype": "float"}, + {"name": "indoor_battery_ok", "dtype": "bool"}, + {"name": "station_temp", "dtype": "float"}, + {"name": "outdoor_hum", "dtype": "float"}, + {"name": "outdoor_dewpoint", "dtype": "float"}, + {"name": "wind", "dtype": "float"}, + {"name": "light", "dtype": "float"}, + {"name": "outdoor_battery_ok", "dtype": "bool"}, + {"name": "station_seen", "dtype": "float"}, + {"name": "rainrate", "dtype": "float"}, + {"name": "esp_state", "port": "state", "dtype": "json"}, + ], + }, + ESP, + ) + flow.add( + { + "id": "th2_in", + "type": "mqtt", + "title": "TinyHouse 2", + "params": { + "topic": { + "temperature": topics["th2_temperature"], + "humidity": topics["th2_humidity"], + "power": topics["th2_battery"], + }, + **_broker(h, "fluksio-th2"), + }, + "provides": [ + {"name": "th2_temperature", "port": "temperature", "dtype": "json"}, + {"name": "th2_humidity", "port": "humidity", "dtype": "json"}, + {"name": "th2_power", "port": "power", "dtype": "json"}, + ], + } + ) + flow.add( + { + "id": "th2", + "type": "python", + "title": "Decode TinyHouse 2", + "requires": [ + {"name": "th2_temperature", "port": "temperature", "dtype": "json"}, + {"name": "th2_humidity", "port": "humidity", "dtype": "json"}, + {"name": "th2_power", "port": "power", "dtype": "json"}, + ], + "provides": [ + {"name": "th2_temp", "dtype": "float"}, + {"name": "th2_hum", "dtype": "float"}, + {"name": "th2_battery", "dtype": "float"}, + ], + }, + TH2, + ) + flow.add( + { + "id": "poll", + "type": "inject", + "title": "Every ten minutes", + "params": {"interval": 600, "at_start": True, "start_delay": 5.0}, + "provides": [{"name": "poll", "dtype": "float"}], + } + ) + flow.add( + { + "id": "owm", + "type": "http", + "title": "OpenWeatherMap", + "params": { + "url": "https://api.openweathermap.org/data/3.0/onecall", + "method": "GET", + # The key is a secret reference, not a value in the flow file — + # which is exactly what the reference got wrong. + "query": { + "lat": h["weather"]["lat"], + "lon": h["weather"]["lon"], + "units": h["weather"]["units"], + "exclude": "minutely,alerts", + "appid": {"$secret": "owm_appid"}, + }, + "send_inputs": False, + "timeout": 20.0, + }, + "requires": [{"name": "poll", "dtype": "float"}], + "provides": [ + {"name": "current", "dtype": "json"}, + {"name": "daily", "dtype": "json"}, + {"name": "hourly", "dtype": "json"}, + ], + } + ) + flow.add( + { + "id": "join_owm", + "type": "join", + "title": "One answer", + "params": {"mode": "object"}, + "requires": [ + {"name": "current", "dtype": "json"}, + {"name": "daily", "dtype": "json"}, + {"name": "hourly", "dtype": "json"}, + ], + "provides": [{"name": "owm", "dtype": "json"}], + } + ) + flow.add( + { + "id": "forecast", + "type": "python", + "title": "Shape the forecast", + "requires": [{"name": "owm", "port": "weather", "dtype": "json"}], + "provides": [ + {"name": "forecast", "dtype": "list", "item": "record"}, + {"name": "forecast_temp", "dtype": "float"}, + {"name": "today_max", "dtype": "float"}, + {"name": "today_min", "dtype": "float"}, + {"name": "tomorrow_day", "dtype": "float"}, + {"name": "tomorrow_clouds", "dtype": "float"}, + {"name": "rain_soon", "dtype": "bool"}, + {"name": "sunrise", "dtype": "float"}, + {"name": "sunset", "dtype": "float"}, + ], + }, + FORECAST, + ) + flow.add( + { + "id": "blend", + "type": "python", + "title": "Sensor or forecast", + # The forecast is what wakes this: the station is a battery sender + # in a garden and is currently reporting itself dead, so a node + # waiting on it would take the whole house's outdoor temperature + # down with it. + "requires": [ + {"name": "station_temp", "dtype": "float", "trigger": False}, + {"name": "forecast_temp", "dtype": "float"}, + {"name": "station_seen", "dtype": "float", "trigger": False}, + ], + "provides": [ + {"name": "outdoor_temp", "dtype": "float"}, + {"name": "station_trust", "dtype": "float"}, + ], + }, + BLEND, + ) + flow.add( + { + "id": "season", + "type": "python", + "title": "Season", + "requires": [ + {"name": "today_max", "dtype": "float"}, + {"name": "today_min", "dtype": "float"}, + ], + "provides": [ + {"name": "watering_season", "dtype": "bool"}, + {"name": "frost_season", "dtype": "bool"}, + {"name": "heating_season", "dtype": "bool"}, + ], + }, + SEASON, + ) + flow.add( + { + "id": "daylight", + "type": "python", + "title": "Daylight", + "requires": [ + {"name": "sunrise", "dtype": "float", "trigger": False}, + {"name": "sunset", "dtype": "float", "trigger": False}, + {"name": "clock.minute", "port": "tick", "dtype": "int"}, + ], + "provides": [ + {"name": "daylight", "dtype": "bool"}, + {"name": "phase", "dtype": "str"}, + ], + }, + DAYLIGHT, + ) + flow.add( + { + "id": "dewpoint", + "type": "python", + "title": "Is it drier outside?", + "requires": [ + {"name": "indoor_dewpoint", "dtype": "float"}, + {"name": "outdoor_dewpoint", "dtype": "float", "trigger": False}, + ], + "provides": [{"name": "dewpoint_delta", "dtype": "float"}], + }, + '''"""The number the window opener is really about: how much drier it is out. + +Positive means opening a window takes moisture away. +""" + + +def process(indoor_dewpoint, outdoor_dewpoint=None): + if outdoor_dewpoint is None: + return None + return {"dewpoint_delta": round(indoor_dewpoint - outdoor_dewpoint, 1)} +''', + ) + flow.add( + { + "id": "batteries", + "type": "python", + "title": "Flat batteries", + "requires": [ + {"name": "indoor_battery_ok", "dtype": "bool"}, + {"name": "outdoor_battery_ok", "dtype": "bool", "trigger": False}, + {"name": "th2_battery", "dtype": "float", "trigger": False}, + ], + "provides": [{"name": "battery_warning", "dtype": "str"}], + }, + BATTERIES, + ) + flow.add( + { + "id": "battery_once", + "type": "delay", + "title": "At most hourly", + "params": {"interval": 3600.0}, + "requires": [{"name": "battery_warning", "dtype": "str"}], + "provides": [{"name": "battery_push", "dtype": "str"}], + } + ) + flow.add( + { + "id": "battery_ntfy", + "type": "ntfy", + "title": "Push", + "params": { + "server": h["ntfy"]["server"], + "topic": h["ntfy"]["topic"], + "title": "Sensor battery", + "priority": "default", + }, + "requires": [{"name": "battery_push", "dtype": "str"}], + } + ) + flow.add( + { + "id": "history", + "type": "influxdb", + "title": "Keep it", + "params": { + "url": h["influx"]["url"], + "token": {"$secret": "influx_token"}, + "org": h["influx"]["org"], + "bucket": h["influx"]["bucket"], + "writes": { + "indoor_temp": _point("environment/temperature/1"), + "outdoor_temp": _point("environment/temperature/2"), + "indoor_hum": _point("environment/humidity/1"), + "outdoor_hum": _point("environment/humidity/2"), + "th2_temp": { + "measurement": "environment/temperature/1", + "field": "value", + "tags": {"name": "TinyHouse2"}, + }, + }, + }, + "requires": [ + {"name": "indoor_temp", "dtype": "float", "interval": 60.0}, + {"name": "outdoor_temp", "dtype": "float", "interval": 60.0}, + {"name": "indoor_hum", "dtype": "float", "interval": 60.0}, + {"name": "outdoor_hum", "dtype": "float", "interval": 60.0}, + {"name": "th2_temp", "dtype": "float", "interval": 60.0}, + ], + } + ) + flow.input("esp_state", "json", {}) + return flow + + +# ── presence ───────────────────────────────────────────────────────────── + +USER_STATE = '''"""Who is in, and what they are doing — as far as the network can tell. + +The reference asked three questions of the WiFi and smoothed the answer over +ten samples, which is a way of saying "phones drop off and come back". The +connector's own away-debounce does that properly now, so what is left here is +the interpretation: a wired laptop means working, no device at all means out, +and the bed shutter being down means asleep. + +The numbers are the reference's, because every threshold downstream was tuned +against them: away 0, arrived 0.25, asleep 0.5, in 0.75, working 1. +""" + +import time + +LEVELS = {"away": 0.0, "arrived": 0.25, "asleep": 0.5, "home": 0.75, "working": 1.0} + + +def process( + anyone_home=False, + at_desk=False, + bed_down=False, + hour=12, + is_weekend=False, + memory=None, + arrived_s=600.0, + night_from=23, + morning_to=7, + weekend_morning_to=8, +): + now = time.time() + memory = dict(memory or {}) + home_since = memory.get("home_since", 0.0) + + if not anyone_home: + where = "away" + home_since = 0.0 + else: + if not home_since: + home_since = now + if bed_down: + where = "asleep" + elif at_desk: + where = "working" + elif now - home_since < arrived_s: + where = "arrived" + else: + where = "home" + + # Asleep is not only the bed: the small hours count even for someone who + # has not put the shutter down yet, which is what stops the house waking + # itself up at four in the morning. + wake = weekend_morning_to if is_weekend else morning_to + sleep_time = bool(bed_down or hour >= night_from or hour < wake) + + out = { + "state": where, + "level": LEVELS[where], + "home": where != "away", + "sleeping": where == "asleep", + "sleep_time": sleep_time, + "memory": {"state": where, "home_since": home_since}, + } + if where != memory.get("state"): + # A port left out of the answer publishes nothing, so the things that + # react to arriving and leaving — the radio, the outside light — hear + # about it once rather than every minute. + out["event"] = where + return out +''' + + +def presence(h: dict[str, Any]) -> Flow: + flow = Flow("presence", "Presence") + unifi = h["unifi"] + controller = { + "host": unifi["host"], + "port": unifi["port"], + "site": unifi["site"], + "unifi_os": unifi["unifi_os"], + "username": unifi["username"], + "password": {"$secret": "unifi_password"}, + "verify_tls": False, + "away_after": 300.0, + "poll_interval": 30.0, + } + flow.add( + { + "id": "network", + "type": "unifi_presence", + "title": "Who is on the network", + "params": {**controller, "track": unifi["track"]}, + "provides": [ + {"name": "anyone_home", "dtype": "bool"}, + {"name": "present", "dtype": "list", "item": "str"}, + {"name": "count", "dtype": "int"}, + ], + } + ) + flow.add( + { + "id": "desk", + "type": "unifi_presence", + "title": "The wired laptop", + # Its own node rather than a lookup into the other one's device + # map: that map is keyed by whatever name the controller has for a + # client, and a rename there should not change who is at a desk. + "params": { + **controller, + "track": [unifi["laptop_wired"]], + "away_after": 120.0, + }, + "provides": [{"name": "at_desk", "port": "anyone_home", "dtype": "bool"}], + } + ) + flow.add( + { + "id": "state", + "type": "python", + "title": "User state", + "requires": [ + {"name": "anyone_home", "dtype": "bool"}, + {"name": "at_desk", "dtype": "bool"}, + {"name": "shutters.bed_down", "port": "bed_down", "dtype": "bool"}, + {"name": "clock.hour", "port": "hour", "dtype": "int"}, + { + "name": "clock.is_weekend", + "port": "is_weekend", + "dtype": "bool", + "trigger": False, + }, + { + "name": "memory", + "port": "memory", + "dtype": "record", + "trigger": False, + }, + ], + "provides": [ + {"name": "state", "dtype": "str"}, + {"name": "level", "dtype": "float"}, + {"name": "home", "dtype": "bool"}, + {"name": "sleeping", "dtype": "bool"}, + {"name": "sleep_time", "dtype": "bool"}, + {"name": "event", "dtype": "str"}, + {"name": "memory", "port": "memory", "dtype": "record"}, + ], + }, + USER_STATE, + ) + flow.add( + { + "id": "history", + "type": "influxdb", + "title": "Keep it", + "params": { + "url": h["influx"]["url"], + "token": {"$secret": "influx_token"}, + "org": h["influx"]["org"], + "bucket": h["influx"]["bucket"], + "writes": {"level": _point("users/presence")}, + }, + "requires": [{"name": "level", "dtype": "float", "interval": 60.0}], + } + ) + flow.input("memory", "record", {}) + return flow + + +# ── calendar ───────────────────────────────────────────────────────────── + + +def calendar(h: dict[str, Any]) -> Flow: + flow = Flow("calendar", "Calendar") + flow.add( + { + "id": "caldav", + "type": "ical", + "title": "Shared calendar", + "params": { + "url": h["calendar"]["url"], + "username": h["calendar"]["username"], + "password": {"$secret": "caldav_password"}, + "horizon_hours": 96.0, + "poll_interval": 300.0, + }, + "provides": [ + {"name": "events", "dtype": "list", "item": "record"}, + {"name": "count", "dtype": "int"}, + {"name": "next_in", "dtype": "float"}, + ], + } + ) + return flow diff --git a/scripts/tinyhouse/test_library.py b/scripts/tinyhouse/test_library.py new file mode 100644 index 0000000..050ba71 --- /dev/null +++ b/scripts/tinyhouse/test_library.py @@ -0,0 +1,144 @@ +"""The two shared nodes, checked the way they will fail. + +Run it directly — it needs no framework and no installation: + + python scripts/tinyhouse/test_library.py + +These two are worth a check because everything else in the house is a table of +thresholds, while these carry state between runs and decide who wins. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from tinyhouse.library import ARBITER, MOTOR # noqa: E402 + + +def _load(source: str): + namespace: dict = {} + exec(compile(source, "", "exec"), namespace) + return namespace["process"] + + +arbiter = _load(ARBITER) +motor = _load(MOTOR) + + +# ── the arbiter ────────────────────────────────────────────────────────── + + +def test_the_house_decides_when_nobody_has_said_otherwise(): + first = arbiter(auto=True, manual=False) + assert first["command"] is True, "the first run adopts what is there" + + later = arbiter(auto=False, manual=first["manual"], state=first["state"]) + assert later["command"] is False, "and follows the house afterwards" + + +def test_a_person_wins_and_keeps_winning_for_the_hold(): + state = arbiter(auto=False, manual=False)["state"] + + pressed = arbiter(auto=False, manual=True, state=state, hold_s=3600) + assert pressed["command"] is True + + # The house asks for off, once a second, for an hour. It does not get it. + state = pressed["state"] + for _ in range(5): + again = arbiter(auto=False, manual=state["manual"], state=state, hold_s=3600) + assert again["command"] is True, "automation must not undo a person" + state = again["state"] + + +def test_the_house_takes_over_once_the_hold_has_run_out(): + state = arbiter(auto=False, manual=False)["state"] + pressed = arbiter(auto=False, manual=True, state=state, hold_s=3600) + + expired = dict(pressed["state"], override_until=time.time() - 1) + after = arbiter(auto=False, manual=expired["manual"], state=expired) + assert after["command"] is False + + +def test_a_hold_of_zero_lasts_until_something_forces_it(): + state = arbiter(auto=False, manual=False, hold_s=0)["state"] + pressed = arbiter(auto=False, manual=True, state=state, hold_s=0) + assert pressed["state"]["override_until"] == -1.0 + + held = arbiter( + auto=False, manual=pressed["manual"], state=pressed["state"], hold_s=0 + ) + assert held["command"] is True, "no expiry means no expiry" + + # Two in the morning arrives as a new timestamp. + forced = arbiter( + auto=False, + manual=held["manual"], + state=held["state"], + force_at=1_700_000_000.0, + force_value=False, + hold_s=0, + ) + assert forced["command"] is False, "a schedule outranks a forgotten override" + assert forced["state"]["override_until"] == 0.0 + + +def test_the_control_shows_what_actually_reached_the_fixture(): + """One tile reads and writes, so what it publishes is what it displays.""" + state = arbiter(auto=False, manual=False)["state"] + on = arbiter(auto=True, manual=False, state=state) + assert on["manual"] == on["command"] is True + + +# ── the motor ──────────────────────────────────────────────────────────── + + +def test_a_shutter_runs_for_as_long_as_that_direction_takes(): + down = motor(cmd="DOWN", up_s=26, down_s=28) + assert (down["run"], down["run_for"]) == ("DOWN", 28) + assert down["state"]["position"] == "DOWN" + + settled = dict(down["state"], moving_until=0.0) + up = motor(cmd="UP", state=settled, up_s=26, down_s=28) + assert (up["run"], up["run_for"]) == ("UP", 26) + + +def test_asking_again_for_where_it_already_is_does_nothing(): + down = motor(cmd="DOWN", up_s=26, down_s=28) + settled = dict(down["state"], moving_until=0.0) + assert motor(cmd="DOWN", state=settled) is None + + +def test_a_reversal_mid_travel_stops_rather_than_driving_both_ways(): + down = motor(cmd="DOWN", up_s=26, down_s=28) + assert down["state"]["moving_until"] > time.time() + + turn = motor(cmd="UP", state=down["state"], up_s=26, down_s=28) + assert turn["run"] == "STOP" and turn["run_for"] == 0.0 + assert turn["state"]["moving"] == "" + + +def test_stop_is_commanded_once_and_then_left_alone(): + """The reference sent STOP forever. This is the whole reason it does not.""" + down = motor(cmd="DOWN", up_s=26, down_s=28) + stopped = motor(cmd="STOP", state=down["state"]) + assert (stopped["run"], stopped["run_for"]) == ("STOP", 0.0) + assert motor(cmd="STOP", state=stopped["state"]) is None, "nothing more to say" + + +def test_a_run_is_over_when_it_has_had_its_time(): + """Nothing reports the end of a run, so the clock is the only witness.""" + down = motor(cmd="DOWN", up_s=26, down_s=28) + expired = dict(down["state"], moving_until=time.time() - 1) + assert motor(cmd="DOWN", state=expired) is None, "it is already there" + assert motor(cmd="UP", state=expired)["run"] == "UP", "and free to go back" + + +if __name__ == "__main__": + checks = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for check in checks: + check() + print(f"{len(checks)} checks passed")