Files
app/backend/fluksio/flow/nodes/logic.py
T
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
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
2026-08-31 10:12:01 +02:00

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