nodes: what porting the house needed from the vocabulary

Four small things, each with a device behind it.

An MQTT filter now routes what it subscribed to. `+` and `#` reached the
broker and were then looked up in an exact-match dict, so every message a
wildcard subscription received was dropped in silence.

`json_key` lifts a value out of the object a device wraps it in — Victron
publishes `{"value": 47}` on every path, which was otherwise a Python node
per port.

The trigger node learned `passthrough` and `wait_port`, because how long to
wait can be a value rather than a constant: a rollershutter takes 26 seconds
up and 28 down. A wait of zero sends nothing afterwards and still cancels
what the last message scheduled, which is how a stop is commanded once
instead of forever.

The HTTP sender takes fixed `query` parameters, so an API key is a secret
reference rather than a message on the canvas, and `send_inputs` off for a
request whose inputs are only a trigger.

Also: `delay` accepts fractional seconds, and `TZ` reaches the container, so
a cron expression means local time. Left unset it is UTC, as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:16:08 +02:00
co-authored by Claude Opus 5
parent 3e7b161950
commit ba707c8051
9 changed files with 286 additions and 17 deletions
+59 -6
View File
@@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
PUBLISH_QUEUE_SIZE = 256
def topic_matches(filter_: str, topic: str) -> bool:
"""Does an MQTT topic filter cover this topic?
The broker already decided to send it, so this is only about which port it
belongs to. ``+`` stands for one level, ``#`` for the rest of them; a
filter with neither is an exact string, which is the common case and the
one the caller checks first.
"""
if filter_ == topic:
return True
parts = filter_.split("/")
levels = topic.split("/")
for i, part in enumerate(parts):
if part == "#":
# Everything before it already matched, and '#' takes the rest —
# including nothing at all, so 'a/#' covers 'a' as the spec says.
return True
if i >= len(levels):
return False
if part != "+" and part != levels[i]:
return False
return len(parts) == len(levels)
class MqttNode(Node):
"""
MQTT node that can act as a subscriber (trigger) or publisher (sender).
@@ -127,7 +151,9 @@ class MqttNode(Node):
"qos",
"retain",
"keepalive",
"json_keys",
"_topic_to_ports",
"_wildcards",
"_subscription_task",
"_mqtt_client",
"_stop_event",
@@ -149,6 +175,10 @@ class MqttNode(Node):
qos: int = 0
retain: bool = False
keepalive: int = 60
# Which key to lift out of a JSON object payload. A device that wraps
# its reading — Victron's ``{"value": 5}`` — is otherwise a Python node
# per port. One key for every port, or a per-port mapping.
json_key: str | dict[str, str] = ""
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
@@ -194,10 +224,20 @@ class MqttNode(Node):
else:
self.topics = {spec.port: cfg.topic for spec in ports}
# Reverse lookup for routing incoming payloads back to ports.
# Reverse lookup for routing incoming payloads back to ports. A filter
# holding a wildcard cannot be found by the incoming topic, so those
# are kept aside and walked when the exact lookup misses.
self._topic_to_ports: dict[str, list[str]] = {}
for port, topic in self.topics.items():
self._topic_to_ports.setdefault(topic, []).append(port)
self._wildcards = [t for t in self._topic_to_ports if "+" in t or "#" in t]
if isinstance(cfg.json_key, dict):
self.json_keys: dict[str, str] = dict(cfg.json_key)
else:
self.json_keys = (
{spec.port: cfg.json_key for spec in ports} if cfg.json_key else {}
)
self.broker_host = cfg.broker_host
self.broker_port = cfg.broker_port
@@ -488,6 +528,17 @@ class MqttNode(Node):
self.name,
)
def _ports_for(self, topic: str) -> list[str]:
"""Which ports an arriving topic feeds — exact mapping, then filters."""
ports = self._topic_to_ports.get(topic)
if ports is not None:
return ports
matched: list[str] = []
for pattern in self._wildcards:
if topic_matches(pattern, topic):
matched.extend(self._topic_to_ports[pattern])
return matched
async def _subscription_loop(self) -> None:
"""
Listen for MQTT messages and trigger the pipeline.
@@ -536,7 +587,7 @@ class MqttNode(Node):
)
# Find which port(s) this topic feeds
ports = self._topic_to_ports.get(incoming_topic, [])
ports = self._ports_for(incoming_topic)
if not ports:
logger.debug(
"[%s] No mapping for topic '%s', ignoring",
@@ -558,10 +609,12 @@ class MqttNode(Node):
if spec is None:
continue
# A JSON object may carry the port as a key;
# anything else is the value itself.
if isinstance(parsed, dict) and port in parsed:
value = parsed[port]
# A JSON object may carry the value under a
# named key — the port's own name, or whatever
# ``json_key`` says the device wraps it in.
key = self.json_keys.get(port, port)
if isinstance(parsed, dict) and key in parsed:
value = parsed[key]
else:
value = parsed