Files
app/backend/fluksio/flow/nodes/trigger.py
T
stroblmeandClaude Opus 5 03ce2b9c73
Docs / docs (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
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>
2026-08-22 14:16:08 +02:00

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)