place-integration-api 0.2.5__tar.gz → 0.2.7__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 (34) hide show
  1. {place_integration_api-0.2.5/src/place_integration_api.egg-info → place_integration_api-0.2.7}/PKG-INFO +13 -1
  2. place_integration_api-0.2.7/README.md +15 -0
  3. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/pyproject.toml +1 -1
  4. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/abstract_auth.py +3 -1
  5. place_integration_api-0.2.7/src/place/errors.py +13 -0
  6. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/mqtt_client.py +29 -0
  7. place_integration_api-0.2.7/src/place/provider.py +43 -0
  8. {place_integration_api-0.2.5 → place_integration_api-0.2.7/src/place_integration_api.egg-info}/PKG-INFO +13 -1
  9. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/SOURCES.txt +1 -0
  10. place_integration_api-0.2.7/tests/test_auth.py +70 -0
  11. place_integration_api-0.2.7/tests/test_mqtt_client.py +138 -0
  12. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/tests/test_provider.py +79 -0
  13. place_integration_api-0.2.5/README.md +0 -3
  14. place_integration_api-0.2.5/src/place/provider.py +0 -38
  15. place_integration_api-0.2.5/tests/test_auth.py +0 -31
  16. place_integration_api-0.2.5/tests/test_mqtt_client.py +0 -48
  17. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/LICENSE +0 -0
  18. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/setup.cfg +0 -0
  19. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/__init__.py +0 -0
  20. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/aws_srp.py +0 -0
  21. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/srp_auth.py +0 -0
  22. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/tokens.py +0 -0
  23. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/config.py +0 -0
  24. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/messages.py +0 -0
  25. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/__init__.py +0 -0
  26. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/credentials.py +0 -0
  27. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/device_shadow.py +0 -0
  28. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/discover_device.py +0 -0
  29. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/models.py +0 -0
  30. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/mqtt_message.py +0 -0
  31. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/dependency_links.txt +0 -0
  32. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/requires.txt +0 -0
  33. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/top_level.txt +0 -0
  34. {place_integration_api-0.2.5 → place_integration_api-0.2.7}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: place-integration-api
3
- Version: 0.2.5
3
+ Version: 0.2.7
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
@@ -27,3 +27,15 @@ Dynamic: license-file
27
27
  # Place integration API
28
28
 
29
29
  This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
30
+
31
+ ## Setup
32
+
33
+ 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.
34
+
35
+ ## Installing dependencies
36
+
37
+ Use `pip` to install required dependencies by running `pip install -r requirements.txt` from the project root.
38
+
39
+ ## Updating dependencies
40
+
41
+ During the course of development, if new packages are added, it's recommended to update [`requirements.txt`](./requirements.txt) by running `pip freeze > requirements.txt`.
@@ -0,0 +1,15 @@
1
+ # Place integration API
2
+
3
+ This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
4
+
5
+ ## Setup
6
+
7
+ 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.
8
+
9
+ ## Installing dependencies
10
+
11
+ Use `pip` to install required dependencies by running `pip install -r requirements.txt` from the project root.
12
+
13
+ ## Updating dependencies
14
+
15
+ During the course of development, if new packages are added, it's recommended to update [`requirements.txt`](./requirements.txt) by running `pip freeze > requirements.txt`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "place-integration-api"
7
- version = "0.2.5"
7
+ version = "0.2.7"
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"
@@ -25,9 +25,11 @@ class AbstractAuth(ABC):
25
25
  access_token = await self.async_get_access_token()
26
26
  headers["authorization"] = f"Bearer {access_token}"
27
27
 
