Source code for ezmsg.qt.subscriber
"""EzSubscriber - Receive messages from ezmsg topics in Qt widgets."""
from __future__ import annotations
from collections.abc import Callable
from enum import Enum
from typing import Any
from typing import TYPE_CHECKING
from qtpy import QtCore
from qtpy import QtWidgets
from .sidecar import normalize_topic
if TYPE_CHECKING:
from ezmsg.core.subclient import Subscriber
from .session import EzSession
TopicLike = Enum | str | None
[docs]
class EzSubscriber(QtCore.QObject):
"""
Receive messages from an ezmsg topic.
EzSubscriber provides a Qt-native interface for subscribing to ezmsg topics.
Messages are delivered via a Qt signal, allowing seamless integration with
Qt's event loop and signal/slot mechanism.
For processing graphs, use ProcessorGraph directly instead.
Example:
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
# Simple subscription
self.velocity_data = EzSubscriber(VelocityTopic.OUTPUT, parent=self)
self.velocity_data.connect(self.on_velocity)
def on_velocity(self, msg):
self.plot.update(msg)
"""
received = QtCore.Signal(object) # pyright: ignore[reportPrivateImportUsage]
switch_started = QtCore.Signal(object) # pyright: ignore[reportPrivateImportUsage]
topic_changed = QtCore.Signal(object) # pyright: ignore[reportPrivateImportUsage]
topic_cleared = QtCore.Signal() # pyright: ignore[reportPrivateImportUsage]
switch_failed = QtCore.Signal(object, str) # pyright: ignore[reportPrivateImportUsage]
[docs]
def __init__(
self,
topic: TopicLike = None,
parent: QtCore.QObject | None = None,
*,
session: EzSession | None = None,
auto_gate: bool = False,
leaky: bool = False,
max_queue: int | None = None,
throttle_hz: float | None = None,
):
"""
Create a subscriber for an ezmsg topic.
Args:
topic: The initial topic to subscribe to, or None to start unsubscribed.
parent: Optional parent QObject for lifecycle management.
auto_gate: If True, suppress delivery while the parent widget is hidden.
leaky: If True, the underlying ezmsg Subscriber will drop old messages
if the receiver can't keep up (no backpressure).
max_queue: Queue depth for leaky mode. If None, ezmsg defaults apply.
throttle_hz: If set, throttle delivery by reading at most this many
messages per second from the underlying ezmsg Subscriber.
"""
super().__init__(parent)
self._topic = self._validate_topic(topic)
self._desired_topic = self._topic
self._sub: Subscriber | None = None # Set by EzSession during setup
self._session: EzSession | None = None
self._auto_gate = bool(auto_gate)
self._leaky = bool(leaky)
self._max_queue = max_queue
self._throttle_hz = throttle_hz
self._emit_epoch = 0
self._visibility_filter: QtCore.QObject | None = None
if (
self._auto_gate
and parent is not None
and not isinstance(parent, QtWidgets.QWidget)
):
raise TypeError("EzSubscriber auto_gate requires a QWidget parent")
if self._max_queue is not None and self._max_queue <= 0:
raise ValueError("max_queue must be positive")
if self._throttle_hz is not None and self._throttle_hz <= 0:
raise ValueError("throttle_hz must be positive")
if session is not None:
session.attach(self)
@property
def topic(self) -> TopicLike:
"""The currently active topic, or None if unsubscribed."""
return self._topic
@property
def session(self) -> EzSession | None:
"""The session this subscriber is attached to, if any."""
return self._session
@property
def auto_gate(self) -> bool:
"""Whether delivery is suppressed while the parent widget is hidden."""
return self._auto_gate
@property
def leaky(self) -> bool:
"""Whether this subscriber drops old messages instead of backpressure."""
return self._leaky
@property
def max_queue(self) -> int | None:
"""Leaky notification queue depth (ignored if leaky=False)."""
return self._max_queue
@property
def throttle_hz(self) -> float | None:
"""If set, throttle reads from the underlying ezmsg Subscriber."""
return self._throttle_hz
@property
def parent_widget(self) -> QtWidgets.QWidget | None:
"""The QWidget used for auto-gating, if available."""
parent = self.parent()
if isinstance(parent, QtWidgets.QWidget):
return parent
return None
[docs]
def connect(self, slot: Callable[[Any], None]) -> None:
"""
Connect a handler to receive messages.
Args:
slot: A callable that will be invoked with each received message.
"""
self.received.connect(slot)
[docs]
def set_topic(self, topic: Enum | str) -> None:
"""Switch to a new topic on a running session.
This call blocks until the session applies the topic change.
"""
validated = self._validate_topic(topic)
session = self._require_running_session()
previous_desired = self._desired_topic
self._desired_topic = validated
self.switch_started.emit(validated)
try:
session._set_subscriber_topic(self, validated)
self.topic_changed.emit(self._topic)
except Exception as exc:
self._desired_topic = previous_desired
self.switch_failed.emit(validated, str(exc))
raise
[docs]
def clear_topic(self) -> None:
"""Unsubscribe from the current topic on a running session.
This call blocks until the session disconnects the route.
"""
session = self._require_running_session()
previous_desired = self._desired_topic
self._desired_topic = None
self.switch_started.emit(None)
try:
session._set_subscriber_topic(self, None)
self.topic_cleared.emit()
except Exception as exc:
self._desired_topic = previous_desired
self.switch_failed.emit(None, str(exc))
raise
@QtCore.Slot(object, int) # pyright: ignore[reportPrivateImportUsage]
def _on_message(self, msg: Any, epoch: int) -> None:
"""Internal slot called from the Qt signal dispatcher."""
if epoch != self._emit_epoch:
return
self.received.emit(msg)
def _bind_session(self, session: EzSession) -> None:
if self._session is not None and self._session is not session:
raise RuntimeError(
"EzSubscriber is already attached to a different session"
)
self._session = session
def _require_running_session(self) -> EzSession:
if self._session is None:
raise RuntimeError(
"EzSubscriber must be attached to a session before switching"
)
if not self._session.running:
raise RuntimeError(
"EzSubscriber topic switching requires a running EzSession"
)
return self._session
@staticmethod
def _validate_topic(topic: TopicLike) -> TopicLike:
if topic is None:
return None
normalize_topic(topic)
return topic