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