"""The flow-logic vocabulary: routing, mapping, filtering and joining. Anything here could be written as a `python` node — that is what the function node is for. These exist because the same handful of shapes account for most of a real installation, and a rule you fill in is easier to read on a canvas, and to change, than five lines of code repeated eighty times. """ from __future__ import annotations import logging from collections.abc import Iterable from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from fluksio.flow.messages import MessageSpec from fluksio.flow.nodes.base import Node logger = logging.getLogger(__name__) Comparison = Literal["eq", "ne", "gt", "gte", "lt", "lte", "contains", "between"] def compare(value: Any, op: Comparison, operand: Any, operand2: Any = None) -> bool: """Evaluate one rule against one value, never raising on a bad pairing.""" try: if op == "eq": return bool(value == operand) if op == "ne": return bool(value != operand) if op == "gt": return bool(value > operand) if op == "gte": return bool(value >= operand) if op == "lt": return bool(value < operand) if op == "lte": return bool(value <= operand) if op == "contains": return operand in value if op == "between": return bool(operand <= value <= operand2) except TypeError: # Comparing a string to a number is a mistake in the rule, not a # reason to take the flow down. return False return False class SwitchNode(Node): """Send a value down one branch or another, by rule. Each rule names an output port; a value that matches leaves through that port and nothing else. Node-RED's *switch*. """ class Rule(BaseModel): port: str op: Comparison = "eq" value: Any = None # Only for ``between``. value2: Any = None class Params(BaseModel): model_config = ConfigDict(extra="allow") rules: list[SwitchNode.Rule] = Field( default_factory=list, description="Checked in order. Each names the output it routes to.", ) stop_at_first: bool = Field( default=True, description="Leave through the first matching rule only.", ) otherwise: str = Field( default="", description="Output for a value that matched nothing.", ) 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._route, requires=requires, provides=provides, params=params, name=name or "switch", ) def _route(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: if not kwargs: return None # One input; routing several at once has no obvious meaning. value = next(iter(kwargs.values())) out: dict[str, Any] = {} for rule in self.cfg.rules: if compare(value, rule.op, rule.value, rule.value2): out[rule.port] = value if self.cfg.stop_at_first: return out if not out and self.cfg.otherwise: out[self.cfg.otherwise] = value return out or None class ChangeNode(Node): """Reshape a value on its way past: scale, offset, map, or replace. Node-RED's *change*, which is the second most common node in a real installation after the function. """ class Params(BaseModel): model_config = ConfigDict(extra="allow") scale: float = Field(default=1.0, description="Multiply numbers by this.") offset: float = Field(default=0.0, description="Then add this.") round_to: int | None = Field( default=None, description="Decimal places to round to, if any." ) mapping: dict[str, Any] = Field( default_factory=dict, description="Replace a value with another, looked up as text.", ) default: Any = Field( default=None, description="Value to use when the lookup misses. Empty passes it through.", ) 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._change, requires=requires, provides=provides, params=params, name=name or "change", ) def _convert(self, value: Any) -> Any: if self.cfg.mapping: key = str(value) if key in self.cfg.mapping: return self.cfg.mapping[key] if self.cfg.default is not None: return self.cfg.default return value if isinstance(value, bool) or not isinstance(value, (int, float)): return value converted = value * self.cfg.scale + self.cfg.offset if self.cfg.round_to is not None: converted = round(converted, self.cfg.round_to) return converted def _change(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: if not kwargs: return None pairs = list(zip(self.input_ports, self.output_ports, strict=False)) if not pairs: return None return { out.port: self._convert(kwargs[inp.port]) for inp, out in pairs if inp.port in kwargs } or None class RbeNode(Node): """Pass a value on only when it has actually changed. Node-RED's *rbe* (report by exception). A sensor that publishes the same reading every two seconds should not wake everything downstream of it. """ class Params(BaseModel): model_config = ConfigDict(extra="allow") deadband: float = Field( default=0.0, ge=0, description="Ignore numeric changes smaller than this.", ) deadband_percent: bool = Field( default=False, description="Read the deadband as a percentage instead." ) 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._filter, requires=requires, provides=provides, params=params, name=name or "rbe", ) def _changed(self, port: str, value: Any) -> bool: previous = self.recall(port, _MISSING) if previous is _MISSING: return True if self.cfg.deadband and isinstance(value, (int, float)): if isinstance(previous, (int, float)): span = abs(value - previous) if self.cfg.deadband_percent: scale = abs(previous) or 1.0 return (span / scale) * 100 >= self.cfg.deadband return span >= self.cfg.deadband return bool(value != previous) def _filter(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: pairs = list(zip(self.input_ports, self.output_ports, strict=False)) out: dict[str, Any] = {} for inp, outp in pairs: if inp.port not in kwargs: continue value = kwargs[inp.port] if self._changed(inp.port, value): self.remember(inp.port, value) out[outp.port] = value return out or None class JoinNode(Node): """Gather several inputs into one message. The engine already waits for every input a node declares, so joining is about the shape of the result: an object keyed by port, or a list. """ class Params(BaseModel): model_config = ConfigDict(extra="allow") mode: Literal["object", "array"] = Field( default="object", description="Combine inputs into an object or a list." ) 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 {}) # Every input has to be fresh, or a join would emit the same # combination each time any one of them arrived. params = dict(params or {}) params.setdefault("synchronous", True) super().__init__( f=self._join, requires=requires, provides=provides, params=params, name=name or "join", ) def _join(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None: if not kwargs or not self.output_ports: return None ordered = [spec.port for spec in self.input_ports if spec.port in kwargs] combined: Any if self.cfg.mode == "array": combined = [kwargs[port] for port in ordered] else: combined = {port: kwargs[port] for port in ordered} return {self.output_ports[0].port: combined} class _Missing: """Distinguishes "never seen" from a value that happens to be falsy.""" _MISSING = _Missing()