power: the Cerbo keepalive sends an empty payload, not the time

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>
This commit is contained in:
2026-08-22 16:40:44 +02:00
co-authored by Claude Opus 5
parent a102a2f57a
commit 13cdfa79b9
2 changed files with 35 additions and 1 deletions
+31
View File
@@ -110,6 +110,7 @@ def check(flows: list[Flow]) -> list[str]:
problems += _check_widgets(known) problems += _check_widgets(known)
problems += _check_sources(flows) problems += _check_sources(flows)
problems += _check_schemas(flows) problems += _check_schemas(flows)
problems += _check_injects(flows)
return problems return problems
@@ -308,6 +309,36 @@ def _rejects(spec: dict[str, Any], sample: Any) -> str:
return "" return ""
def _check_injects(flows: list[Flow]) -> list[str]:
"""What an inject emits has to be what its port declared.
An inject left to itself emits the current time, so a port expecting
anything but a number gets a float and the node raises — five times, and
the supervisor quarantines the flow. Nothing else catches it: an inject is
not a Python node, so it has no source to run, and both sides of the
declaration agree with each other while disagreeing with reality.
"""
problems = []
for flow in flows:
for node in flow.nodes:
if node["type"] != "inject":
continue
params = node.get("params", {})
payloads = dict(params.get("payloads") or {})
for spec in node.get("provides", []):
port = spec.get("port") or spec["name"]
if port in payloads:
value = payloads[port]
elif "payload" in params:
value = params["payload"]
else:
value = 0.0 # the current time, which is what it defaults to
bad = _rejects(spec, value)
if bad:
problems.append(f"{flow.name}.{node['id']} emits: {bad}")
return problems
def _check_schemas(flows: list[Flow]) -> list[str]: def _check_schemas(flows: list[Flow]) -> list[str]:
"""Hand every node to the engine's own models before the API sees them. """Hand every node to the engine's own models before the API sees them.
+4 -1
View File
@@ -165,7 +165,10 @@ def power(h: dict[str, Any]) -> Flow:
"id": "keepalive_tick", "id": "keepalive_tick",
"type": "inject", "type": "inject",
"title": "Every 30 seconds", "title": "Every 30 seconds",
"params": {"interval": 30, "at_start": True}, # 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"}], "provides": [{"name": "keepalive", "dtype": "str"}],
} }
) )