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

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:
2026-08-20 21:57:27 +02:00
parent 06875a77ac
commit f88dcf81c0
8 changed files with 575 additions and 12 deletions
+20 -6
View File
@@ -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
# -------------------------------------------------------------------------
+5 -1
View File
@@ -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,