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.
- {place_integration_api-0.2.5/src/place_integration_api.egg-info → place_integration_api-0.2.7}/PKG-INFO +13 -1
- place_integration_api-0.2.7/README.md +15 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/pyproject.toml +1 -1
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/abstract_auth.py +3 -1
- place_integration_api-0.2.7/src/place/errors.py +13 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/mqtt_client.py +29 -0
- place_integration_api-0.2.7/src/place/provider.py +43 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7/src/place_integration_api.egg-info}/PKG-INFO +13 -1
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/SOURCES.txt +1 -0
- place_integration_api-0.2.7/tests/test_auth.py +70 -0
- place_integration_api-0.2.7/tests/test_mqtt_client.py +138 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/tests/test_provider.py +79 -0
- place_integration_api-0.2.5/README.md +0 -3
- place_integration_api-0.2.5/src/place/provider.py +0 -38
- place_integration_api-0.2.5/tests/test_auth.py +0 -31
- place_integration_api-0.2.5/tests/test_mqtt_client.py +0 -48
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/LICENSE +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/setup.cfg +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/__init__.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/aws_srp.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/srp_auth.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/auth/tokens.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/config.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/messages.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/__init__.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/credentials.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/device_shadow.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/discover_device.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/models.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/mqtt_message.py +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/dependency_links.txt +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/requires.txt +0 -0
- {place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place_integration_api.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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,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,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()
|
|
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.5 → place_integration_api-0.2.7}/src/place/models/device_shadow.py
RENAMED
|
File without changes
|
{place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/discover_device.py
RENAMED
|
File without changes
|
|
File without changes
|
{place_integration_api-0.2.5 → place_integration_api-0.2.7}/src/place/models/mqtt_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|