An inject left to itself emits the current time. That went into a port declared `str`, the node raised on every tick, and after five the supervisor quarantined the whole `power` flow — which is every Victron reading in the house. It was invisible because Node-RED is still sending its own keepalive, so the Cerbo kept publishing anyway; the first thing to notice would have been the data stopping some minutes after Node-RED did. The preflight now checks what an inject emits against the port that receives it. Nothing else could: an inject has no source to run, and both halves of the declaration agreed with each other while disagreeing with reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1127 lines
38 KiB
Python
1127 lines
38 KiB
Python
"""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",
|
|
# An empty payload, as the reference sends: the Cerbo only wants
|
|
# to know somebody is listening. Left to itself an inject emits
|
|
# the time, which is a float going into a string port.
|
|
"params": {"interval": 30, "at_start": True, "payload": ""},
|
|
"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"},
|
|
{
|
|
"name": "seen_trouble",
|
|
"port": "seen",
|
|
"dtype": "bool",
|
|
"trigger": False,
|
|
},
|
|
],
|
|
"provides": [
|
|
{"name": "push", "dtype": "str"},
|
|
{"name": "seen_trouble", "port": "seen_trouble", "dtype": "bool"},
|
|
],
|
|
},
|
|
'''"""A level going back to normal is worth knowing; the first one is not.
|
|
|
|
Recovery only reads as recovery if something went wrong first. An engine that
|
|
has just started has nothing to recover from, so it says nothing — otherwise
|
|
every restart pushes "power levels are normal" at somebody's phone.
|
|
"""
|
|
|
|
|
|
def process(alert, seen=False):
|
|
trouble = alert.get("severity") != "info"
|
|
if not trouble and not seen:
|
|
return {"seen_trouble": False}
|
|
return {"push": alert.get("body", ""), "seen_trouble": trouble}
|
|
''',
|
|
)
|
|
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.input("seen_trouble", "bool", False)
|
|
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"},
|
|
# Read, never waited on: this flow has to run while the
|
|
# shutters are still Node-RED's, which is most of the
|
|
# changeover. Someone going to bed is noticed on the next
|
|
# minute rather than the same second, which is soon enough.
|
|
{
|
|
"name": "shutters.bed_down",
|
|
"port": "bed_down",
|
|
"dtype": "bool",
|
|
"trigger": False,
|
|
},
|
|
{"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
|