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