Connection nodes speak of listener and sender, and of send_queue_size

mqtt and http take websocket's vocabulary: their Mode is LISTENER/SENDER,
default names read mqtt_listener_… and http_listener_…, and mqtt's
publish_queue_size is send_queue_size. No alias stays behind, so the flow
store rewrites the old setting in every published and draft mqtt node when it
opens, in the same migration as trigger → debounce. Every mqtt and http
setting carries a one-line description for the editor, and the node-types
reference uses one wording for direction and says that an http sender
publishes a JSON object response on its outputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 09:44:22 +02:00
co-authored by Claude Opus 5.5
parent e98808e9d8
commit a1bdae3484
12 changed files with 241 additions and 160 deletions
+3 -3
View File
@@ -19,7 +19,7 @@ from fluksio.flow.store import FlowStore
BROKER = {"broker_host": "mosquitto", "broker_port": 1883, "topic": "sensors/temp"}
def subscriber(node_id: str) -> NodeDef:
def listener(node_id: str) -> NodeDef:
return NodeDef(
id=node_id,
type="mqtt",
@@ -35,7 +35,7 @@ def controller(tmp_path: Path) -> FlowController:
FlowDef(
name="house",
nodes=[
subscriber("sensor"),
listener("sensor"),
NodeDef(
id="scale",
type="change",
@@ -45,7 +45,7 @@ def controller(tmp_path: Path) -> FlowController:
],
)
)
store.write_flow(FlowDef(name="shed", nodes=[subscriber("sensor")]))
store.write_flow(FlowDef(name="shed", nodes=[listener("sensor")]))
return FlowController(store)
+4 -4
View File
@@ -29,7 +29,7 @@ def test_a_filter_does_not_cover_a_neighbouring_topic():
assert not topic_matches("sensors/living/temp", "sensors/living")
def _subscriber(topic, **params) -> MqttNode:
def _listener(topic, **params) -> MqttNode:
node = MqttNode(
provides=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
params={"topic": {"reading": topic}, **params},
@@ -40,7 +40,7 @@ def _subscriber(topic, **params) -> MqttNode:
def test_a_wildcard_subscription_routes_to_its_port():
"""It used to subscribe and then drop every message it was sent."""
node = _subscriber("shellypm/status/#")
node = _listener("shellypm/status/#")
assert node._ports_for("shellypm/status/pm1:0") == ["reading"]
assert node._ports_for("shellypmc/status/pm1:0") == []
@@ -63,12 +63,12 @@ def test_an_exact_topic_still_wins_over_a_filter():
def test_json_key_lifts_the_value_a_device_wraps():
"""Victron publishes {"value": 47} on every one of its topics."""
node = _subscriber("N/x/battery/Soc", json_key="value")
node = _listener("N/x/battery/Soc", json_key="value")
assert node.json_keys == {"reading": "value"}
def test_without_json_key_the_port_name_is_the_key_as_before():
node = _subscriber("sensors/temp")
node = _listener("sensors/temp")
assert node.json_keys == {}
+14 -14
View File
@@ -20,7 +20,7 @@ def test_http_senders_share_one_pooled_client():
close_shared_client()
def _publisher() -> MqttNode:
def _sender() -> MqttNode:
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}},
@@ -29,27 +29,27 @@ def _publisher() -> MqttNode:
return node
def test_a_started_publisher_queues_instead_of_connecting():
def test_a_started_sender_queues_instead_of_connecting():
"""The handler runs on a worker thread; it must not block on the broker."""
node = _publisher()
node = _sender()
async def scenario() -> None:
node._publish_queue = asyncio.Queue(maxsize=4)
node._send_queue = asyncio.Queue(maxsize=4)
node._loop = asyncio.get_running_loop()
await asyncio.to_thread(node._publisher_handler, {}, setpoint=21.0)
await asyncio.to_thread(node._sender_handler, {}, setpoint=21.0)
# call_soon_threadsafe lands on the next loop pass.
await asyncio.sleep(0)
assert node._publish_queue.qsize() == 1
assert node._publish_queue.get_nowait() == {"setpoint": 21.0}
assert node._send_queue.qsize() == 1
assert node._send_queue.get_nowait() == {"setpoint": 21.0}
asyncio.run(scenario())
def test_a_full_publish_queue_drops_the_oldest():
def test_a_full_send_queue_drops_the_oldest():
"""A broker that cannot keep up must not grow the queue without bound."""
node = _publisher()
node = _sender()
health: list[tuple[str, str | None]] = []
node._on_health = lambda _n, status, detail: health.append((status, detail))
@@ -61,7 +61,7 @@ def test_a_full_publish_queue_drops_the_oldest():
assert queue.qsize() == 2
assert queue.get_nowait() == {"setpoint": 2.0}
assert queue.get_nowait() == {"setpoint": 3.0}
assert health == [("degraded", "publish queue full")]
assert health == [("degraded", "send queue full")]
asyncio.run(scenario())
@@ -121,7 +121,7 @@ def test_the_configured_timeout_reaches_the_broker_client(monkeypatch):
assert seen["timeout"] == 2.5
def test_the_configured_backlog_reaches_the_publish_queue(monkeypatch):
def test_the_configured_backlog_reaches_the_send_queue(monkeypatch):
"""The depth is read when the queue is built, so it has to be per node."""
import aiomqtt
@@ -139,14 +139,14 @@ def test_the_configured_backlog_reaches_the_publish_queue(monkeypatch):
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}, "publish_queue_size": 8},
params={"topic": {"setpoint": "heating/setpoint"}, "send_queue_size": 8},
)
node.assign_flow("heating", "out")
async def scenario() -> int:
await node.start_publisher()
assert node._publish_queue is not None
size = node._publish_queue.maxsize
assert node._send_queue is not None
size = node._send_queue.maxsize
await node.stop_publisher()
return size
+14
View File
@@ -159,3 +159,17 @@ def test_opening_the_store_renames_trigger_nodes_to_debounce(store: FlowStore):
FlowStore(store.root)
assert commit_count(store) == commits + 1
def test_opening_the_store_renames_the_mqtt_publish_queue_size(store: FlowStore):
"""The mqtt setting is now `send_queue_size`, with no alias for the old name."""
relay = NodeDef(id="relay", type="mqtt", params={"publish_queue_size": 8})
store.write_flow(FlowDef(name="hall", nodes=[relay]))
commits = commit_count(store)
reopened = FlowStore(store.root)
published = reopened.read_flow("hall")
assert published.nodes[0].params == {"send_queue_size": 8}
assert published.version == 2
assert commit_count(store) == commits + 1