place-integration-api 0.1.0__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 (26) hide show
  1. place_integration_api-0.1.0/LICENSE +19 -0
  2. place_integration_api-0.1.0/PKG-INFO +22 -0
  3. place_integration_api-0.1.0/README.md +3 -0
  4. place_integration_api-0.1.0/pyproject.toml +33 -0
  5. place_integration_api-0.1.0/setup.cfg +4 -0
  6. place_integration_api-0.1.0/src/place/auth/__init__.py +9 -0
  7. place_integration_api-0.1.0/src/place/auth/abstract_auth.py +33 -0
  8. place_integration_api-0.1.0/src/place/auth/aws_srp.py +321 -0
  9. place_integration_api-0.1.0/src/place/auth/srp_auth.py +69 -0
  10. place_integration_api-0.1.0/src/place/config.py +31 -0
  11. place_integration_api-0.1.0/src/place/messages.py +86 -0
  12. place_integration_api-0.1.0/src/place/models/__init__.py +12 -0
  13. place_integration_api-0.1.0/src/place/models/credentials.py +13 -0
  14. place_integration_api-0.1.0/src/place/models/discover_device.py +41 -0
  15. place_integration_api-0.1.0/src/place/models/models.py +5 -0
  16. place_integration_api-0.1.0/src/place/models/mqtt_message.py +11 -0
  17. place_integration_api-0.1.0/src/place/mqtt_client.py +166 -0
  18. place_integration_api-0.1.0/src/place/provider.py +38 -0
  19. place_integration_api-0.1.0/src/place_integration_api.egg-info/PKG-INFO +22 -0
  20. place_integration_api-0.1.0/src/place_integration_api.egg-info/SOURCES.txt +24 -0
  21. place_integration_api-0.1.0/src/place_integration_api.egg-info/dependency_links.txt +1 -0
  22. place_integration_api-0.1.0/src/place_integration_api.egg-info/requires.txt +8 -0
  23. place_integration_api-0.1.0/src/place_integration_api.egg-info/top_level.txt +1 -0
  24. place_integration_api-0.1.0/tests/test_auth.py +45 -0
  25. place_integration_api-0.1.0/tests/test_mqtt_client.py +51 -0
  26. place_integration_api-0.1.0/tests/test_provider.py +107 -0
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Gentex
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: place-integration-api
3
+ Version: 0.1.0
4
+ Summary: Python APIs for Cognito-authenticated MQTT flows and Home Assistant discovery for Gentex Place.
5
+ Author-email: Nicholas Aelick <niaexa@syntronic.com>, Andrew Tran <andrew.tran@gentex.com>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://pypi.org/project/place-integration-api/
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: boto3
12
+ Requires-Dist: aiohttp
13
+ Requires-Dist: paho-mqtt
14
+ Requires-Dist: six
15
+ Requires-Dist: python-decouple
16
+ Provides-Extra: dev
17
+ Requires-Dist: basedpyright; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # Place integration API
21
+
22
+ This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
@@ -0,0 +1,3 @@
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
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "place-integration-api"
7
+ version = "0.1.0"
8
+ description = "Python APIs for Cognito-authenticated MQTT flows and Home Assistant discovery for Gentex Place."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [
12
+ { name="Nicholas Aelick", email="niaexa@syntronic.com" },
13
+ { name="Andrew Tran", email="andrew.tran@gentex.com" },
14
+ ]
15
+ license = { text = "Proprietary" }
16
+ dependencies = [
17
+ "boto3",
18
+ "aiohttp",
19
+ "paho-mqtt",
20
+ "six",
21
+ "python-decouple"
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "basedpyright",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://pypi.org/project/place-integration-api/"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .srp_auth import login, get_iot_credentials
4
+
5
+ __all__ = [
6
+ "login",
7
+ "get_iot_credentials",
8
+ ]
9
+
@@ -0,0 +1,33 @@
1
+ from abc import ABC, abstractmethod
2
+ from aiohttp import ClientSession, ClientResponse
3
+
4
+
5
+ class AbstractAuth(ABC):
6
+ """Abstract class to make authenticated requests."""
7
+
8
+ def __init__(self, websession: ClientSession) -> None:
9
+ """Initialize the auth."""
10
+ self.websession = websession
11
+
12
+ @abstractmethod
13
+ async def async_get_access_token(self) -> str:
14
+ """Return a valid access token."""
15
+
16
+ async def request(self, method, url, **kwargs) -> ClientResponse:
17
+ """Make a request."""
18
+ headers = kwargs.get("headers")
19
+
20
+ if headers is None:
21
+ headers = {}
22
+ else:
23
+ headers = dict(headers)
24
+
25
+ access_token = await self.async_get_access_token()
26
+ headers["authorization"] = f"Bearer {access_token}"
27
+
28
+ return await self.websession.request(
29
+ method,
30
+ url,
31
+ **kwargs,
32
+ headers=headers,
33
+ )
@@ -0,0 +1,321 @@
1
+ # Taken from https://github.com/capless/warrant/blob/master/warrant/aws_srp.py
2
+ import base64
3
+ import binascii
4
+ import datetime
5
+ import hashlib
6
+ import hmac
7
+ import re
8
+
9
+ import os
10
+
11
+ os.environ.setdefault("AWS_SHARED_CREDENTIALS_FILE", os.devnull)
12
+ os.environ.setdefault("AWS_CONFIG_FILE", os.devnull)
13
+
14
+ import boto3
15
+ from botocore import UNSIGNED
16
+ from botocore.config import Config
17
+ import six
18
+
19
+
20
+ class WarrantException(Exception):
21
+ """Base class for all Warrant exceptions"""
22
+
23
+
24
+ class ForceChangePasswordException(WarrantException):
25
+ """Raised when the user is forced to change their password"""
26
+
27
+
28
+ # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22
29
+ n_hex = (
30
+ "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
31
+ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
32
+ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
33
+ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
34
+ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D"
35
+ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F"
36
+ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D"
37
+ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
38
+ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9"
39
+ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510"
40
+ + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64"
41
+ + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7"
42
+ + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B"
43
+ + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C"
44
+ + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31"
45
+ + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
46
+ )
47
+ # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49
48
+ g_hex = "2"
49
+ info_bits = bytearray("Caldera Derived Key", "utf-8")
50
+
51
+
52
+ def hash_sha256(buf):
53
+ """AuthenticationHelper.hash"""
54
+ a = hashlib.sha256(buf).hexdigest()
55
+ return (64 - len(a)) * "0" + a
56
+
57
+
58
+ def hex_hash(hex_string):
59
+ return hash_sha256(bytearray.fromhex(hex_string))
60
+
61
+
62
+ def hex_to_long(hex_string):
63
+ return int(hex_string, 16)
64
+
65
+
66
+ def long_to_hex(long_num):
67
+ return "%x" % long_num
68
+
69
+
70
+ def get_random(nbytes):
71
+ random_hex = binascii.hexlify(os.urandom(nbytes))
72
+ return hex_to_long(random_hex)
73
+
74
+
75
+ def pad_hex(long_int):
76
+ """
77
+ Converts a Long integer (or hex string) to hex format padded with zeroes for hashing
78
+ :param {Long integer|String} long_int Number or string to pad.
79
+ :return {String} Padded hex string.
80
+ """
81
+ if not isinstance(long_int, six.string_types):
82
+ hash_str = long_to_hex(long_int)
83
+ else:
84
+ hash_str = long_int
85
+ if len(hash_str) % 2 == 1:
86
+ hash_str = "0%s" % hash_str
87
+ elif hash_str[0] in "89ABCDEFabcdef":
88
+ hash_str = "00%s" % hash_str
89
+ return hash_str
90
+
91
+
92
+ def compute_hkdf(ikm, salt):
93
+ """
94
+ Standard hkdf algorithm
95
+ :param {Buffer} ikm Input key material.
96
+ :param {Buffer} salt Salt value.
97
+ :return {Buffer} Strong key material.
98
+ @private
99
+ """
100
+ prk = hmac.new(salt, ikm, hashlib.sha256).digest()
101
+ info_bits_update = info_bits + bytearray(chr(1), "utf-8")
102
+ hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest()
103
+ return hmac_hash[:16]
104
+
105
+
106
+ def calculate_u(big_a, big_b):
107
+ """
108
+ Calculate the client's value U which is the hash of A and B
109
+ :param {Long integer} big_a Large A value.
110
+ :param {Long integer} big_b Server B value.
111
+ :return {Long integer} Computed U value.
112
+ """
113
+ u_hex_hash = hex_hash(pad_hex(big_a) + pad_hex(big_b))
114
+ return hex_to_long(u_hex_hash)
115
+
116
+
117
+ class AWSSRP(object):
118
+
119
+ NEW_PASSWORD_REQUIRED_CHALLENGE = "NEW_PASSWORD_REQUIRED"
120
+ PASSWORD_VERIFIER_CHALLENGE = "PASSWORD_VERIFIER"
121
+
122
+ def __init__(
123
+ self,
124
+ username,
125
+ password,
126
+ pool_id,
127
+ client_id,
128
+ pool_region=None,
129
+ client=None,
130
+ client_secret=None,
131
+ ):
132
+ if pool_region is not None and client is not None:
133
+ raise ValueError(
134
+ "pool_region and client should not both be specified "
135
+ "(region should be passed to the boto3 client instead)"
136
+ )
137
+
138
+ self.username = username
139
+ self.password = password
140
+ self.pool_id = pool_id
141
+ self.client_id = client_id
142
+ self.client_secret = client_secret
143
+ self.client = (
144
+ client if client else boto3.client(
145
+ "cognito-idp",
146
+ region_name=pool_region,
147
+ config=Config(signature_version=UNSIGNED),
148
+ )
149
+ )
150
+ self.big_n = hex_to_long(n_hex)
151
+ self.g = hex_to_long(g_hex)
152
+ self.k = hex_to_long(hex_hash("00" + n_hex + "0" + g_hex))
153
+ self.small_a_value = self.generate_random_small_a()
154
+ self.large_a_value = self.calculate_a()
155
+
156
+ def generate_random_small_a(self):
157
+ """
158
+ helper function to generate a random big integer
159
+ :return {Long integer} a random value.
160
+ """
161
+ random_long_int = get_random(128)
162
+ return random_long_int % self.big_n
163
+
164
+ def calculate_a(self):
165
+ """
166
+ Calculate the client's public value A = g^a%N
167
+ with the generated random number a
168
+ :param {Long integer} a Randomly generated small A.
169
+ :return {Long integer} Computed large A.
170
+ """
171
+ big_a = pow(self.g, self.small_a_value, self.big_n)
172
+ # safety check
173
+ if (big_a % self.big_n) == 0:
174
+ raise ValueError("Safety check for A failed")
175
+ return big_a
176
+
177
+ def get_password_authentication_key(self, username, password, server_b_value, salt):
178
+ """
179
+ Calculates the final hkdf based on computed S value, and computed U value and the key
180
+ :param {String} username Username.
181
+ :param {String} password Password.
182
+ :param {Long integer} server_b_value Server B value.
183
+ :param {Long integer} salt Generated salt.
184
+ :return {Buffer} Computed HKDF value.
185
+ """
186
+ u_value = calculate_u(self.large_a_value, server_b_value)
187
+ if u_value == 0:
188
+ raise ValueError("U cannot be zero.")
189
+ username_password = "%s%s:%s" % (self.pool_id.split("_")[1], username, password)
190
+ username_password_hash = hash_sha256(username_password.encode("utf-8"))
191
+
192
+ x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash))
193
+ g_mod_pow_xn = pow(self.g, x_value, self.big_n)
194
+ int_value2 = server_b_value - self.k * g_mod_pow_xn
195
+ s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n)
196
+ hkdf = compute_hkdf(
197
+ bytearray.fromhex(pad_hex(s_value)),
198
+ bytearray.fromhex(pad_hex(long_to_hex(u_value))),
199
+ )
200
+ return hkdf
201
+
202
+ def get_auth_params(self):
203
+ auth_params = {
204
+ "USERNAME": self.username,
205
+ "SRP_A": long_to_hex(self.large_a_value),
206
+ }
207
+ if self.client_secret is not None:
208
+ auth_params.update(
209
+ {
210
+ "SECRET_HASH": self.get_secret_hash(
211
+ self.username, self.client_id, self.client_secret
212
+ )
213
+ }
214
+ )
215
+ return auth_params
216
+
217
+ @staticmethod
218
+ def get_secret_hash(username, client_id, client_secret):
219
+ message = bytearray(username + client_id, "utf-8")
220
+ hmac_obj = hmac.new(bytearray(client_secret, "utf-8"), message, hashlib.sha256)
221
+ return base64.standard_b64encode(hmac_obj.digest()).decode("utf-8")
222
+
223
+ def process_challenge(self, challenge_parameters):
224
+ user_id_for_srp = challenge_parameters["USER_ID_FOR_SRP"]
225
+ salt_hex = challenge_parameters["SALT"]
226
+ srp_b_hex = challenge_parameters["SRP_B"]
227
+ secret_block_b64 = challenge_parameters["SECRET_BLOCK"]
228
+ # re strips leading zero from a day number (required by AWS Cognito)
229
+ timestamp = re.sub(
230
+ r" 0(\d) ",
231
+ r" \1 ",
232
+ datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y"),
233
+ )
234
+ hkdf = self.get_password_authentication_key(
235
+ user_id_for_srp, self.password, hex_to_long(srp_b_hex), salt_hex
236
+ )
237
+ secret_block_bytes = base64.standard_b64decode(secret_block_b64)
238
+ msg = (
239
+ bytearray(self.pool_id.split("_")[1], "utf-8")
240
+ + bytearray(user_id_for_srp, "utf-8")
241
+ + bytearray(secret_block_bytes)
242
+ + bytearray(timestamp, "utf-8")
243
+ )
244
+ hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256)
245
+ signature_string = base64.standard_b64encode(hmac_obj.digest())
246
+ response = {
247
+ "TIMESTAMP": timestamp,
248
+ "USERNAME": user_id_for_srp,
249
+ "PASSWORD_CLAIM_SECRET_BLOCK": secret_block_b64,
250
+ "PASSWORD_CLAIM_SIGNATURE": signature_string.decode("utf-8"),
251
+ }
252
+ if self.client_secret is not None:
253
+ response.update(
254
+ {
255
+ "SECRET_HASH": self.get_secret_hash(
256
+ self.username, self.client_id, self.client_secret
257
+ )
258
+ }
259
+ )
260
+ return response
261
+
262
+ def authenticate_user(self, client=None):
263
+ boto_client = self.client or client
264
+ auth_params = self.get_auth_params()
265
+ response = boto_client.initiate_auth(
266
+ AuthFlow="USER_SRP_AUTH",
267
+ AuthParameters=auth_params,
268
+ ClientId=self.client_id,
269
+ )
270
+ if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE:
271
+ challenge_response = self.process_challenge(response["ChallengeParameters"])
272
+ tokens = boto_client.respond_to_auth_challenge(
273
+ ClientId=self.client_id,
274
+ ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE,
275
+ ChallengeResponses=challenge_response,
276
+ )
277
+
278
+ if tokens.get("ChallengeName") == self.NEW_PASSWORD_REQUIRED_CHALLENGE:
279
+ raise ForceChangePasswordException(
280
+ "Change password before authenticating"
281
+ )
282
+
283
+ return tokens
284
+ else:
285
+ raise NotImplementedError(
286
+ "The %s challenge is not supported" % response["ChallengeName"]
287
+ )
288
+
289
+ def set_new_password_challenge(self, new_password, client=None):
290
+ boto_client = self.client or client
291
+ auth_params = self.get_auth_params()
292
+ response = boto_client.initiate_auth(
293
+ AuthFlow="USER_SRP_AUTH",
294
+ AuthParameters=auth_params,
295
+ ClientId=self.client_id,
296
+ )
297
+ if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE:
298
+ challenge_response = self.process_challenge(response["ChallengeParameters"])
299
+ tokens = boto_client.respond_to_auth_challenge(
300
+ ClientId=self.client_id,
301
+ ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE,
302
+ ChallengeResponses=challenge_response,
303
+ )
304
+
305
+ if tokens["ChallengeName"] == self.NEW_PASSWORD_REQUIRED_CHALLENGE:
306
+ challenge_response = {
307
+ "USERNAME": auth_params["USERNAME"],
308
+ "NEW_PASSWORD": new_password,
309
+ }
310
+ new_password_response = boto_client.respond_to_auth_challenge(
311
+ ClientId=self.client_id,
312
+ ChallengeName=self.NEW_PASSWORD_REQUIRED_CHALLENGE,
313
+ Session=tokens["Session"],
314
+ ChallengeResponses=challenge_response,
315
+ )
316
+ return new_password_response
317
+ return tokens
318
+ else:
319
+ raise NotImplementedError(
320
+ "The %s challenge is not supported" % response["ChallengeName"]
321
+ )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ import boto3
6
+ from botocore import UNSIGNED
7
+ from botocore.config import Config
8
+
9
+
10
+ from .aws_srp import AWSSRP
11
+ from ..config import REGION, COGNITO_CLIENT_ID, COGNITO_USER_POOL_ID, COGNITO_IDENTITY_POOL_ID
12
+ from ..models import Credentials
13
+
14
+
15
+ def login(username: str, password: str) -> Dict[str, Any]:
16
+ return get_tokens_via_srp(
17
+ user_pool_id=COGNITO_USER_POOL_ID,
18
+ client_id=COGNITO_CLIENT_ID,
19
+ username=username,
20
+ password=password,
21
+ )
22
+
23
+ def get_iot_credentials(id_token: str, access_token: str) -> Credentials:
24
+ """Exchange a Cognito ID token for AWS IoT credentials."""
25
+ identity = boto3.client(
26
+ "cognito-identity",
27
+ region_name=REGION,
28
+ config=Config(signature_version=UNSIGNED),
29
+ )
30
+ provider_key = f"cognito-idp.{REGION}.amazonaws.com/{COGNITO_USER_POOL_ID}"
31
+ logins = {provider_key: id_token}
32
+
33
+ identity_id = identity.get_id(
34
+ IdentityPoolId=COGNITO_IDENTITY_POOL_ID, Logins=logins
35
+ )["IdentityId"]
36
+
37
+ creds = identity.get_credentials_for_identity(
38
+ IdentityId=identity_id, Logins=logins
39
+ )["Credentials"]
40
+
41
+ return Credentials(
42
+ access_key_id=creds["AccessKeyId"],
43
+ secret_access_key=creds["SecretKey"],
44
+ session_token=creds["SessionToken"],
45
+ identity_id=identity_id,
46
+ access_token=access_token,
47
+ )
48
+
49
+
50
+ def get_tokens_via_srp(
51
+ *,
52
+ user_pool_id: str,
53
+ client_id: str,
54
+ username: str,
55
+ password: str,
56
+ region: str = REGION,
57
+ client_secret: str | None = None,
58
+ ) -> Dict[str, Any]:
59
+ aws = AWSSRP(
60
+ username=username,
61
+ password=password,
62
+ pool_id=user_pool_id,
63
+ client_id=client_id,
64
+ pool_region=region,
65
+ client_secret=client_secret,
66
+ )
67
+ return aws.authenticate_user()
68
+
69
+
@@ -0,0 +1,31 @@
1
+ import decouple
2
+
3
+ REGION = decouple.config("AWS_REGION", default="us-east-2")
4
+ SERVICE = decouple.config("AWS_SERVICE", default="iotdevicegateway")
5
+ ALGORITHM = decouple.config("AWS_ALGORITHM", default="AWS4-HMAC-SHA256")
6
+ SCHEME = decouple.config("AWS_SCHEME", default="wss")
7
+ PATH = decouple.config("AWS_PATH", default="/mqtt")
8
+ EXPIRE_SEC = decouple.config("AWS_EXPIRE_SEC", default=86400)
9
+ KEEP_ALIVE_SEC = decouple.config("AWS_KEEP_ALIVE_SEC", default=30)
10
+ FULFILLMENT_URL = decouple.config(
11
+ "AWS_FULFILLMENT_URL",
12
+ default="https://ymxta4fyrl.execute-api.us-east-2.amazonaws.com/prod/fulfillment",
13
+ )
14
+ # Note: must be the regional IoT endpoint for now. Currently the custom IoT endpoint/domain configuration uses ApplicationProtocol.SECURE_MQTT
15
+ # a second domain configuration must be created for ApplicationProtocol.MQTT_WSS in order to use the custom IoT endpoint instead.
16
+ IOT_ENDPOINT = decouple.config(
17
+ "AWS_IOT_ENDPOINT", default="a2ksnv5v3x6m50-ats.iot.us-east-2.amazonaws.com"
18
+ )
19
+ COGNITO_USER_POOL_ID = decouple.config(
20
+ "AWS_COGNITO_USER_POOL_ID", default="us-east-2_LKSPO9tT6"
21
+ )
22
+ COGNITO_CLIENT_ID = decouple.config(
23
+ "AWS_COGNITO_CLIENT_ID", default="5blr1qf2evvj4ivircqbpqikev"
24
+ )
25
+ COGNITO_IDENTITY_POOL_ID = decouple.config(
26
+ "AWS_COGNITO_IDENTITY_POOL_ID",
27
+ default="us-east-2:77c64042-63a1-4126-bdae-bd4150a73ad1",
28
+ )
29
+ OAUTH2_TOKEN_URL = decouple.config(
30
+ "OAUTH2_TOKEN_URL", default="https://auth.connectedsmoke.com/oauth2/token"
31
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .mqtt_client import MqttClient
7
+
8
+
9
+ HOUSEHOLD_PREFIX = "connectedsmoke/household"
10
+ SHADOW_GET_PREFIX = "$aws/things"
11
+
12
+
13
+ def parse_payload(raw: bytes) -> dict[str, Any]:
14
+ if not raw or not raw.strip():
15
+ return {}
16
+ try:
17
+ return json.loads(raw.decode("utf-8"))
18
+ except (json.JSONDecodeError, UnicodeDecodeError):
19
+ return {}
20
+
21
+
22
+ def message_kind(topic: str, payload: dict[str, Any]) -> str:
23
+ checks = [
24
+ (lambda: "state" in payload and payload.get("state", {}).get("reported") is not None, "shadow"),
25
+ (lambda: "connectivity" in topic.lower(), "presence"),
26
+ (lambda: "command/response" in topic, "command"),
27
+ (lambda: "events/" in topic, "event"),
28
+ (lambda: topic.startswith("connectedsmoke/household/"), "household"),
29
+ ]
30
+ return next((label for pred, label in checks if pred()), "msg")
31
+
32
+
33
+ def household_subscription_topic(household_id: str) -> str:
34
+ return f"{HOUSEHOLD_PREFIX}/{household_id}/#"
35
+
36
+
37
+ def shadow_get_topic(thing_name: str) -> str:
38
+ return f"{SHADOW_GET_PREFIX}/{thing_name}/shadow/get"
39
+
40
+
41
+ def shadow_subscription_topic(thing_name: str) -> str:
42
+ return f"{SHADOW_GET_PREFIX}/{thing_name}/shadow/#"
43
+
44
+
45
+ def describe_message(topic: str, raw: bytes) -> str:
46
+ payload = parse_payload(raw)
47
+ kind = message_kind(topic, payload)
48
+ extra = (
49
+ f" -> reported: {list(payload.get('state', {}).get('reported', {}).keys())}"
50
+ if kind == "shadow"
51
+ else f" {payload}"
52
+ if kind == "msg"
53
+ else ""
54
+ )
55
+ return f"[{kind}] {topic}{extra}"
56
+
57
+
58
+ class PlaceMessages:
59
+ def __init__(self, client: MqttClient):
60
+ self._client = client
61
+
62
+ def subscribe_household(self, household_id: str, qos: int = 1) -> str:
63
+ hid = household_id.strip()
64
+ assert hid
65
+ topic = household_subscription_topic(hid)
66
+ self._client.subscribe(topic, qos=qos)
67
+ return hid
68
+
69
+ def subscribe_shadow(self, thing_name: str, qos: int = 1) -> str:
70
+ name = thing_name.strip()
71
+ assert name
72
+ topic = shadow_subscription_topic(name)
73
+ self._client.subscribe(topic, qos=qos)
74
+ return name
75
+
76
+ def publish_shadow_get(self, thing_name: str, qos: int = 1) -> str:
77
+ name = thing_name.strip()
78
+ assert name
79
+ topic = shadow_get_topic(name)
80
+ self._client.publish(topic, qos=qos)
81
+ return name
82
+
83
+ def describe(self, topic: str, raw: bytes) -> str:
84
+ return describe_message(topic, raw)
85
+
86
+
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from .credentials import Credentials
4
+ from .discover_device import DiscoverDevice
5
+ from .mqtt_message import MqttMessage
6
+
7
+ __all__ = [
8
+ "Credentials",
9
+ "DiscoverDevice",
10
+ "MqttMessage",
11
+ ]
12
+
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class Credentials:
8
+ access_key_id: str
9
+ secret_access_key: str
10
+ session_token: str
11
+ identity_id: str
12
+ access_token: str | None = None
13
+
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping
5
+
6
+
7
+ def _as_str(value: Any) -> str | None:
8
+ if value is None:
9
+ return None
10
+ return str(value)
11
+
12
+
13
+ def _as_bool(value: Any) -> bool | None:
14
+ if value is None:
15
+ return None
16
+ return bool(value)
17
+
18
+
19
+ @dataclass
20
+ class DiscoverDevice:
21
+ location: str | None
22
+ shadow: dict[str, Any]
23
+ device_name: str | None
24
+ thing_name: str | None
25
+ firmware_version: str | None
26
+ model_number: str | None
27
+ device_id: str | None
28
+ online: bool | None
29
+
30
+ @staticmethod
31
+ def from_dict(data: Mapping[str, Any]) -> DiscoverDevice:
32
+ return DiscoverDevice(
33
+ location=_as_str(data.get("location")),
34
+ shadow=dict(data.get("shadow") or {}),
35
+ device_name=_as_str(data.get("deviceName")),
36
+ thing_name=_as_str(data.get("thingName")),
37
+ firmware_version=_as_str(data.get("firmwareVersion")),
38
+ model_number=_as_str(data.get("modelNumber")),
39
+ device_id=_as_str(data.get("deviceId")),
40
+ online=_as_bool(data.get("online")),
41
+ )
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .credentials import Credentials
4
+ from .mqtt_message import MqttMessage
5
+
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class MqttMessage:
9
+ topic: str
10
+ payload: dict[str, Any]
11
+
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Callable
8
+ from urllib.parse import quote
9
+
10
+ import paho.mqtt.client as mqtt
11
+
12
+ from .config import ALGORITHM, EXPIRE_SEC, KEEP_ALIVE_SEC, PATH, REGION, SCHEME, SERVICE
13
+ from .models import Credentials
14
+
15
+
16
+ def _hmac_sha256(key: bytes, msg: str) -> bytes:
17
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
18
+
19
+
20
+ def _sha256_hex(msg: str) -> str:
21
+ return hashlib.sha256(msg.encode("utf-8")).hexdigest().lower()
22
+
23
+
24
+ def _sign(key: bytes, msg: str) -> bytes:
25
+ return _hmac_sha256(key, msg)
26
+
27
+
28
+ def get_signed_uri(
29
+ *,
30
+ access_key_id: str,
31
+ secret_access_key: str,
32
+ session_token: str,
33
+ host: str,
34
+ ) -> str:
35
+ now = datetime.now(timezone.utc)
36
+ date_stamp = now.strftime("%Y%m%d")
37
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
38
+ credential_scope = f"{access_key_id}/{date_stamp}/{REGION}/{SERVICE}/aws4_request"
39
+ signed_headers = "host"
40
+ canonical_headers = f"host:{host}\n"
41
+ payload_hash = _sha256_hex("")
42
+
43
+ def enc(s: str) -> str:
44
+ return quote(str(s), safe="")
45
+
46
+ query = {
47
+ "X-Amz-Algorithm": ALGORITHM,
48
+ "X-Amz-Credential": credential_scope,
49
+ "X-Amz-Date": amz_date,
50
+ "X-Amz-Expires": str(EXPIRE_SEC),
51
+ "X-Amz-SignedHeaders": signed_headers,
52
+ }
53
+ canonical_query = "&".join(f"{enc(k)}={enc(v)}" for k, v in sorted(query.items()))
54
+ canonical_request = "\n".join(
55
+ [
56
+ "GET",
57
+ PATH,
58
+ canonical_query,
59
+ canonical_headers,
60
+ signed_headers,
61
+ payload_hash,
62
+ ]
63
+ )
64
+ request_hash = _sha256_hex(canonical_request)
65
+ string_to_sign = "\n".join(
66
+ [
67
+ ALGORITHM,
68
+ amz_date,
69
+ f"{date_stamp}/{REGION}/{SERVICE}/aws4_request",
70
+ request_hash,
71
+ ]
72
+ )
73
+ k_date = _sign(("AWS4" + secret_access_key).encode("utf-8"), date_stamp)
74
+ k_region = _sign(k_date, REGION)
75
+ k_service = _sign(k_region, SERVICE)
76
+ k_signing = _sign(k_service, "aws4_request")
77
+ signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
78
+
79
+ query["X-Amz-Signature"] = signature
80
+ query["X-Amz-Security-Token"] = session_token
81
+ query_string = "&".join(f"{enc(k)}={enc(v)}" for k, v in sorted(query.items()))
82
+ return f"{SCHEME}://{host}{PATH}?{query_string}"
83
+
84
+
85
+ class MqttClient:
86
+ def __init__(
87
+ self,
88
+ *,
89
+ endpoint: str,
90
+ credentials: Credentials,
91
+ ):
92
+ self.endpoint = endpoint
93
+ self.credentials = credentials
94
+ self._client: mqtt.Client | None = None
95
+
96
+ def connect(
97
+ self,
98
+ on_message: Callable[[str, bytes], None] | None = None,
99
+ on_connect: Callable[[], None] | None = None,
100
+ ) -> None:
101
+ signed_uri = get_signed_uri(
102
+ access_key_id=self.credentials.access_key_id,
103
+ secret_access_key=self.credentials.secret_access_key,
104
+ session_token=self.credentials.session_token,
105
+ host=self.endpoint,
106
+ )
107
+ client_id = f"{self.credentials.identity_id}-{uuid.uuid4()}"
108
+
109
+ client = mqtt.Client(
110
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
111
+ client_id=client_id,
112
+ transport="websockets",
113
+ protocol=mqtt.MQTTv311,
114
+ )
115
+ path_with_query = "/mqtt" + signed_uri.split("/mqtt", 1)[1]
116
+ print(f"WebSocket path: {path_with_query[:80]}...")
117
+ client.ws_set_options(path=path_with_query, headers={"Host": self.endpoint})
118
+ client.tls_set()
119
+
120
+ def _on_connect(_client, _userdata, _flags, reason_code, _properties):
121
+ if reason_code.is_failure:
122
+ print(f"Connect failed: {reason_code}")
123
+ return
124
+ print("Connected")
125
+ if on_connect:
126
+ try:
127
+ on_connect()
128
+ except Exception as exc:
129
+ print(f"on_connect error: {exc}")
130
+
131
+ def _on_message(_client, _userdata, msg):
132
+ if on_message:
133
+ on_message(msg.topic, msg.payload)
134
+
135
+ def _on_disconnect(_client, _userdata, _flags, reason_code, _properties):
136
+ print(f"Disconnected: {reason_code}")
137
+
138
+ client.on_connect = _on_connect
139
+ client.on_message = _on_message
140
+ client.on_disconnect = _on_disconnect
141
+ client.enable_logger()
142
+ client.connect(self.endpoint, 443, KEEP_ALIVE_SEC)
143
+ self._client = client
144
+
145
+ def disconnect(self):
146
+ if self._client:
147
+ self._client.loop_stop()
148
+ self._client.disconnect()
149
+
150
+ def subscribe(self, topic: str, qos: int = 1) -> None:
151
+ assert self._client is not None
152
+ self._client.subscribe(topic, qos=qos)
153
+
154
+ def publish(self, topic: str, payload: str | bytes = b"", qos: int = 1) -> None:
155
+ assert self._client is not None
156
+ data = payload.encode("utf-8") if isinstance(payload, str) else payload
157
+ self._client.publish(topic, data, qos=qos)
158
+
159
+ def loop_start(self):
160
+ assert self._client is not None
161
+ self._client.loop_start()
162
+
163
+ def loop_forever(self) -> None:
164
+ assert self._client is not None
165
+ self._client.loop_forever()
166
+
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: place-integration-api
3
+ Version: 0.1.0
4
+ Summary: Python APIs for Cognito-authenticated MQTT flows and Home Assistant discovery for Gentex Place.
5
+ Author-email: Nicholas Aelick <niaexa@syntronic.com>, Andrew Tran <andrew.tran@gentex.com>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://pypi.org/project/place-integration-api/
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: boto3
12
+ Requires-Dist: aiohttp
13
+ Requires-Dist: paho-mqtt
14
+ Requires-Dist: six
15
+ Requires-Dist: python-decouple
16
+ Provides-Extra: dev
17
+ Requires-Dist: basedpyright; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # Place integration API
21
+
22
+ This project contains a python API to connect MQTT data from Place devices to Place's Home Assistant integration
@@ -0,0 +1,24 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/place/config.py
5
+ src/place/messages.py
6
+ src/place/mqtt_client.py
7
+ src/place/provider.py
8
+ src/place/auth/__init__.py
9
+ src/place/auth/abstract_auth.py
10
+ src/place/auth/aws_srp.py
11
+ src/place/auth/srp_auth.py
12
+ src/place/models/__init__.py
13
+ src/place/models/credentials.py
14
+ src/place/models/discover_device.py
15
+ src/place/models/models.py
16
+ src/place/models/mqtt_message.py
17
+ src/place_integration_api.egg-info/PKG-INFO
18
+ src/place_integration_api.egg-info/SOURCES.txt
19
+ src/place_integration_api.egg-info/dependency_links.txt
20
+ src/place_integration_api.egg-info/requires.txt
21
+ src/place_integration_api.egg-info/top_level.txt
22
+ tests/test_auth.py
23
+ tests/test_mqtt_client.py
24
+ tests/test_provider.py
@@ -0,0 +1,8 @@
1
+ boto3
2
+ aiohttp
3
+ paho-mqtt
4
+ six
5
+ python-decouple
6
+
7
+ [dev]
8
+ basedpyright
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from place_integration_api.auth import get_credentials_via_cognito
6
+
7
+
8
+ @patch("place_integration_api.auth.srp_auth.boto3")
9
+ @patch("place_integration_api.auth.srp_auth.get_tokens_via_srp")
10
+ def test_get_credentials_via_cognito_success(
11
+ mock_get_tokens_via_srp: MagicMock, mock_boto3: MagicMock
12
+ ) -> None:
13
+ identity_client = MagicMock()
14
+ mock_boto3.client.return_value = identity_client
15
+
16
+ mock_get_tokens_via_srp.return_value = {
17
+ "AuthenticationResult": {
18
+ "IdToken": "id-token",
19
+ "AccessToken": "access-token",
20
+ }
21
+ }
22
+ identity_client.get_id.return_value = {"IdentityId": "identity-123"}
23
+ identity_client.get_credentials_for_identity.return_value = {
24
+ "Credentials": {
25
+ "AccessKeyId": "AKIA...",
26
+ "SecretKey": "secret",
27
+ "SessionToken": "session",
28
+ }
29
+ }
30
+
31
+ creds = get_credentials_via_cognito(
32
+ user_pool_id="pool",
33
+ client_id="client",
34
+ identity_pool_id="identity-pool",
35
+ username="user",
36
+ password="pass",
37
+ region="us-east-2",
38
+ )
39
+
40
+ assert creds.access_key_id == "AKIA..."
41
+ assert creds.secret_access_key == "secret"
42
+ assert creds.session_token == "session"
43
+ assert creds.identity_id == "identity-123"
44
+ assert creds.access_token == "access-token"
45
+
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from place_integration_api.models import Credentials
6
+ from place_integration_api.mqtt_client import get_signed_uri, run_mqtt_flow
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_integration_api.mqtt_client.mqtt.Client")
23
+ @patch("place_integration_api.mqtt_client.get_signed_uri")
24
+ def test_run_mqtt_flow_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
+ run_mqtt_flow(
40
+ endpoint="example.iot.amazonaws.com",
41
+ credentials=creds,
42
+ household_ids=["hh1"],
43
+ thing_names=["thing1"],
44
+ )
45
+
46
+ mock_get_signed_uri.assert_called_once()
47
+ client.ws_set_options.assert_called()
48
+ client.tls_set.assert_called_once()
49
+ client.connect.assert_called_once()
50
+ client.loop_forever.assert_called_once()
51
+
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from place_integration_api.auth.abstract_auth import AbstractAuth
6
+ from place_integration_api.config import FULFILLMENT_URL
7
+ from place_integration_api.provider import Provider
8
+
9
+
10
+ def test_provider_discover_parses_response() -> None:
11
+ payload = {
12
+ "success": True,
13
+ "data": {
14
+ "devices": [
15
+ {"householdId": "h1", "thingName": "t1", "deviceId": "d1"},
16
+ {"householdId": "h2", "thingName": "t2", "deviceId": "d2"},
17
+ ]
18
+ },
19
+ }
20
+
21
+ class DummyAuth(AbstractAuth):
22
+ def __init__(self) -> None:
23
+ self.calls: list[tuple[str, str, dict]] = []
24
+
25
+ async def async_get_access_token(self) -> str:
26
+ return "token"
27
+
28
+ async def request(self, method, url, **kwargs):
29
+ self.calls.append((method, url, kwargs))
30
+
31
+ class DummyResponse:
32
+ async def json(self_inner):
33
+ return payload
34
+
35
+ return DummyResponse()
36
+
37
+ auth = DummyAuth()
38
+ provider = Provider(auth)
39
+ devices = asyncio.run(provider.discover())
40
+
41
+ assert auth.calls == [("POST", FULFILLMENT_URL, {"json": {"command": "DISCOVER", "data": {}}})]
42
+
43
+ household_ids = sorted(
44
+ {d.household_id for d in devices if d.household_id is not None}
45
+ )
46
+ thing_names = sorted(
47
+ {d.thing_name for d in devices if d.thing_name is not None}
48
+ )
49
+
50
+ assert household_ids == ["h1", "h2"]
51
+ assert thing_names == ["t1", "t2"]
52
+ assert [d.device_id for d in devices] == ["d1", "d2"]
53
+
54
+
55
+ def test_provider_enable_sends_enable_command() -> None:
56
+ payload = {"success": True, "data": {"status": "enabled"}}
57
+
58
+ class DummyAuth(AbstractAuth):
59
+ def __init__(self) -> None:
60
+ self.calls: list[tuple[str, str, dict]] = []
61
+
62
+ async def async_get_access_token(self) -> str:
63
+ return "token"
64
+
65
+ async def request(self, method, url, **kwargs):
66
+ self.calls.append((method, url, kwargs))
67
+
68
+ class DummyResponse:
69
+ async def json(self_inner):
70
+ return payload
71
+
72
+ return DummyResponse()
73
+
74
+ auth = DummyAuth()
75
+ provider = Provider(auth)
76
+ result = asyncio.run(provider.enable())
77
+
78
+ assert auth.calls == [("POST", FULFILLMENT_URL, {"json": {"command": "ENABLE", "data": {}}})]
79
+ assert result == payload
80
+
81
+
82
+ def test_provider_disable_sends_disable_command() -> None:
83
+ payload = {"success": True, "data": {"status": "disabled"}}
84
+
85
+ class DummyAuth(AbstractAuth):
86
+ def __init__(self) -> None:
87
+ self.calls: list[tuple[str, str, dict]] = []
88
+
89
+ async def async_get_access_token(self) -> str:
90
+ return "token"
91
+
92
+ async def request(self, method, url, **kwargs):
93
+ self.calls.append((method, url, **kwargs))
94
+
95
+ class DummyResponse:
96
+ async def json(self_inner):
97
+ return payload
98
+
99
+ return DummyResponse()
100
+
101
+ auth = DummyAuth()
102
+ provider = Provider(auth)
103
+ result = asyncio.run(provider.disable())
104
+
105
+ assert auth.calls == [("POST", FULFILLMENT_URL, {"json": {"command": "DISABLE", "data": {}}})]
106
+ assert result == payload
107
+