Let a connector write, and publish strings bare
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
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
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.
This commit is contained in:
@@ -14,7 +14,10 @@ What a connector gets from the base class:
|
||||
* :meth:`Node.report_health`, so a connection problem shows on the node rather
|
||||
than only in the log;
|
||||
* the lifecycle hooks the controller drives, so nothing device-specific has to
|
||||
be known by the engine.
|
||||
be known by the engine;
|
||||
* :meth:`ConnectorNode.write`, the other direction — values arriving on the
|
||||
node's input ports, for a connector that commands something rather than only
|
||||
reading it.
|
||||
|
||||
The message schemas and the parameter model are the rest of the contract, and
|
||||
they are the same ones the built-in nodes use. See ``docs/connectors/`` for the
|
||||
@@ -81,16 +84,15 @@ class ConnectorNode(Node):
|
||||
__slots__ = ("config", "_poll_task", "_stop_event", "_last_published")
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(f=self._unused, **kwargs)
|
||||
super().__init__(f=self._dispatch, **kwargs)
|
||||
self.config = type(self).Params(**self.params)
|
||||
self._poll_task: asyncio.Task[None] | None = None
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._last_published: dict[str, Any] = {}
|
||||
|
||||
@staticmethod
|
||||
def _unused(**_: Any) -> None:
|
||||
"""A connector publishes from its own loop, not from the scheduler."""
|
||||
return None
|
||||
def _dispatch(self, params: dict[str, Any], **ports: Any) -> dict[str, Any] | None:
|
||||
"""The scheduler's entry point. Settings are already on ``self.config``."""
|
||||
return self.write(**ports)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# What a connector implements
|
||||
@@ -104,6 +106,18 @@ class ConnectorNode(Node):
|
||||
"""
|
||||
return None
|
||||
|
||||
def write(self, **ports: Any) -> dict[str, Any] | None:
|
||||
"""Send incoming values to the device. Values arrive keyed by input port.
|
||||
|
||||
A connector that only reads leaves this alone — the default discards
|
||||
whatever reaches it, which is what a node with no inputs gets anyway.
|
||||
Return ``None`` unless the device answers something worth publishing,
|
||||
in which case return it keyed by output port like :meth:`poll` does.
|
||||
|
||||
This runs on the scheduler's thread, so it must not block for long.
|
||||
"""
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# What the engine drives
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -379,7 +379,11 @@ class MqttNode(Node):
|
||||
)
|
||||
continue
|
||||
|
||||
payload = json.dumps(value)
|
||||
# A string goes on the wire as it stands. Devices on a shared
|
||||
# broker expect bare values, and the subscriber below already
|
||||
# falls back to the raw text when it is not JSON, so a
|
||||
# fluksio-to-fluksio round trip is unaffected.
|
||||
payload = value if isinstance(value, str) else json.dumps(value)
|
||||
await client.publish(
|
||||
topic,
|
||||
payload=payload,
|
||||
|
||||
@@ -100,6 +100,34 @@ def test_a_failing_poll_reports_down_and_keeps_going():
|
||||
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
|
||||
|
||||
@@ -64,3 +64,28 @@ def test_a_full_publish_queue_drops_the_oldest():
|
||||
assert health == [("degraded", "publish queue full")]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_a_string_goes_on_the_wire_bare():
|
||||
"""Devices on a shared broker expect `ON`, not `"ON"`."""
|
||||
|
||||
class Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.published: list[tuple[str, str]] = []
|
||||
|
||||
async def publish(self, topic, payload, **_):
|
||||
self.published.append((topic, payload))
|
||||
|
||||
node = MqttNode(
|
||||
requires=[
|
||||
MessageSpec(name="plug", port="plug", dtype=DType.STR),
|
||||
MessageSpec(name="level", port="level", dtype=DType.INT),
|
||||
],
|
||||
params={"topic": {"plug": "actor/plug", "level": "light/level"}},
|
||||
)
|
||||
node.assign_flow("house", "out")
|
||||
client = Recorder()
|
||||
|
||||
asyncio.run(node._publish_with(client, {"plug": "ON", "level": 60}))
|
||||
|
||||
assert client.published == [("actor/plug", "ON"), ("light/level", "60")]
|
||||
|
||||
Reference in New Issue
Block a user