place-integration-api 0.2.7__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.7/src/place_integration_api.egg-info → place_integration_api-0.2.8}/PKG-INFO +9 -1
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/README.md +8 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/pyproject.toml +1 -1
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/errors.py +4 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/messages.py +12 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/mqtt_client.py +46 -4
- {place_integration_api-0.2.7 → 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.7/tests/test_mqtt_client.py +0 -138
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/LICENSE +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/setup.cfg +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/__init__.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/abstract_auth.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/aws_srp.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/srp_auth.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/tokens.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/config.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/__init__.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/credentials.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/device_shadow.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/discover_device.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/models.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/mqtt_message.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/provider.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/SOURCES.txt +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/dependency_links.txt +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/requires.txt +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/top_level.txt +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/tests/test_auth.py +0 -0
- {place_integration_api-0.2.7 → place_integration_api-0.2.8}/tests/test_models.py +0 -0
- {place_integration_api-0.2.7 → 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"
|
|
@@ -11,3 +11,7 @@ class PlaceFulfillmentError(PlaceApiError):
|
|
|
11
11
|
|
|
12
12
|
class PlaceMqttConnectionError(PlaceApiError):
|
|
13
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,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import hmac
|
|
5
|
+
import logging
|
|
5
6
|
import threading
|
|
6
7
|
import uuid
|
|
7
8
|
from datetime import datetime, timezone
|
|
@@ -12,9 +13,11 @@ import paho.mqtt.client as mqtt
|
|
|
12
13
|
from paho.mqtt.reasoncodes import ReasonCode
|
|
13
14
|
|
|
14
15
|
from .config import ALGORITHM, EXPIRE_SEC, KEEP_ALIVE_SEC, PATH, REGION, SCHEME, SERVICE
|
|
15
|
-
from .errors import PlaceMqttConnectionError
|
|
16
|
+
from .errors import PlaceMqttConnectionError, PlaceMqttSubscriptionError
|
|
16
17
|
from .models import Credentials
|
|
17
18
|
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
|
20
23
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
@@ -97,6 +100,8 @@ class MqttClient:
|
|
|
97
100
|
self._client: mqtt.Client | None = None
|
|
98
101
|
self._connected_event = threading.Event()
|
|
99
102
|
self._connect_error: ReasonCode | None = None
|
|
103
|
+
self._subscription_lock = threading.RLock()
|
|
104
|
+
self._pending_subscriptions: dict[int, tuple[str, Callable[[], None]]] = {}
|
|
100
105
|
|
|
101
106
|
def connect(
|
|
102
107
|
self,
|
|
@@ -125,6 +130,8 @@ class MqttClient:
|
|
|
125
130
|
client.tls_set()
|
|
126
131
|
|
|
127
132
|
def _on_connect(_client, _userdata, _flags, reason_code, _properties):
|
|
133
|
+
with self._subscription_lock:
|
|
134
|
+
self._pending_subscriptions.clear()
|
|
128
135
|
if reason_code.is_failure:
|
|
129
136
|
print(f"Connect failed: {reason_code}")
|
|
130
137
|
self._connect_error = reason_code
|
|
@@ -143,17 +150,38 @@ class MqttClient:
|
|
|
143
150
|
if on_message:
|
|
144
151
|
on_message(msg.topic, msg.payload)
|
|
145
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
|
+
|
|
146
169
|
def _on_disconnect(_client, _userdata, _flags, reason_code, _properties):
|
|
170
|
+
with self._subscription_lock:
|
|
171
|
+
self._pending_subscriptions.clear()
|
|
147
172
|
print(f"Disconnected: {reason_code}")
|
|
148
173
|
|
|
149
174
|
client.on_connect = _on_connect
|
|
150
175
|
client.on_message = _on_message
|
|
176
|
+
client.on_subscribe = _on_subscribe
|
|
151
177
|
client.on_disconnect = _on_disconnect
|
|
152
178
|
client.enable_logger()
|
|
153
179
|
client.connect(self.endpoint, 443, KEEP_ALIVE_SEC)
|
|
154
180
|
self._client = client
|
|
155
181
|
|
|
156
182
|
def disconnect(self) -> None:
|
|
183
|
+
with self._subscription_lock:
|
|
184
|
+
self._pending_subscriptions.clear()
|
|
157
185
|
if self._client:
|
|
158
186
|
self._client.disconnect()
|
|
159
187
|
|
|
@@ -178,9 +206,24 @@ class MqttClient:
|
|
|
178
206
|
f"MQTT connection rejected: {self._connect_error}"
|
|
179
207
|
)
|
|
180
208
|
|
|
181
|
-
def subscribe(
|
|
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
|
+
"""
|
|
182
217
|
assert self._client is not None
|
|
183
|
-
|
|
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)
|
|
184
227
|
|
|
185
228
|
def publish(self, topic: str, payload: str | bytes = b"", qos: int = 1) -> None:
|
|
186
229
|
assert self._client is not None
|
|
@@ -194,4 +237,3 @@ class MqttClient:
|
|
|
194
237
|
def loop_forever(self) -> None:
|
|
195
238
|
assert self._client is not None
|
|
196
239
|
self._client.loop_forever()
|
|
197
|
-
|
|
@@ -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,138 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from unittest.mock import MagicMock, patch
|
|
4
|
-
|
|
5
|
-
from paho.mqtt.packettypes import PacketTypes
|
|
6
|
-
from paho.mqtt.reasoncodes import ReasonCode
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from place.errors import PlaceMqttConnectionError
|
|
10
|
-
from place.models import Credentials
|
|
11
|
-
from place.mqtt_client import get_signed_uri, MqttClient
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_get_signed_uri_includes_host_and_token() -> None:
|
|
15
|
-
uri = get_signed_uri(
|
|
16
|
-
access_key_id="AKIA...",
|
|
17
|
-
secret_access_key="secret",
|
|
18
|
-
session_token="token123",
|
|
19
|
-
host="example.iot.amazonaws.com",
|
|
20
|
-
)
|
|
21
|
-
assert uri.startswith("wss://example.iot.amazonaws.com/mqtt?")
|
|
22
|
-
assert "X-Amz-Algorithm=" in uri
|
|
23
|
-
assert "X-Amz-Signature=" in uri
|
|
24
|
-
assert "X-Amz-Security-Token=token123" in uri
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@patch("place.mqtt_client.mqtt.Client")
|
|
28
|
-
@patch("place.mqtt_client.get_signed_uri")
|
|
29
|
-
def test_mqtt_client_connect_sets_up_client(
|
|
30
|
-
mock_get_signed_uri: MagicMock,
|
|
31
|
-
mock_client_cls: MagicMock,
|
|
32
|
-
) -> None:
|
|
33
|
-
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
34
|
-
client = MagicMock()
|
|
35
|
-
mock_client_cls.return_value = client
|
|
36
|
-
|
|
37
|
-
creds = Credentials(
|
|
38
|
-
access_key_id="AKIA...",
|
|
39
|
-
secret_access_key="secret",
|
|
40
|
-
session_token="token",
|
|
41
|
-
identity_id="identity-123",
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
mqtt_client = MqttClient(
|
|
45
|
-
endpoint="example.iot.amazonaws.com",
|
|
46
|
-
credentials=creds,
|
|
47
|
-
)
|
|
48
|
-
mqtt_client.connect()
|
|
49
|
-
|
|
50
|
-
mock_get_signed_uri.assert_called_once()
|
|
51
|
-
client.ws_set_options.assert_called()
|
|
52
|
-
client.tls_set.assert_called_once()
|
|
53
|
-
client.connect.assert_called_once()
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@patch("place.mqtt_client.mqtt.Client")
|
|
57
|
-
@patch("place.mqtt_client.get_signed_uri")
|
|
58
|
-
def test_wait_for_connection_succeeds_on_connack_success(
|
|
59
|
-
mock_get_signed_uri: MagicMock,
|
|
60
|
-
mock_client_cls: MagicMock,
|
|
61
|
-
) -> None:
|
|
62
|
-
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
63
|
-
client = MagicMock()
|
|
64
|
-
mock_client_cls.return_value = client
|
|
65
|
-
|
|
66
|
-
creds = Credentials(
|
|
67
|
-
access_key_id="AKIA...",
|
|
68
|
-
secret_access_key="secret",
|
|
69
|
-
session_token="token",
|
|
70
|
-
identity_id="identity-123",
|
|
71
|
-
)
|
|
72
|
-
mqtt_client = MqttClient(endpoint="example.iot.amazonaws.com", credentials=creds)
|
|
73
|
-
mqtt_client.connect()
|
|
74
|
-
|
|
75
|
-
client.on_connect(
|
|
76
|
-
client, None, None, ReasonCode(PacketTypes.CONNACK, identifier=0), None
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
mqtt_client.wait_for_connection(timeout=1)
|
|
80
|
-
|
|
81
|
-
client.disconnect.assert_not_called()
|
|
82
|
-
client.loop_stop.assert_not_called()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@patch("place.mqtt_client.mqtt.Client")
|
|
86
|
-
@patch("place.mqtt_client.get_signed_uri")
|
|
87
|
-
def test_wait_for_connection_raises_on_connack_rejection(
|
|
88
|
-
mock_get_signed_uri: MagicMock,
|
|
89
|
-
mock_client_cls: MagicMock,
|
|
90
|
-
) -> None:
|
|
91
|
-
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
92
|
-
client = MagicMock()
|
|
93
|
-
mock_client_cls.return_value = client
|
|
94
|
-
|
|
95
|
-
creds = Credentials(
|
|
96
|
-
access_key_id="AKIA...",
|
|
97
|
-
secret_access_key="secret",
|
|
98
|
-
session_token="token",
|
|
99
|
-
identity_id="identity-123",
|
|
100
|
-
)
|
|
101
|
-
mqtt_client = MqttClient(endpoint="example.iot.amazonaws.com", credentials=creds)
|
|
102
|
-
mqtt_client.connect()
|
|
103
|
-
|
|
104
|
-
client.on_connect(
|
|
105
|
-
client, None, None, ReasonCode(PacketTypes.CONNACK, identifier=135), None
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
with pytest.raises(PlaceMqttConnectionError, match="rejected"):
|
|
109
|
-
mqtt_client.wait_for_connection(timeout=1)
|
|
110
|
-
|
|
111
|
-
client.disconnect.assert_called_once()
|
|
112
|
-
client.loop_stop.assert_called_once()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
@patch("place.mqtt_client.mqtt.Client")
|
|
116
|
-
@patch("place.mqtt_client.get_signed_uri")
|
|
117
|
-
def test_wait_for_connection_raises_on_timeout(
|
|
118
|
-
mock_get_signed_uri: MagicMock,
|
|
119
|
-
mock_client_cls: MagicMock,
|
|
120
|
-
) -> None:
|
|
121
|
-
mock_get_signed_uri.return_value = "wss://example.iot.amazonaws.com/mqtt?x=1"
|
|
122
|
-
client = MagicMock()
|
|
123
|
-
mock_client_cls.return_value = client
|
|
124
|
-
|
|
125
|
-
creds = Credentials(
|
|
126
|
-
access_key_id="AKIA...",
|
|
127
|
-
secret_access_key="secret",
|
|
128
|
-
session_token="token",
|
|
129
|
-
identity_id="identity-123",
|
|
130
|
-
)
|
|
131
|
-
mqtt_client = MqttClient(endpoint="example.iot.amazonaws.com", credentials=creds)
|
|
132
|
-
mqtt_client.connect()
|
|
133
|
-
|
|
134
|
-
with pytest.raises(PlaceMqttConnectionError, match="Timed out"):
|
|
135
|
-
mqtt_client.wait_for_connection(timeout=0.05)
|
|
136
|
-
|
|
137
|
-
client.disconnect.assert_called_once()
|
|
138
|
-
client.loop_stop.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.7 → place_integration_api-0.2.8}/src/place/models/device_shadow.py
RENAMED
|
File without changes
|
{place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/discover_device.py
RENAMED
|
File without changes
|
|
File without changes
|
{place_integration_api-0.2.7 → 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
|