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:
@@ -42,6 +42,54 @@ FIXTURES: dict[str, dict] = {
|
||||
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||
"provides": [MessageSpec(name="score", dtype=DType.FLOAT)],
|
||||
},
|
||||
"inject": {
|
||||
"params": {"payload": 1.0, "interval": 60},
|
||||
"requires": [],
|
||||
"provides": [MessageSpec(name="tick", dtype=DType.FLOAT)],
|
||||
},
|
||||
"switch": {
|
||||
"params": {"rules": [{"port": "hot", "op": "gt", "value": 20}]},
|
||||
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||
"provides": [MessageSpec(name="hot", dtype=DType.FLOAT)],
|
||||
},
|
||||
"change": {
|
||||
"params": {"scale": 0.1, "offset": -273.15},
|
||||
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||
"provides": [MessageSpec(name="celsius", dtype=DType.FLOAT)],
|
||||
},
|
||||
"rbe": {
|
||||
"params": {"deadband": 0.5},
|
||||
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||
"provides": [MessageSpec(name="changed", dtype=DType.FLOAT)],
|
||||
},
|
||||
"join": {
|
||||
"params": {"mode": "object"},
|
||||
"requires": [
|
||||
MessageSpec(name="temp", dtype=DType.FLOAT),
|
||||
MessageSpec(name="humidity", dtype=DType.FLOAT),
|
||||
],
|
||||
"provides": [MessageSpec(name="reading", dtype=DType.JSON)],
|
||||
},
|
||||
"trigger": {
|
||||
"params": {"first": True, "then": False, "wait": 120},
|
||||
"requires": [MessageSpec(name="motion", dtype=DType.BOOL)],
|
||||
"provides": [MessageSpec(name="light", dtype=DType.BOOL)],
|
||||
},
|
||||
"exec": {
|
||||
"params": {"command": "echo hello"},
|
||||
"requires": [MessageSpec(name="go", dtype=DType.BOOL)],
|
||||
"provides": [MessageSpec(name="stdout", dtype=DType.STR)],
|
||||
},
|
||||
"file": {
|
||||
"params": {"path": "readings.log", "mode": "append"},
|
||||
"requires": [MessageSpec(name="line", dtype=DType.STR)],
|
||||
"provides": [],
|
||||
},
|
||||
"ntfy": {
|
||||
"params": {"topic": "house", "title": "Alert"},
|
||||
"requires": [MessageSpec(name="text", dtype=DType.STR)],
|
||||
"provides": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user