Two things the installation found that the checks did not. A script with three `from()` statements in it produces three results all called `_result`, and InfluxDB refuses that outright — so the measurements go into one filter and the rows come back tagged with which one they are. And the answer a database node hands back holds a *list* of rows, which a record may not: a record is flat scalars. It was declared one, so every chart failed on the type check the moment a real answer arrived. The second one is now caught before anything is pushed: the preflight runs each sample shape past the port that would receive it, which is what turns "expected record, got dict" from a runtime surprise into a line of output. Also records the two engine faults this seeding session surfaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
506 lines
16 KiB
Python
506 lines
16 KiB
Python
"""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.
|
|
|
|
One script, not one per line: several `from()` statements in a single script
|
|
each produce a result called `_result`, and InfluxDB refuses a script that
|
|
names two the same. The measurements go into one filter instead, and the rows
|
|
come back tagged with which one they are.
|
|
"""
|
|
|
|
BUCKET = "{bucket}"
|
|
|
|
|
|
def process(chart_request, series=()):
|
|
span = int(chart_request["range_s"])
|
|
every = max(1, int(chart_request["interval_s"]))
|
|
wanted = " or ".join('r["_measurement"] == "%s"' % m for m in series)
|
|
flux = "\\n".join(
|
|
[
|
|
'from(bucket: "%s")' % BUCKET,
|
|
" |> range(start: -%ds)" % span,
|
|
" |> filter(fn: (r) => %s)" % (wanted or "false"),
|
|
' |> filter(fn: (r) => r["_field"] == "value")',
|
|
" |> aggregateWindow(every: %ds, fn: mean, createEmpty: false)" % every,
|
|
]
|
|
)
|
|
return {
|
|
"query": {
|
|
"flux": flux,
|
|
"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"}
|
|
],
|
|
# The answer holds a list of rows, and a record may only hold
|
|
# flat scalars.
|
|
"provides": [{"name": f"{name}_rows", "port": "rows", "dtype": "json"}],
|
|
}
|
|
)
|
|
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": "json"}],
|
|
"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",
|
|
"trigger": False,
|
|
},
|
|
],
|
|
"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
|