Files
app/backend/tests/flow/test_connector.py
T
stroblme f88dcf81c0
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
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Let a connector write, and publish strings bare
Two things stopped the engine commanding this house. ConnectorNode hardwired
its node function to a no-op, so an input message reaching a connector was
discarded and Art-Net's packet builder was unreachable; write() now carries
the input ports, which is additive so the contract version holds. And the MQTT
publisher JSON-encoded every payload, so "ON" went on the wire quoted and the
devices on a shared broker, which speak bare values, ignored it.

seed_house_control.py is the rig: a flow that drives the washing machine plug,
a dimmer and a colour fixture over MQTT, carries the same two as DMX on an
Art-Net node with transmit still off, and a dashboard to drive it by hand.
2026-08-20 21:57:27 +02:00

185 lines
5.2 KiB
Python

"""The connector contract: polling, deduplication, health and discovery."""
import asyncio
from typing import Any
from app.flow.connector import CONTRACT_VERSION, ConnectorNode
from app.flow.controller import NODE_TYPES
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
from app.flow.plugins import load_plugins
class Sensor(ConnectorNode):
contract = CONTRACT_VERSION
title = "Test sensor"
description = "Reads whatever it is told to."
class Params(ConnectorNode.Params):
secret_token: str | None = None
def __init__(self, readings: list[Any] | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._readings = list(readings or [])
self.polls = 0
async def poll(self) -> dict[str, Any] | None:
self.polls += 1
if not self._readings:
return None
value = self._readings.pop(0)
if isinstance(value, Exception):
raise value
return {"reading": value}
def a_sensor(readings: list[Any], **params: Any) -> Sensor:
node = Sensor(
readings=readings,
provides=[MessageSpec(name="reading", dtype=DType.FLOAT)],
params={"poll_interval": 0.01, **params},
)
node.assign_flow("demo", "sensor")
return node
def run_briefly(node: ConnectorNode, seconds: float = 0.12) -> None:
"""Start the poll loop, let it tick a few times, stop it."""
async def cycle() -> None:
await node.start()
await asyncio.sleep(seconds)
await node.stop()
asyncio.run(cycle())
def test_polling_publishes_what_it_reads():
node = a_sensor([21.5])
pipeline = Pipeline(nodes=[node])
run_briefly(node)
assert pipeline.state["demo.reading"] == 21.5
def test_an_unchanged_reading_is_not_republished():
node = a_sensor([21.5, 21.5, 21.5])
consumer_ran: list[float] = []
def consume(reading, params):
consumer_ran.append(reading)
return None
consumer = Node(
f=consume,
requires=[MessageSpec(name="reading", dtype=DType.FLOAT)],
name="consumer",
)
consumer.assign_flow("demo", "consumer")
Pipeline(nodes=[node, consumer])
run_briefly(node)
# Polled repeatedly, but the value never changed, so downstream ran once.
assert node.polls > 1
assert consumer_ran == [21.5]
def test_a_failing_poll_reports_down_and_keeps_going():
health: list[tuple[str, str | None]] = []
node = a_sensor([RuntimeError("device unplugged"), 21.5])
node._on_health = lambda _node, status, detail: health.append((status, detail))
Pipeline(nodes=[node])
run_briefly(node)
assert ("down", "RuntimeError: device unplugged") in health
# It recovered rather than giving up.
assert health[-1][0] == "ok"
class Actuator(ConnectorNode):
"""A connector that commands something instead of reading it."""
contract = CONTRACT_VERSION
title = "Test actuator"
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.commands: list[dict[str, Any]] = []
def write(self, **ports: Any) -> None:
self.commands.append(ports)
return None
def test_an_incoming_message_reaches_a_connector_that_writes():
node = Actuator(requires=[MessageSpec(name="level", dtype=DType.INT)])
node.assign_flow("demo", "actuator")
assert node.execute({"demo.level": 255}) is None
assert node.commands == [{"level": 255}]
def test_a_read_only_connector_ignores_what_reaches_it():
node = a_sensor([])
assert node.execute({}) is None
def test_a_credential_param_is_marked_for_the_editor():
schema = Sensor.Params.model_json_schema()
assert schema["properties"]["poll_interval"]["default"] == 0
assert "secret_token" in schema["properties"]
def test_a_connector_is_discovered_from_its_entry_point(monkeypatch):
class FakeDist:
name = "fluksio-connector-test"
version = "0.1.0"
class FakeEntry:
name = "test_sensor"
dist = FakeDist()
def load(self):
return Sensor
monkeypatch.setattr("app.flow.plugins.entry_points", lambda group: [FakeEntry()])
try:
assert load_plugins() == ["test_sensor"]
assert NODE_TYPES["test_sensor"].plugin == "fluksio-connector-test 0.1.0"
assert NODE_TYPES["test_sensor"].title == "Test sensor"
finally:
NODE_TYPES.pop("test_sensor", None)
def test_a_connector_written_for_another_contract_is_refused(monkeypatch):
class Outdated(ConnectorNode):
contract = CONTRACT_VERSION + 1
class FakeEntry:
name = "outdated"
dist = None
def load(self):
return Outdated
monkeypatch.setattr("app.flow.plugins.entry_points", lambda group: [FakeEntry()])
assert load_plugins() == []
assert "outdated" not in NODE_TYPES
def test_a_connector_may_not_take_over_a_built_in_type(monkeypatch):
class FakeEntry:
name = "mqtt"
dist = None
def load(self): # pragma: no cover - never reached
raise AssertionError("should not be loaded")
monkeypatch.setattr("app.flow.plugins.entry_points", lambda group: [FakeEntry()])
assert load_plugins() == []
assert NODE_TYPES["mqtt"].plugin is None