homelink-integration-api 0.0.1__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 (24) hide show
  1. homelink_integration_api-0.0.1/LICENSE +19 -0
  2. homelink_integration_api-0.0.1/PKG-INFO +32 -0
  3. homelink_integration_api-0.0.1/README.md +15 -0
  4. homelink_integration_api-0.0.1/pyproject.toml +31 -0
  5. homelink_integration_api-0.0.1/setup.cfg +4 -0
  6. homelink_integration_api-0.0.1/src/homelink/__init__.py +0 -0
  7. homelink_integration_api-0.0.1/src/homelink/auth/abstract_auth.py +33 -0
  8. homelink_integration_api-0.0.1/src/homelink/auth/aws_srp.py +311 -0
  9. homelink_integration_api-0.0.1/src/homelink/auth/srp_auth.py +30 -0
  10. homelink_integration_api-0.0.1/src/homelink/model/__init__.py +0 -0
  11. homelink_integration_api-0.0.1/src/homelink/model/button.py +5 -0
  12. homelink_integration_api-0.0.1/src/homelink/model/device.py +9 -0
  13. homelink_integration_api-0.0.1/src/homelink/mqtt_provider.py +142 -0
  14. homelink_integration_api-0.0.1/src/homelink/mqtt_util.py +45 -0
  15. homelink_integration_api-0.0.1/src/homelink/provider.py +52 -0
  16. homelink_integration_api-0.0.1/src/homelink/samples/__init__.py +0 -0
  17. homelink_integration_api-0.0.1/src/homelink/samples/mqtt_sample.py +42 -0
  18. homelink_integration_api-0.0.1/src/homelink/settings.py +25 -0
  19. homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/PKG-INFO +32 -0
  20. homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/SOURCES.txt +22 -0
  21. homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/dependency_links.txt +1 -0
  22. homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/requires.txt +2 -0
  23. homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/top_level.txt +1 -0
  24. homelink_integration_api-0.0.1/tests/test_provider.py +52 -0
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2024 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,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: homelink-integration-api
3
+ Version: 0.0.1
4
+ Summary: API to interact with Homelink cloud for MQTT-enabled smart home platforms
5
+ Author-email: Nicholas Aelick <niaexa@syntronic.com>, Ryan Jones <ryan.jones@gentex.com>, Evgeniy Burbyga <evbuxa@syntronic.com>
6
+ Project-URL: Homepage, https://www.homelink.com/
7
+ Project-URL: Issues, https://www.homelink.com/support
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: python-decouple>=3.8
15
+ Requires-Dist: boto3
16
+ Dynamic: license-file
17
+
18
+ # HomeLink smart home integration API
19
+
20
+ This project contains a python API to connect MQTT-enabled smart home platforms to HomeLink smarthome cloud
21
+
22
+ ## Setup
23
+
24
+ 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.
25
+
26
+ ## Installing dependencies
27
+
28
+ Use `pip` to install required dependencies by running `pip install -r requirements.txt` from the project root.
29
+
30
+ ## Updating dependencies
31
+
32
+ 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
+ # HomeLink smart home integration API
2
+
3
+ This project contains a python API to connect MQTT-enabled smart home platforms to HomeLink smarthome cloud
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`.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "homelink-integration-api"
3
+ version = "0.0.1"
4
+ authors = [
5
+ { name="Nicholas Aelick", email="niaexa@syntronic.com" },
6
+ { name="Ryan Jones", email="ryan.jones@gentex.com" },
7
+ { name="Evgeniy Burbyga", email="evbuxa@syntronic.com" },
8
+ ]
9
+ description = "API to interact with Homelink cloud for MQTT-enabled smart home platforms"
10
+ readme = "README.md"
11
+ requires-python = ">=3.12"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ dependencies = ["python-decouple>=3.8", "boto3"]
18
+
19
+ [tool.pytest.ini_options]
20
+ pythonpath = [
21
+ "src"
22
+ ]
23
+ asyncio_default_fixture_loop_scope = "function"
24
+
25
+ [project.urls]
26
+ Homepage = "https://www.homelink.com/"
27
+ Issues = "https://www.homelink.com/support"
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=61.0"]
31
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,311 @@
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 boto3
10
+ import os
11
+ import six
12
+
13
+
14
+ class WarrantException(Exception):
15
+ """Base class for all Warrant exceptions"""
16
+
17
+
18
+ class ForceChangePasswordException(WarrantException):
19
+ """Raised when the user is forced to change their password"""
20
+
21
+
22
+ # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22
23
+ n_hex = (
24
+ "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
25
+ + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
26
+ + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
27
+ + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
28
+ + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D"
29
+ + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F"
30
+ + "83655D23DCA3AD961C62F356208552BB9ED529077096966D"
31
+ + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
32
+ + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9"
33
+ + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510"
34
+ + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64"
35
+ + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7"
36
+ + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B"
37
+ + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C"
38
+ + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31"
39
+ + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
40
+ )
41
+ # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49
42
+ g_hex = "2"
43
+ info_bits = bytearray("Caldera Derived Key", "utf-8")
44
+
45
+
46
+ def hash_sha256(buf):
47
+ """AuthenticationHelper.hash"""
48
+ a = hashlib.sha256(buf).hexdigest()
49
+ return (64 - len(a)) * "0" + a
50
+
51
+
52
+ def hex_hash(hex_string):
53
+ return hash_sha256(bytearray.fromhex(hex_string))
54
+
55
+
56
+ def hex_to_long(hex_string):
57
+ return int(hex_string, 16)
58
+
59
+
60
+ def long_to_hex(long_num):
61
+ return "%x" % long_num
62
+
63
+
64
+ def get_random(nbytes):
65
+ random_hex = binascii.hexlify(os.urandom(nbytes))
66
+ return hex_to_long(random_hex)
67
+
68
+
69
+ def pad_hex(long_int):
70
+ """
71
+ Converts a Long integer (or hex string) to hex format padded with zeroes for hashing
72
+ :param {Long integer|String} long_int Number or string to pad.
73
+ :return {String} Padded hex string.
74
+ """
75
+ if not isinstance(long_int, six.string_types):
76
+ hash_str = long_to_hex(long_int)
77
+ else:
78
+ hash_str = long_int
79
+ if len(hash_str) % 2 == 1:
80
+ hash_str = "0%s" % hash_str
81
+ elif hash_str[0] in "89ABCDEFabcdef":
82
+ hash_str = "00%s" % hash_str
83
+ return hash_str
84
+
85
+
86
+ def compute_hkdf(ikm, salt):
87
+ """
88
+ Standard hkdf algorithm
89
+ :param {Buffer} ikm Input key material.
90
+ :param {Buffer} salt Salt value.
91
+ :return {Buffer} Strong key material.
92
+ @private
93
+ """
94
+ prk = hmac.new(salt, ikm, hashlib.sha256).digest()
95
+ info_bits_update = info_bits + bytearray(chr(1), "utf-8")
96
+ hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest()
97
+ return hmac_hash[:16]
98
+
99
+
100
+ def calculate_u(big_a, big_b):
101
+ """
102
+ Calculate the client's value U which is the hash of A and B
103
+ :param {Long integer} big_a Large A value.
104
+ :param {Long integer} big_b Server B value.
105
+ :return {Long integer} Computed U value.
106
+ """
107
+ u_hex_hash = hex_hash(pad_hex(big_a) + pad_hex(big_b))
108
+ return hex_to_long(u_hex_hash)
109
+
110
+
111
+ class AWSSRP(object):
112
+
113
+ NEW_PASSWORD_REQUIRED_CHALLENGE = "NEW_PASSWORD_REQUIRED"
114
+ PASSWORD_VERIFIER_CHALLENGE = "PASSWORD_VERIFIER"
115
+
116
+ def __init__(
117
+ self,
118
+ username,
119
+ password,
120
+ pool_id,
121
+ client_id,
122
+ pool_region=None,
123
+ client=None,
124
+ client_secret=None,
125
+ ):
126
+ if pool_region is not None and client is not None:
127
+ raise ValueError(
128
+ "pool_region and client should not both be specified "
129
+ "(region should be passed to the boto3 client instead)"
130
+ )
131
+
132
+ self.username = username
133
+ self.password = password
134
+ self.pool_id = pool_id
135
+ self.client_id = client_id
136
+ self.client_secret = client_secret
137
+ self.client = (
138
+ client if client else boto3.client("cognito-idp", region_name=pool_region)
139
+ )
140
+ self.big_n = hex_to_long(n_hex)
141
+ self.g = hex_to_long(g_hex)
142
+ self.k = hex_to_long(hex_hash("00" + n_hex + "0" + g_hex))
143
+ self.small_a_value = self.generate_random_small_a()
144
+ self.large_a_value = self.calculate_a()
145
+
146
+ def generate_random_small_a(self):
147
+ """
148
+ helper function to generate a random big integer
149
+ :return {Long integer} a random value.
150
+ """
151
+ random_long_int = get_random(128)
152
+ return random_long_int % self.big_n
153
+
154
+ def calculate_a(self):
155
+ """
156
+ Calculate the client's public value A = g^a%N
157
+ with the generated random number a
158
+ :param {Long integer} a Randomly generated small A.
159
+ :return {Long integer} Computed large A.
160
+ """
161
+ big_a = pow(self.g, self.small_a_value, self.big_n)
162
+ # safety check
163
+ if (big_a % self.big_n) == 0:
164
+ raise ValueError("Safety check for A failed")
165
+ return big_a
166
+
167
+ def get_password_authentication_key(self, username, password, server_b_value, salt):
168
+ """
169
+ Calculates the final hkdf based on computed S value, and computed U value and the key
170
+ :param {String} username Username.
171
+ :param {String} password Password.
172
+ :param {Long integer} server_b_value Server B value.
173
+ :param {Long integer} salt Generated salt.
174
+ :return {Buffer} Computed HKDF value.
175
+ """
176
+ u_value = calculate_u(self.large_a_value, server_b_value)
177
+ if u_value == 0:
178
+ raise ValueError("U cannot be zero.")
179
+ username_password = "%s%s:%s" % (self.pool_id.split("_")[1], username, password)
180
+ username_password_hash = hash_sha256(username_password.encode("utf-8"))
181
+
182
+ x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash))
183
+ g_mod_pow_xn = pow(self.g, x_value, self.big_n)
184
+ int_value2 = server_b_value - self.k * g_mod_pow_xn
185
+ s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n)
186
+ hkdf = compute_hkdf(
187
+ bytearray.fromhex(pad_hex(s_value)),
188
+ bytearray.fromhex(pad_hex(long_to_hex(u_value))),
189
+ )
190
+ return hkdf
191
+
192
+ def get_auth_params(self):
193
+ auth_params = {
194
+ "USERNAME": self.username,
195
+ "SRP_A": long_to_hex(self.large_a_value),
196
+ }
197
+ if self.client_secret is not None:
198
+ auth_params.update(
199
+ {
200
+ "SECRET_HASH": self.get_secret_hash(
201
+ self.username, self.client_id, self.client_secret
202
+ )
203
+ }
204
+ )
205
+ return auth_params
206
+
207
+ @staticmethod
208
+ def get_secret_hash(username, client_id, client_secret):
209
+ message = bytearray(username + client_id, "utf-8")
210
+ hmac_obj = hmac.new(bytearray(client_secret, "utf-8"), message, hashlib.sha256)
211
+ return base64.standard_b64encode(hmac_obj.digest()).decode("utf-8")
212
+
213
+ def process_challenge(self, challenge_parameters):
214
+ user_id_for_srp = challenge_parameters["USER_ID_FOR_SRP"]
215
+ salt_hex = challenge_parameters["SALT"]
216
+ srp_b_hex = challenge_parameters["SRP_B"]
217
+ secret_block_b64 = challenge_parameters["SECRET_BLOCK"]
218
+ # re strips leading zero from a day number (required by AWS Cognito)
219
+ timestamp = re.sub(
220
+ r" 0(\d) ",
221
+ r" \1 ",
222
+ datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y"),
223
+ )
224
+ hkdf = self.get_password_authentication_key(
225
+ user_id_for_srp, self.password, hex_to_long(srp_b_hex), salt_hex
226
+ )
227
+ secret_block_bytes = base64.standard_b64decode(secret_block_b64)
228
+ msg = (
229
+ bytearray(self.pool_id.split("_")[1], "utf-8")
230
+ + bytearray(user_id_for_srp, "utf-8")
231
+ + bytearray(secret_block_bytes)
232
+ + bytearray(timestamp, "utf-8")
233
+ )
234
+ hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256)
235
+ signature_string = base64.standard_b64encode(hmac_obj.digest())
236
+ response = {
237
+ "TIMESTAMP": timestamp,
238
+ "USERNAME": user_id_for_srp,
239
+ "PASSWORD_CLAIM_SECRET_BLOCK": secret_block_b64,
240
+ "PASSWORD_CLAIM_SIGNATURE": signature_string.decode("utf-8"),
241
+ }
242
+ if self.client_secret is not None:
243
+ response.update(
244
+ {
245
+ "SECRET_HASH": self.get_secret_hash(
246
+ self.username, self.client_id, self.client_secret
247
+ )
248
+ }
249
+ )
250
+ return response
251
+
252
+ def authenticate_user(self, client=None):
253
+ boto_client = self.client or client
254
+ auth_params = self.get_auth_params()
255
+ response = boto_client.initiate_auth(
256
+ AuthFlow="USER_SRP_AUTH",
257
+ AuthParameters=auth_params,
258
+ ClientId=self.client_id,
259
+ )
260
+ if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE:
261
+ challenge_response = self.process_challenge(response["ChallengeParameters"])
262
+ tokens = boto_client.respond_to_auth_challenge(
263
+ ClientId=self.client_id,
264
+ ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE,
265
+ ChallengeResponses=challenge_response,
266
+ )
267
+
268
+ if tokens.get("ChallengeName") == self.NEW_PASSWORD_REQUIRED_CHALLENGE:
269
+ raise ForceChangePasswordException(
270
+ "Change password before authenticating"
271
+ )
272
+
273
+ return tokens
274
+ else:
275
+ raise NotImplementedError(
276
+ "The %s challenge is not supported" % response["ChallengeName"]
277
+ )
278
+
279
+ def set_new_password_challenge(self, new_password, client=None):
280
+ boto_client = self.client or client
281
+ auth_params = self.get_auth_params()
282
+ response = boto_client.initiate_auth(
283
+ AuthFlow="USER_SRP_AUTH",
284
+ AuthParameters=auth_params,
285
+ ClientId=self.client_id,
286
+ )
287
+ if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE:
288
+ challenge_response = self.process_challenge(response["ChallengeParameters"])
289
+ tokens = boto_client.respond_to_auth_challenge(
290
+ ClientId=self.client_id,
291
+ ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE,
292
+ ChallengeResponses=challenge_response,
293
+ )
294
+
295
+ if tokens["ChallengeName"] == self.NEW_PASSWORD_REQUIRED_CHALLENGE:
296
+ challenge_response = {
297
+ "USERNAME": auth_params["USERNAME"],
298
+ "NEW_PASSWORD": new_password,
299
+ }
300
+ new_password_response = boto_client.respond_to_auth_challenge(
301
+ ClientId=self.client_id,
302
+ ChallengeName=self.NEW_PASSWORD_REQUIRED_CHALLENGE,
303
+ Session=tokens["Session"],
304
+ ChallengeResponses=challenge_response,
305
+ )
306
+ return new_password_response
307
+ return tokens
308
+ else:
309
+ raise NotImplementedError(
310
+ "The %s challenge is not supported" % response["ChallengeName"]
311
+ )
@@ -0,0 +1,30 @@
1
+ import boto3
2
+ from homelink.auth.aws_srp import AWSSRP
3
+ from homelink.auth.abstract_auth import AbstractAuth
4
+ from homelink.settings import COGNITO_POOL_ID, COGNITO_CLIENT_ID
5
+ import hmac
6
+ import hashlib
7
+ import base64
8
+
9
+
10
+ # Function used to calculate SecretHash value for a given client
11
+ def calculateSecretHash(client_id, client_secret, username):
12
+ key = bytes(client_secret, "utf-8")
13
+ message = bytes(f"{username}{client_id}", "utf-8")
14
+ return base64.b64encode(
15
+ hmac.new(key, message, digestmod=hashlib.sha256).digest()
16
+ ).decode()
17
+
18
+
19
+ class SRPAuth:
20
+
21
+ def async_get_access_token(self, username, password):
22
+ client = boto3.client("cognito-idp", region_name="us-east-2")
23
+ aws = AWSSRP(
24
+ username=username,
25
+ password=password,
26
+ pool_id=COGNITO_POOL_ID,
27
+ client_id=COGNITO_CLIENT_ID,
28
+ client=client,
29
+ )
30
+ return aws.authenticate_user()
@@ -0,0 +1,5 @@
1
+ class Button:
2
+ def __init__(self, id, name, device):
3
+ self.id = id
4
+ self.name = name
5
+ self.device = device
@@ -0,0 +1,9 @@
1
+ from homelink.model.button import Button
2
+
3
+
4
+ class Device:
5
+
6
+ def __init__(self, id, name):
7
+ self.id = id
8
+ self.name = name
9
+ self.buttons = []
@@ -0,0 +1,142 @@
1
+ import logging
2
+
3
+ from homelink.model.button import Button
4
+ from homelink.model.device import Device
5
+ from homelink.settings import DISCOVER_URL, ENABLE_URL, MQTT_IOT_ENDPOINT, MQTT_IOT_PORT
6
+
7
+ import paho.mqtt.client as mqtt
8
+ import homelink.mqtt_util as mqtt_util
9
+ import ssl
10
+ import json
11
+ import tempfile
12
+ import os
13
+ import aiofiles
14
+ import asyncio
15
+
16
+
17
+ class MQTTProvider:
18
+ def __init__(self, authorized_session):
19
+ self.authorized_session = authorized_session
20
+ self.mqtt_client = None
21
+ self.listeners = []
22
+
23
+ async def discover(self):
24
+ resp = await self.authorized_session.request(
25
+ "POST",
26
+ DISCOVER_URL,
27
+ json={"command": "DISCOVER"},
28
+ )
29
+ device_data = await resp.json()
30
+ logging.info(device_data)
31
+ devices = []
32
+
33
+ for raw_device in device_data["data"]["devices"]:
34
+ d = Device(raw_device["id"], raw_device["name"])
35
+ for raw_button in raw_device["buttons"]:
36
+ d.buttons.append(Button(raw_button["id"], raw_button["name"], d))
37
+ devices.append(d)
38
+
39
+ return devices
40
+
41
+ async def enable(self, sslContext=None):
42
+ asyncio_loop = asyncio.get_running_loop()
43
+ pkey, csr = await mqtt_util.generate_csr()
44
+ _, pk_file_path = tempfile.mkstemp()
45
+ async with aiofiles.open(pk_file_path, "wb") as f:
46
+ await f.write(pkey)
47
+ enable_resp = await self.authorized_session.request(
48
+ "POST",
49
+ ENABLE_URL,
50
+ json={"command": "ENABLE", "data": {"csr": csr}},
51
+ )
52
+
53
+ resp_json = await enable_resp.json()
54
+ f, cert_file_path = tempfile.mkstemp(text=True)
55
+ async with aiofiles.open(cert_file_path, "w") as f:
56
+ await f.write(resp_json["data"]["certificatePem"])
57
+
58
+ topic = None
59
+ topics = []
60
+ try:
61
+ topic = resp_json["data"]["topic"]
62
+ except KeyError:
63
+ pass
64
+
65
+ try:
66
+ topics = resp_json["data"]["topics"]
67
+ except KeyError:
68
+ pass
69
+
70
+ self.mqtt_client = mqtt.Client(client_id="TODO", protocol=mqtt.MQTTv5)
71
+ self.mqtt_client.user_data_set(
72
+ {"topics": list(dict.fromkeys(topics, topic)), "listeners": self.listeners}
73
+ )
74
+
75
+ if sslContext:
76
+ await asyncio_loop.run_in_executor(
77
+ None, sslContext.load_cert_chain, cert_file_path, pk_file_path, None
78
+ )
79
+ await asyncio_loop.run_in_executor(
80
+ None, sslContext.load_verify_locations, cert_file_path
81
+ )
82
+ self.mqtt_client.tls_set_context(sslContext)
83
+ else:
84
+ self.mqtt_client.tls_set(
85
+ certfile=cert_file_path,
86
+ keyfile=pk_file_path,
87
+ cert_reqs=ssl.CERT_REQUIRED,
88
+ tls_version=ssl.PROTOCOL_TLSv1_2,
89
+ ciphers=None,
90
+ )
91
+ self.mqtt_client.on_connect = self._on_connect
92
+ self.mqtt_client.on_message = self._on_message
93
+ self.mqtt_client.on_connect_fail = self._on_connect_fail
94
+ self.mqtt_client.on_disconnect = self._on_disconnect
95
+
96
+ self.mqtt_client.connect(MQTT_IOT_ENDPOINT, MQTT_IOT_PORT, keepalive=60)
97
+ self.mqtt_client.loop_start()
98
+
99
+ os.remove(cert_file_path)
100
+ os.remove(pk_file_path)
101
+
102
+ return resp_json
103
+
104
+ async def disable(self):
105
+ self.mqtt_client.loop_stop()
106
+ self.mqtt_client.disconnect()
107
+ enable_resp = await self.authorized_session.request(
108
+ "POST", ENABLE_URL, json={"command": "DISABLE"}
109
+ )
110
+ return await enable_resp.json()
111
+
112
+ def listen(self, cb):
113
+ self.listeners.append(cb)
114
+
115
+ def _on_connect(self, client, userdata, flags, rc, properties=None):
116
+ if rc == 0:
117
+ for topic in userdata["topics"]:
118
+ client.subscribe(topic, qos=1)
119
+ else:
120
+ raise ConnectionError("Failed to connect")
121
+
122
+ def _on_message(self, client, userdata, msg):
123
+ res = {}
124
+ json_msg = json.loads(msg.payload.decode("utf-8"))
125
+ if "state" in json_msg:
126
+ res = {"type": "state", "data": json_msg["state"]}
127
+ elif "requestSync" in json_msg:
128
+ res = {"type": "requestSync", "data": json_msg["requestSync"]}
129
+ else:
130
+ raise ConnectionError("Unidentified message type recieved")
131
+ for listener in userdata["listeners"]:
132
+ listener(msg.topic, res)
133
+
134
+ def _on_connect_fail(self, client, userdata):
135
+ res = {"type": "connect_fail", "data": {}}
136
+ for listener in userdata["listeners"]:
137
+ listener("connect_fail", res)
138
+
139
+ def _on_disconnect(self, client, userdata, reason_code, properties):
140
+ res = {"type": "disconnect", "data": {"code": reason_code}}
141
+ for listener in userdata["listeners"]:
142
+ listener("disconnect", res)
@@ -0,0 +1,45 @@
1
+ from OpenSSL import crypto
2
+ import re
3
+ from homelink.settings import (
4
+ MQTT_ROOT_CA,
5
+ MQTT_ROOT_CA_REPOSITORY,
6
+ MQTT_PRIVATE_KEY_SIZE,
7
+ )
8
+ from aiohttp import ClientTimeout, request
9
+
10
+
11
+ def format_csr(csr_pem):
12
+ return (
13
+ re.search(
14
+ r"-+BEGIN CERTIFICATE REQUEST-+\s+(.*?)\s+-+END CERTIFICATE REQUEST-+",
15
+ csr_pem,
16
+ flags=re.DOTALL,
17
+ )
18
+ .group(1)
19
+ .strip()
20
+ .replace("\n", "")
21
+ )
22
+
23
+
24
+ async def generate_csr():
25
+ url = f"{MQTT_ROOT_CA_REPOSITORY}/{MQTT_ROOT_CA}"
26
+ client_timeout = ClientTimeout(total=60)
27
+ async with request("GET", url=url, timeout=client_timeout) as response:
28
+ cert_data = await response.text()
29
+ if response.status != 200 or "error" in cert_data:
30
+ raise Exception("Failed to get root certificate")
31
+ key = crypto.PKey()
32
+ key.generate_key(crypto.TYPE_RSA, MQTT_PRIVATE_KEY_SIZE)
33
+ key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode("utf-8")
34
+ bytes_private_key = key_pem.encode("utf-8")
35
+
36
+ csr = crypto.X509Req()
37
+ csr.get_subject().CN = "gentex"
38
+ csr.set_pubkey(key)
39
+ csr.sign(key, "sha512")
40
+
41
+ csr_pem = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr).decode(
42
+ encoding="utf-8"
43
+ )
44
+
45
+ return bytes_private_key, format_csr(csr_pem)
@@ -0,0 +1,52 @@
1
+ import json
2
+ import logging
3
+
4
+ from homelink.auth.abstract_auth import AbstractAuth
5
+ from homelink.model.button import Button
6
+ from homelink.model.device import Device
7
+ from homelink.settings import DISCOVER_URL, STATE_URL, ENABLE_URL
8
+
9
+
10
+ class Provider:
11
+ def __init__(self, authorized_session):
12
+ self.authorized_session = authorized_session
13
+
14
+ async def discover(self):
15
+ resp = await self.authorized_session.request(
16
+ "POST",
17
+ DISCOVER_URL,
18
+ json={"command": "DISCOVER"},
19
+ )
20
+ device_data = await resp.json()
21
+ logging.info(device_data)
22
+ devices = []
23
+
24
+ for raw_device in device_data["data"]["devices"]:
25
+ d = Device(raw_device["id"], raw_device["name"])
26
+ for raw_button in raw_device["buttons"]:
27
+ d.buttons.append(Button(raw_button["id"], raw_button["name"], d))
28
+ devices.append(d)
29
+
30
+ return devices
31
+
32
+ async def enable(self):
33
+ enable_resp = await self.authorized_session.request(
34
+ "POST",
35
+ ENABLE_URL,
36
+ json={"command": "ENABLE"},
37
+ )
38
+ return await enable_resp.json()
39
+
40
+ async def get_state(self):
41
+ resp = await self.authorized_session.request("GET", STATE_URL)
42
+ resp_data = await resp.json()
43
+ if "data" not in resp_data or resp_data["data"] is None:
44
+ return None, {}
45
+
46
+ if "requestSync" in resp_data["data"]:
47
+ should_sync = resp_data["data"]["requestSync"]
48
+ else:
49
+ should_sync = None
50
+ return should_sync, (
51
+ resp_data["data"]["state"] if "state" in resp_data["data"] else {}
52
+ )
@@ -0,0 +1,42 @@
1
+ from homelink.auth.abstract_auth import AbstractAuth
2
+ from homelink.auth.srp_auth import SRPAuth
3
+ from homelink.mqtt_provider import MQTTProvider
4
+ import asyncio
5
+ from aiohttp import ClientSession
6
+
7
+
8
+ USERNAME = ""
9
+ PASSWORD = ""
10
+
11
+
12
+ class AuthorizedSession(AbstractAuth):
13
+
14
+ def __init__(self, session, srp_auth):
15
+ super().__init__(session)
16
+ self.srp_auth = srp_auth
17
+
18
+ async def async_get_access_token(self):
19
+ tokens = self.srp_auth.async_get_access_token(USERNAME, PASSWORD)
20
+ return tokens["AuthenticationResult"]["AccessToken"]
21
+
22
+
23
+ def on_message(topic, message):
24
+ print(topic, message)
25
+
26
+
27
+ async def main():
28
+ async with ClientSession() as client:
29
+ srp_auth = SRPAuth()
30
+ auth_session = AuthorizedSession(client, srp_auth)
31
+ provider = MQTTProvider(auth_session)
32
+
33
+ provider.listen(on_message)
34
+ print(await provider.enable())
35
+ print(await provider.discover())
36
+
37
+ while True:
38
+ pass
39
+
40
+
41
+ if __name__ == "__main__":
42
+ asyncio.run(main())
@@ -0,0 +1,25 @@
1
+ from decouple import config
2
+
3
+ HOST_URL = config("HOMELINK_HOST_URL", default="homelinkcloud.com")
4
+ PLATFORM = config("HOMELINK_SMART_HOME_PLATFORM", default="home-assistant")
5
+ DISCOVER_URL = config(
6
+ "HOMELINK_DISCOVER_URL",
7
+ default=f"https://{HOST_URL}/services/v2/{PLATFORM}/fulfillment",
8
+ )
9
+ ENABLE_URL = config(
10
+ "HOMELINK_ENABLE_URL",
11
+ default=f"https://{HOST_URL}/services/v2/{PLATFORM}/fulfillment",
12
+ )
13
+ STATE_URL = config(
14
+ "HOMELINK_STATE_URL", default=f"https://state.{HOST_URL}/services/v2/{PLATFORM}"
15
+ )
16
+
17
+ COGNITO_POOL_ID = config("COGNITO_POOL_ID", default="us-east-2_sBYr2OD1J")
18
+ COGNITO_CLIENT_ID = config("COGNITO_CLIENT_ID", default="701cln3h6bgqfldh61rcf21ko0")
19
+ MQTT_ROOT_CA = config("MQTT_ROOT_CA", "AmazonRootCA1.pem")
20
+ MQTT_ROOT_CA_REPOSITORY = config(
21
+ "MQTT_ROOT_CA_REPOSITORY", "https://www.amazontrust.com/repository"
22
+ )
23
+ MQTT_PRIVATE_KEY_SIZE = config("MQTT_PRIVATE_KEY_SIZE", 2048)
24
+ MQTT_IOT_ENDPOINT = config("MQTT_IOT_ENDPOINT", "iot.homelinkcloud.com")
25
+ MQTT_IOT_PORT = config("MQTT_IOT_PORT", 8883)
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: homelink-integration-api
3
+ Version: 0.0.1
4
+ Summary: API to interact with Homelink cloud for MQTT-enabled smart home platforms
5
+ Author-email: Nicholas Aelick <niaexa@syntronic.com>, Ryan Jones <ryan.jones@gentex.com>, Evgeniy Burbyga <evbuxa@syntronic.com>
6
+ Project-URL: Homepage, https://www.homelink.com/
7
+ Project-URL: Issues, https://www.homelink.com/support
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: python-decouple>=3.8
15
+ Requires-Dist: boto3
16
+ Dynamic: license-file
17
+
18
+ # HomeLink smart home integration API
19
+
20
+ This project contains a python API to connect MQTT-enabled smart home platforms to HomeLink smarthome cloud
21
+
22
+ ## Setup
23
+
24
+ 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.
25
+
26
+ ## Installing dependencies
27
+
28
+ Use `pip` to install required dependencies by running `pip install -r requirements.txt` from the project root.
29
+
30
+ ## Updating dependencies
31
+
32
+ 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,22 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/homelink/__init__.py
5
+ src/homelink/mqtt_provider.py
6
+ src/homelink/mqtt_util.py
7
+ src/homelink/provider.py
8
+ src/homelink/settings.py
9
+ src/homelink/auth/abstract_auth.py
10
+ src/homelink/auth/aws_srp.py
11
+ src/homelink/auth/srp_auth.py
12
+ src/homelink/model/__init__.py
13
+ src/homelink/model/button.py
14
+ src/homelink/model/device.py
15
+ src/homelink/samples/__init__.py
16
+ src/homelink/samples/mqtt_sample.py
17
+ src/homelink_integration_api.egg-info/PKG-INFO
18
+ src/homelink_integration_api.egg-info/SOURCES.txt
19
+ src/homelink_integration_api.egg-info/dependency_links.txt
20
+ src/homelink_integration_api.egg-info/requires.txt
21
+ src/homelink_integration_api.egg-info/top_level.txt
22
+ tests/test_provider.py
@@ -0,0 +1,2 @@
1
+ python-decouple>=3.8
2
+ boto3
@@ -0,0 +1,52 @@
1
+ import pytest
2
+ import json
3
+ from .auth_mock import AuthMock
4
+ from homelink.provider import Provider
5
+ from homelink.settings import DISCOVER_URL, ENABLE_URL, STATE_URL
6
+
7
+
8
+ @pytest.fixture
9
+ def authorized_provider():
10
+ with (
11
+ open("tests/fixtures/discover_post.json") as discover_post_json,
12
+ open("tests/fixtures/enable_post.json") as enable_post_json,
13
+ open("tests/fixtures/state_get.json") as state_get_json,
14
+ ):
15
+ auth = AuthMock(
16
+ {
17
+ DISCOVER_URL: {
18
+ "POST": {
19
+ "DISCOVER": json.load(discover_post_json),
20
+ "ENABLE": json.load(enable_post_json),
21
+ }
22
+ },
23
+ STATE_URL: {"GET": json.load(state_get_json)},
24
+ }
25
+ )
26
+ provider = Provider(auth)
27
+ return provider
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_discover(authorized_provider):
32
+ devices = await authorized_provider.discover()
33
+ assert len(devices) == 1
34
+ assert devices[0].name == "PhiDevice"
35
+ assert len(devices[0].buttons) == 3
36
+ assert [b.name for b in devices[0].buttons] == ["Button 1", "Button 2", "Button 3"]
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_enable(authorized_provider):
41
+ enable_data = await authorized_provider.enable()
42
+ assert enable_data["success"] == True
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_get_state(authorized_provider):
47
+ request_sync, state = await authorized_provider.get_state()
48
+ assert request_sync
49
+ assert request_sync["requestId"]
50
+ assert request_sync["timestamp"]
51
+ assert state
52
+ assert state["9084ba1f-5f4c-4ccf-a168-13aabefc32a4"]