Draw every flow as one graph, merged on what it talks to
A node type can now say which outside thing its parameters point at, and nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single neuron on a new /brain canvas. That makes the wiring which runs between flows through a broker visible for the first time; no single flow's canvas can show it. The key is read off stored parameters, so a credential reference never reaches an id. Layout is a d3 force simulation settled once and then frozen, lit by the socket the editor already listens to: a neuron pulses when any node behind it publishes, and its connections light as values pass. Fixes the message pulse while here: interpolating the stroke against the edge's `color-mix()` resting colour went through oklab and left the gamut, which turned every pulse on both canvases fluorescent yellow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Nodes talking to the same outside thing are one neuron, across flows.
|
||||
|
||||
That merge is the whole point of the brain graph: a broker topic is a single
|
||||
physical thing, and two flows sharing one are wired together through it even
|
||||
though neither canvas can draw the other end.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flow.controller import FlowController
|
||||
from app.flow.messages import DType, MessageSpec
|
||||
from app.flow.nodes import MqttNode
|
||||
from app.flow.schemas import FlowDef, NodeDef
|
||||
from app.flow.store import FlowStore
|
||||
|
||||
BROKER = {"broker_host": "mosquitto", "broker_port": 1883, "topic": "sensors/temp"}
|
||||
|
||||
|
||||
def subscriber(node_id: str) -> NodeDef:
|
||||
return NodeDef(
|
||||
id=node_id,
|
||||
type="mqtt",
|
||||
params=dict(BROKER),
|
||||
provides=[MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(tmp_path: Path) -> FlowController:
|
||||
store = FlowStore(tmp_path / "flows")
|
||||
store.write_flow(
|
||||
FlowDef(
|
||||
name="house",
|
||||
nodes=[
|
||||
subscriber("sensor"),
|
||||
NodeDef(
|
||||
id="scale",
|
||||
type="change",
|
||||
requires=[MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||
provides=[MessageSpec(name="scaled", dtype=DType.FLOAT)],
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
store.write_flow(FlowDef(name="shed", nodes=[subscriber("sensor")]))
|
||||
return FlowController(store)
|
||||
|
||||
|
||||
def test_two_flows_on_one_topic_are_one_neuron(controller: FlowController):
|
||||
graph = controller.brain_graph()
|
||||
|
||||
merged = [node for node in graph.nodes if node.kind == "mqtt"]
|
||||
assert len(merged) == 1
|
||||
assert merged[0].id == "mqtt:mosquitto:1883/sensors/temp"
|
||||
assert merged[0].members == ["house.sensor", "shed.sensor"]
|
||||
assert merged[0].flows == ["house", "shed"]
|
||||
|
||||
|
||||
def test_a_node_with_no_outside_thing_stays_its_own(controller: FlowController):
|
||||
graph = controller.brain_graph()
|
||||
|
||||
scale = next(node for node in graph.nodes if node.kind == "change")
|
||||
assert scale.id == "house.scale"
|
||||
assert scale.members == ["house.scale"]
|
||||
|
||||
|
||||
def test_an_edge_carries_the_qualified_message(controller: FlowController):
|
||||
graph = controller.brain_graph()
|
||||
|
||||
assert [(edge.source, edge.target, edge.messages) for edge in graph.edges] == [
|
||||
("mqtt:mosquitto:1883/sensors/temp", "house.scale", ["house.temp"])
|
||||
]
|
||||
|
||||
|
||||
def test_a_credential_never_reaches_the_key():
|
||||
# Stored params, so a secret is still a reference. Neither its name nor its
|
||||
# value belongs in something the browser gets to see.
|
||||
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
|
||||
|
||||
assert key == "mosquitto:1883/sensors/temp"
|
||||
Reference in New Issue
Block a user