pyhive-integration 2.0.0b1__py3-none-any.whl
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.
- apyhiveapi/__init__.py +29 -0
- apyhiveapi/action.py +15 -0
- apyhiveapi/api/__init__.py +1 -0
- apyhiveapi/api/device_registration.py +339 -0
- apyhiveapi/api/hive_api.py +237 -0
- apyhiveapi/api/hive_async_api.py +239 -0
- apyhiveapi/api/hive_auth.py +626 -0
- apyhiveapi/api/hive_auth_async.py +502 -0
- apyhiveapi/api/srp_crypto.py +89 -0
- apyhiveapi/boost.py +15 -0
- apyhiveapi/color.py +15 -0
- apyhiveapi/data/data.json +4064 -0
- apyhiveapi/device_attributes.py +15 -0
- apyhiveapi/heating.py +15 -0
- apyhiveapi/helper/__init__.py +4 -0
- apyhiveapi/helper/compat_aliases.py +145 -0
- apyhiveapi/helper/const.py +325 -0
- apyhiveapi/helper/device_attributes.py +96 -0
- apyhiveapi/helper/device_handler_base.py +78 -0
- apyhiveapi/helper/hive_exceptions.py +133 -0
- apyhiveapi/helper/hive_helper.py +342 -0
- apyhiveapi/helper/hivedataclasses.py +113 -0
- apyhiveapi/helper/map.py +25 -0
- apyhiveapi/hive.py +65 -0
- apyhiveapi/hotwater.py +15 -0
- apyhiveapi/hub.py +15 -0
- apyhiveapi/light.py +15 -0
- apyhiveapi/plug.py +15 -0
- apyhiveapi/sensor.py +15 -0
- apyhiveapi/session_discovery.py +15 -0
- apyhiveapi/session_polling.py +15 -0
- apyhiveapi/session_tokens.py +15 -0
- pyhive/__init__.py +29 -0
- pyhive/action.py +15 -0
- pyhive/api/__init__.py +1 -0
- pyhive/api/device_registration.py +339 -0
- pyhive/api/hive_api.py +237 -0
- pyhive/api/hive_async_api.py +239 -0
- pyhive/api/hive_auth.py +626 -0
- pyhive/api/hive_auth_async.py +502 -0
- pyhive/api/srp_crypto.py +89 -0
- pyhive/boost.py +15 -0
- pyhive/color.py +15 -0
- pyhive/data/data.json +4064 -0
- pyhive/device_attributes.py +15 -0
- pyhive/heating.py +15 -0
- pyhive/helper/__init__.py +4 -0
- pyhive/helper/compat_aliases.py +145 -0
- pyhive/helper/const.py +325 -0
- pyhive/helper/device_attributes.py +96 -0
- pyhive/helper/device_handler_base.py +78 -0
- pyhive/helper/hive_exceptions.py +133 -0
- pyhive/helper/hive_helper.py +342 -0
- pyhive/helper/hivedataclasses.py +113 -0
- pyhive/helper/map.py +25 -0
- pyhive/hive.py +65 -0
- pyhive/hotwater.py +15 -0
- pyhive/hub.py +15 -0
- pyhive/light.py +15 -0
- pyhive/plug.py +15 -0
- pyhive/sensor.py +15 -0
- pyhive/session_discovery.py +15 -0
- pyhive/session_polling.py +15 -0
- pyhive/session_tokens.py +15 -0
- pyhive_integration-2.0.0b1.dist-info/METADATA +228 -0
- pyhive_integration-2.0.0b1.dist-info/RECORD +100 -0
- pyhive_integration-2.0.0b1.dist-info/WHEEL +5 -0
- pyhive_integration-2.0.0b1.dist-info/licenses/LICENSE +21 -0
- pyhive_integration-2.0.0b1.dist-info/top_level.txt +2 -0
- pyhiveapi/__init__.py +29 -0
- pyhiveapi/action.py +15 -0
- pyhiveapi/api/__init__.py +1 -0
- pyhiveapi/api/device_registration.py +339 -0
- pyhiveapi/api/hive_api.py +237 -0
- pyhiveapi/api/hive_async_api.py +239 -0
- pyhiveapi/api/hive_auth.py +626 -0
- pyhiveapi/api/hive_auth_async.py +502 -0
- pyhiveapi/api/srp_crypto.py +89 -0
- pyhiveapi/boost.py +15 -0
- pyhiveapi/color.py +15 -0
- pyhiveapi/device_attributes.py +15 -0
- pyhiveapi/heating.py +15 -0
- pyhiveapi/helper/__init__.py +4 -0
- pyhiveapi/helper/compat_aliases.py +145 -0
- pyhiveapi/helper/const.py +325 -0
- pyhiveapi/helper/device_attributes.py +96 -0
- pyhiveapi/helper/device_handler_base.py +78 -0
- pyhiveapi/helper/hive_exceptions.py +133 -0
- pyhiveapi/helper/hive_helper.py +342 -0
- pyhiveapi/helper/hivedataclasses.py +113 -0
- pyhiveapi/helper/map.py +25 -0
- pyhiveapi/hive.py +65 -0
- pyhiveapi/hotwater.py +15 -0
- pyhiveapi/hub.py +15 -0
- pyhiveapi/light.py +15 -0
- pyhiveapi/plug.py +15 -0
- pyhiveapi/sensor.py +15 -0
- pyhiveapi/session_discovery.py +15 -0
- pyhiveapi/session_polling.py +15 -0
- pyhiveapi/session_tokens.py +15 -0
apyhiveapi/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""__init__.py."""
|
|
2
|
+
|
|
3
|
+
# pylint: skip-file
|
|
4
|
+
# ruff: noqa
|
|
5
|
+
if __name__ == "pyhiveapi": # pragma: no cover
|
|
6
|
+
from .api.hive_api import HiveApi as API # type: ignore[assignment] # pragma: no cover
|
|
7
|
+
from .api.hive_auth import HiveAuth as Auth # type: ignore[assignment] # pragma: no cover
|
|
8
|
+
else:
|
|
9
|
+
from .api.hive_async_api import HiveApiAsync as API # type: ignore[assignment]
|
|
10
|
+
from .api.hive_auth_async import HiveAuthAsync as Auth # type: ignore[assignment]
|
|
11
|
+
|
|
12
|
+
from .helper.const import SMS_REQUIRED
|
|
13
|
+
from .helper.hive_exceptions import (
|
|
14
|
+
HiveApiError,
|
|
15
|
+
HiveAuthCredentialError,
|
|
16
|
+
HiveAuthError,
|
|
17
|
+
HiveConfigurationError,
|
|
18
|
+
HiveConnectionError,
|
|
19
|
+
HiveError,
|
|
20
|
+
HiveFailedToRefreshTokens,
|
|
21
|
+
HiveInvalid2FACode,
|
|
22
|
+
HiveInvalidDeviceAuthentication,
|
|
23
|
+
HiveInvalidPassword,
|
|
24
|
+
HiveInvalidUsername,
|
|
25
|
+
HiveReauthRequired,
|
|
26
|
+
HiveRefreshTokenExpired,
|
|
27
|
+
HiveUnknownConfiguration,
|
|
28
|
+
)
|
|
29
|
+
from .hive import Hive
|
apyhiveapi/action.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Backwards-compatible shim — use apyhiveapi.devices.action instead."""
|
|
2
|
+
|
|
3
|
+
# pylint: skip-file
|
|
4
|
+
# ruff: noqa: F401, E402
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
warnings.warn(
|
|
8
|
+
"apyhiveapi.action is deprecated; import from apyhiveapi.devices.action",
|
|
9
|
+
DeprecationWarning,
|
|
10
|
+
stacklevel=2,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .devices.action import HiveAction
|
|
14
|
+
|
|
15
|
+
__all__ = ["HiveAction"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""__init__.py file."""
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Device registration and management mixin for HiveAuthAsync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import datetime
|
|
7
|
+
import functools
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import botocore
|
|
17
|
+
|
|
18
|
+
from ..helper.hive_exceptions import HiveApiError, HiveInvalid2FACode
|
|
19
|
+
from .srp_crypto import (
|
|
20
|
+
G_HEX,
|
|
21
|
+
N_HEX,
|
|
22
|
+
calculate_u,
|
|
23
|
+
compute_hkdf,
|
|
24
|
+
get_random,
|
|
25
|
+
hash_sha256,
|
|
26
|
+
hex_hash,
|
|
27
|
+
hex_to_long,
|
|
28
|
+
long_to_hex,
|
|
29
|
+
pad_hex,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DeviceRegistrationMixin:
|
|
36
|
+
"""Device registration, confirmation, and management methods.
|
|
37
|
+
|
|
38
|
+
Expects ``self.client``, ``self.loop``, ``self._client_id``,
|
|
39
|
+
``self.access_token``, ``self.device_group_key``, ``self.device_key``,
|
|
40
|
+
``self.device_password``, ``self.k``, ``self.g_value``, ``self.big_n``,
|
|
41
|
+
``self.small_a_value``, ``self.large_a_value``, and ``self.client_secret``
|
|
42
|
+
to be set up by the owning class's ``__init__`` / ``async_init``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Attributes provided by HiveAuthAsync.__init__ / async_init
|
|
46
|
+
client: Any
|
|
47
|
+
loop: Any
|
|
48
|
+
_client_id: str | None
|
|
49
|
+
access_token: str | None
|
|
50
|
+
device_group_key: str | None
|
|
51
|
+
device_key: str | None
|
|
52
|
+
device_password: str | None
|
|
53
|
+
k: int
|
|
54
|
+
g_value: int
|
|
55
|
+
big_n: int
|
|
56
|
+
small_a_value: int
|
|
57
|
+
large_a_value: int
|
|
58
|
+
client_secret: str | None
|
|
59
|
+
|
|
60
|
+
def generate_hash_device(self, device_group_key, device_key):
|
|
61
|
+
"""Generate device hash key."""
|
|
62
|
+
# source: https://github.com/amazon-archives/amazon-cognito-identity-js/blob/6b87f1a30a998072b4d98facb49dcaf8780d15b0/src/AuthenticationHelper.js#L137 # pylint: disable=line-too-long
|
|
63
|
+
|
|
64
|
+
device_password = base64.standard_b64encode(os.urandom(40)).decode("utf-8")
|
|
65
|
+
combined_string = f"{device_group_key}{device_key}:{device_password}"
|
|
66
|
+
combined_string_hash = hash_sha256(combined_string.encode("utf-8"))
|
|
67
|
+
salt = pad_hex(get_random(16))
|
|
68
|
+
|
|
69
|
+
x_value = hex_to_long(hex_hash(salt + combined_string_hash))
|
|
70
|
+
g_value = hex_to_long(G_HEX)
|
|
71
|
+
big_n = hex_to_long(N_HEX)
|
|
72
|
+
verifier_device_not_padded = pow(g_value, x_value, big_n)
|
|
73
|
+
verifier = pad_hex(verifier_device_not_padded)
|
|
74
|
+
|
|
75
|
+
device_secret_verifier_config = {
|
|
76
|
+
"PasswordVerifier": base64.standard_b64encode(
|
|
77
|
+
bytearray.fromhex(verifier)
|
|
78
|
+
).decode("utf-8"),
|
|
79
|
+
"Salt": base64.standard_b64encode(bytearray.fromhex(salt)).decode("utf-8"),
|
|
80
|
+
}
|
|
81
|
+
self.device_password = device_password
|
|
82
|
+
return device_secret_verifier_config
|
|
83
|
+
|
|
84
|
+
def get_device_authentication_key( # pylint: disable=too-many-positional-arguments
|
|
85
|
+
self, device_group_key, device_key, device_password, server_b_value, salt
|
|
86
|
+
):
|
|
87
|
+
"""Get device authentication key."""
|
|
88
|
+
u_value = calculate_u(self.large_a_value, server_b_value)
|
|
89
|
+
if u_value == 0:
|
|
90
|
+
raise ValueError("U cannot be zero.")
|
|
91
|
+
username_password = f"{device_group_key}{device_key}:{device_password}"
|
|
92
|
+
username_password_hash = hash_sha256(username_password.encode("utf-8"))
|
|
93
|
+
|
|
94
|
+
x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash))
|
|
95
|
+
g_mod_pow_xn = pow(self.g_value, x_value, self.big_n)
|
|
96
|
+
int_value2 = (server_b_value - self.k * g_mod_pow_xn) % self.big_n
|
|
97
|
+
exp = self.small_a_value + u_value * x_value
|
|
98
|
+
s_value = pow(int_value2, exp, self.big_n)
|
|
99
|
+
hkdf = compute_hkdf(
|
|
100
|
+
bytearray.fromhex(pad_hex(s_value)),
|
|
101
|
+
bytearray.fromhex(pad_hex(long_to_hex(u_value))),
|
|
102
|
+
)
|
|
103
|
+
return hkdf
|
|
104
|
+
|
|
105
|
+
async def process_device_challenge(self, challenge_parameters):
|
|
106
|
+
"""Process device challenge."""
|
|
107
|
+
username = challenge_parameters["USERNAME"]
|
|
108
|
+
salt_hex = (
|
|
109
|
+
challenge_parameters["SALT"]
|
|
110
|
+
if isinstance(challenge_parameters["SALT"], str)
|
|
111
|
+
else pad_hex(challenge_parameters["SALT"])
|
|
112
|
+
)
|
|
113
|
+
srp_b_hex = challenge_parameters["SRP_B"]
|
|
114
|
+
secret_block_b64 = challenge_parameters["SECRET_BLOCK"]
|
|
115
|
+
# re strips leading zero from a day number (required by AWS Cognito)
|
|
116
|
+
timestamp = re.sub(
|
|
117
|
+
r" 0(\d) ",
|
|
118
|
+
r" \1 ",
|
|
119
|
+
datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
120
|
+
"%a %b %d %H:%M:%S UTC %Y"
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
hkdf = self.get_device_authentication_key(
|
|
124
|
+
self.device_group_key,
|
|
125
|
+
self.device_key,
|
|
126
|
+
self.device_password,
|
|
127
|
+
hex_to_long(srp_b_hex),
|
|
128
|
+
salt_hex,
|
|
129
|
+
)
|
|
130
|
+
secret_block_bytes = base64.standard_b64decode(secret_block_b64)
|
|
131
|
+
msg = (
|
|
132
|
+
bytearray(self.device_group_key, "utf-8")
|
|
133
|
+
+ bytearray(self.device_key, "utf-8")
|
|
134
|
+
+ bytearray(secret_block_bytes)
|
|
135
|
+
+ bytearray(timestamp, "utf-8")
|
|
136
|
+
)
|
|
137
|
+
hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256)
|
|
138
|
+
signature_string = base64.standard_b64encode(hmac_obj.digest())
|
|
139
|
+
response = {
|
|
140
|
+
"TIMESTAMP": timestamp,
|
|
141
|
+
"USERNAME": username,
|
|
142
|
+
"PASSWORD_CLAIM_SECRET_BLOCK": secret_block_b64,
|
|
143
|
+
"PASSWORD_CLAIM_SIGNATURE": signature_string.decode("utf-8"),
|
|
144
|
+
"DEVICE_KEY": self.device_key,
|
|
145
|
+
}
|
|
146
|
+
if self.client_secret is not None:
|
|
147
|
+
response.update(
|
|
148
|
+
{
|
|
149
|
+
"SECRET_HASH": self.get_secret_hash(
|
|
150
|
+
username, self._client_id, self.client_secret
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return response
|
|
155
|
+
|
|
156
|
+
async def device_registration(self, device_name: str | None = None):
|
|
157
|
+
"""Register device with Hive."""
|
|
158
|
+
_LOGGER.debug("device_registration - Registering device with Hive.")
|
|
159
|
+
await self.confirm_device(device_name)
|
|
160
|
+
await self.update_device_status()
|
|
161
|
+
|
|
162
|
+
async def confirm_device(self, device_name: str | None = None):
|
|
163
|
+
"""Confirm Hive Device."""
|
|
164
|
+
if self.client is None:
|
|
165
|
+
await self.async_init() # type: ignore[attr-defined]
|
|
166
|
+
|
|
167
|
+
if device_name is None:
|
|
168
|
+
device_name = socket.gethostname()
|
|
169
|
+
|
|
170
|
+
result = None
|
|
171
|
+
try:
|
|
172
|
+
device_secret_verifier_config = self.generate_hash_device(
|
|
173
|
+
self.device_group_key, self.device_key
|
|
174
|
+
)
|
|
175
|
+
result = await self.loop.run_in_executor(
|
|
176
|
+
None,
|
|
177
|
+
functools.partial(
|
|
178
|
+
self.client.confirm_device,
|
|
179
|
+
AccessToken=self.access_token,
|
|
180
|
+
DeviceKey=self.device_key,
|
|
181
|
+
DeviceName=device_name,
|
|
182
|
+
DeviceSecretVerifierConfig=device_secret_verifier_config,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
except botocore.exceptions.ClientError as err:
|
|
186
|
+
code = (err.response or {}).get("Error", {}).get("Code", "")
|
|
187
|
+
if code == "CodeMismatchException":
|
|
188
|
+
raise HiveInvalid2FACode from err
|
|
189
|
+
raise HiveApiError from err
|
|
190
|
+
except botocore.exceptions.EndpointConnectionError as err:
|
|
191
|
+
raise HiveApiError from err
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
async def update_device_status(self):
|
|
196
|
+
"""Update Device Hive."""
|
|
197
|
+
if self.client is None:
|
|
198
|
+
await self.async_init() # type: ignore[attr-defined]
|
|
199
|
+
result = None
|
|
200
|
+
try:
|
|
201
|
+
result = await self.loop.run_in_executor(
|
|
202
|
+
None,
|
|
203
|
+
functools.partial(
|
|
204
|
+
self.client.update_device_status,
|
|
205
|
+
AccessToken=self.access_token,
|
|
206
|
+
DeviceKey=self.device_key,
|
|
207
|
+
DeviceRememberedStatus="remembered",
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
except botocore.exceptions.EndpointConnectionError as err:
|
|
211
|
+
raise HiveApiError from err
|
|
212
|
+
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
async def get_device_data(self):
|
|
216
|
+
"""Get key device information for device authentication.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
tuple: (device_group_key, device_key, device_password, token_created)
|
|
220
|
+
token_created is a datetime marking when the current tokens were issued.
|
|
221
|
+
Pass all four values as ``device_data`` in ``start_session`` config so the
|
|
222
|
+
session can compute token expiry from the real issue time rather than epoch.
|
|
223
|
+
"""
|
|
224
|
+
return (
|
|
225
|
+
self.device_group_key,
|
|
226
|
+
self.device_key,
|
|
227
|
+
self.device_password,
|
|
228
|
+
self.token_created,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def is_device_registered(self, access_token=None, device_key=None):
|
|
232
|
+
"""Check if the current device is registered with Cognito.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
access_token (str, optional): Access token. Defaults to self.access_token.
|
|
236
|
+
device_key (str, optional): Device key. Defaults to self.device_key.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
bool: True if device is registered and remembered, False otherwise.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
HiveApiError: If unable to reach Cognito endpoint.
|
|
243
|
+
"""
|
|
244
|
+
if self.client is None:
|
|
245
|
+
await self.async_init() # type: ignore[attr-defined]
|
|
246
|
+
|
|
247
|
+
token = access_token or self.access_token
|
|
248
|
+
key = device_key or self.device_key
|
|
249
|
+
|
|
250
|
+
if not token or not key:
|
|
251
|
+
_LOGGER.debug(
|
|
252
|
+
"is_device_registered - Missing access token or device key, "
|
|
253
|
+
"device not registered"
|
|
254
|
+
)
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
_LOGGER.debug(
|
|
258
|
+
"is_device_registered - Checking device registration status for device: %s",
|
|
259
|
+
key,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
result = await self.loop.run_in_executor(
|
|
264
|
+
None,
|
|
265
|
+
functools.partial(
|
|
266
|
+
self.client.get_device,
|
|
267
|
+
AccessToken=token,
|
|
268
|
+
DeviceKey=key,
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if result and "Device" in result:
|
|
273
|
+
device_status = result["Device"].get("DeviceAttributes", [])
|
|
274
|
+
for attr in device_status:
|
|
275
|
+
if (
|
|
276
|
+
attr.get("Name") == "dev:device_remembered_status"
|
|
277
|
+
and attr.get("Value") == "remembered"
|
|
278
|
+
):
|
|
279
|
+
_LOGGER.debug(
|
|
280
|
+
"is_device_registered - Device %s is registered and remembered",
|
|
281
|
+
key,
|
|
282
|
+
)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
_LOGGER.debug(
|
|
286
|
+
"is_device_registered - Device %s is registered but not remembered",
|
|
287
|
+
key,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
except botocore.exceptions.ClientError as err:
|
|
291
|
+
error = (err.response or {}).get("Error", {})
|
|
292
|
+
error_code = error.get("Code")
|
|
293
|
+
error_message = error.get("Message", "")
|
|
294
|
+
|
|
295
|
+
if error_code == "ResourceNotFoundException":
|
|
296
|
+
_LOGGER.debug(
|
|
297
|
+
"is_device_registered - Device %s not found in Cognito", key
|
|
298
|
+
)
|
|
299
|
+
elif error_code == "NotAuthorizedException":
|
|
300
|
+
_LOGGER.warning(
|
|
301
|
+
"is_device_registered - Not authorized to check device status: %s",
|
|
302
|
+
error_message,
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
_LOGGER.error(
|
|
306
|
+
"is_device_registered - Error checking device status: %s - %s",
|
|
307
|
+
error_code,
|
|
308
|
+
error_message,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
except botocore.exceptions.EndpointConnectionError as err:
|
|
312
|
+
_LOGGER.error(
|
|
313
|
+
"is_device_registered - Cannot reach Cognito endpoint: %s", str(err)
|
|
314
|
+
)
|
|
315
|
+
raise HiveApiError from err
|
|
316
|
+
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
async def forget_device(self, access_token, device_key):
|
|
320
|
+
"""Forget device registered with Hive."""
|
|
321
|
+
if self.client is None:
|
|
322
|
+
await self.async_init() # type: ignore[attr-defined]
|
|
323
|
+
result = None
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
result = await self.loop.run_in_executor(
|
|
327
|
+
None,
|
|
328
|
+
functools.partial(
|
|
329
|
+
self.client.forget_device,
|
|
330
|
+
AccessToken=access_token,
|
|
331
|
+
DeviceKey=device_key,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
except botocore.exceptions.ClientError as err:
|
|
335
|
+
raise HiveApiError from err
|
|
336
|
+
except botocore.exceptions.EndpointConnectionError as err:
|
|
337
|
+
raise HiveApiError from err
|
|
338
|
+
|
|
339
|
+
return result
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Hive API Module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from pyquery import PyQuery
|
|
9
|
+
|
|
10
|
+
from ..helper.hive_exceptions import HiveConnectionError
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_NO_RESPONSE = "No response to Hive API request"
|
|
15
|
+
_ERROR_RESPONSE = "Error making API call"
|
|
16
|
+
|
|
17
|
+
# requests exceptions all subclass OSError; response.json() raises a
|
|
18
|
+
# json.JSONDecodeError subclass.
|
|
19
|
+
_REQUEST_ERRORS = (OSError, RuntimeError, json.JSONDecodeError)
|
|
20
|
+
|
|
21
|
+
_SSO_ASSIGNMENT = re.compile(r'window\.(\w+)\s*=\s*"([^"]*)"')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HiveApi:
|
|
25
|
+
"""Hive API Code."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, hive_session=None, token=None):
|
|
28
|
+
"""Hive API initialisation."""
|
|
29
|
+
self.urls = {
|
|
30
|
+
"properties": "https://sso.hivehome.com/",
|
|
31
|
+
"login": "https://beekeeper.hivehome.com/1.0/cognito/login",
|
|
32
|
+
"refresh": "https://beekeeper.hivehome.com/1.0/cognito/refresh-token",
|
|
33
|
+
"long_lived": "https://api.prod.bgchprod.info/omnia/accessTokens",
|
|
34
|
+
"base": "https://beekeeper-uk.hivehome.com/1.0",
|
|
35
|
+
"weather": "https://weather.prod.bgchprod.info/weather",
|
|
36
|
+
"holiday_mode": "/holiday-mode",
|
|
37
|
+
"all": "/nodes/all?products=true&devices=true&actions=true",
|
|
38
|
+
"devices": "/devices",
|
|
39
|
+
"products": "/products",
|
|
40
|
+
"actions": "/actions",
|
|
41
|
+
"nodes": "/nodes/{0}/{1}",
|
|
42
|
+
}
|
|
43
|
+
self.timeout = 5
|
|
44
|
+
self.json_return = {
|
|
45
|
+
"original": _NO_RESPONSE,
|
|
46
|
+
"parsed": _NO_RESPONSE,
|
|
47
|
+
}
|
|
48
|
+
self.session = hive_session
|
|
49
|
+
self.token = token
|
|
50
|
+
self.headers = {
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
"Accept": "*/*",
|
|
53
|
+
"authorization": "",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def request(self, http_method, url, jsc=None):
|
|
57
|
+
"""Make API request."""
|
|
58
|
+
_LOGGER.debug("request - Making %s request to: %s", http_method, url)
|
|
59
|
+
if jsc:
|
|
60
|
+
_LOGGER.debug("request - Request payload: %s", jsc)
|
|
61
|
+
|
|
62
|
+
if self.session is not None:
|
|
63
|
+
self.headers["authorization"] = self.session.tokens.token_data["token"]
|
|
64
|
+
else:
|
|
65
|
+
self.headers["authorization"] = self.token
|
|
66
|
+
|
|
67
|
+
_LOGGER.debug(
|
|
68
|
+
"request - Request headers: %s",
|
|
69
|
+
{k: v for k, v in self.headers.items() if k.lower() != "authorization"},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
if http_method == "GET":
|
|
74
|
+
return requests.get(
|
|
75
|
+
url=url, headers=self.headers, data=jsc, timeout=self.timeout
|
|
76
|
+
)
|
|
77
|
+
if http_method == "POST":
|
|
78
|
+
return requests.post(
|
|
79
|
+
url=url, headers=self.headers, data=jsc, timeout=self.timeout
|
|
80
|
+
)
|
|
81
|
+
if http_method == "DELETE":
|
|
82
|
+
return requests.delete(
|
|
83
|
+
url=url, headers=self.headers, data=jsc, timeout=self.timeout
|
|
84
|
+
)
|
|
85
|
+
raise ValueError(f"Unsupported request type: {http_method}")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
_LOGGER.error("Request failed: %s", e)
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
def _call_endpoint(self, http_method, url, jsc=None):
|
|
91
|
+
"""Call an endpoint and return a fresh result dict for this call."""
|
|
92
|
+
json_return = {
|
|
93
|
+
"original": _NO_RESPONSE,
|
|
94
|
+
"parsed": _NO_RESPONSE,
|
|
95
|
+
}
|
|
96
|
+
try:
|
|
97
|
+
response = self.request(http_method, url, jsc)
|
|
98
|
+
if response is not None:
|
|
99
|
+
json_return["original"] = response.status_code
|
|
100
|
+
json_return["parsed"] = response.json()
|
|
101
|
+
else:
|
|
102
|
+
_LOGGER.error("No response from Hive API call to %s", url)
|
|
103
|
+
except _REQUEST_ERRORS as e:
|
|
104
|
+
_LOGGER.error("Hive API call to %s failed: %s", url, e)
|
|
105
|
+
json_return = self.error()
|
|
106
|
+
|
|
107
|
+
return json_return
|
|
108
|
+
|
|
109
|
+
def get_login_info(self):
|
|
110
|
+
"""Get login properties to make the login request."""
|
|
111
|
+
_LOGGER.debug(
|
|
112
|
+
"get_login_info - Fetching login info from: %s", self.urls["properties"]
|
|
113
|
+
)
|
|
114
|
+
url = self.urls["properties"]
|
|
115
|
+
try:
|
|
116
|
+
data = requests.get(url=url, timeout=self.timeout)
|
|
117
|
+
_LOGGER.debug(
|
|
118
|
+
"get_login_info - Login info response status: %s", data.status_code
|
|
119
|
+
)
|
|
120
|
+
script_text = PyQuery(data.content)("script:first").text()
|
|
121
|
+
sso_values = dict(_SSO_ASSIGNMENT.findall(script_text))
|
|
122
|
+
|
|
123
|
+
login_data = {
|
|
124
|
+
"UPID": sso_values["HiveSSOPoolId"],
|
|
125
|
+
"CLIID": sso_values["HiveSSOPublicCognitoClientId"],
|
|
126
|
+
"REGION": sso_values["HiveSSOPoolId"],
|
|
127
|
+
}
|
|
128
|
+
_LOGGER.debug("get_login_info - Login info extracted successfully")
|
|
129
|
+
return login_data
|
|
130
|
+
except OSError as e:
|
|
131
|
+
# requests' timeouts and connection errors are OSError subclasses.
|
|
132
|
+
_LOGGER.error("Failed to get login info: %s", str(e))
|
|
133
|
+
self.error()
|
|
134
|
+
raise HiveConnectionError("Could not reach the Hive login page") from e
|
|
135
|
+
except (RuntimeError, KeyError) as e:
|
|
136
|
+
_LOGGER.error("Failed to get login info: %s", str(e))
|
|
137
|
+
self.error()
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def get_all(self):
|
|
141
|
+
"""Build and query all endpoint."""
|
|
142
|
+
_LOGGER.debug("get_all - Fetching all devices/products/actions from Hive API")
|
|
143
|
+
url = self.urls["base"] + self.urls["all"]
|
|
144
|
+
return self._call_endpoint("GET", url)
|
|
145
|
+
|
|
146
|
+
def get_devices(self):
|
|
147
|
+
"""Call the get devices endpoint."""
|
|
148
|
+
url = self.urls["base"] + self.urls["devices"]
|
|
149
|
+
return self._call_endpoint("GET", url)
|
|
150
|
+
|
|
151
|
+
def get_products(self):
|
|
152
|
+
"""Call the get products endpoint."""
|
|
153
|
+
url = self.urls["base"] + self.urls["products"]
|
|
154
|
+
return self._call_endpoint("GET", url)
|
|
155
|
+
|
|
156
|
+
def get_actions(self):
|
|
157
|
+
"""Call the get actions endpoint."""
|
|
158
|
+
url = self.urls["base"] + self.urls["actions"]
|
|
159
|
+
return self._call_endpoint("GET", url)
|
|
160
|
+
|
|
161
|
+
def motion_sensor(self, sensor, fromepoch, toepoch):
|
|
162
|
+
"""Call a way to get motion sensor info."""
|
|
163
|
+
url = (
|
|
164
|
+
self.urls["base"]
|
|
165
|
+
+ self.urls["products"]
|
|
166
|
+
+ "/"
|
|
167
|
+
+ sensor["type"]
|
|
168
|
+
+ "/"
|
|
169
|
+
+ sensor["id"]
|
|
170
|
+
+ "/events?from="
|
|
171
|
+
+ str(fromepoch)
|
|
172
|
+
+ "&to="
|
|
173
|
+
+ str(toepoch)
|
|
174
|
+
)
|
|
175
|
+
return self._call_endpoint("GET", url)
|
|
176
|
+
|
|
177
|
+
def get_weather(self, weather_url):
|
|
178
|
+
"""Call endpoint to get local weather from Hive API."""
|
|
179
|
+
t_url = self.urls["weather"] + weather_url
|
|
180
|
+
url = t_url.replace(" ", "%20")
|
|
181
|
+
return self._call_endpoint("GET", url)
|
|
182
|
+
|
|
183
|
+
def set_state(self, n_type, n_id, **kwargs):
|
|
184
|
+
"""Set the state of a Device."""
|
|
185
|
+
_LOGGER.debug(
|
|
186
|
+
"set_state - Setting state for device %s (type: %s): %s",
|
|
187
|
+
n_id,
|
|
188
|
+
n_type,
|
|
189
|
+
kwargs,
|
|
190
|
+
)
|
|
191
|
+
jsc = json.dumps(kwargs)
|
|
192
|
+
url = self.urls["base"] + self.urls["nodes"].format(n_type, n_id)
|
|
193
|
+
return self._call_endpoint("POST", url, jsc)
|
|
194
|
+
|
|
195
|
+
def set_action(self, n_id, data):
|
|
196
|
+
"""Set the state of a Action."""
|
|
197
|
+
jsc = data
|
|
198
|
+
url = self.urls["base"] + self.urls["actions"] + "/" + n_id
|
|
199
|
+
return self._call_endpoint("POST", url, jsc)
|
|
200
|
+
|
|
201
|
+
def get_holiday_mode(self):
|
|
202
|
+
"""Get the current holiday mode configuration."""
|
|
203
|
+
url = self.urls["base"] + self.urls["holiday_mode"]
|
|
204
|
+
return self._call_endpoint("GET", url)
|
|
205
|
+
|
|
206
|
+
def set_holiday_mode(self, start, end, temperature):
|
|
207
|
+
"""Schedule holiday mode.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
start: Start time as epoch milliseconds.
|
|
211
|
+
end: End time as epoch milliseconds.
|
|
212
|
+
temperature: Frost-protection temperature to hold during holiday mode.
|
|
213
|
+
"""
|
|
214
|
+
jsc = json.dumps({"start": start, "end": end, "temperature": temperature})
|
|
215
|
+
url = self.urls["base"] + self.urls["holiday_mode"]
|
|
216
|
+
return self._call_endpoint("POST", url, jsc)
|
|
217
|
+
|
|
218
|
+
def cancel_holiday_mode(self):
|
|
219
|
+
"""Cancel any scheduled or active holiday mode."""
|
|
220
|
+
url = self.urls["base"] + self.urls["holiday_mode"]
|
|
221
|
+
return self._call_endpoint("DELETE", url, json.dumps({}))
|
|
222
|
+
|
|
223
|
+
def error(self):
|
|
224
|
+
"""An error has occurred interacting with the Hive API."""
|
|
225
|
+
_LOGGER.error("API error occurred - returning error response")
|
|
226
|
+
error_return = {
|
|
227
|
+
"original": _ERROR_RESPONSE,
|
|
228
|
+
"parsed": _ERROR_RESPONSE,
|
|
229
|
+
}
|
|
230
|
+
# Kept in sync for backwards compatibility with callers that read
|
|
231
|
+
# the last error state off the instance.
|
|
232
|
+
self.json_return.update(error_return)
|
|
233
|
+
return error_return
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class UnknownConfig(Exception):
|
|
237
|
+
"""Unknown API config."""
|