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")