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.
- place_integration_api-0.1.0/LICENSE +19 -0
- place_integration_api-0.1.0/PKG-INFO +22 -0
- place_integration_api-0.1.0/README.md +3 -0
- place_integration_api-0.1.0/pyproject.toml +33 -0
- place_integration_api-0.1.0/setup.cfg +4 -0
- place_integration_api-0.1.0/src/place/auth/__init__.py +9 -0
- place_integration_api-0.1.0/src/place/auth/abstract_auth.py +33 -0
- place_integration_api-0.1.0/src/place/auth/aws_srp.py +321 -0
- place_integration_api-0.1.0/src/place/auth/srp_auth.py +69 -0
- place_integration_api-0.1.0/src/place/config.py +31 -0
- place_integration_api-0.1.0/src/place/messages.py +86 -0
- place_integration_api-0.1.0/src/place/models/__init__.py +12 -0
- place_integration_api-0.1.0/src/place/models/credentials.py +13 -0
- place_integration_api-0.1.0/src/place/models/discover_device.py +41 -0
- place_integration_api-0.1.0/src/place/models/models.py +5 -0
- place_integration_api-0.1.0/src/place/models/mqtt_message.py +11 -0
- place_integration_api-0.1.0/src/place/mqtt_client.py +166 -0
- place_integration_api-0.1.0/src/place/provider.py +38 -0
- place_integration_api-0.1.0/src/place_integration_api.egg-info/PKG-INFO +22 -0
- place_integration_api-0.1.0/src/place_integration_api.egg-info/SOURCES.txt +24 -0
- place_integration_api-0.1.0/src/place_integration_api.egg-info/dependency_links.txt +1 -0
- place_integration_api-0.1.0/src/place_integration_api.egg-info/requires.txt +8 -0
- place_integration_api-0.1.0/src/place_integration_api.egg-info/top_level.txt +1 -0
- place_integration_api-0.1.0/tests/test_auth.py +45 -0
- place_integration_api-0.1.0/tests/test_mqtt_client.py +51 -0
- 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,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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
place
|
|
@@ -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
|
+
|