pyqttier 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyqttier/__init__.py +2 -0
- pyqttier/connection.py +200 -0
- pyqttier/interface.py +57 -0
- pyqttier/lwt.py +23 -0
- pyqttier/message.py +223 -0
- pyqttier/mock.py +144 -0
- pyqttier/py.typed +0 -0
- pyqttier/transport.py +27 -0
- pyqttier-0.1.0.dist-info/METADATA +88 -0
- pyqttier-0.1.0.dist-info/RECORD +12 -0
- pyqttier-0.1.0.dist-info/WHEEL +4 -0
- pyqttier-0.1.0.dist-info/licenses/LICENSE +21 -0
pyqttier/__init__.py
ADDED
pyqttier/connection.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from concurrent.futures import Future
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Callable, Optional, Tuple, Any, Union, List, Dict
|
|
6
|
+
from paho.mqtt.client import Client as MqttClient, topic_matches_sub
|
|
7
|
+
from paho.mqtt.enums import MQTTProtocolVersion, CallbackAPIVersion
|
|
8
|
+
from paho.mqtt.properties import Properties as MqttProperties
|
|
9
|
+
from paho.mqtt.packettypes import PacketTypes
|
|
10
|
+
from queue import Queue, Empty
|
|
11
|
+
from .interface import IBrokerConnection, MessageCallback
|
|
12
|
+
from .transport import MqttTransport
|
|
13
|
+
from .message import Message
|
|
14
|
+
from .lwt import OnlinePresence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Mqtt5Connection(IBrokerConnection):
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PendingSubscription:
|
|
24
|
+
topic: str
|
|
25
|
+
subscription_id: int
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PendingPublish:
|
|
29
|
+
msg: Message
|
|
30
|
+
future: Future
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
transport: MqttTransport,
|
|
35
|
+
client_id: Optional[str] = None,
|
|
36
|
+
lwt: Optional[OnlinePresence] = None,
|
|
37
|
+
):
|
|
38
|
+
self._logger = logging.getLogger("MqttConnection")
|
|
39
|
+
self._transport = transport
|
|
40
|
+
self._client_id = client_id or str(uuid.uuid4())
|
|
41
|
+
self._queued_messages = Queue() # type: Queue[Mqtt5Connection.PendingPublish]
|
|
42
|
+
self._queued_subscriptions = (
|
|
43
|
+
Queue()
|
|
44
|
+
) # type: Queue[Mqtt5Connection.PendingSubscription]
|
|
45
|
+
self._connected: bool = False
|
|
46
|
+
|
|
47
|
+
lwt_properties = MqttProperties(PacketTypes.PUBLISH)
|
|
48
|
+
lwt_properties.ContentType = "application/json"
|
|
49
|
+
lwt_properties.MessageExpiryInterval = 60 * 60 * 24 # 1 day
|
|
50
|
+
self._lwt = lwt or OnlinePresence.default(self._client_id)
|
|
51
|
+
|
|
52
|
+
self._connect_inner_mqtt_client()
|
|
53
|
+
|
|
54
|
+
self._message_handling_lock = threading.Lock()
|
|
55
|
+
with self._message_handling_lock:
|
|
56
|
+
self._subscription_callbacks = dict() # type: Dict[int, MessageCallback]
|
|
57
|
+
self._message_callbacks = [] # type: List[MessageCallback]
|
|
58
|
+
|
|
59
|
+
self._publishing_lock = threading.Lock()
|
|
60
|
+
with self._publishing_lock:
|
|
61
|
+
self._publish_futures = dict() # type: Dict[int, Future]
|
|
62
|
+
|
|
63
|
+
self._client.loop_start()
|
|
64
|
+
self._next_subscription_id = 10
|
|
65
|
+
|
|
66
|
+
def _connect_inner_mqtt_client(self):
|
|
67
|
+
"""
|
|
68
|
+
Private method to be called from constructor only.
|
|
69
|
+
"""
|
|
70
|
+
self._client = MqttClient(
|
|
71
|
+
CallbackAPIVersion.VERSION2,
|
|
72
|
+
protocol=MQTTProtocolVersion.MQTTv5,
|
|
73
|
+
transport=self._transport.transport.value,
|
|
74
|
+
client_id=self._client_id,
|
|
75
|
+
reconnect_on_failure=True,
|
|
76
|
+
)
|
|
77
|
+
self._client.on_connect = self._on_connect
|
|
78
|
+
self._client.on_message = self._on_message
|
|
79
|
+
self._client.on_publish = self.on_publish_complete
|
|
80
|
+
self._client.will_set(**self._lwt.online.paho_kwargs())
|
|
81
|
+
host = self._transport.host_or_path or "localhost"
|
|
82
|
+
self._client.connect(host, self._transport.port)
|
|
83
|
+
|
|
84
|
+
def __del__(self):
|
|
85
|
+
if self._lwt is not None:
|
|
86
|
+
self._client.publish(**self._lwt.offline.paho_kwargs()).wait_for_publish()
|
|
87
|
+
self._client.disconnect()
|
|
88
|
+
self._client.loop_stop()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def online_topic(self) -> str:
|
|
92
|
+
return self._lwt.topic
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def client_id(self) -> str:
|
|
96
|
+
return self._client_id
|
|
97
|
+
|
|
98
|
+
def is_connected(self) -> bool:
|
|
99
|
+
return self._connected
|
|
100
|
+
|
|
101
|
+
def get_next_subscription_id(self) -> int:
|
|
102
|
+
sub_id = self._next_subscription_id
|
|
103
|
+
self._next_subscription_id += 1
|
|
104
|
+
return sub_id
|
|
105
|
+
|
|
106
|
+
def add_message_callback(self, callback: MessageCallback):
|
|
107
|
+
self._message_callbacks.append(callback)
|
|
108
|
+
|
|
109
|
+
def _on_message(self, client, userdata, msg):
|
|
110
|
+
self._logger.debug("Got a message to %s : %s", msg.topic, msg.payload.decode())
|
|
111
|
+
message = Message.from_paho_message(msg)
|
|
112
|
+
if message.subscription_id is not None:
|
|
113
|
+
sub_id = message.subscription_id
|
|
114
|
+
with self._message_handling_lock:
|
|
115
|
+
if sub_id in self._subscription_callbacks:
|
|
116
|
+
self._subscription_callbacks[sub_id](message)
|
|
117
|
+
return
|
|
118
|
+
with self._message_handling_lock:
|
|
119
|
+
for callback in self._message_callbacks:
|
|
120
|
+
callback(message)
|
|
121
|
+
|
|
122
|
+
def _on_connect(self, client, userdata, flags, reason_code, properties):
|
|
123
|
+
if reason_code == 0: # Connection successful
|
|
124
|
+
self._connected = True
|
|
125
|
+
self._logger.info(
|
|
126
|
+
"Connected to %s:%d", self._transport.host_or_path, self._transport.port
|
|
127
|
+
)
|
|
128
|
+
while not self._queued_subscriptions.empty():
|
|
129
|
+
try:
|
|
130
|
+
pending_subscr = self._queued_subscriptions.get_nowait()
|
|
131
|
+
except Empty:
|
|
132
|
+
break
|
|
133
|
+
else:
|
|
134
|
+
self._logger.debug(
|
|
135
|
+
"Connected and subscribing to %s", pending_subscr.topic
|
|
136
|
+
)
|
|
137
|
+
sub_props = MqttProperties(PacketTypes.SUBSCRIBE)
|
|
138
|
+
sub_props.SubscriptionIdentifier = pending_subscr.subscription_id
|
|
139
|
+
self._client.subscribe(
|
|
140
|
+
pending_subscr.topic, qos=1, properties=sub_props
|
|
141
|
+
)
|
|
142
|
+
while not self._queued_messages.empty():
|
|
143
|
+
try:
|
|
144
|
+
msg = self._queued_messages.get_nowait()
|
|
145
|
+
except Empty:
|
|
146
|
+
break
|
|
147
|
+
else:
|
|
148
|
+
self._logger.info(f"Publishing queued up message")
|
|
149
|
+
pub_info = self._client.publish(**msg.msg.paho_kwargs())
|
|
150
|
+
with self._publishing_lock:
|
|
151
|
+
self._publish_futures[pub_info.mid] = msg.future
|
|
152
|
+
|
|
153
|
+
self._client.publish(**self._lwt.online.paho_kwargs())
|
|
154
|
+
else:
|
|
155
|
+
self._logger.error(
|
|
156
|
+
"Connection failed with reason code %s", str(reason_code)
|
|
157
|
+
)
|
|
158
|
+
self._connected = False
|
|
159
|
+
|
|
160
|
+
def on_publish_complete(
|
|
161
|
+
self, client, userdata, mid, reason_code=None, properties=None
|
|
162
|
+
):
|
|
163
|
+
with self._publishing_lock:
|
|
164
|
+
if mid in self._publish_futures:
|
|
165
|
+
fut = self._publish_futures.pop(mid)
|
|
166
|
+
fut.set_result(None)
|
|
167
|
+
|
|
168
|
+
def publish(self, message: Message) -> Future:
|
|
169
|
+
"""Publish a message to mqtt, or queue it if not connected yet. Returns a Future that completes when the message is published."""
|
|
170
|
+
fut = Future() # type: Future
|
|
171
|
+
if self._connected:
|
|
172
|
+
self._logger.info("Publishing %s", message.topic)
|
|
173
|
+
msg_info = self._client.publish(**message.paho_kwargs())
|
|
174
|
+
with self._publishing_lock:
|
|
175
|
+
self._publish_futures[msg_info.mid] = fut
|
|
176
|
+
else:
|
|
177
|
+
self._logger.info("Queueing %s for publishing later", message.topic)
|
|
178
|
+
pending_pub = self.PendingPublish(msg=message, future=fut)
|
|
179
|
+
self._queued_messages.put(pending_pub)
|
|
180
|
+
return fut
|
|
181
|
+
|
|
182
|
+
def subscribe(self, topic: str, callback: Optional[MessageCallback] = None) -> int:
|
|
183
|
+
"""Subscribes to a topic. If the connection is not established, the subscription is queued.
|
|
184
|
+
Returns the subscription ID.
|
|
185
|
+
"""
|
|
186
|
+
sub_id = self.get_next_subscription_id()
|
|
187
|
+
if self._connected:
|
|
188
|
+
self._logger.debug("Subscribing to %s", topic)
|
|
189
|
+
sub_props = MqttProperties(PacketTypes.SUBSCRIBE)
|
|
190
|
+
sub_props.SubscriptionIdentifier = sub_id
|
|
191
|
+
self._client.subscribe(topic, qos=1, properties=sub_props)
|
|
192
|
+
else:
|
|
193
|
+
self._logger.debug("Pending subscription to %s", topic)
|
|
194
|
+
self._queued_subscriptions.put(self.PendingSubscription(topic, sub_id))
|
|
195
|
+
if callback is not None:
|
|
196
|
+
self._subscription_callbacks[sub_id] = callback
|
|
197
|
+
return sub_id
|
|
198
|
+
|
|
199
|
+
def is_topic_sub(self, topic: str, sub: str) -> bool:
|
|
200
|
+
return topic_matches_sub(sub, topic)
|
pyqttier/interface.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Callable, Dict, Any, Union, Optional
|
|
3
|
+
from .message import Message
|
|
4
|
+
from concurrent.futures import Future
|
|
5
|
+
|
|
6
|
+
MessageCallback = Callable[[Message], None]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IBrokerConnection(ABC):
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def publish(self, message: Message) -> Future:
|
|
13
|
+
"""
|
|
14
|
+
Publishes a message to the provided topic with the provided parameters. If the connection to the broker is not established,
|
|
15
|
+
the message may be queued until the connection is established.
|
|
16
|
+
"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def subscribe(self, topic: str, callback: Optional[MessageCallback] = None) -> int:
|
|
21
|
+
"""
|
|
22
|
+
Subscribes to the provided topic. May queue the subscription until the connection to the broker is established.
|
|
23
|
+
When a message is received on the topic, the provided callback will be executed if provided.
|
|
24
|
+
Returns a subscription identifier for the subscription.
|
|
25
|
+
"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def add_message_callback(self, callback: MessageCallback) -> None:
|
|
30
|
+
"""
|
|
31
|
+
The provided callback is called for all received messages that do not have a specific callback registered via the subscribe method.
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def is_topic_sub(self, topic: str, sub: str) -> bool:
|
|
37
|
+
"""Returns True if the provided topic matches the provided subscription filter."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def online_topic(self) -> Optional[str]:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def client_id(self) -> str:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def is_connected(self) -> bool:
|
|
52
|
+
"""Returns True if the connection to the broker is established."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def unpublish_retained(self, topic):
|
|
56
|
+
msg = Message(topic=topic, payload=b"", qos=1, retain=True)
|
|
57
|
+
self.publish(msg)
|
pyqttier/lwt.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from .message import Message
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OnlinePresence(BaseModel):
|
|
6
|
+
topic: str
|
|
7
|
+
online: Message
|
|
8
|
+
offline: Message
|
|
9
|
+
|
|
10
|
+
def __post_init__(self):
|
|
11
|
+
self.online.topic = self.topic
|
|
12
|
+
self.offline.topic = self.topic
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def default(cls, client_id: str) -> "OnlinePresence":
|
|
16
|
+
online_topic = f"client/{client_id}/online"
|
|
17
|
+
online_msg = Message(
|
|
18
|
+
topic=online_topic, payload=b'{"online":true}', qos=1, retain=True
|
|
19
|
+
)
|
|
20
|
+
offline_msg = Message(
|
|
21
|
+
topic=online_topic, payload=b'{"online":false}', qos=1, retain=True
|
|
22
|
+
)
|
|
23
|
+
return cls(topic=online_topic, online=online_msg, offline=offline_msg)
|
pyqttier/message.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, Dict, Union, Any
|
|
3
|
+
from paho.mqtt.client import MQTTMessage
|
|
4
|
+
from paho.mqtt.properties import Properties as MqttProperties
|
|
5
|
+
from paho.mqtt.packettypes import PacketTypes
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from enum import Enum
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Message:
|
|
13
|
+
topic: str
|
|
14
|
+
payload: bytes
|
|
15
|
+
qos: int
|
|
16
|
+
retain: bool = False
|
|
17
|
+
content_type: Optional[str] = None
|
|
18
|
+
correlation_data: Optional[bytes] = None
|
|
19
|
+
response_topic: Optional[str] = None
|
|
20
|
+
subscription_id: Optional[int] = None # Ignored on publish
|
|
21
|
+
message_expiry_interval: Optional[int] = None
|
|
22
|
+
user_properties: Optional[Dict[str, str]] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
if self.user_properties is None:
|
|
26
|
+
self.user_properties = dict()
|
|
27
|
+
|
|
28
|
+
def paho_kwargs(self) -> Dict[str, Any]:
|
|
29
|
+
props = MqttProperties(PacketTypes.PUBLISH)
|
|
30
|
+
if self.content_type is not None:
|
|
31
|
+
props.ContentType = self.content_type
|
|
32
|
+
if self.correlation_data is not None:
|
|
33
|
+
props.CorrelationData = self.correlation_data
|
|
34
|
+
if self.response_topic is not None:
|
|
35
|
+
props.ResponseTopic = self.response_topic
|
|
36
|
+
if self.message_expiry_interval is not None:
|
|
37
|
+
props.MessageExpiryInterval = self.message_expiry_interval
|
|
38
|
+
if self.user_properties is not None and len(self.user_properties) > 0:
|
|
39
|
+
props.UserProperty = list(self.user_properties.items())
|
|
40
|
+
kwargs = {
|
|
41
|
+
"topic": self.topic,
|
|
42
|
+
"payload": self.payload,
|
|
43
|
+
"qos": self.qos,
|
|
44
|
+
"retain": self.retain,
|
|
45
|
+
"properties": props,
|
|
46
|
+
}
|
|
47
|
+
return kwargs
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_paho_message(cls, paho_msg: MQTTMessage) -> "Message":
|
|
51
|
+
msg_obj = cls(
|
|
52
|
+
topic=paho_msg.topic,
|
|
53
|
+
payload=paho_msg.payload,
|
|
54
|
+
qos=paho_msg.qos,
|
|
55
|
+
retain=paho_msg.retain,
|
|
56
|
+
)
|
|
57
|
+
properties = (
|
|
58
|
+
paho_msg.properties.__dict__ if hasattr(paho_msg, "properties") else {}
|
|
59
|
+
)
|
|
60
|
+
if "UserProperty" in properties:
|
|
61
|
+
msg_obj.user_properties = dict(properties["UserProperty"])
|
|
62
|
+
if "ContentType" in properties:
|
|
63
|
+
msg_obj.content_type = properties["ContentType"]
|
|
64
|
+
if "CorrelationData" in properties:
|
|
65
|
+
msg_obj.correlation_data = properties["CorrelationData"]
|
|
66
|
+
if "ResponseTopic" in properties:
|
|
67
|
+
msg_obj.response_topic = properties["ResponseTopic"]
|
|
68
|
+
if "MessageExpiryInterval" in properties:
|
|
69
|
+
msg_obj.message_expiry_interval = properties["MessageExpiryInterval"]
|
|
70
|
+
return msg_obj
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def status_message(
|
|
74
|
+
cls, topic, status_message: BaseModel, expiry_seconds: int
|
|
75
|
+
) -> "Message":
|
|
76
|
+
return cls(
|
|
77
|
+
topic=topic,
|
|
78
|
+
payload=status_message.model_dump_json(by_alias=True).encode("utf-8"),
|
|
79
|
+
qos=1,
|
|
80
|
+
retain=True,
|
|
81
|
+
message_expiry_interval=expiry_seconds,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def error_response_message(
|
|
86
|
+
cls,
|
|
87
|
+
topic: str,
|
|
88
|
+
return_code: int,
|
|
89
|
+
correlation_id: Union[str, bytes],
|
|
90
|
+
debug_info: Optional[str] = None,
|
|
91
|
+
) -> "Message":
|
|
92
|
+
msg_obj = cls(
|
|
93
|
+
topic=topic,
|
|
94
|
+
payload=b"{}",
|
|
95
|
+
qos=1,
|
|
96
|
+
retain=False,
|
|
97
|
+
correlation_data=(
|
|
98
|
+
correlation_id.encode("utf-8")
|
|
99
|
+
if isinstance(correlation_id, str)
|
|
100
|
+
else correlation_id
|
|
101
|
+
),
|
|
102
|
+
user_properties={"ReturnCode": str(return_code)},
|
|
103
|
+
)
|
|
104
|
+
if (
|
|
105
|
+
debug_info is not None and msg_obj.user_properties is not None
|
|
106
|
+
): # user_properties should never be None here, but checking to satisfy type checker
|
|
107
|
+
msg_obj.user_properties["DebugInfo"] = debug_info
|
|
108
|
+
return msg_obj
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def response_message(
|
|
112
|
+
cls,
|
|
113
|
+
response_topic: str,
|
|
114
|
+
response_obj: BaseModel,
|
|
115
|
+
return_code: int,
|
|
116
|
+
correlation_id: Union[str, bytes],
|
|
117
|
+
) -> "Message":
|
|
118
|
+
msg_obj = cls(
|
|
119
|
+
topic=response_topic,
|
|
120
|
+
payload=response_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
121
|
+
qos=1,
|
|
122
|
+
retain=False,
|
|
123
|
+
correlation_data=(
|
|
124
|
+
correlation_id.encode("utf-8")
|
|
125
|
+
if isinstance(correlation_id, str)
|
|
126
|
+
else correlation_id
|
|
127
|
+
),
|
|
128
|
+
user_properties={"ReturnCode": str(return_code)},
|
|
129
|
+
)
|
|
130
|
+
return msg_obj
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def property_state_message(
|
|
134
|
+
cls, topic: str, state_obj: BaseModel, state_version: Optional[int] = None
|
|
135
|
+
) -> "Message":
|
|
136
|
+
msg_obj = cls(
|
|
137
|
+
topic=topic,
|
|
138
|
+
payload=state_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
139
|
+
qos=1,
|
|
140
|
+
retain=True,
|
|
141
|
+
)
|
|
142
|
+
if state_version is not None:
|
|
143
|
+
msg_obj.user_properties = {"PropertyVersion": str(state_version)}
|
|
144
|
+
return msg_obj
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def property_update_request_message(
|
|
148
|
+
cls,
|
|
149
|
+
topic: str,
|
|
150
|
+
property_obj: BaseModel,
|
|
151
|
+
version: str,
|
|
152
|
+
response_topic: str,
|
|
153
|
+
correlation_id: Union[str, bytes, None] = None,
|
|
154
|
+
) -> "Message":
|
|
155
|
+
msg_obj = cls(
|
|
156
|
+
topic=topic,
|
|
157
|
+
payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
158
|
+
qos=1,
|
|
159
|
+
retain=False,
|
|
160
|
+
response_topic=response_topic,
|
|
161
|
+
correlation_data=(
|
|
162
|
+
correlation_id.encode("utf-8")
|
|
163
|
+
if isinstance(correlation_id, str)
|
|
164
|
+
else correlation_id
|
|
165
|
+
),
|
|
166
|
+
user_properties={"PropertyVersion": str(version)},
|
|
167
|
+
)
|
|
168
|
+
return msg_obj
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def property_response_message(
|
|
172
|
+
cls,
|
|
173
|
+
response_topic: str,
|
|
174
|
+
property_obj: BaseModel,
|
|
175
|
+
version: str,
|
|
176
|
+
return_code: int,
|
|
177
|
+
correlation_id: Union[str, bytes],
|
|
178
|
+
debug_info: Optional[str] = None,
|
|
179
|
+
) -> "Message":
|
|
180
|
+
msg_obj = cls(
|
|
181
|
+
topic=response_topic,
|
|
182
|
+
payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
183
|
+
qos=1,
|
|
184
|
+
retain=False,
|
|
185
|
+
correlation_data=(
|
|
186
|
+
correlation_id.encode("utf-8")
|
|
187
|
+
if isinstance(correlation_id, str)
|
|
188
|
+
else correlation_id
|
|
189
|
+
),
|
|
190
|
+
user_properties={
|
|
191
|
+
"ReturnCode": str(return_code),
|
|
192
|
+
"PropertyVersion": str(version),
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
if (
|
|
196
|
+
debug_info is not None and msg_obj.user_properties is not None
|
|
197
|
+
): # user_properties should never be None here, but checking to satisfy type checker
|
|
198
|
+
msg_obj.user_properties["DebugInfo"] = debug_info
|
|
199
|
+
return msg_obj
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def publish_request(
|
|
203
|
+
cls,
|
|
204
|
+
topic: str,
|
|
205
|
+
request_obj: BaseModel,
|
|
206
|
+
response_topic: str,
|
|
207
|
+
correlation_id: Union[str, bytes, None] = None,
|
|
208
|
+
) -> "Message":
|
|
209
|
+
if correlation_id is None:
|
|
210
|
+
correlation_id = str(uuid.uuid4())
|
|
211
|
+
msg_obj = cls(
|
|
212
|
+
topic=topic,
|
|
213
|
+
payload=request_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
214
|
+
qos=1,
|
|
215
|
+
retain=False,
|
|
216
|
+
response_topic=response_topic,
|
|
217
|
+
correlation_data=(
|
|
218
|
+
correlation_id.encode("utf-8")
|
|
219
|
+
if isinstance(correlation_id, str)
|
|
220
|
+
else correlation_id
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
return msg_obj
|
pyqttier/mock.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from concurrent.futures import Future
|
|
2
|
+
from typing import Optional, Dict, List, Callable, Tuple
|
|
3
|
+
from copy import copy
|
|
4
|
+
from .interface import IBrokerConnection, MessageCallback
|
|
5
|
+
from .message import Message
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MockConnection(IBrokerConnection):
|
|
10
|
+
"""
|
|
11
|
+
Mock implementation of IBrokerConnection for testing purposes.
|
|
12
|
+
Simulates broker behavior without requiring an actual MQTT broker.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._connected = True
|
|
17
|
+
self._subscriptions = (
|
|
18
|
+
{}
|
|
19
|
+
) # type: Dict[int, Tuple[str, Optional[MessageCallback]]]
|
|
20
|
+
self._message_callbacks = [] # type: List[MessageCallback]
|
|
21
|
+
self._published_messages = [] # type: List[Message]
|
|
22
|
+
self._next_subscription_id = 1 # type: int
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def client_id(self) -> str:
|
|
27
|
+
return "mock-client"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def online_topic(self) -> Optional[str]:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def published_messages(self) -> List[Message]:
|
|
35
|
+
"""Returns list of all published messages for testing verification."""
|
|
36
|
+
with self._lock:
|
|
37
|
+
return self._published_messages.copy()
|
|
38
|
+
|
|
39
|
+
def clear_published_messages(self) -> None:
|
|
40
|
+
"""Clears the list of published messages."""
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._published_messages.clear()
|
|
43
|
+
|
|
44
|
+
def set_connected(self, connected: bool) -> "MockConnection":
|
|
45
|
+
"""Sets the connection status for testing."""
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._connected = connected
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def simulate_message(self, message: Message) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Simulates receiving a message from the broker.
|
|
53
|
+
Triggers appropriate callbacks based on subscriptions.
|
|
54
|
+
"""
|
|
55
|
+
with self._lock:
|
|
56
|
+
# Check subscription-specific callbacks
|
|
57
|
+
for sub_id, (topic_filter, callback) in self._subscriptions.items():
|
|
58
|
+
if self.is_topic_sub(message.topic, topic_filter):
|
|
59
|
+
if callback is not None:
|
|
60
|
+
receiving_msg = copy(message)
|
|
61
|
+
receiving_msg.subscription_id = sub_id
|
|
62
|
+
callback(receiving_msg)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# If no specific callback matched, call general message callbacks
|
|
66
|
+
for callback in self._message_callbacks:
|
|
67
|
+
callback(message)
|
|
68
|
+
|
|
69
|
+
def publish(self, message: Message) -> Future:
|
|
70
|
+
"""
|
|
71
|
+
Records the published message and returns a completed Future.
|
|
72
|
+
"""
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._published_messages.append(message)
|
|
75
|
+
|
|
76
|
+
future: Future = Future()
|
|
77
|
+
future.set_result(None)
|
|
78
|
+
return future
|
|
79
|
+
|
|
80
|
+
def subscribe(self, topic: str, callback: Optional[MessageCallback] = None) -> int:
|
|
81
|
+
"""
|
|
82
|
+
Registers a subscription and returns a subscription ID.
|
|
83
|
+
"""
|
|
84
|
+
with self._lock:
|
|
85
|
+
sub_id = self._next_subscription_id
|
|
86
|
+
self._next_subscription_id += 1
|
|
87
|
+
self._subscriptions[sub_id] = (topic, callback)
|
|
88
|
+
return sub_id
|
|
89
|
+
|
|
90
|
+
def add_message_callback(self, callback: MessageCallback) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Adds a callback for messages without specific subscription callbacks.
|
|
93
|
+
"""
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._message_callbacks.append(callback)
|
|
96
|
+
|
|
97
|
+
def is_topic_sub(self, topic: str, sub: str) -> bool:
|
|
98
|
+
"""
|
|
99
|
+
Simple topic matching implementation.
|
|
100
|
+
Supports MQTT wildcards: + (single level) and # (multi level).
|
|
101
|
+
"""
|
|
102
|
+
topic_parts = topic.split("/")
|
|
103
|
+
sub_parts = sub.split("/")
|
|
104
|
+
|
|
105
|
+
# # must be last and matches everything after
|
|
106
|
+
if "#" in sub_parts:
|
|
107
|
+
hash_idx = sub_parts.index("#")
|
|
108
|
+
if hash_idx != len(sub_parts) - 1:
|
|
109
|
+
return False # # must be last
|
|
110
|
+
sub_parts = sub_parts[:hash_idx]
|
|
111
|
+
topic_parts = topic_parts[:hash_idx]
|
|
112
|
+
|
|
113
|
+
if len(topic_parts) != len(sub_parts):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
for topic_part, sub_part in zip(topic_parts, sub_parts):
|
|
117
|
+
if sub_part == "+":
|
|
118
|
+
continue # + matches any single level
|
|
119
|
+
if topic_part != sub_part:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def is_connected(self) -> bool:
|
|
125
|
+
"""Returns the connection status."""
|
|
126
|
+
with self._lock:
|
|
127
|
+
return self._connected
|
|
128
|
+
|
|
129
|
+
def unsubscribe(self, subscription_id: int) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Helper method to unsubscribe by subscription ID.
|
|
132
|
+
Not part of the interface but useful for testing.
|
|
133
|
+
"""
|
|
134
|
+
with self._lock:
|
|
135
|
+
if subscription_id in self._subscriptions:
|
|
136
|
+
del self._subscriptions[subscription_id]
|
|
137
|
+
|
|
138
|
+
def get_subscription_count(self) -> int:
|
|
139
|
+
"""
|
|
140
|
+
Returns the number of active subscriptions.
|
|
141
|
+
Helper method for testing.
|
|
142
|
+
"""
|
|
143
|
+
with self._lock:
|
|
144
|
+
return len(self._subscriptions)
|
pyqttier/py.typed
ADDED
|
File without changes
|
pyqttier/transport.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MqttTransportType(Enum):
|
|
6
|
+
"""Defines ways to connect to an MQTT broker."""
|
|
7
|
+
|
|
8
|
+
TCP = "tcp"
|
|
9
|
+
WEBSOCKET = "websockets"
|
|
10
|
+
UNIX = "unix"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MqttTransport:
|
|
14
|
+
"""Defines the transport parameters for connecting to an MQTT broker."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
transport_type: MqttTransportType,
|
|
19
|
+
host: Optional[str] = None,
|
|
20
|
+
port: Optional[int] = None,
|
|
21
|
+
socket_path: Optional[str] = None,
|
|
22
|
+
):
|
|
23
|
+
self.transport = transport_type
|
|
24
|
+
self.host_or_path = (
|
|
25
|
+
socket_path if transport_type == MqttTransportType.UNIX else host
|
|
26
|
+
)
|
|
27
|
+
self.port = 0 if transport_type == MqttTransportType.UNIX else (port or 1883)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyqttier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: Jacob Brunson <github@jacobbrunson.com>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.7
|
|
8
|
+
Requires-Dist: paho-mqtt>=2.1.0
|
|
9
|
+
Requires-Dist: pydantic>=2.5.3
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# PyQTTier
|
|
13
|
+
|
|
14
|
+
A Python MQTT client library providing a clean, type-safe wrapper around paho-mqtt with support for MQTT 5.0 features.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **MQTT 5.0 Support** - Full support for MQTT 5.0 protocol features including message properties, correlation data, and response topics
|
|
19
|
+
- **Type Safety** - Strongly typed with mypy-checked type hints for reliability
|
|
20
|
+
- **Multiple Transports** - Supports TCP, WebSocket, and Unix socket connections
|
|
21
|
+
- **Mock Implementation** - Built-in `MockConnection` for easy testing without a broker
|
|
22
|
+
- **Python 3.7+** - Compatible with Python 3.7 through 3.12
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install pyqttier
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### Basic Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pyqttier.connection import Mqtt5Connection
|
|
36
|
+
from pyqttier.transport import MqttTransport, MqttTransportType
|
|
37
|
+
from pyqttier.message import Message
|
|
38
|
+
|
|
39
|
+
# Connect to broker
|
|
40
|
+
transport = MqttTransport(MqttTransportType.TCP, host="localhost", port=1883)
|
|
41
|
+
conn = Mqtt5Connection(transport=transport, client_id="my-client")
|
|
42
|
+
|
|
43
|
+
# Subscribe to a topic
|
|
44
|
+
def on_message(msg: Message):
|
|
45
|
+
print(f"Received: {msg.payload.decode()}")
|
|
46
|
+
|
|
47
|
+
conn.subscribe("sensors/temperature", callback=on_message)
|
|
48
|
+
|
|
49
|
+
# Publish a message
|
|
50
|
+
msg = Message(topic="sensors/temperature", payload=b"23.5", qos=1)
|
|
51
|
+
conn.publish(msg)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Testing with MockConnection
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from pyqttier.mock import MockConnection
|
|
58
|
+
from pyqttier.message import Message
|
|
59
|
+
|
|
60
|
+
# Create mock connection for testing
|
|
61
|
+
conn = MockConnection()
|
|
62
|
+
|
|
63
|
+
# Publish and verify
|
|
64
|
+
conn.publish(Message(topic="test", payload=b"data", qos=1))
|
|
65
|
+
assert len(conn.published_messages) == 1
|
|
66
|
+
assert conn.published_messages[0].topic == "test"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Examples
|
|
70
|
+
|
|
71
|
+
See the [examples/](examples/) directory for more detailed usage examples:
|
|
72
|
+
|
|
73
|
+
- `usage_example.py` - Real broker connections, wildcards, request-response patterns
|
|
74
|
+
- `mock_example.py` - Testing with MockConnection
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Type checking
|
|
80
|
+
uv run mypy --check-untyped-defs ./src/
|
|
81
|
+
|
|
82
|
+
# Unit tests
|
|
83
|
+
uv run pytest
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
See [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyqttier/__init__.py,sha256=UvsVYguOyK6Sj2n7AnSFT1kfZeYwCtejhnKw6f_hvCY,54
|
|
2
|
+
pyqttier/connection.py,sha256=b3TPcDN_-msTbqOwbsWnga3igxnBqRhbVFITuDuMnew,7917
|
|
3
|
+
pyqttier/interface.py,sha256=jqA8Q7TecWjPdMLfx13xzJIoWIXmUSs2Q2YHbSePvyQ,1887
|
|
4
|
+
pyqttier/lwt.py,sha256=NNTB8ZxJivC4aCz2lZaJJ14RQ_nKbbwto-8eAtpHKVc,707
|
|
5
|
+
pyqttier/message.py,sha256=7gG5HATDXxl8YdQG8nQ3_gU8SqhEV09YkQvhRgHD-VQ,7558
|
|
6
|
+
pyqttier/mock.py,sha256=LvMmlKUdHjgPtCoxn1okfuYAZXdI6mQLUTmfS5eoDOE,4952
|
|
7
|
+
pyqttier/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pyqttier/transport.py,sha256=Lf898T4wre6rJu2yv2xFs9XxnTvzI9xEKIab_RSssTU,742
|
|
9
|
+
pyqttier-0.1.0.dist-info/METADATA,sha256=I8VoJ-nQui3ksgaoRHuwD01HDH4pc7n4qagdk6aFduA,2315
|
|
10
|
+
pyqttier-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
+
pyqttier-0.1.0.dist-info/licenses/LICENSE,sha256=_T-8ExmblJbhv-1AxC6XDVEHg1JdJA-126NvRiMqS-I,1070
|
|
12
|
+
pyqttier-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jacob Brunson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|