Follows the portal: the noun is "instance" everywhere the app says it — UI strings, CLI output, error details, docs and comments. The wire keys (`instance_id`, `instance_token`) and the hub route this calls move with it. An existing cloud.json is adopted rather than refused: without the key alias the dataclass fails to parse, which the caller swallows and reads as "never enrolled" instead of "reconnect". `instance_key` on a node type becomes `target_key`. It means the outside thing a node points at, which is a different sense of the word, and keeping both would put two meanings of "instance" in one codebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
295 lines
9.7 KiB
Python
295 lines
9.7 KiB
Python
"""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 instance, 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
|
|
instance 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()
|