Docs / docs (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Four small things, each with a device behind it.
An MQTT filter now routes what it subscribed to. `+` and `#` reached the
broker and were then looked up in an exact-match dict, so every message a
wildcard subscription received was dropped in silence.
`json_key` lifts a value out of the object a device wraps it in — Victron
publishes `{"value": 47}` on every path, which was otherwise a Python node
per port.
The trigger node learned `passthrough` and `wait_port`, because how long to
wait can be a value rather than a constant: a rollershutter takes 26 seconds
up and 28 down. A wait of zero sends nothing afterwards and still cancels
what the last message scheduled, which is how a stop is commanded once
instead of forever.
The HTTP sender takes fixed `query` parameters, so an API key is a secret
reference rather than a message on the canvas, and `send_inputs` off for a
request whose inputs are only a trigger.
Also: `delay` accepts fractional seconds, and `TZ` reaches the container, so
a cron expression means local time. Left unset it is UTC, as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
"""What the house port needed from the built-in nodes.
|
|
|
|
Three additions, each with a device behind it: an MQTT filter that actually
|
|
routes what it subscribed to, a value lifted out of the object a device wraps
|
|
it in, and a wait that differs per message because a shutter takes longer to
|
|
come up than to go down.
|
|
"""
|
|
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import MqttNode
|
|
from fluksio.flow.nodes.mqtt import topic_matches
|
|
from fluksio.flow.nodes.trigger import TriggerNode
|
|
|
|
# ── MQTT topic filters ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_a_filter_covers_what_the_broker_would_send_it():
|
|
assert topic_matches("sensors/#", "sensors/living/temp")
|
|
assert topic_matches("sensors/#", "sensors")
|
|
assert topic_matches("sensors/+/temp", "sensors/living/temp")
|
|
assert topic_matches("#", "anything/at/all")
|
|
assert topic_matches("shelly/status", "shelly/status")
|
|
|
|
|
|
def test_a_filter_does_not_cover_a_neighbouring_topic():
|
|
assert not topic_matches("sensors/+/temp", "sensors/living/kitchen/temp")
|
|
assert not topic_matches("sensors/+/temp", "sensors/living/hum")
|
|
assert not topic_matches("sensors/#", "actors/living/temp")
|
|
assert not topic_matches("sensors/living/temp", "sensors/living")
|
|
|
|
|
|
def _subscriber(topic, **params) -> MqttNode:
|
|
node = MqttNode(
|
|
provides=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
|
|
params={"topic": {"reading": topic}, **params},
|
|
)
|
|
node.assign_flow("house", "in")
|
|
return node
|
|
|
|
|
|
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/#")
|
|
assert node._ports_for("shellypm/status/pm1:0") == ["reading"]
|
|
assert node._ports_for("shellypmc/status/pm1:0") == []
|
|
|
|
|
|
def test_an_exact_topic_still_wins_over_a_filter():
|
|
node = MqttNode(
|
|
provides=[
|
|
MessageSpec(name="soc", port="soc", dtype=DType.FLOAT),
|
|
MessageSpec(name="rest", port="rest", dtype=DType.JSON),
|
|
],
|
|
params={"topic": {"soc": "N/x/battery/Soc", "rest": "N/x/#"}},
|
|
)
|
|
node.assign_flow("power", "in")
|
|
assert node._ports_for("N/x/battery/Soc") == ["soc"]
|
|
assert node._ports_for("N/x/vebus/P") == ["rest"]
|
|
|
|
|
|
# ── json_key ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
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")
|
|
assert node.json_keys == {"reading": "value"}
|
|
|
|
|
|
def test_without_json_key_the_port_name_is_the_key_as_before():
|
|
node = _subscriber("sensors/temp")
|
|
assert node.json_keys == {}
|
|
|
|
|
|
# ── trigger: passthrough and a per-message wait ───────────────────────────
|
|
|
|
|
|
class _Pipeline:
|
|
"""Just enough pipeline to record what was deferred and for how long."""
|
|
|
|
def __init__(self):
|
|
self.deferred = []
|
|
self.state = {}
|
|
|
|
def defer(self, node, outputs, seconds, guard=None, kind="cascade"):
|
|
self.deferred.append((outputs, seconds, guard))
|
|
return True
|
|
|
|
|
|
def _trigger(**params) -> tuple[TriggerNode, _Pipeline]:
|
|
node = TriggerNode(
|
|
requires=[
|
|
MessageSpec(name="run", port="run", dtype=DType.STR),
|
|
MessageSpec(name="run_for", port="run_for", dtype=DType.FLOAT),
|
|
],
|
|
provides=[MessageSpec(name="cmd", port="cmd", dtype=DType.STR)],
|
|
params={"then": "STOP", "passthrough": True, "wait_port": "run_for", **params},
|
|
)
|
|
node.assign_flow("shutters", "door")
|
|
pipeline = _Pipeline()
|
|
node._pipeline = pipeline
|
|
return node, pipeline
|
|
|
|
|
|
def test_the_incoming_value_passes_through_and_the_wait_comes_from_a_port():
|
|
node, pipeline = _trigger()
|
|
|
|
assert node._trigger({}, run="DOWN", run_for=28.0) == {"cmd": "DOWN"}
|
|
(outputs, seconds, _guard) = pipeline.deferred[-1]
|
|
assert outputs == {"shutters.cmd": "STOP"} and seconds == 28.0
|
|
|
|
node._trigger({}, run="UP", run_for=26.0)
|
|
assert pipeline.deferred[-1][1] == 26.0
|
|
|
|
|
|
def test_a_second_command_invalidates_the_stop_the_first_one_scheduled():
|
|
node, pipeline = _trigger()
|
|
node._trigger({}, run="DOWN", run_for=28.0)
|
|
first = pipeline.deferred[-1][2]
|
|
node._trigger({}, run="UP", run_for=26.0)
|
|
assert pipeline.deferred[-1][2] != first
|
|
|
|
|
|
def test_a_wait_of_zero_sends_nothing_afterwards():
|
|
"""STOP is commanded once. Nothing follows it, forever."""
|
|
node, pipeline = _trigger()
|
|
node._trigger({}, run="DOWN", run_for=28.0)
|
|
scheduled = len(pipeline.deferred)
|
|
|
|
assert node._trigger({}, run="STOP", run_for=0.0) == {"cmd": "STOP"}
|
|
assert len(pipeline.deferred) == scheduled
|
|
assert node.recall("armed", 0) == 0
|