Files
app/backend/fluksio/flow/nodes/mqtt.py
T
stroblmeandClaude Opus 5 640654bd66 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00

612 lines
22 KiB
Python

"""MQTT nodes: a subscriber that wakes the graph, a publisher that speaks for it."""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterable
from enum import Enum
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field
from fluksio.flow.messages import MessageSpec
from fluksio.flow.nodes.base import Node
if TYPE_CHECKING:
from fastapi import FastAPI
logger = logging.getLogger(__name__)
# Deep enough to ride out a broker hiccup, shallow enough that a publisher
# which cannot keep up drops old values instead of growing without bound.
PUBLISH_QUEUE_SIZE = 256
class MqttNode(Node):
"""
MQTT node that can act as a subscriber (trigger) or publisher (sender).
This node integrates with an MQTT broker to either:
- **Trigger mode (Subscriber)**: Subscribe to MQTT topics and inject received
messages into the pipeline. Used when ``provides`` is specified but
``requires`` is empty.
- **Sender mode (Publisher)**: Publish pipeline data to MQTT topics. Used when
``requires`` is specified.
The ``topic`` parameter in ``params`` controls the mapping between pipeline
message names and MQTT topics:
- **dict**: Explicit mapping from message name to MQTT topic, e.g.
``{"temperature": "sensors/room1/temp", "humidity": "sensors/room1/hum"}``.
- **str** (legacy): A single topic string. All messages are mapped to this
one topic (subscriber receives from it, publisher sends to it).
:param requires: Messages required by this node (makes it a publisher node).
:type requires: MessageSpec | list[MessageSpec] | None
:param provides: Messages provided by this node (makes it a subscriber node).
:type provides: MessageSpec | list[MessageSpec] | None
:param params: Parameters dict containing:
- ``topic`` (str | dict): MQTT topic(s). A dict maps message names to
individual topics. A plain string uses that topic for all messages.
- ``broker_host`` (str): MQTT broker hostname (default: "localhost")
- ``broker_port`` (int): MQTT broker port (default: 1883)
- ``username`` (str | None): Optional username for authentication
- ``password`` (str | None): Optional password for authentication
- ``client_id`` (str | None): Optional client ID
- ``qos`` (int): Quality of Service level 0, 1, or 2 (default: 0)
- ``retain`` (bool): Retain flag for published messages (default: False)
- ``keepalive`` (int): Keepalive interval in seconds (default: 60)
:type params: dict
:param name: Optional name for the node.
:type name: str | None
:raises ValueError: If both ``requires`` and ``provides`` are empty.
:example:
Subscriber with per-message topics:
>>> subscriber = MqttNode(
... provides=[
... MessageSpec(name="inverter_input", dtype=DType.FLOAT),
... MessageSpec(name="inverter_output", dtype=DType.FLOAT),
... ],
... params={
... "topic": {
... "inverter_input": "sensors/pv",
... "inverter_output": "sensors/output",
... },
... "broker_host": "localhost",
... },
... )
Publisher with per-message topics:
>>> publisher = MqttNode(
... requires=[
... MessageSpec(name="target_temp", dtype=DType.FLOAT),
... MessageSpec(name="fan_speed", dtype=DType.INT),
... ],
... params={
... "topic": {
... "target_temp": "actuators/hvac/temp",
... "fan_speed": "actuators/hvac/fan",
... },
... "broker_host": "localhost",
... "qos": 1,
... },
... )
Legacy single-topic subscriber:
>>> subscriber = MqttNode(
... provides=[MessageSpec(name="temperature", dtype=DType.FLOAT)],
... params={"topic": "sensors/temperature", "broker_host": "localhost"},
... )
"""
class Mode(Enum):
"""Operating mode of the MQTT node."""
SUBSCRIBER = "subscriber" # Receives MQTT messages (trigger)
PUBLISHER = "publisher" # Sends MQTT messages (sender)
# Publishing again is a second command to whatever is listening.
idempotent = False
__slots__ = (
"topics",
"mode",
"broker_host",
"broker_port",
"username",
"password",
"client_id",
"qos",
"retain",
"keepalive",
"_topic_to_ports",
"_subscription_task",
"_mqtt_client",
"_stop_event",
"_publish_queue",
"_publisher_task",
"_loop",
)
class Params(BaseModel):
model_config = ConfigDict(extra="allow")
# One topic for every port, or a per-port mapping.
topic: str | dict[str, str] = "*"
broker_host: str = "localhost"
broker_port: int = 1883
username: str | None = None
password: str | None = Field(default=None, json_schema_extra={"x-secret": True})
client_id: str | None = None
qos: int = 0
retain: bool = False
keepalive: int = 60
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""The broker and topic, which is one physical thing.
A publisher and a subscriber on the same topic get the same key on
purpose: they are two ends of one wire, and drawing them as one neuron
is the only way the path through the broker shows up at all.
"""
# ponytail: publisher and subscriber merge into one neuron; key on mode
# as well if the two directions ever need telling apart.
fields = cls.Params.model_fields
topic = params.get("topic", fields["topic"].default)
if isinstance(topic, dict):
topic = json.dumps(topic, sort_keys=True)
host = params.get("broker_host", fields["broker_host"].default)
port = params.get("broker_port", fields["broker_port"].default)
return f"{host}:{port}/{topic}"
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
provides: MessageSpec | Iterable[MessageSpec] = (),
params: dict[str, Any] | None = None,
name: str | None = None,
):
cfg = self.Params.model_validate(params or {})
requires = Node._normalize_ports(requires)
provides = Node._normalize_ports(provides)
if not requires and not provides:
raise ValueError(
"An MQTT node needs either inputs (to publish) or outputs "
"(to subscribe)"
)
# Inputs mean this node publishes; outputs mean it subscribes.
self.mode = MqttNode.Mode.PUBLISHER if requires else MqttNode.Mode.SUBSCRIBER
ports = provides if self.mode == MqttNode.Mode.SUBSCRIBER else requires
if isinstance(cfg.topic, dict):
self.topics: dict[str, str] = dict(cfg.topic)
else:
self.topics = {spec.port: cfg.topic for spec in ports}
# Reverse lookup for routing incoming payloads back to ports.
self._topic_to_ports: dict[str, list[str]] = {}
for port, topic in self.topics.items():
self._topic_to_ports.setdefault(topic, []).append(port)
self.broker_host = cfg.broker_host
self.broker_port = cfg.broker_port
self.username = cfg.username
self.password = cfg.password
self.client_id = cfg.client_id
self.qos = cfg.qos
self.retain = cfg.retain
self.keepalive = cfg.keepalive
# Runtime state
self._subscription_task: asyncio.Task[None] | None = None
self._mqtt_client = None
self._stop_event: asyncio.Event | None = None
self._publish_queue: asyncio.Queue[dict[str, Any]] | None = None
self._publisher_task: asyncio.Task[None] | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# Set default name based on mode and topics
if name is None:
unique_topics = set(self.topics.values())
if len(unique_topics) == 1:
safe_topic = (
next(iter(unique_topics))
.replace("/", "_")
.replace("+", "x")
.replace("#", "all")
.strip("_")
)
else:
safe_topic = f"{len(unique_topics)}topics"
name = f"mqtt_{self.mode.value}_{safe_topic}"
# Initialize parent with appropriate function
# For subscriber mode, f is a no-op since data is injected via inject()
# For publisher mode, f handles the outgoing MQTT publish
super().__init__(
f=(
self._noop_subscriber
if self.mode == MqttNode.Mode.SUBSCRIBER
else self._publisher_handler
),
requires=requires,
provides=provides,
params=params,
name=name,
)
@staticmethod
def _noop_subscriber(
params: dict[str, Any], **kwargs: Any
) -> dict[str, Any] | None:
"""
No-op function for subscriber mode nodes.
Subscriber mode nodes inject data via :meth:`inject`, not :meth:`__call__`.
This function exists only to satisfy the Node interface and should not
be called directly.
:param params: Node parameters (unused).
:type params: dict
:param kwargs: Additional arguments (unused).
:type kwargs: Any
:returns: Always returns None.
:rtype: None
"""
return None
def _publisher_handler(
self, params: dict[str, Any], **kwargs: Any
) -> dict[str, Any] | None:
"""
Publish pipeline data to MQTT topic (publisher mode).
This method is called when upstream dependencies are satisfied.
Handing the payload to the node's publisher task is all that happens
here: the task holds one connection for the node's lifetime, where
connecting per message would cost a full handshake every time.
:param params: Node parameters.
:type params: dict
:param kwargs: Pipeline data to publish (from required messages).
:type kwargs: Any
:returns: None (publishing is fire-and-forget).
:rtype: dict | None
"""
loop, queue = self._loop, self._publish_queue
if loop is not None and queue is not None:
loop.call_soon_threadsafe(self._enqueue, queue, dict(kwargs))
return None
# No publisher task: a node built for a draft preview or a test. Send it
# the one-shot way rather than silently dropping the message.
try:
asyncio.run(self._publish_once(kwargs))
except RuntimeError:
logger.warning(
"Node '%s' cannot publish from a running event loop unstarted",
self.name,
)
return None
def _enqueue(
self, queue: asyncio.Queue[dict[str, Any]], data: dict[str, Any]
) -> None:
"""Queue a payload, dropping the oldest when the broker cannot keep up."""
if queue.full():
try:
queue.get_nowait()
logger.warning("Publish queue full for node '%s', dropped", self.name)
self.report_health("degraded", "publish queue full")
except asyncio.QueueEmpty:
pass
queue.put_nowait(data)
async def _publisher_loop(self) -> None:
"""Hold one connection and drain the publish queue over it.
A dropped connection raises, and the supervisor decides when to
reconnect — the same arrangement the subscriber uses.
"""
import aiomqtt
queue = self._publish_queue
if queue is None:
return
async with aiomqtt.Client(
hostname=self.broker_host,
port=self.broker_port,
username=self.username,
password=self.password,
identifier=self.client_id,
keepalive=self.keepalive,
) as client:
self.report_health("ok")
while True:
data = await queue.get()
try:
await self._publish_with(client, data)
except Exception as exc:
self.report_health("down", str(exc))
raise
async def _publish_once(self, data: dict[str, Any]) -> None:
"""Connect, publish, disconnect — the unstarted node's path."""
import aiomqtt
async with aiomqtt.Client(
hostname=self.broker_host,
port=self.broker_port,
username=self.username,
password=self.password,
identifier=self.client_id,
keepalive=self.keepalive,
) as client:
await self._publish_with(client, data)
async def _publish_with(self, client: Any, data: dict[str, Any]) -> None:
"""
Publish messages to their mapped MQTT topics.
Each message in *data* is published to its corresponding topic
from the ``topics`` mapping. Messages are sent as individual
JSON payloads per topic.
:param data: Data to publish, keyed by port name.
:type data: dict
"""
import json
for port, value in data.items():
topic = self.topics.get(port)
if topic is None:
logger.warning(
"No topic mapping for port '%s' in node '%s', skipping",
port,
self.name,
)
continue
# 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,
qos=self.qos,
retain=self.retain,
)
logger.info(
"Published to '%s' from node '%s': %s",
topic,
self.name,
payload,
)
async def start(self, app: FastAPI | None = None) -> None:
"""A subscriber listens; a publisher opens the connection it will reuse."""
if self.mode is MqttNode.Mode.SUBSCRIBER:
await self.start_subscription()
else:
await self.start_publisher()
async def stop(self, app: FastAPI | None = None) -> None:
await self.stop_subscription()
await self.stop_publisher()
async def start_publisher(self) -> None:
"""Run the task that owns this node's connection to the broker."""
if self._publish_queue is not None:
return
self._publish_queue = asyncio.Queue(maxsize=PUBLISH_QUEUE_SIZE)
self._loop = asyncio.get_running_loop()
self._publisher_task = self._run_supervised("mqtt-out", self._publisher_loop)
async def stop_publisher(self) -> None:
"""Drop the queue and let the connection go."""
if self._publish_queue is None:
return
if self._publisher_task is not None:
self._publisher_task.cancel()
try:
await self._publisher_task
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
pass
self._publisher_task = None
self._publish_queue = None
self._loop = None
async def start_subscription(self) -> None:
"""
Start the MQTT subscription for trigger mode nodes.
This method starts a background task that listens for messages
on the subscribed topic and triggers the pipeline when messages arrive.
:raises RuntimeError: If called on a publisher mode node.
:example:
>>> subscriber = MqttNode(
... topic="sensors/#",
... provides=[MessageSpec(name="value", dtype=DType.FLOAT)],
... params={"broker_host": "localhost"},
... )
>>> await subscriber.start_subscription()
"""
if self.mode != MqttNode.Mode.SUBSCRIBER:
raise RuntimeError("Can only start subscription for subscriber mode nodes")
if self._subscription_task is not None:
return # Already running
self._stop_event = asyncio.Event()
self._subscription_task = self._run_supervised("mqtt", self._subscription_loop)
logger.info(
"Started MQTT subscription for node '%s' to topics %s",
self.name,
list(self._topic_to_ports.keys()),
)
async def stop_subscription(self) -> None:
"""
Stop the MQTT subscription.
Gracefully stops the background subscription task. A supervised
subscription is cancelled with the rest of them at teardown; only an
unsupervised one is this method's to cancel.
"""
if self._stop_event is None:
return
self._stop_event.set()
if self._subscription_task is not None:
self._subscription_task.cancel()
try:
await self._subscription_task
except asyncio.CancelledError:
pass
self._subscription_task = None
self._stop_event = None
logger.info(
"Stopped MQTT subscription for node '%s'",
self.name,
)
async def _subscription_loop(self) -> None:
"""
Listen for MQTT messages and trigger the pipeline.
Subscribes to all unique topics from the ``topics`` mapping and
uses the reverse lookup ``_topic_to_ports`` to route incoming
payloads to the correct pipeline message names.
One connection attempt: a dropped broker raises, and the supervisor
decides when to try again. Reconnecting here as well would mean two
backoff policies fighting over the same socket.
"""
import json
import aiomqtt
if not (self._stop_event and self._stop_event.is_set()):
try:
async with aiomqtt.Client(
hostname=self.broker_host,
port=self.broker_port,
username=self.username,
password=self.password,
identifier=self.client_id,
keepalive=self.keepalive,
) as client:
# Subscribe to every unique topic
for topic in self._topic_to_ports:
await client.subscribe(topic, qos=self.qos)
logger.info("[%s] Subscribed to %s", self.name, topic)
self.report_health("ok")
async for message in client.messages:
if self._stop_event and self._stop_event.is_set():
break
try:
payload = message.payload.decode("utf-8")
incoming_topic = str(message.topic)
logger.info(
"[%s] Received on %s: %s",
self.name,
incoming_topic,
payload,
)
# Find which port(s) this topic feeds
ports = self._topic_to_ports.get(incoming_topic, [])
if not ports:
logger.debug(
"[%s] No mapping for topic '%s', ignoring",
self.name,
incoming_topic,
)
continue
# Parse the payload value
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
parsed = payload
by_port = {s.port: s for s in self.output_ports}
typed_data = {}
for port in ports:
spec = by_port.get(port)
if spec is None:
continue
# A JSON object may carry the port as a key;
# anything else is the value itself.
if isinstance(parsed, dict) and port in parsed:
value = parsed[port]
else:
value = parsed
typed_data[port] = spec.coerce(value)
if typed_data:
await asyncio.to_thread(self.inject, typed_data)
except Exception as e:
logger.error(
"[%s] Error processing message: %s",
self.name,
e,
exc_info=True,
)
# Falling out of the message iterator without being told to
# stop means the broker went away quietly. Raising is how the
# supervisor hears about it.
if not (self._stop_event and self._stop_event.is_set()):
self.report_health("down", "subscription ended")
raise ConnectionError(
f"MQTT subscription for '{self.name}' ended unexpectedly"
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
"MQTT subscription for node '%s' failed: %s", self.name, e
)
self.report_health("down", str(e))
if self._stop_event and self._stop_event.is_set():
return
raise
@property
def is_subscribed(self) -> bool:
"""
Check if the subscription is currently active.
:returns: True if subscription task is running.
:rtype: bool
"""
return (
self._subscription_task is not None and not self._subscription_task.done()
)