Files
stroblme cfb9e53d68 Rename the trigger node type to debounce, migrating stored flows
The store rewrites `"type": "trigger"` to `debounce` in every published and
draft flow when it opens, bumps the version and commits once. No alias stays
behind, so `trigger` is now an unknown type.
2026-10-15 09:34:40 +02:00

129 lines
4.6 KiB
Python

"""Debounce: send one thing now, another if nothing follows.
Node-RED calls this node *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 DebounceNode(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._debounce,
requires=requires,
provides=provides,
params=params,
name=name or "debounce",
)
def _outputs(self, value: Any) -> dict[str, Any]:
return {spec.port: value for spec in self.output_ports}
def _debounce(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)