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.
- homelink_integration_api-0.0.1/LICENSE +19 -0
- homelink_integration_api-0.0.1/PKG-INFO +32 -0
- homelink_integration_api-0.0.1/README.md +15 -0
- homelink_integration_api-0.0.1/pyproject.toml +31 -0
- homelink_integration_api-0.0.1/setup.cfg +4 -0
- homelink_integration_api-0.0.1/src/homelink/__init__.py +0 -0
- homelink_integration_api-0.0.1/src/homelink/auth/abstract_auth.py +33 -0
- homelink_integration_api-0.0.1/src/homelink/auth/aws_srp.py +311 -0
- homelink_integration_api-0.0.1/src/homelink/auth/srp_auth.py +30 -0
- homelink_integration_api-0.0.1/src/homelink/model/__init__.py +0 -0
- homelink_integration_api-0.0.1/src/homelink/model/button.py +5 -0
- homelink_integration_api-0.0.1/src/homelink/model/device.py +9 -0
- homelink_integration_api-0.0.1/src/homelink/mqtt_provider.py +142 -0
- homelink_integration_api-0.0.1/src/homelink/mqtt_util.py +45 -0
- homelink_integration_api-0.0.1/src/homelink/provider.py +52 -0
- homelink_integration_api-0.0.1/src/homelink/samples/__init__.py +0 -0
- homelink_integration_api-0.0.1/src/homelink/samples/mqtt_sample.py +42 -0
- homelink_integration_api-0.0.1/src/homelink/settings.py +25 -0
- homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/PKG-INFO +32 -0
- homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/SOURCES.txt +22 -0
- homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/dependency_links.txt +1 -0
- homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/requires.txt +2 -0
- homelink_integration_api-0.0.1/src/homelink_integration_api.egg-info/top_level.txt +1 -0
- 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"
|
|
File without changes
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
homelink
|
|
@@ -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"]
|