place-integration-api 0.2.6__tar.gz → 0.2.8__tar.gz
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.
- {place_integration_api-0.2.6/src/place_integration_api.egg-info → place_integration_api-0.2.8}/PKG-INFO +9 -1
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/README.md +8 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/pyproject.toml +1 -1
- place_integration_api-0.2.8/src/place/errors.py +17 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/messages.py +12 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/mqtt_client.py +74 -3
- {place_integration_api-0.2.6 → place_integration_api-0.2.8/src/place_integration_api.egg-info}/PKG-INFO +9 -1
- place_integration_api-0.2.8/tests/test_mqtt_client.py +286 -0
- place_integration_api-0.2.6/src/place/errors.py +0 -9
- place_integration_api-0.2.6/tests/test_mqtt_client.py +0 -48
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/LICENSE +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/setup.cfg +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/auth/__init__.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/auth/abstract_auth.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/auth/aws_srp.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/auth/srp_auth.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/auth/tokens.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/config.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/__init__.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/credentials.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/device_shadow.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/discover_device.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/models.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/mqtt_message.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/provider.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/SOURCES.txt +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/dependency_links.txt +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/requires.txt +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/top_level.txt +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/tests/test_auth.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/tests/test_models.py +0 -0
- {place_integration_api-0.2.6 → place_integration_api-0.2.8}/tests/test_provider.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: place-integration-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: Python APIs for Cognito-authenticated MQTT flows and Home Assistant discovery for Gentex Place.
|
|
5
5
|
Author-email: Nicholas Aelick <niaexa@syntronic.com>, Andrew Tran <andrew.tran@gentex.com>
|
|
6
6
|
Project-URL: Homepage, https://placehomesolutions.com
|
|
@@ -28,6 +28,14 @@ Dynamic: license-file
|
|
|
28
28
|
|
|
29
29
|
This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
|
|
30
30
|
|
|
31
|
+
## Requesting a device shadow
|
|
32
|
+
|
|
33
|
+
Call `PlaceMessages.subscribe_shadow_and_get(thing_name)` from the MQTT client's
|
|
34
|
+
`on_connect` callback. It subscribes to the device's shadow topics and sends the
|
|
35
|
+
shadow GET only after the broker acknowledges the subscription. The `on_connect`
|
|
36
|
+
callback is also invoked on reconnect, so it should subscribe every device again.
|
|
37
|
+
Rejected subscriptions are logged and do not send a GET.
|
|
38
|
+
|
|
31
39
|
## Setup
|
|
32
40
|
|
|
33
41
|
This repo utilizes VS Code [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) so you don't need to alter your global python environment.
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
|
|
4
4
|
|
|
5
|
+
## Requesting a device shadow
|
|
6
|
+
|
|
7
|
+
Call `PlaceMessages.subscribe_shadow_and_get(thing_name)` from the MQTT client's
|
|
8
|
+
`on_connect` callback. It subscribes to the device's shadow topics and sends the
|
|
9
|
+
shadow GET only after the broker acknowledges the subscription. The `on_connect`
|
|
10
|
+
callback is also invoked on reconnect, so it should subscribe every device again.
|
|
11
|
+
Rejected subscriptions are logged and do not send a GET.
|
|
12
|
+
|
|
5
13
|
## Setup
|
|
6
14
|
|
|
7
15
|
This repo utilizes VS Code [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) so you don't need to alter your global python environment.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "place-integration-api"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.8"
|
|
8
8
|
description = "Python APIs for Cognito-authenticated MQTT flows and Home Assistant discovery for Gentex Place."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors for the Place API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PlaceApiError(Exception):
|
|
5
|
+
"""Base error for Place API failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlaceFulfillmentError(PlaceApiError):
|
|
9
|
+
"""Raised when the fulfillment API reports a business-logic failure."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlaceMqttConnectionError(PlaceApiError):
|
|
13
|
+
"""Raised when the MQTT broker rejects or fails to establish a connection."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlaceMqttSubscriptionError(PlaceApiError):
|
|
17
|
+
"""Raised when an MQTT subscription cannot be sent."""
|
|
@@ -73,6 +73,18 @@ class PlaceMessages:
|
|
|
73
73
|
self._client.subscribe(topic, qos=qos)
|
|
74
74
|
return name
|
|
75
75
|
|
|
76
|
+
def subscribe_shadow_and_get(self, thing_name: str, qos: int = 1) -> str:
|
|
77
|
+
"""Subscribe to a shadow and request its state after a successful SUBACK."""
|
|
78
|
+
name = thing_name.strip()
|
|
79
|
+
assert name
|
|
80
|
+
topic = shadow_subscription_topic(name)
|
|
81
|
+
self._client.subscribe(
|
|
82
|
+
topic,
|
|
83
|
+
qos=qos,
|
|
84
|
+
on_subscribe=lambda: self.publish_shadow_get(name, qos=qos),
|
|
85
|
+
)
|
|
86
|
+
return name
|
|
87
|
+
|
|
76
88
|
def publish_shadow_get(self, thing_name: str, qos: int = 1) -> str:
|
|
77
89
|
name = thing_name.strip()
|
|
78
90
|
assert name
|
|
@@ -2,16 +2,22 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
5
7
|
import uuid
|
|
6
8
|
from datetime import datetime, timezone
|
|
7
9
|
from typing import Callable
|
|
8
10
|
from urllib.parse import quote
|
|
9
11
|
|
|
10
12
|
import paho.mqtt.client as mqtt
|
|
13
|
+
from paho.mqtt.reasoncodes import ReasonCode
|
|
11
14
|
|
|
12
15
|
from .config import ALGORITHM, EXPIRE_SEC, KEEP_ALIVE_SEC, PATH, REGION, SCHEME, SERVICE
|
|
16
|
+
from .errors import PlaceMqttConnectionError, PlaceMqttSubscriptionError
|
|
13
17
|
from .models import Credentials
|
|
14
18
|
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
15
21
|
|
|
16
22
|
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
|
17
23
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
@@ -92,12 +98,19 @@ class MqttClient:
|
|
|
92
98
|
self.endpoint = endpoint
|
|
93
99
|
self.credentials = credentials
|
|
94
100
|
self._client: mqtt.Client | None = None
|
|
101
|
+
self._connected_event = threading.Event()
|
|
102
|
+
self._connect_error: ReasonCode | None = None
|
|
103
|
+
self._subscription_lock = threading.RLock()
|
|
104
|
+
self._pending_subscriptions: dict[int, tuple[str, Callable[[], None]]] = {}
|
|
95
105
|
|
|
96
106
|
def connect(
|
|
97
107
|
self,
|
|
98
108
|
on_message: Callable[[str, bytes], None] | None = None,
|
|
99
109
|
on_connect: Callable[[], None] | None = None,
|
|
100
110
|
) -> None:
|
|
111
|
+
self._connected_event.clear()
|
|
112
|
+
self._connect_error = None
|
|
113
|
+
|
|
101
114
|
signed_uri = get_signed_uri(
|
|
102
115
|
access_key_id=self.credentials.access_key_id,
|
|
103
116
|
secret_access_key=self.credentials.secret_access_key,
|
|
@@ -117,10 +130,16 @@ class MqttClient:
|
|
|
117
130
|
client.tls_set()
|
|
118
131
|
|
|
119
132
|
def _on_connect(_client, _userdata, _flags, reason_code, _properties):
|
|
133
|
+
with self._subscription_lock:
|
|
134
|
+
self._pending_subscriptions.clear()
|
|
120
135
|
if reason_code.is_failure:
|
|
121
136
|
print(f"Connect failed: {reason_code}")
|
|
137
|
+
self._connect_error = reason_code
|
|
138
|
+
self._connected_event.set()
|
|
122
139
|
return
|
|
123
140
|
print("Connected")
|
|
141
|
+
self._connect_error = None
|
|
142
|
+
self._connected_event.set()
|
|
124
143
|
if on_connect:
|
|
125
144
|
try:
|
|
126
145
|
on_connect()
|
|
@@ -131,17 +150,38 @@ class MqttClient:
|
|
|
131
150
|
if on_message:
|
|
132
151
|
on_message(msg.topic, msg.payload)
|
|
133
152
|
|
|
153
|
+
def _on_subscribe(_client, _userdata, mid, reason_codes, _properties):
|
|
154
|
+
with self._subscription_lock:
|
|
155
|
+
pending = self._pending_subscriptions.pop(mid, None)
|
|
156
|
+
if pending is None:
|
|
157
|
+
return
|
|
158
|
+
topic, callback = pending
|
|
159
|
+
if not reason_codes or any(code.is_failure for code in reason_codes):
|
|
160
|
+
_LOGGER.error(
|
|
161
|
+
"MQTT subscription to %s was rejected: %s", topic, reason_codes
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
callback()
|
|
166
|
+
except Exception:
|
|
167
|
+
_LOGGER.exception("MQTT subscription callback failed for %s", topic)
|
|
168
|
+
|
|
134
169
|
def _on_disconnect(_client, _userdata, _flags, reason_code, _properties):
|
|
170
|
+
with self._subscription_lock:
|
|
171
|
+
self._pending_subscriptions.clear()
|
|
135
172
|
print(f"Disconnected: {reason_code}")
|
|
136
173
|
|
|
137
174
|
client.on_connect = _on_connect
|
|
138
175
|
client.on_message = _on_message
|
|
176
|
+
client.on_subscribe = _on_subscribe
|
|
139
177
|
client.on_disconnect = _on_disconnect
|
|
140
178
|
client.enable_logger()
|
|
141
179
|
client.connect(self.endpoint, 443, KEEP_ALIVE_SEC)
|
|
142
180
|
self._client = client
|
|
143
181
|
|
|
144
182
|
def disconnect(self) -> None:
|
|
183
|
+
with self._subscription_lock:
|
|
184
|
+
self._pending_subscriptions.clear()
|
|
145
185
|
if self._client:
|
|
146
186
|
self._client.disconnect()
|
|
147
187
|
|
|
@@ -149,9 +189,41 @@ class MqttClient:
|
|
|
149
189
|
if self._client:
|
|
150
190
|
self._client.loop_stop()
|
|
151
191
|
|
|
152
|
-
def
|
|
192
|
+
def wait_for_connection(self, timeout: float = 10.0) -> None:
|
|
193
|
+
"""Block until the initial CONNACK is received.
|
|
194
|
+
|
|
195
|
+
Raises PlaceMqttConnectionError if the broker rejects the connection
|
|
196
|
+
or none arrives within timeout, tearing down the client in that case.
|
|
197
|
+
"""
|
|
198
|
+
if not self._connected_event.wait(timeout):
|
|
199
|
+
self.disconnect()
|
|
200
|
+
self.loop_stop()
|
|
201
|
+
raise PlaceMqttConnectionError("Timed out waiting for MQTT CONNACK")
|
|
202
|
+
if self._connect_error is not None:
|
|
203
|
+
self.disconnect()
|
|
204
|
+
self.loop_stop()
|
|
205
|
+
raise PlaceMqttConnectionError(
|
|
206
|
+
f"MQTT connection rejected: {self._connect_error}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def subscribe(
|
|
210
|
+
self, topic: str, qos: int = 1, *, on_subscribe: Callable[[], None] | None = None
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Subscribe to a topic and optionally call back after a successful SUBACK.
|
|
213
|
+
|
|
214
|
+
The callback runs on the MQTT network thread. Pending callbacks are
|
|
215
|
+
discarded on disconnect, so resubscribe from on_connect after a reconnect.
|
|
216
|
+
"""
|
|
153
217
|
assert self._client is not None
|
|
154
|
-
|
|
218
|
+
# Keep the network thread from handling SUBACK before the MID (message identifier) is recorded.
|
|
219
|
+
with self._subscription_lock:
|
|
220
|
+
result, mid = self._client.subscribe(topic, qos=qos)
|
|
221
|
+
if result != mqtt.MQTT_ERR_SUCCESS:
|
|
222
|
+
raise PlaceMqttSubscriptionError(
|
|
223
|
+
f"Could not subscribe to {topic}: {mqtt.error_string(result)}"
|
|
224
|
+
)
|
|
225
|
+
if on_subscribe is not None:
|
|
226
|
+
self._pending_subscriptions[mid] = (topic, on_subscribe)
|
|
155
227
|
|
|
156
228
|
def publish(self, topic: str, payload: str | bytes = b"", qos: int = 1) -> None:
|
|
157
229
|
assert self._client is not None
|
|
@@ -165,4 +237,3 @@ class MqttClient:
|
|
|
165
237
|
def loop_forever(self) -> None:
|
|
166
238
|
assert self._client is not None
|
|
167
239
|
self._client.loop_forever()
|
|
168
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: place-integration-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: Python APIs for Cognito-authenticated MQTT flows and Home Assistant discovery for Gentex Place.
|
|
5
5
|
Author-email: Nicholas Aelick <niaexa@syntronic.com>, Andrew Tran <andrew.tran@gentex.com>
|
|
6
6
|
Project-URL: Homepage, https://placehomesolutions.com
|
|
@@ -28,6 +28,14 @@ Dynamic: license-file
|
|
|
28
28
|
|
|
29
29
|
This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
|
|
30
30
|
|
|
31
|
+
## Requesting a device shadow
|
|
32
|
+
|
|
33
|
+
Call `PlaceMessages.subscribe_shadow_and_get(thing_name)` from the MQTT client's
|
|
34
|
+
`on_connect` callback. It subscribes to the device's shadow topics and sends the
|
|
35
|
+
shadow GET only after the broker acknowledges the subscription. The `on_connect`
|
|
36
|
+
callback is also invoked on reconnect, so it should subscribe every device again.
|
|
37
|
+
Rejected subscriptions are logged and do not send a GET.
|
|
38
|
+
|
|
31
39
|
## Setup
|
|
32
40
|
|
|
33
41
|
This repo utilizes VS Code [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) so you don't need to alter your global python environment.
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from threading import Event, Thread
|
|
5
|
+
from unittest.mock import MagicMock, call, patch
|
|
6
|
+
|
|
7
|
+
import paho.mqtt.client as mqtt
|
|
8
|
+
from paho.mqtt.packettypes import PacketTypes
|
|
9
|
+
from paho.mqtt.reasoncodes import ReasonCode
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from place.errors import PlaceMqttConnectionError, PlaceMqttSubscriptionError
|
|
13
|
+
from place.messages import PlaceMessages
|
|
14
|
+
from place.models import Credentials
|
|
15
|
+
from place.mqtt_client import get_signed_uri, MqttClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_get_signed_uri_includes_host_and_token() -> None:
|
|
19
|
+
uri = get_signed_uri(
|
|
20
|
+
access_key_id="AKIA...",
|
|
21
|
+
secret_access_key="secret",
|
|
22
|
+
session_token="token123",
|
|
23
|
+
host="example.iot.amazonaws.com",
|
|
24
|
+
)
|
|
25
|
+
assert uri.startswith("wss://example.iot.amazonaws.com/mqtt?")
|
|
26
|
+
assert "X-Amz-Algorithm=" in uri
|
|
27
|
+
assert "X-Amz-Signature=" in uri
|
|
28
|
+
assert "X-Amz-Security-Token=token123" in uri
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@patch("place.mqtt_client.mqtt.Client")
|
|
32
|
+
@patch("place.mqtt_client.get_signed_uri")
|
|
33
|
+
def test_mqtt_client_connect_sets_up_client(
|
|
34
|
+
mock_get_signed_uri: MagicMock,
|
|
35
|
+
mock_client_cls: MagicMock,
|
|
36
|
+
) -> None:
|
|
37
|
+
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
38
|
+
client = MagicMock()
|
|
39
|
+
mock_client_cls.return_value = client
|
|
40
|
+
|
|
41
|
+
creds = Credentials(
|
|
42
|
+
access_key_id="AKIA...",
|
|
43
|
+
secret_access_key="secret",
|
|
44
|
+
session_token="token",
|
|
45
|
+
identity_id="identity-123",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
mqtt_client = MqttClient(
|
|
49
|
+
endpoint="example.iot.amazonaws.com",
|
|
50
|
+
credentials=creds,
|
|
51
|
+
)
|
|
52
|
+
mqtt_client.connect()
|
|
53
|
+
|
|
54
|
+
mock_get_signed_uri.assert_called_once()
|
|
55
|
+
client.ws_set_options.assert_called()
|
|
56
|
+
client.tls_set.assert_called_once()
|
|
57
|
+
client.connect.assert_called_once()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@patch("place.mqtt_client.mqtt.Client")
|
|
61
|
+
@patch("place.mqtt_client.get_signed_uri")
|
|
62
|
+
def test_wait_for_connection_succeeds_on_connack_success(
|
|
63
|
+
mock_get_signed_uri: MagicMock,
|
|
64
|
+
mock_client_cls: MagicMock,
|
|
65
|
+
) -> None:
|
|
66
|
+
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
67
|
+
client = MagicMock()
|
|
68
|
+
mock_client_cls.return_value = client
|
|
69
|
+
|
|
70
|
+
creds = Credentials(
|
|
71
|
+
access_key_id="AKIA...",
|
|
72
|
+
secret_access_key="secret",
|
|
73
|
+
session_token="token",
|
|
74
|
+
identity_id="identity-123",
|
|
75
|
+
)
|
|
76
|
+
mqtt_client = MqttClient(endpoint="example.iot.amazonaws.com", credentials=creds)
|
|
77
|
+
mqtt_client.connect()
|
|
78
|
+
|
|
79
|
+
client.on_connect(
|
|
80
|
+
client, None, None, ReasonCode(PacketTypes.CONNACK, identifier=0), None
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
mqtt_client.wait_for_connection(timeout=1)
|
|
84
|
+
|
|
85
|
+
client.disconnect.assert_not_called()
|
|
86
|
+
client.loop_stop.assert_not_called()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@patch("place.mqtt_client.mqtt.Client")
|
|
90
|
+
@patch("place.mqtt_client.get_signed_uri")
|
|
91
|
+
def test_wait_for_connection_raises_on_connack_rejection(
|
|
92
|
+
mock_get_signed_uri: MagicMock,
|
|
93
|
+
mock_client_cls: MagicMock,
|
|
94
|
+
) -> None:
|
|
95
|
+
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
96
|
+
client = MagicMock()
|
|
97
|
+
mock_client_cls.return_value = client
|
|
98
|
+
|
|
99
|
+
creds = Credentials(
|
|
100
|
+
access_key_id="AKIA...",
|
|
101
|
+
secret_access_key="secret",
|
|
102
|
+
session_token="token",
|
|
103
|
+
identity_id="identity-123",
|
|
104
|
+
)
|
|
105
|
+
mqtt_client = MqttClient(endpoint="example.iot.amazonaws.com", credentials=creds)
|
|
106
|
+
mqtt_client.connect()
|
|
107
|
+
|
|
108
|
+
client.on_connect(
|
|
109
|
+
client, None, None, ReasonCode(PacketTypes.CONNACK, identifier=135), None
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
with pytest.raises(PlaceMqttConnectionError, match="rejected"):
|
|
113
|
+
mqtt_client.wait_for_connection(timeout=1)
|
|
114
|
+
|
|
115
|
+
client.disconnect.assert_called_once()
|
|
116
|
+
client.loop_stop.assert_called_once()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@patch("place.mqtt_client.mqtt.Client")
|
|
120
|
+
@patch("place.mqtt_client.get_signed_uri")
|
|
121
|
+
def test_wait_for_connection_raises_on_timeout(
|
|
122
|
+
mock_get_signed_uri: MagicMock,
|
|
123
|
+
mock_client_cls: MagicMock,
|
|
124
|
+
) -> None:
|
|
125
|
+
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
126
|
+
client = MagicMock()
|
|
127
|
+
mock_client_cls.return_value = client
|
|
128
|
+
|
|
129
|
+
creds = Credentials(
|
|
130
|
+
access_key_id="AKIA...",
|
|
131
|
+
secret_access_key="secret",
|
|
132
|
+
session_token="token",
|
|
133
|
+
identity_id="identity-123",
|
|
134
|
+
)
|
|
135
|
+
mqtt_client = MqttClient(endpoint="example.iot.amazonaws.com", credentials=creds)
|
|
136
|
+
mqtt_client.connect()
|
|
137
|
+
|
|
138
|
+
with pytest.raises(PlaceMqttConnectionError, match="Timed out"):
|
|
139
|
+
mqtt_client.wait_for_connection(timeout=0.05)
|
|
140
|
+
|
|
141
|
+
client.disconnect.assert_called_once()
|
|
142
|
+
client.loop_stop.assert_called_once()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.fixture
|
|
146
|
+
def mqtt_pair() -> tuple[MqttClient, MagicMock]:
|
|
147
|
+
"""Set up the wrapper with a mocked Paho client."""
|
|
148
|
+
with patch("place.mqtt_client.mqtt.Client") as client_cls:
|
|
149
|
+
client = client_cls.return_value
|
|
150
|
+
credentials = Credentials(
|
|
151
|
+
access_key_id="key",
|
|
152
|
+
secret_access_key="secret",
|
|
153
|
+
session_token="token",
|
|
154
|
+
identity_id="identity",
|
|
155
|
+
)
|
|
156
|
+
wrapper = MqttClient(
|
|
157
|
+
endpoint="example.iot.amazonaws.com", credentials=credentials
|
|
158
|
+
)
|
|
159
|
+
wrapper.connect()
|
|
160
|
+
return wrapper, client
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_shadow_get_waits_for_each_successful_suback(
|
|
164
|
+
mqtt_pair: tuple[MqttClient, MagicMock],
|
|
165
|
+
) -> None:
|
|
166
|
+
wrapper, client = mqtt_pair
|
|
167
|
+
messages = PlaceMessages(wrapper)
|
|
168
|
+
client.subscribe.side_effect = [(mqtt.MQTT_ERR_SUCCESS, mid) for mid in range(1, 5)]
|
|
169
|
+
|
|
170
|
+
for name in ("one", "two", "three", "four"):
|
|
171
|
+
messages.subscribe_shadow_and_get(name)
|
|
172
|
+
|
|
173
|
+
client.publish.assert_not_called()
|
|
174
|
+
for mid, name in ((3, "three"), (1, "one"), (4, "four"), (2, "two")):
|
|
175
|
+
client.on_subscribe(
|
|
176
|
+
client, None, mid, [ReasonCode(PacketTypes.SUBACK, identifier=1)], None
|
|
177
|
+
)
|
|
178
|
+
assert client.publish.call_args == call(
|
|
179
|
+
f"$aws/things/{name}/shadow/get", b"", qos=1
|
|
180
|
+
)
|
|
181
|
+
assert client.publish.call_count == 4
|
|
182
|
+
client.on_subscribe(
|
|
183
|
+
client, None, 1, [ReasonCode(PacketTypes.SUBACK, identifier=1)], None
|
|
184
|
+
)
|
|
185
|
+
assert client.publish.call_count == 4
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize("reason_codes", [[ReasonCode(PacketTypes.SUBACK, identifier=128)], []])
|
|
189
|
+
def test_failed_suback_does_not_request_shadow(
|
|
190
|
+
mqtt_pair: tuple[MqttClient, MagicMock],
|
|
191
|
+
caplog: pytest.LogCaptureFixture,
|
|
192
|
+
reason_codes: list[ReasonCode],
|
|
193
|
+
) -> None:
|
|
194
|
+
wrapper, client = mqtt_pair
|
|
195
|
+
client.subscribe.return_value = (mqtt.MQTT_ERR_SUCCESS, 1)
|
|
196
|
+
PlaceMessages(wrapper).subscribe_shadow_and_get("one")
|
|
197
|
+
|
|
198
|
+
with caplog.at_level(logging.ERROR):
|
|
199
|
+
client.on_subscribe(client, None, 1, reason_codes, None)
|
|
200
|
+
|
|
201
|
+
client.publish.assert_not_called()
|
|
202
|
+
assert "$aws/things/one/shadow/#" in caplog.text
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_shadow_subscription_preserves_name_and_qos(
|
|
206
|
+
mqtt_pair: tuple[MqttClient, MagicMock],
|
|
207
|
+
) -> None:
|
|
208
|
+
wrapper, client = mqtt_pair
|
|
209
|
+
client.subscribe.return_value = (mqtt.MQTT_ERR_SUCCESS, 1)
|
|
210
|
+
assert PlaceMessages(wrapper).subscribe_shadow_and_get(" one ", qos=0) == "one"
|
|
211
|
+
client.subscribe.assert_called_once_with("$aws/things/one/shadow/#", qos=0)
|
|
212
|
+
client.on_subscribe(
|
|
213
|
+
client, None, 1, [ReasonCode(PacketTypes.SUBACK, identifier=0)], None
|
|
214
|
+
)
|
|
215
|
+
client.publish.assert_called_once_with("$aws/things/one/shadow/get", b"", qos=0)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_subscribe_send_failure_raises(
|
|
219
|
+
mqtt_pair: tuple[MqttClient, MagicMock],
|
|
220
|
+
) -> None:
|
|
221
|
+
wrapper, client = mqtt_pair
|
|
222
|
+
client.subscribe.return_value = (mqtt.MQTT_ERR_NO_CONN, None)
|
|
223
|
+
|
|
224
|
+
with pytest.raises(PlaceMqttSubscriptionError, match="one"):
|
|
225
|
+
PlaceMessages(wrapper).subscribe_shadow_and_get("one")
|
|
226
|
+
|
|
227
|
+
client.publish.assert_not_called()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_reconnect_discards_pending_subscriptions(
|
|
231
|
+
mqtt_pair: tuple[MqttClient, MagicMock],
|
|
232
|
+
) -> None:
|
|
233
|
+
wrapper, client = mqtt_pair
|
|
234
|
+
messages = PlaceMessages(wrapper)
|
|
235
|
+
client.subscribe.side_effect = [
|
|
236
|
+
(mqtt.MQTT_ERR_SUCCESS, 1),
|
|
237
|
+
(mqtt.MQTT_ERR_SUCCESS, 2),
|
|
238
|
+
]
|
|
239
|
+
with patch("place.mqtt_client.mqtt.Client", return_value=client):
|
|
240
|
+
wrapper.connect(on_connect=lambda: messages.subscribe_shadow_and_get("one"))
|
|
241
|
+
client.on_connect(
|
|
242
|
+
client, None, None, ReasonCode(PacketTypes.CONNACK, identifier=0), None
|
|
243
|
+
)
|
|
244
|
+
client.on_disconnect(client, None, None, None, None)
|
|
245
|
+
client.on_connect(
|
|
246
|
+
client, None, None, ReasonCode(PacketTypes.CONNACK, identifier=0), None
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
client.on_subscribe(
|
|
250
|
+
client, None, 1, [ReasonCode(PacketTypes.SUBACK, identifier=1)], None
|
|
251
|
+
)
|
|
252
|
+
client.publish.assert_not_called()
|
|
253
|
+
client.on_subscribe(
|
|
254
|
+
client, None, 2, [ReasonCode(PacketTypes.SUBACK, identifier=1)], None
|
|
255
|
+
)
|
|
256
|
+
assert client.subscribe.call_count == 2
|
|
257
|
+
client.publish.assert_called_once_with("$aws/things/one/shadow/get", b"", qos=1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_suback_cannot_arrive_before_subscription_is_registered(
|
|
261
|
+
mqtt_pair: tuple[MqttClient, MagicMock],
|
|
262
|
+
) -> None:
|
|
263
|
+
wrapper, client = mqtt_pair
|
|
264
|
+
attempted_ack = Event()
|
|
265
|
+
ack_finished = Event()
|
|
266
|
+
|
|
267
|
+
def acknowledge() -> None:
|
|
268
|
+
attempted_ack.set()
|
|
269
|
+
client.on_subscribe(
|
|
270
|
+
client, None, 1, [ReasonCode(PacketTypes.SUBACK, identifier=1)], None
|
|
271
|
+
)
|
|
272
|
+
ack_finished.set()
|
|
273
|
+
|
|
274
|
+
def subscribe(topic: str, qos: int) -> tuple[int, int]:
|
|
275
|
+
ack_thread.start()
|
|
276
|
+
assert attempted_ack.wait(1)
|
|
277
|
+
assert not ack_finished.is_set()
|
|
278
|
+
return mqtt.MQTT_ERR_SUCCESS, 1
|
|
279
|
+
|
|
280
|
+
ack_thread = Thread(target=acknowledge)
|
|
281
|
+
client.subscribe.side_effect = subscribe
|
|
282
|
+
PlaceMessages(wrapper).subscribe_shadow_and_get("one")
|
|
283
|
+
ack_thread.join(timeout=1)
|
|
284
|
+
|
|
285
|
+
assert not ack_thread.is_alive()
|
|
286
|
+
client.publish.assert_called_once_with("$aws/things/one/shadow/get", b"", qos=1)
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from unittest.mock import MagicMock, patch
|
|
4
|
-
|
|
5
|
-
from place.models import Credentials
|
|
6
|
-
from place.mqtt_client import get_signed_uri, MqttClient
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_get_signed_uri_includes_host_and_token() -> None:
|
|
10
|
-
uri = get_signed_uri(
|
|
11
|
-
access_key_id="AKIA...",
|
|
12
|
-
secret_access_key="secret",
|
|
13
|
-
session_token="token123",
|
|
14
|
-
host="example.iot.amazonaws.com",
|
|
15
|
-
)
|
|
16
|
-
assert uri.startswith("wss://example.iot.amazonaws.com/mqtt?")
|
|
17
|
-
assert "X-Amz-Algorithm=" in uri
|
|
18
|
-
assert "X-Amz-Signature=" in uri
|
|
19
|
-
assert "X-Amz-Security-Token=token123" in uri
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@patch("place.mqtt_client.mqtt.Client")
|
|
23
|
-
@patch("place.mqtt_client.get_signed_uri")
|
|
24
|
-
def test_mqtt_client_connect_sets_up_client(
|
|
25
|
-
mock_get_signed_uri: MagicMock,
|
|
26
|
-
mock_client_cls: MagicMock,
|
|
27
|
-
) -> None:
|
|
28
|
-
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
29
|
-
client = MagicMock()
|
|
30
|
-
mock_client_cls.return_value = client
|
|
31
|
-
|
|
32
|
-
creds = Credentials(
|
|
33
|
-
access_key_id="AKIA...",
|
|
34
|
-
secret_access_key="secret",
|
|
35
|
-
session_token="token",
|
|
36
|
-
identity_id="identity-123",
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
mqtt_client = MqttClient(
|
|
40
|
-
endpoint="example.iot.amazonaws.com",
|
|
41
|
-
credentials=creds,
|
|
42
|
-
)
|
|
43
|
-
mqtt_client.connect()
|
|
44
|
-
|
|
45
|
-
mock_get_signed_uri.assert_called_once()
|
|
46
|
-
client.ws_set_options.assert_called()
|
|
47
|
-
client.tls_set.assert_called_once()
|
|
48
|
-
client.connect.assert_called_once()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/device_shadow.py
RENAMED
|
File without changes
|
{place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/discover_device.py
RENAMED
|
File without changes
|
|
File without changes
|
{place_integration_api-0.2.6 → place_integration_api-0.2.8}/src/place/models/mqtt_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|