"""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. """ 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.", ) __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 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. generation = int(self.recall("generation", 0)) + 1 self.remember("generation", generation) self.remember("armed", 1) if self.cfg.then is not None and self._pipeline is not None: deferred = self._outputs(self.cfg.then) scheduled = self._pipeline.defer( self, self._to_messages(deferred) or {}, self.cfg.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) return self._outputs(self.cfg.first)