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.
Files changed (31) hide show
  1. {place_integration_api-0.2.7/src/place_integration_api.egg-info → place_integration_api-0.2.8}/PKG-INFO +9 -1
  2. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/README.md +8 -0
  3. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/pyproject.toml +1 -1
  4. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/errors.py +4 -0
  5. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/messages.py +12 -0
  6. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/mqtt_client.py +46 -4
  7. {place_integration_api-0.2.7 → place_integration_api-0.2.8/src/place_integration_api.egg-info}/PKG-INFO +9 -1
  8. place_integration_api-0.2.8/tests/test_mqtt_client.py +286 -0
  9. place_integration_api-0.2.7/tests/test_mqtt_client.py +0 -138
  10. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/LICENSE +0 -0
  11. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/setup.cfg +0 -0
  12. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/__init__.py +0 -0
  13. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/abstract_auth.py +0 -0
  14. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/aws_srp.py +0 -0
  15. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/srp_auth.py +0 -0
  16. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/auth/tokens.py +0 -0
  17. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/config.py +0 -0
  18. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/__init__.py +0 -0
  19. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/credentials.py +0 -0
  20. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/device_shadow.py +0 -0
  21. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/discover_device.py +0 -0
  22. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/models.py +0 -0
  23. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/models/mqtt_message.py +0 -0
  24. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place/provider.py +0 -0
  25. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/SOURCES.txt +0 -0
  26. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/dependency_links.txt +0 -0
  27. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/requires.txt +0 -0
  28. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/src/place_integration_api.egg-info/top_level.txt +0 -0
  29. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/tests/test_auth.py +0 -0
  30. {place_integration_api-0.2.7 → place_integration_api-0.2.8}/tests/test_models.py +0 -0
  31. {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.7
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"
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(self, topic: str, qos: int = 1) -> None:
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
- self._client.subscribe(topic, qos=qos)
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.7
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()