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>
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""Trigger: send one thing now, another if nothing follows.
|
|
|
|
Node-RED's *trigger*. The shape it exists for is "the door opened — turn the
|
|
light on, and off again in two minutes unless it opens again", which is
|
|
awkward to express any other way.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Iterable
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from fluksio.flow.messages import MessageSpec
|
|
from fluksio.flow.nodes.base import Node
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TriggerNode(Node):
|
|
"""Emit ``first`` on arrival, then ``then`` once the wait runs out.
|
|
|
|
A value arriving during the wait extends it, so the second emission only
|
|
happens when things have actually gone quiet. ``passthrough`` sends the
|
|
incoming value instead of ``first``, and ``wait_port`` names an input
|
|
carrying the wait, for the case where how long to wait is itself a value —
|
|
a shutter that takes 26 s up and 28 s down.
|
|
"""
|
|
|
|
class Params(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
first: Any = Field(default=True, description="Sent as soon as a value arrives.")
|
|
then: Any = Field(
|
|
default=False,
|
|
description="Sent when the wait expires. Empty sends nothing.",
|
|
)
|
|
wait: float = Field(
|
|
default=60.0, gt=0, description="Seconds of quiet before the second value."
|
|
)
|
|
extend: bool = Field(
|
|
default=True,
|
|
description="A value arriving during the wait starts it over.",
|
|
)
|
|
passthrough: bool = Field(
|
|
default=False,
|
|
description="Send the incoming value instead of 'first'.",
|
|
)
|
|
wait_port: str = Field(
|
|
default="",
|
|
description=(
|
|
"An input port carrying the wait in seconds, for a wait that "
|
|
"differs per message. Zero or less sends nothing afterwards."
|
|
),
|
|
)
|
|
|
|
__slots__ = ("cfg",)
|
|
|
|
def __init__(
|
|
self,
|
|
requires: MessageSpec | Iterable[MessageSpec] = (),
|
|
provides: MessageSpec | Iterable[MessageSpec] = (),
|
|
params: dict[str, Any] | None = None,
|
|
name: str | None = None,
|
|
):
|
|
self.cfg = self.Params.model_validate(params or {})
|
|
super().__init__(
|
|
f=self._trigger,
|
|
requires=requires,
|
|
provides=provides,
|
|
params=params,
|
|
name=name or "trigger",
|
|
)
|
|
|
|
def _outputs(self, value: Any) -> dict[str, Any]:
|
|
return {spec.port: value for spec in self.output_ports}
|
|
|
|
def _trigger(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
|
|
if not kwargs or not self.output_ports:
|
|
return None
|
|
|
|
wait = self.cfg.wait
|
|
if self.cfg.wait_port:
|
|
# A value port rather than a setting: a shutter's run-time differs
|
|
# by direction, and that is a message the graph carries.
|
|
wait = float(kwargs.get(self.cfg.wait_port, wait) or 0)
|
|
|
|
armed = self.recall("armed", 0)
|
|
if armed and not self.cfg.extend:
|
|
# Already counting down and not extending: ignore the new value.
|
|
return None
|
|
|
|
# A generation counter, so a value arriving mid-wait invalidates the
|
|
# deferred emission that was already scheduled. Bumped even when this
|
|
# message asks for no second value, which is how a wait is cancelled.
|
|
generation = int(self.recall("generation", 0)) + 1
|
|
self.remember("generation", generation)
|
|
self.remember("armed", 1 if wait > 0 else 0)
|
|
|
|
if self.cfg.then is not None and self._pipeline is not None and wait > 0:
|
|
deferred = self._outputs(self.cfg.then)
|
|
scheduled = self._pipeline.defer(
|
|
self,
|
|
self._to_messages(deferred) or {},
|
|
wait,
|
|
guard=("generation", generation),
|
|
)
|
|
if not scheduled:
|
|
logger.warning(
|
|
"Node '%s' cannot wait without a work queue; sending only "
|
|
"its first value",
|
|
self.name,
|
|
)
|
|
self.remember("armed", 0)
|
|
|
|
if self.cfg.passthrough:
|
|
# Whatever woke the node, minus the port that only said how long
|
|
# to wait. Several inputs are ambiguous, so the first one wins.
|
|
values = {
|
|
port: value
|
|
for port, value in kwargs.items()
|
|
if port != self.cfg.wait_port
|
|
}
|
|
if values:
|
|
return self._outputs(next(iter(values.values())))
|
|
return self._outputs(self.cfg.first)
|