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