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
264 lines
7.4 KiB
Python
264 lines
7.4 KiB
Python
"""What the flow-logic nodes actually do.
|
|
|
|
These are the shapes a Node-RED instance is mostly made of, so their
|
|
behaviour is worth pinning rather than just their construction.
|
|
"""
|
|
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import (
|
|
ChangeNode,
|
|
ExecNode,
|
|
FileNode,
|
|
JoinNode,
|
|
RbeNode,
|
|
SwitchNode,
|
|
)
|
|
from fluksio.flow.pipeline import Pipeline
|
|
from fluksio.flow.state import MemoryState
|
|
|
|
|
|
def spec(name: str, dtype: DType = DType.FLOAT) -> MessageSpec:
|
|
return MessageSpec(name=name, port=name, dtype=dtype)
|
|
|
|
|
|
def place(node, flow: str = "f"):
|
|
"""Give a node a flow and a pipeline, as the controller would."""
|
|
node.assign_flow(flow, node.name)
|
|
Pipeline(nodes=[node], state=MemoryState())
|
|
return node
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Switch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def switch(**params) -> SwitchNode:
|
|
node = SwitchNode(
|
|
requires=[spec("temp")],
|
|
provides=[spec("hot"), spec("cold")],
|
|
params=params,
|
|
name="switch",
|
|
)
|
|
return place(node)
|
|
|
|
|
|
def test_a_value_leaves_through_the_branch_whose_rule_it_matches():
|
|
node = switch(
|
|
rules=[
|
|
{"port": "hot", "op": "gt", "value": 20},
|
|
{"port": "cold", "op": "lte", "value": 20},
|
|
]
|
|
)
|
|
|
|
assert node.execute({"f.temp": 25.0}) == {"f.hot": 25.0}
|
|
assert node.execute({"f.temp": 15.0}) == {"f.cold": 15.0}
|
|
|
|
|
|
def test_a_value_matching_nothing_goes_nowhere_by_default():
|
|
node = switch(rules=[{"port": "hot", "op": "gt", "value": 100}])
|
|
|
|
assert node.execute({"f.temp": 15.0}) is None
|
|
|
|
|
|
def test_an_otherwise_branch_catches_what_the_rules_missed():
|
|
node = switch(rules=[{"port": "hot", "op": "gt", "value": 100}], otherwise="cold")
|
|
|
|
assert node.execute({"f.temp": 15.0}) == {"f.cold": 15.0}
|
|
|
|
|
|
def test_a_rule_comparing_incompatible_things_does_not_take_the_flow_down():
|
|
node = switch(rules=[{"port": "hot", "op": "gt", "value": "twenty"}])
|
|
|
|
assert node.execute({"f.temp": 25.0}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Change
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def change(**params) -> ChangeNode:
|
|
node = ChangeNode(
|
|
requires=[spec("raw")],
|
|
provides=[spec("scaled")],
|
|
params=params,
|
|
name="change",
|
|
)
|
|
return place(node)
|
|
|
|
|
|
def test_scaling_and_offsetting_a_reading():
|
|
node = change(scale=0.1, offset=-273.15, round_to=2)
|
|
|
|
assert node.execute({"f.raw": 3000.0}) == {"f.scaled": 26.85}
|
|
|
|
|
|
def test_mapping_one_value_onto_another():
|
|
# The mapped output is text, and the port has to say so — the type check
|
|
# between nodes applies to a change node like any other.
|
|
node = ChangeNode(
|
|
requires=[spec("raw")],
|
|
provides=[spec("label", DType.STR)],
|
|
params={"mapping": {"1": "on", "0": "off"}, "default": "unknown"},
|
|
name="change",
|
|
)
|
|
place(node)
|
|
|
|
assert node.execute({"f.raw": 1}) == {"f.label": "on"}
|
|
assert node.execute({"f.raw": 7}) == {"f.label": "unknown"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filter unchanged
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def rbe(**params) -> RbeNode:
|
|
node = RbeNode(
|
|
requires=[spec("temp")],
|
|
provides=[spec("changed")],
|
|
params=params,
|
|
name="rbe",
|
|
)
|
|
return place(node)
|
|
|
|
|
|
def test_the_same_reading_twice_only_passes_once():
|
|
node = rbe()
|
|
|
|
assert node.execute({"f.temp": 21.0}) == {"f.changed": 21.0}
|
|
assert node.execute({"f.temp": 21.0}) is None
|
|
assert node.execute({"f.temp": 22.0}) == {"f.changed": 22.0}
|
|
|
|
|
|
def test_a_deadband_swallows_the_jitter():
|
|
node = rbe(deadband=1.0)
|
|
|
|
assert node.execute({"f.temp": 21.0}) == {"f.changed": 21.0}
|
|
assert node.execute({"f.temp": 21.4}) is None
|
|
assert node.execute({"f.temp": 22.5}) == {"f.changed": 22.5}
|
|
|
|
|
|
def test_a_falsy_first_reading_still_counts_as_new():
|
|
"""Zero is a reading, not the absence of one."""
|
|
node = rbe()
|
|
|
|
assert node.execute({"f.temp": 0.0}) == {"f.changed": 0.0}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Join
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_join_gathers_its_inputs_into_one_message():
|
|
node = JoinNode(
|
|
requires=[spec("temp"), spec("humidity")],
|
|
provides=[spec("reading", DType.JSON)],
|
|
params={"mode": "object"},
|
|
name="join",
|
|
)
|
|
place(node)
|
|
|
|
result = node.execute({"f.temp": 21.0, "f.humidity": 40.0})
|
|
|
|
assert result == {"f.reading": {"temp": 21.0, "humidity": 40.0}}
|
|
|
|
|
|
def test_join_can_produce_a_list_instead():
|
|
node = JoinNode(
|
|
requires=[spec("a"), spec("b")],
|
|
provides=[spec("both", DType.JSON)],
|
|
params={"mode": "array"},
|
|
name="join",
|
|
)
|
|
place(node)
|
|
|
|
assert node.execute({"f.a": 1.0, "f.b": 2.0}) == {"f.both": [1.0, 2.0]}
|
|
|
|
|
|
def test_join_waits_for_every_input_to_be_fresh():
|
|
node = JoinNode(
|
|
requires=[spec("a"), spec("b")],
|
|
provides=[spec("both", DType.JSON)],
|
|
name="join",
|
|
)
|
|
|
|
# Otherwise one input arriving would re-emit the previous combination.
|
|
assert node.synchronous
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command and file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_command_hands_back_what_it_printed():
|
|
node = ExecNode(
|
|
requires=[spec("go", DType.BOOL)],
|
|
provides=[spec("stdout", DType.STR)],
|
|
params={"command": "echo hello"},
|
|
name="exec",
|
|
)
|
|
place(node)
|
|
|
|
assert node.execute({"f.go": True}) == {"f.stdout": "hello\n"}
|
|
|
|
|
|
def test_a_command_that_is_not_installed_says_so():
|
|
node = ExecNode(
|
|
requires=[spec("go", DType.BOOL)],
|
|
provides=[spec("stdout", DType.STR)],
|
|
params={"command": "definitely-not-a-real-command"},
|
|
name="exec",
|
|
)
|
|
place(node)
|
|
|
|
try:
|
|
node.execute({"f.go": True})
|
|
except FileNotFoundError as exc:
|
|
assert "not available in this container" in str(exc)
|
|
else: # pragma: no cover - the command really should not exist
|
|
raise AssertionError("expected a FileNotFoundError")
|
|
|
|
|
|
def test_a_file_node_refuses_to_leave_its_directory():
|
|
node = FileNode(
|
|
provides=[spec("contents", DType.STR)],
|
|
params={"path": "../../secrets.enc", "mode": "read"},
|
|
name="file",
|
|
)
|
|
place(node)
|
|
|
|
try:
|
|
node.execute({})
|
|
except ValueError as exc:
|
|
assert "outside the files directory" in str(exc)
|
|
else: # pragma: no cover
|
|
raise AssertionError("expected the path to be refused")
|
|
|
|
|
|
def test_a_file_round_trips_through_the_sandbox(tmp_path, monkeypatch):
|
|
from fluksio.core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "FLOWS_DIR", tmp_path / "flows")
|
|
|
|
writer = FileNode(
|
|
requires=[spec("line", DType.STR)],
|
|
params={"path": "readings.log", "mode": "append"},
|
|
name="writer",
|
|
)
|
|
place(writer)
|
|
writer.execute({"f.line": "21.0"})
|
|
writer.execute({"f.line": "22.0"})
|
|
|
|
reader = FileNode(
|
|
provides=[spec("contents", DType.STR)],
|
|
params={"path": "readings.log", "mode": "read"},
|
|
name="reader",
|
|
)
|
|
place(reader)
|
|
|
|
assert reader.execute({}) == {"f.contents": "21.0\n22.0\n"}
|