"""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)