python-hilo 2024.2.2__tar.gz → 2024.4.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.
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/PKG-INFO +1 -1
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/api.py +64 -194
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/const.py +14 -16
- python_hilo-2024.4.1/pyhilo/oauth2.py +77 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyproject.toml +2 -2
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/LICENSE +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/README.md +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/__init__.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/device/__init__.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/device/climate.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/device/light.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/device/sensor.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/device/switch.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/devices.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/event.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/exceptions.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/util/__init__.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/util/state.py +0 -0
- {python_hilo-2024.2.2 → python_hilo-2024.4.1}/pyhilo/websocket.py +0 -0
|
@@ -6,12 +6,13 @@ import json
|
|
|
6
6
|
import random
|
|
7
7
|
import string
|
|
8
8
|
import sys
|
|
9
|
-
from typing import
|
|
9
|
+
from typing import Any, Callable, Union, cast
|
|
10
10
|
from urllib import parse
|
|
11
11
|
|
|
12
12
|
from aiohttp import ClientSession
|
|
13
13
|
from aiohttp.client_exceptions import ClientResponseError
|
|
14
14
|
import backoff
|
|
15
|
+
from homeassistant.helpers import config_entry_oauth2_flow
|
|
15
16
|
|
|
16
17
|
from pyhilo.const import (
|
|
17
18
|
ANDROID_CLIENT_ENDPOINT,
|
|
@@ -26,16 +27,7 @@ from pyhilo.const import (
|
|
|
26
27
|
API_NOTIFICATIONS_ENDPOINT,
|
|
27
28
|
API_REGISTRATION_ENDPOINT,
|
|
28
29
|
API_REGISTRATION_HEADERS,
|
|
29
|
-
AUTH_CLIENT_ID,
|
|
30
|
-
AUTH_ENDPOINT,
|
|
31
|
-
AUTH_HOSTNAME,
|
|
32
|
-
AUTH_RESPONSE_TYPE,
|
|
33
|
-
AUTH_SCOPE,
|
|
34
|
-
AUTH_TYPE_PASSWORD,
|
|
35
|
-
AUTH_TYPE_REFRESH,
|
|
36
30
|
AUTOMATION_DEVICEHUB_ENDPOINT,
|
|
37
|
-
AUTOMATION_HOSTNAME,
|
|
38
|
-
CONTENT_TYPE_FORM,
|
|
39
31
|
DEFAULT_STATE_FILE,
|
|
40
32
|
DEFAULT_USER_AGENT,
|
|
41
33
|
FB_APP_ID,
|
|
@@ -52,9 +44,7 @@ from pyhilo.const import (
|
|
|
52
44
|
)
|
|
53
45
|
from pyhilo.device import DeviceAttribute, HiloDevice, get_device_attributes
|
|
54
46
|
from pyhilo.exceptions import InvalidCredentialsError, RequestError
|
|
55
|
-
from pyhilo.util import schedule_callback
|
|
56
47
|
from pyhilo.util.state import (
|
|
57
|
-
TokenDict,
|
|
58
48
|
WebsocketDict,
|
|
59
49
|
WebsocketTransportsDict,
|
|
60
50
|
get_state,
|
|
@@ -76,104 +66,73 @@ class API:
|
|
|
76
66
|
self,
|
|
77
67
|
*,
|
|
78
68
|
session: ClientSession,
|
|
69
|
+
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
|
79
70
|
request_retries: int = REQUEST_RETRY,
|
|
71
|
+
log_traces: bool = False,
|
|
80
72
|
) -> None:
|
|
81
73
|
"""Initialize"""
|
|
82
|
-
self._access_token: str | None = None
|
|
83
74
|
self._backoff_refresh_lock_api = asyncio.Lock()
|
|
84
75
|
self._backoff_refresh_lock_ws = asyncio.Lock()
|
|
85
|
-
self._reg_id: str | None = None
|
|
86
76
|
self._request_retries = request_retries
|
|
87
77
|
self._state_yaml: str = DEFAULT_STATE_FILE
|
|
88
|
-
self.
|
|
78
|
+
self.state = get_state(self._state_yaml)
|
|
89
79
|
self.async_request = self._wrap_request_method(self._request_retries)
|
|
90
80
|
self.device_attributes = get_device_attributes()
|
|
91
81
|
self.session: ClientSession = session
|
|
82
|
+
self._oauth_session = oauth_session
|
|
92
83
|
self.websocket: WebsocketClient
|
|
93
|
-
self.
|
|
94
|
-
self._refresh_token_callbacks: list[Callable[..., Any]] = []
|
|
95
|
-
self.log_traces: bool = False
|
|
84
|
+
self.log_traces = log_traces
|
|
96
85
|
self._get_device_callbacks: list[Callable[..., Any]] = []
|
|
97
86
|
|
|
98
|
-
@property
|
|
99
|
-
def headers(self) -> dict[str, Any]:
|
|
100
|
-
headers = {
|
|
101
|
-
"User-Agent": DEFAULT_USER_AGENT,
|
|
102
|
-
}
|
|
103
|
-
if not self._access_token:
|
|
104
|
-
return headers
|
|
105
|
-
return {
|
|
106
|
-
**headers,
|
|
107
|
-
**{
|
|
108
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
109
|
-
"Ocp-Apim-Subscription-Key": SUBSCRIPTION_KEY,
|
|
110
|
-
"authorization": f"Bearer {self._access_token}",
|
|
111
|
-
},
|
|
112
|
-
}
|
|
113
|
-
|
|
114
87
|
@classmethod
|
|
115
|
-
async def
|
|
88
|
+
async def async_create(
|
|
116
89
|
cls,
|
|
117
90
|
*,
|
|
118
91
|
session: ClientSession,
|
|
119
|
-
|
|
92
|
+
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
|
120
93
|
request_retries: int = REQUEST_RETRY,
|
|
121
|
-
state_yaml: str = DEFAULT_STATE_FILE,
|
|
122
94
|
log_traces: bool = False,
|
|
123
95
|
) -> API:
|
|
124
|
-
|
|
125
|
-
api.log_traces = log_traces
|
|
126
|
-
api._state_yaml = state_yaml
|
|
127
|
-
api.state = get_state(state_yaml)
|
|
128
|
-
if provided_refresh_token:
|
|
129
|
-
api._refresh_token = provided_refresh_token
|
|
130
|
-
else:
|
|
131
|
-
token_state = api.state.get("token", {})
|
|
132
|
-
api._refresh_token = token_state.get("refresh")
|
|
133
|
-
if not api._refresh_token:
|
|
134
|
-
raise InvalidCredentialsError
|
|
135
|
-
|
|
136
|
-
await api._async_refresh_access_token()
|
|
137
|
-
await api._async_post_init()
|
|
138
|
-
return api
|
|
139
|
-
|
|
140
|
-
@classmethod
|
|
141
|
-
async def async_auth_password(
|
|
142
|
-
cls,
|
|
143
|
-
username: str,
|
|
144
|
-
password: str,
|
|
145
|
-
*,
|
|
146
|
-
session: ClientSession,
|
|
147
|
-
request_retries: int = REQUEST_RETRY,
|
|
148
|
-
state_yaml: str = DEFAULT_STATE_FILE,
|
|
149
|
-
log_traces: bool = False,
|
|
150
|
-
) -> API:
|
|
151
|
-
"""Get an authenticated API object from a username and password.
|
|
152
|
-
:param username: the username
|
|
153
|
-
:type username: ``str``
|
|
154
|
-
:param password: the password
|
|
155
|
-
:type the password: ``str``
|
|
96
|
+
"""Get an authenticated API object.
|
|
156
97
|
:param session: The ``aiohttp`` ``ClientSession`` session used for all HTTP requests
|
|
157
98
|
:type session: ``aiohttp.client.ClientSession``
|
|
99
|
+
:param oauth_session: The session to make requests authenticated with OAuth2.
|
|
100
|
+
:type oauth_session: ``config_entry_oauth2_flow.OAuth2Session``
|
|
158
101
|
:param request_retries: The default number of request retries to use
|
|
159
102
|
:type request_retries: ``int``
|
|
160
|
-
:param state_yaml: File where we store registration ID
|
|
161
|
-
:type state_yaml: ``str``
|
|
162
103
|
:rtype: :meth:`pyhilo.api.API`
|
|
163
104
|
"""
|
|
164
|
-
api = cls(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
password = parse.quote(password, safe="!@#$%^?&*()_+")
|
|
170
|
-
auth_body = api.auth_body(
|
|
171
|
-
AUTH_TYPE_PASSWORD, username=username, password=password
|
|
105
|
+
api = cls(
|
|
106
|
+
session=session,
|
|
107
|
+
oauth_session=oauth_session,
|
|
108
|
+
request_retries=request_retries,
|
|
109
|
+
log_traces=log_traces,
|
|
172
110
|
)
|
|
173
|
-
|
|
111
|
+
# Test token before post init
|
|
112
|
+
await api.async_get_access_token()
|
|
174
113
|
await api._async_post_init()
|
|
175
114
|
return api
|
|
176
115
|
|
|
116
|
+
@property
|
|
117
|
+
def headers(self) -> dict[str, Any]:
|
|
118
|
+
headers = {
|
|
119
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
**headers,
|
|
123
|
+
**{
|
|
124
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
125
|
+
"Ocp-Apim-Subscription-Key": SUBSCRIPTION_KEY,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async def async_get_access_token(self) -> str:
|
|
130
|
+
"""Return a valid access token."""
|
|
131
|
+
if not self._oauth_session.valid_token:
|
|
132
|
+
await self._oauth_session.async_ensure_token_valid()
|
|
133
|
+
|
|
134
|
+
return str(self._oauth_session.token["access_token"])
|
|
135
|
+
|
|
177
136
|
def dev_atts(
|
|
178
137
|
self, attribute: str, value_type: Union[str, None] = None
|
|
179
138
|
) -> Union[DeviceAttribute, str]:
|
|
@@ -241,96 +200,6 @@ class API:
|
|
|
241
200
|
await self.fb_install(self._fb_id)
|
|
242
201
|
self._get_fid_state()
|
|
243
202
|
|
|
244
|
-
async def _async_refresh_access_token(self) -> None:
|
|
245
|
-
"""Update access/refresh tokens from a refresh token
|
|
246
|
-
and schedule a callback for later to refresh it.
|
|
247
|
-
"""
|
|
248
|
-
auth_body = self.auth_body(
|
|
249
|
-
AUTH_TYPE_REFRESH,
|
|
250
|
-
refresh_token=self._refresh_token,
|
|
251
|
-
)
|
|
252
|
-
await self.async_auth_post(auth_body)
|
|
253
|
-
for callback in self._refresh_token_callbacks:
|
|
254
|
-
schedule_callback(callback, self._refresh_token)
|
|
255
|
-
|
|
256
|
-
async def async_auth_post(self, body: dict) -> None:
|
|
257
|
-
"""Prepares an authentication request for the Web API.
|
|
258
|
-
|
|
259
|
-
:param body: Contains the parameters passed to get tokens
|
|
260
|
-
:type body: dict
|
|
261
|
-
:raises InvalidCredentialsError: Invalid username/password
|
|
262
|
-
:raises RequestError: Other error
|
|
263
|
-
"""
|
|
264
|
-
try:
|
|
265
|
-
LOG.debug("Authentication intiated")
|
|
266
|
-
resp = await self._async_request(
|
|
267
|
-
"post",
|
|
268
|
-
AUTH_ENDPOINT,
|
|
269
|
-
host=AUTH_HOSTNAME,
|
|
270
|
-
headers={
|
|
271
|
-
"Content-Type": CONTENT_TYPE_FORM,
|
|
272
|
-
},
|
|
273
|
-
data=body,
|
|
274
|
-
)
|
|
275
|
-
except ClientResponseError as err:
|
|
276
|
-
LOG.error(f"ClientResponseError: {err}")
|
|
277
|
-
if err.status in (400, 401, 403):
|
|
278
|
-
LOG.error(f"Raising InvalidCredentialsError from {err}")
|
|
279
|
-
raise InvalidCredentialsError("Invalid credentials") from err
|
|
280
|
-
raise RequestError(err) from err
|
|
281
|
-
self._access_token = resp.get("access_token")
|
|
282
|
-
self._access_token_expire_dt = datetime.now() + timedelta(
|
|
283
|
-
seconds=int(str(resp.get("expires_in")))
|
|
284
|
-
)
|
|
285
|
-
self._refresh_token = resp.get(AUTH_TYPE_REFRESH, "")
|
|
286
|
-
token_dict: TokenDict = {
|
|
287
|
-
"access": self._access_token,
|
|
288
|
-
"refresh": self._refresh_token,
|
|
289
|
-
"expires_at": self._access_token_expire_dt,
|
|
290
|
-
}
|
|
291
|
-
set_state(self._state_yaml, "token", token_dict)
|
|
292
|
-
|
|
293
|
-
def auth_body(
|
|
294
|
-
self,
|
|
295
|
-
grant_type: str,
|
|
296
|
-
*,
|
|
297
|
-
username: str = "",
|
|
298
|
-
password: str = "",
|
|
299
|
-
refresh_token: str = "",
|
|
300
|
-
) -> dict[Any, Any]:
|
|
301
|
-
"""Generates a dict to pass to the authentication endpoint for
|
|
302
|
-
the Web API.
|
|
303
|
-
|
|
304
|
-
:param grant_type: either password or refresh_token
|
|
305
|
-
:type grant_type: str
|
|
306
|
-
:param username: defaults to ""
|
|
307
|
-
:type username: str, optional
|
|
308
|
-
:param password: defaults to ""
|
|
309
|
-
:type password: str, optional
|
|
310
|
-
:param refresh_token: Refresh token received from a previous password auth, defaults to ""
|
|
311
|
-
:type refresh_token: str, optional
|
|
312
|
-
:return: Dict structured for authentication
|
|
313
|
-
:rtype: dict[Any, Any]
|
|
314
|
-
"""
|
|
315
|
-
LOG.debug(f"Auth body for grant {grant_type}")
|
|
316
|
-
body = {
|
|
317
|
-
"grant_type": grant_type,
|
|
318
|
-
"client_id": AUTH_CLIENT_ID,
|
|
319
|
-
"scope": AUTH_SCOPE,
|
|
320
|
-
}
|
|
321
|
-
if grant_type == AUTH_TYPE_PASSWORD:
|
|
322
|
-
body = {
|
|
323
|
-
**body,
|
|
324
|
-
**{
|
|
325
|
-
"response_type": AUTH_RESPONSE_TYPE,
|
|
326
|
-
"username": username,
|
|
327
|
-
"password": password,
|
|
328
|
-
},
|
|
329
|
-
}
|
|
330
|
-
elif grant_type == AUTH_TYPE_REFRESH:
|
|
331
|
-
body[AUTH_TYPE_REFRESH] = refresh_token
|
|
332
|
-
return body
|
|
333
|
-
|
|
334
203
|
async def _async_request(
|
|
335
204
|
self, method: str, endpoint: str, host: str = API_HOSTNAME, **kwargs: Any
|
|
336
205
|
) -> dict[str, Any]:
|
|
@@ -352,7 +221,11 @@ class API:
|
|
|
352
221
|
kwargs["headers"] = {**kwargs["headers"], **FB_INSTALL_HEADERS}
|
|
353
222
|
if endpoint.startswith(ANDROID_CLIENT_ENDPOINT):
|
|
354
223
|
kwargs["headers"] = {**kwargs["headers"], **ANDROID_CLIENT_HEADERS}
|
|
224
|
+
if host == API_HOSTNAME:
|
|
225
|
+
access_token = await self.async_get_access_token()
|
|
226
|
+
kwargs["headers"]["authorization"] = f"Bearer {access_token}"
|
|
355
227
|
kwargs["headers"]["Host"] = host
|
|
228
|
+
|
|
356
229
|
data: dict[str, Any] = {}
|
|
357
230
|
url = parse.urljoin(f"https://{host}", endpoint)
|
|
358
231
|
if self.log_traces:
|
|
@@ -433,13 +306,6 @@ class API:
|
|
|
433
306
|
(self.ws_url, self.ws_token) = await self.post_devicehub_negociate()
|
|
434
307
|
await self.get_websocket_params()
|
|
435
308
|
return
|
|
436
|
-
if TYPE_CHECKING:
|
|
437
|
-
assert self._access_token_expire_dt
|
|
438
|
-
async with self._backoff_refresh_lock_api:
|
|
439
|
-
if datetime.now() <= self._access_token_expire_dt:
|
|
440
|
-
return
|
|
441
|
-
LOG.info("401 detected on api; refreshing api token")
|
|
442
|
-
await self._async_refresh_access_token()
|
|
443
309
|
|
|
444
310
|
@staticmethod
|
|
445
311
|
def _handle_on_giveup(_: dict[str, Any]) -> None:
|
|
@@ -482,22 +348,6 @@ class API:
|
|
|
482
348
|
"""Enable the request retry mechanism."""
|
|
483
349
|
self.async_request = self._wrap_request_method(self._request_retries)
|
|
484
350
|
|
|
485
|
-
def add_refresh_token_callback(
|
|
486
|
-
self, callback: Callable[..., None]
|
|
487
|
-
) -> Callable[..., None]:
|
|
488
|
-
"""Add a callback that should be triggered when tokens are refreshed.
|
|
489
|
-
Note that callbacks should expect to receive a refresh token as a parameter.
|
|
490
|
-
:param callback: The method to call after receiving an event.
|
|
491
|
-
:type callback: ``Callable[..., None]``
|
|
492
|
-
"""
|
|
493
|
-
self._refresh_token_callbacks.append(callback)
|
|
494
|
-
|
|
495
|
-
def remove() -> None:
|
|
496
|
-
"""Remove the callback."""
|
|
497
|
-
self._refresh_token_callbacks.remove(callback)
|
|
498
|
-
|
|
499
|
-
return remove
|
|
500
|
-
|
|
501
351
|
async def _async_post_init(self) -> None:
|
|
502
352
|
"""Perform some post-init actions."""
|
|
503
353
|
LOG.debug("Websocket postinit")
|
|
@@ -513,7 +363,7 @@ class API:
|
|
|
513
363
|
async def post_devicehub_negociate(self) -> tuple[str, str]:
|
|
514
364
|
LOG.debug("Getting websocket url")
|
|
515
365
|
url = f"{AUTOMATION_DEVICEHUB_ENDPOINT}/negotiate"
|
|
516
|
-
resp = await self.async_request("post", url
|
|
366
|
+
resp = await self.async_request("post", url)
|
|
517
367
|
ws_url = resp.get("url")
|
|
518
368
|
ws_token = resp.get("accessToken")
|
|
519
369
|
set_state(
|
|
@@ -764,6 +614,7 @@ class API:
|
|
|
764
614
|
]
|
|
765
615
|
"""
|
|
766
616
|
url = self._get_url("Seasons", location_id, challenge=True)
|
|
617
|
+
LOG.debug(f"Seasons URL is {url}")
|
|
767
618
|
return cast(dict[str, Any], await self.async_request("get", url))
|
|
768
619
|
|
|
769
620
|
async def get_gateway(self, location_id: int) -> dict[str, Any]:
|
|
@@ -795,3 +646,22 @@ class API:
|
|
|
795
646
|
for attr in saved_attrs:
|
|
796
647
|
gw[attr] = {"value": req[0].get(attr)}
|
|
797
648
|
return gw
|
|
649
|
+
|
|
650
|
+
async def get_weather(self, location_id: int) -> dict[str, Any]:
|
|
651
|
+
"""This will return the current weather like in the app
|
|
652
|
+
https://api.hiloenergie.com/Automation/v1/api/Locations/XXXX/Weather
|
|
653
|
+
[
|
|
654
|
+
{
|
|
655
|
+
"temperature": -9.0,
|
|
656
|
+
"time":"0001-01-01T00:00:00Z",
|
|
657
|
+
"condition":"Foggy",
|
|
658
|
+
"icon":0,
|
|
659
|
+
"humidity":92.0
|
|
660
|
+
}
|
|
661
|
+
]
|
|
662
|
+
"""
|
|
663
|
+
url = self._get_url("Weather", location_id)
|
|
664
|
+
LOG.debug(f"Weather URL is {url}")
|
|
665
|
+
response = await self.async_request("get", url)
|
|
666
|
+
LOG.debug(f"Weather API response: {response}")
|
|
667
|
+
return cast(dict[str, Any], await self.async_request("get", url))
|
|
@@ -8,26 +8,20 @@ import homeassistant.core
|
|
|
8
8
|
LOG: Final = logging.getLogger(__package__)
|
|
9
9
|
DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
|
|
10
10
|
REQUEST_RETRY: Final = 9
|
|
11
|
-
|
|
12
|
-
TOKEN_EXPIRATION_PADDING: Final = 300
|
|
13
|
-
VERIFY: Final = True
|
|
14
|
-
DEVICE_REFRESH_TIME: Final = 1800
|
|
15
|
-
PYHILO_VERSION: Final = "2023.12.01"
|
|
11
|
+
PYHILO_VERSION: Final = "2024.04.01"
|
|
16
12
|
# TODO: Find a way to keep previous line in sync with pyproject.toml automatically
|
|
17
13
|
|
|
18
14
|
CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
|
|
19
15
|
ANDROID_PKG_NAME: Final = "com.hiloenergie.hilo"
|
|
20
16
|
DOMAIN: Final = "hilo"
|
|
21
17
|
# Auth constants
|
|
22
|
-
AUTH_HOSTNAME: Final = "
|
|
23
|
-
AUTH_ENDPOINT: Final =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
AUTH_RESPONSE_TYPE: Final = "token id_token"
|
|
30
|
-
AUTH_SCOPE: Final = "openid 9870f087-25f8-43b6-9cad-d4b74ce512e1 offline_access"
|
|
18
|
+
AUTH_HOSTNAME: Final = "connexion.hiloenergie.com"
|
|
19
|
+
AUTH_ENDPOINT: Final = "/HiloDirectoryB2C.onmicrosoft.com/B2C_1A_SIGN_IN/oauth2/v2.0/"
|
|
20
|
+
AUTH_AUTHORIZE: Final = f"https://{AUTH_HOSTNAME}{AUTH_ENDPOINT}authorize"
|
|
21
|
+
AUTH_TOKEN: Final = f"https://{AUTH_HOSTNAME}{AUTH_ENDPOINT}token"
|
|
22
|
+
AUTH_CHALLENGE_METHOD: Final = "S256"
|
|
23
|
+
AUTH_CLIENT_ID: Final = "1ca9f585-4a55-4085-8e30-9746a65fa561"
|
|
24
|
+
AUTH_SCOPE: Final = "openid https://HiloDirectoryB2C.onmicrosoft.com/hiloapis/user_impersonation offline_access"
|
|
31
25
|
SUBSCRIPTION_KEY: Final = "20eeaedcb86945afa3fe792cea89b8bf"
|
|
32
26
|
|
|
33
27
|
# API constants
|
|
@@ -46,8 +40,7 @@ API_REGISTRATION_HEADERS: Final = {
|
|
|
46
40
|
"Hilo-Tenant": DOMAIN,
|
|
47
41
|
}
|
|
48
42
|
|
|
49
|
-
# Automation server
|
|
50
|
-
AUTOMATION_HOSTNAME: Final = "automation.hiloenergie.com"
|
|
43
|
+
# Automation server constant
|
|
51
44
|
AUTOMATION_DEVICEHUB_ENDPOINT: Final = "/DeviceHub"
|
|
52
45
|
|
|
53
46
|
# Request constants
|
|
@@ -246,6 +239,7 @@ HILO_PROVIDERS: Final = {
|
|
|
246
239
|
}
|
|
247
240
|
|
|
248
241
|
JASCO_MODELS: Final = [
|
|
242
|
+
"43080",
|
|
249
243
|
"43082",
|
|
250
244
|
"43076",
|
|
251
245
|
"43078",
|
|
@@ -254,12 +248,14 @@ JASCO_MODELS: Final = [
|
|
|
254
248
|
"9063",
|
|
255
249
|
"45678",
|
|
256
250
|
"42405",
|
|
251
|
+
"43094",
|
|
257
252
|
"43095",
|
|
258
253
|
"45853",
|
|
259
254
|
]
|
|
260
255
|
|
|
261
256
|
JASCO_OUTLETS: Final = [
|
|
262
257
|
"42405",
|
|
258
|
+
"43094",
|
|
263
259
|
"43095",
|
|
264
260
|
"43100",
|
|
265
261
|
"45853",
|
|
@@ -267,5 +263,7 @@ JASCO_OUTLETS: Final = [
|
|
|
267
263
|
|
|
268
264
|
UNMONITORED_DEVICES: Final = [
|
|
269
265
|
"43076",
|
|
266
|
+
"43080",
|
|
267
|
+
"43094",
|
|
270
268
|
"43100",
|
|
271
269
|
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Custom OAuth2 implementation."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from homeassistant.core import HomeAssistant
|
|
9
|
+
from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation
|
|
10
|
+
|
|
11
|
+
from pyhilo.const import (
|
|
12
|
+
AUTH_AUTHORIZE,
|
|
13
|
+
AUTH_CHALLENGE_METHOD,
|
|
14
|
+
AUTH_CLIENT_ID,
|
|
15
|
+
AUTH_SCOPE,
|
|
16
|
+
AUTH_TOKEN,
|
|
17
|
+
DOMAIN,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthCodeWithPKCEImplementation(LocalOAuth2Implementation): # type: ignore[misc]
|
|
22
|
+
"""Custom OAuth2 implementation."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
hass: HomeAssistant,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize AuthCodeWithPKCEImplementation."""
|
|
29
|
+
super().__init__(
|
|
30
|
+
hass,
|
|
31
|
+
DOMAIN,
|
|
32
|
+
AUTH_CLIENT_ID,
|
|
33
|
+
"",
|
|
34
|
+
AUTH_AUTHORIZE,
|
|
35
|
+
AUTH_TOKEN,
|
|
36
|
+
)
|
|
37
|
+
self._code_verifier = self._get_code_verifier()
|
|
38
|
+
self._code_challenge = self._get_code_challange(self._code_verifier)
|
|
39
|
+
|
|
40
|
+
# ... Override AbstractOAuth2Implementation details
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""Name of the implementation."""
|
|
44
|
+
return "Hilo"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def extra_authorize_data(self) -> dict:
|
|
48
|
+
"""Extra data that needs to be appended to the authorize url."""
|
|
49
|
+
return {
|
|
50
|
+
"scope": AUTH_SCOPE,
|
|
51
|
+
"code_challenge": self._code_challenge,
|
|
52
|
+
"code_challenge_method": AUTH_CHALLENGE_METHOD,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async def async_resolve_external_data(self, external_data: Any) -> dict:
|
|
56
|
+
"""Resolve the authorization code to tokens."""
|
|
57
|
+
return cast(
|
|
58
|
+
dict,
|
|
59
|
+
await self._token_request(
|
|
60
|
+
{
|
|
61
|
+
"grant_type": "authorization_code",
|
|
62
|
+
"code": external_data["code"],
|
|
63
|
+
"redirect_uri": external_data["state"]["redirect_uri"],
|
|
64
|
+
"code_verifier": self._code_verifier,
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Ref : https://blog.sanghviharshit.com/reverse-engineering-private-api-oauth-code-flow-with-pkce/
|
|
70
|
+
def _get_code_verifier(self) -> str:
|
|
71
|
+
code = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8")
|
|
72
|
+
return re.sub("[^a-zA-Z0-9]+", "", code)
|
|
73
|
+
|
|
74
|
+
def _get_code_challange(self, verifier: str) -> str:
|
|
75
|
+
sha_verifier = hashlib.sha256(verifier.encode("utf-8")).digest()
|
|
76
|
+
code = base64.urlsafe_b64encode(sha_verifier).decode("utf-8")
|
|
77
|
+
return code.replace("=", "")
|
|
@@ -40,7 +40,7 @@ exclude = ".venv/.*"
|
|
|
40
40
|
|
|
41
41
|
[tool.poetry]
|
|
42
42
|
name = "python-hilo"
|
|
43
|
-
version = "2024.
|
|
43
|
+
version = "2024.4.1"
|
|
44
44
|
description = "A Python3, async interface to the Hilo API"
|
|
45
45
|
readme = "README.md"
|
|
46
46
|
authors = ["David Vallee Delisle <me@dvd.dev>"]
|
|
@@ -82,7 +82,7 @@ asynctest = "^0.13.0"
|
|
|
82
82
|
pre-commit = "^3.2.2"
|
|
83
83
|
pytest = "^8.0.0"
|
|
84
84
|
pytest-aiohttp = "^1.0.4"
|
|
85
|
-
pytest-cov = "^
|
|
85
|
+
pytest-cov = "^5.0.0"
|
|
86
86
|
sphinx-rtd-theme = "^2.0.0"
|
|
87
87
|
types-pytz = "^2024.1.0"
|
|
88
88
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|