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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-hilo
3
- Version: 2024.2.2
3
+ Version: 2024.4.1
4
4
  Summary: A Python3, async interface to the Hilo API
5
5
  Home-page: https://github.com/dvd-dev/python-hilo
6
6
  License: MIT
@@ -6,12 +6,13 @@ import json
6
6
  import random
7
7
  import string
8
8
  import sys
9
- from typing import TYPE_CHECKING, Any, Callable, Union, cast
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._token_expiration: datetime | None = None
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._username: str
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 async_auth_refresh_token(
88
+ async def async_create(
116
89
  cls,
117
90
  *,
118
91
  session: ClientSession,
119
- provided_refresh_token: Union[str, None] = None,
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
- api = cls(session=session, request_retries=request_retries)
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(session=session, request_retries=request_retries)
165
- api.log_traces = log_traces
166
- api._username = username
167
- api._state_yaml = state_yaml
168
- api.state = get_state(state_yaml)
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
- await api.async_auth_post(auth_body)
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, host=AUTOMATION_HOSTNAME)
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
- TIMEOUT: Final = 10
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 = "hilodirectoryb2c.b2clogin.com"
23
- AUTH_ENDPOINT: Final = (
24
- "/hilodirectoryb2c.onmicrosoft.com/oauth2/v2.0/token?p=B2C_1A_B2C_1_PasswordFlow"
25
- )
26
- AUTH_CLIENT_ID: Final = "9870f087-25f8-43b6-9cad-d4b74ce512e1"
27
- AUTH_TYPE_PASSWORD: Final = "password"
28
- AUTH_TYPE_REFRESH: Final = "refresh_token"
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 constants
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.2.2"
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 = "^4.0.0"
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