Add the vocabulary a real installation is mostly made of

Every one of these could be written as a function node, and until now had
to be. But nine node types account for most of what an actual Node-RED
installation contains — inject alone is placed 58 times, change 62 — and
a rule you fill in reads better on a canvas, and survives editing better,
than the same five lines repeated eighty times.

Inject, switch, change, filter-unchanged, join, trigger, command, file
and ntfy. They configure themselves through the editor's generated form,
so none of them needed frontend work beyond an icon.

The stateful ones (filter-unchanged, trigger) keep what they remember in
the engine's own state under a reserved prefix, never as a message. That
is the line: code you write is a pure function of its inputs, node types
the engine ships may remember things.

Verified against a live instance — a hook feeding change into
filter-unchanged into switch scales a raw reading, swallows a change
inside the deadband, and routes the rest to the right branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
root
2026-08-16 08:08:40 +02:00
co-authored by Claude Fable 5
parent dbdbcc1091
commit d9150c118d
15 changed files with 1304 additions and 2 deletions
+256
View File
@@ -0,0 +1,256 @@
"""What the flow-logic nodes actually do.
These are the shapes a Node-RED installation is mostly made of, so their
behaviour is worth pinning rather than just their construction.
"""
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import ChangeNode, ExecNode, FileNode, JoinNode, RbeNode, SwitchNode
from app.flow.pipeline import Pipeline
from app.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 app.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"}