28
- return await self.websession.request(
28
+ resp = await self.websession.request(
29
29
  method,
30
30
  url,
31
31
  **kwargs,
32
32
  headers=headers,
33
33
  )
34
+ resp.raise_for_status()
35
+ return resp
@@ -0,0 +1,13 @@
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."""
@@ -2,14 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import hmac
5
+ import threading
5
6
  import uuid
6
7
  from datetime import datetime, timezone
7
8
  from typing import Callable
8
9
  from urllib.parse import quote
9
10
 
10
11
  import paho.mqtt.client as mqtt
12
+ from paho.mqtt.reasoncodes import ReasonCode
11
13
 
12
14
  from .config import ALGORITHM, EXPIRE_SEC, KEEP_ALIVE_SEC, PATH, REGION, SCHEME, SERVICE
15
+ from .errors import PlaceMqttConnectionError
13
16
  from .models import Credentials
14
17
 
15
18
 
@@ -92,12 +95,17 @@ class MqttClient:
92
95
  self.endpoint = endpoint
93
96
  self.credentials = credentials
94
97
  self._client: mqtt.Client | None = None
98
+ self._connected_event = threading.Event()
99
+ self._connect_error: ReasonCode | None = None
95
100
 
96
101
  def connect(
97
102
  self,
98
103
  on_message: Callable[[str, bytes], None] | None = None,
99
104
  on_connect: Callable[[], None] | None = None,
100
105
  ) -> None:
106
+ self._connected_event.clear()
107
+ self._connect_error = None
108
+
101
109
  signed_uri = get_signed_uri(
102
110
  access_key_id=self.credentials.access_key_id,
103
111
  secret_access_key=self.credentials.secret_access_key,
@@ -119,8 +127,12 @@ class MqttClient:
119
127
  def _on_connect(_client, _userdata, _flags, reason_code, _properties):
120
128
  if reason_code.is_failure:
121
129
  print(f"Connect failed: {reason_code}")
130
+ self._connect_error = reason_code
131
+ self._connected_event.set()
122
132
  return
123
133
  print("Connected")
134
+ self._connect_error = None
135
+ self._connected_event.set()
124
136
  if on_connect:
125
137
  try:
126
138
  on_connect()
@@ -149,6 +161,23 @@ class MqttClient:
149
161
  if self._client:
150
162
  self._client.loop_stop()
151
163
 
164
+ def wait_for_connection(self, timeout: float = 10.0) -> None:
165
+ """Block until the initial CONNACK is received.
166
+
167
+ Raises PlaceMqttConnectionError if the broker rejects the connection
168
+ or none arrives within timeout, tearing down the client in that case.
169
+ """
170
+ if not self._connected_event.wait(timeout):
171
+ self.disconnect()
172
+ self.loop_stop()
173
+ raise PlaceMqttConnectionError("Timed out waiting for MQTT CONNACK")
174
+ if self._connect_error is not None:
175
+ self.disconnect()
176
+ self.loop_stop()
177
+ raise PlaceMqttConnectionError(
178
+ f"MQTT connection rejected: {self._connect_error}"
179
+ )
180
+
152
181
  def subscribe(self, topic: str, qos: int = 1) -> None:
153
182
  assert self._client is not None
154
183
  self._client.subscribe(topic, qos=qos)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from aiohttp import ContentTypeError
6
+
7
+ from .auth.abstract_auth import AbstractAuth
8
+ from .errors import PlaceFulfillmentError
9
+ from .models.discover_device import DiscoverDevice
10
+ from .config import FULFILLMENT_URL
11
+
12
+
13
+ async def _send_command(authorized_session: AbstractAuth, command: str) -> dict[str, Any]:
14
+ """Send a fulfillment command and return the parsed response body."""
15
+ body = {"command": command, "data": {}}
16
+ resp = await authorized_session.request("POST", FULFILLMENT_URL, json=body)
17
+ try:
18
+ data = await resp.json()
19
+ except (ContentTypeError, ValueError) as err:
20
+ raise PlaceFulfillmentError(
21
+ f"Invalid response from fulfillment API: {err}"
22
+ ) from err
23
+ if not isinstance(data, dict):
24
+ raise PlaceFulfillmentError(f"Invalid response from fulfillment API: {data!r}")
25
+ if not data.get("success", True):
26
+ raise PlaceFulfillmentError(f"Home Assistant error: {data.get('message', data)}")
27
+ return data
28
+
29
+
30
+ class Provider:
31
+ def __init__(self, authorized_session: AbstractAuth) -> None:
32
+ self.authorized_session = authorized_session
33
+
34
+ async def discover(self) -> list[DiscoverDevice]:
35
+ data = await _send_command(self.authorized_session, "DISCOVER")
36
+ devices_raw = (data.get("data") or {}).get("devices") or []
37
+ return [DiscoverDevice.from_dict(raw) for raw in devices_raw]
38
+
39
+ async def enable(self):
40
+ return await _send_command(self.authorized_session, "ENABLE")
41
+
42
+ async def disable(self):
43
+ return await _send_command(self.authorized_session, "DISABLE")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: place-integration-api
3
- Version: 0.2.5
3
+ Version: 0.2.7
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
@@ -27,3 +27,15 @@ Dynamic: license-file
27
27
  # Place integration API
28
28
 
29
29
  This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
30
+
31
+ ## Setup
32
+
33
+ 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.
34
+
35
+ ## Installing dependencies
36
+
37
+ Use `pip` to install required dependencies by running `pip install -r requirements.txt` from the project root.
38
+
39
+ ## Updating dependencies
40
+
41
+ During the course of development, if new packages are added, it's recommended to update [`requirements.txt`](./requirements.txt) by running `pip freeze > requirements.txt`.
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/place/config.py
5
+ src/place/errors.py
5
6
  src/place/messages.py
6
7
  src/place/mqtt_client.py
7
8
  src/place/provider.py
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+ from aiohttp import ClientResponseError
8
+
9
+ from place.auth import get_iot_credentials
10
+ from place.auth.abstract_auth import AbstractAuth
11
+
12
+
13
+ class _FakeAuth(AbstractAuth):
14
+ async def async_get_access_token(self) -> str:
15
+ return "token"
16
+
17
+
18
+ @patch("place.auth.srp_auth.boto3")
19
+ def test_get_iot_credentials_success(mock_boto3: MagicMock) -> None:
20
+ identity_client = MagicMock()
21
+ mock_boto3.client.return_value = identity_client
22
+
23
+ identity_client.get_id.return_value = {"IdentityId": "identity-123"}
24
+ identity_client.get_credentials_for_identity.return_value = {
25
+ "Credentials": {
26
+ "AccessKeyId": "AKIA...",
27
+ "SecretKey": "secret",
28
+ "SessionToken": "session",
29
+ }
30
+ }
31
+
32
+
33
+ creds = get_iot_credentials(
34
+ id_token="id-token",
35
+ access_token="access-token",
36
+ )
37
+
38
+ assert creds.access_key_id == "AKIA..."
39
+ assert creds.secret_access_key == "secret"
40
+ assert creds.session_token == "session"
41
+ assert creds.identity_id == "identity-123"
42
+ assert creds.access_token == "access-token"
43
+
44
+
45
+ def test_request_raises_for_non_2xx_status() -> None:
46
+ response = MagicMock()
47
+ response.raise_for_status.side_effect = ClientResponseError(
48
+ request_info=MagicMock(), history=(), status=401
49
+ )
50
+ websession = MagicMock()
51
+ websession.request = AsyncMock(return_value=response)
52
+
53
+ auth = _FakeAuth(websession)
54
+
55
+ with pytest.raises(ClientResponseError):
56
+ asyncio.run(auth.request("POST", "https://example.com"))
57
+
58
+
59
+ def test_request_returns_response_for_2xx_status() -> None:
60
+ response = MagicMock()
61
+ response.raise_for_status.return_value = None
62
+ websession = MagicMock()
63
+ websession.request = AsyncMock(return_value=response)
64
+
65
+ auth = _FakeAuth(websession)
66
+
67
+ result = asyncio.run(auth.request("POST", "https://example.com"))
68
+
69
+ assert result is response
70
+ response.raise_for_status.assert_called_once()
@@ -0,0 +1,138 @@
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()
@@ -2,8 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
 
5
+ import pytest
6
+
5
7
  from place.auth.abstract_auth import AbstractAuth
6
8
  from place.config import FULFILLMENT_URL
9
+ from place.errors import PlaceFulfillmentError
7
10
  from place.provider import Provider
8
11
 
9
12
 
@@ -101,3 +104,79 @@ def test_provider_disable_sends_disable_command() -> None:
101
104
 
102
105
  assert auth.calls == [("POST", FULFILLMENT_URL, {"json": {"command": "DISABLE", "data": {}}})]
103
106
  assert result == payload
107
+
108
+
109
+ def test_provider_discover_raises_on_success_false() -> None:
110
+ payload = {"success": False, "message": "not authorized"}
111
+
112
+ class DummyAuth(AbstractAuth):
113
+ async def async_get_access_token(self) -> str:
114
+ return "token"
115
+
116
+ async def request(self, method, url, **kwargs):
117
+ class DummyResponse:
118
+ async def json(self_inner):
119
+ return payload
120
+
121
+ return DummyResponse()
122
+
123
+ provider = Provider(DummyAuth(None))
124
+
125
+ with pytest.raises(PlaceFulfillmentError):
126
+ asyncio.run(provider.discover())
127
+
128
+
129
+ def test_provider_enable_raises_on_success_false() -> None:
130
+ payload = {"success": False, "message": "not authorized"}
131
+
132
+ class DummyAuth(AbstractAuth):
133
+ async def async_get_access_token(self) -> str:
134
+ return "token"
135
+
136
+ async def request(self, method, url, **kwargs):
137
+ class DummyResponse:
138
+ async def json(self_inner):
139
+ return payload
140
+
141
+ return DummyResponse()
142
+
143
+ provider = Provider(DummyAuth(None))
144
+
145
+ with pytest.raises(PlaceFulfillmentError):
146
+ asyncio.run(provider.enable())
147
+
148
+
149
+ def test_provider_raises_on_non_dict_response() -> None:
150
+ class DummyAuth(AbstractAuth):
151
+ async def async_get_access_token(self) -> str:
152
+ return "token"
153
+
154
+ async def request(self, method, url, **kwargs):
155
+ class DummyResponse:
156
+ async def json(self_inner):
157
+ return None
158
+
159
+ return DummyResponse()
160
+
161
+ provider = Provider(DummyAuth(None))
162
+
163
+ with pytest.raises(PlaceFulfillmentError):
164
+ asyncio.run(provider.discover())
165
+
166
+
167
+ def test_provider_raises_on_invalid_json_body() -> None:
168
+ class DummyAuth(AbstractAuth):
169
+ async def async_get_access_token(self) -> str:
170
+ return "token"
171
+
172
+ async def request(self, method, url, **kwargs):
173
+ class DummyResponse:
174
+ async def json(self_inner):
175
+ raise ValueError("Expecting value: line 1 column 1 (char 0)")
176
+
177
+ return DummyResponse()
178
+
179
+ provider = Provider(DummyAuth(None))
180
+
181
+ with pytest.raises(PlaceFulfillmentError):
182
+ asyncio.run(provider.discover())
@@ -1,3 +0,0 @@
1
- # Place integration API
2
-
3
- This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
@@ -1,38 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from .auth.abstract_auth import AbstractAuth
4
- from .models.discover_device import DiscoverDevice
5
- from .config import FULFILLMENT_URL
6
-
7
- class Provider:
8
- def __init__(self, authorized_session: AbstractAuth) -> None:
9
- self.authorized_session = authorized_session
10
-
11
-
12
- async def discover(self) -> list[DiscoverDevice]:
13
- body = {"command": "DISCOVER", "data": {}}
14
- resp = await self.authorized_session.request("POST", FULFILLMENT_URL, json=body)
15
- data = await resp.json()
16
- if not data.get("success", True):
17
- raise RuntimeError(f"Home Assistant error: {data.get('message', data)}")
18
- devices_raw = (data.get("data") or {}).get("devices") or []
19
- devices: list[DiscoverDevice] = []
20
- for raw in devices_raw:
21
- devices.append(DiscoverDevice.from_dict(raw))
22
- return devices
23
-
24
- async def enable(self):
25
- body = {"command": "ENABLE", "data": {}}
26
- resp = await self.authorized_session.request("POST", FULFILLMENT_URL, json=body)
27
- data = await resp.json()
28
- if not data.get("success", True):
29
- raise RuntimeError(f"Home Assistant error: {data.get('message', data)}")
30
- return data
31
-
32
- async def disable(self):
33
- body = {"command": "DISABLE", "data": {}}
34
- resp = await self.authorized_session.request("POST", FULFILLMENT_URL, json=body)
35
- data = await resp.json()
36
- if not data.get("success", True):
37
- raise RuntimeError(f"Home Assistant error: {data.get('message', data)}")
38
- return data
@@ -1,31 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from unittest.mock import MagicMock, patch
4
- from place.auth import get_iot_credentials
5
-
6
-
7
- @patch("place.auth.srp_auth.boto3")
8
- def test_get_iot_credentials_success(mock_boto3: MagicMock) -> None:
9
- identity_client = MagicMock()
10
- mock_boto3.client.return_value = identity_client
11
-
12
- identity_client.get_id.return_value = {"IdentityId": "identity-123"}
13
- identity_client.get_credentials_for_identity.return_value = {
14
- "Credentials": {
15
- "AccessKeyId": "AKIA...",
16
- "SecretKey": "secret",
17
- "SessionToken": "session",
18
- }
19
- }
20
-
21
-
22
- creds = get_iot_credentials(
23
- id_token="id-token",
24
- access_token="access-token",
25
- )
26
-
27
- assert creds.access_key_id == "AKIA..."
28
- assert creds.secret_access_key == "secret"
29
- assert creds.session_token == "session"
30
- assert creds.identity_id == "identity-123"
31
- assert creds.access_token == "access-token"
@@ -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()