Files
app/scripts/tinyhouse/screens.py
T
stroblmeandClaude Opus 5 1b455b5e55 history: one Flux script per chart, and rows are json
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>
2026-08-22 16:24:38 +02:00

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