PySwitchbot 2.8.0__tar.gz → 2.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/PKG-INFO +57 -6
  2. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/README.md +56 -5
  3. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/pyproject.toml +1 -1
  4. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/__init__.py +14 -0
  5. pyswitchbot-2.9.0/switchbot/__version__.py +1 -0
  6. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/device.py +177 -11
  7. pyswitchbot-2.9.0/switchbot/oauth.py +183 -0
  8. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/utils.py +16 -0
  9. pyswitchbot-2.8.0/switchbot/__version__.py +0 -1
  10. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/LICENSE +0 -0
  11. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parser.py +0 -0
  12. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/__init__.py +0 -0
  13. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/_sensor_th.py +0 -0
  14. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/air_purifier.py +0 -0
  15. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/art_frame.py +0 -0
  16. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  17. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/bot.py +0 -0
  18. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/bulb.py +0 -0
  19. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  20. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/climate_panel.py +0 -0
  21. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/contact.py +0 -0
  22. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/curtain.py +0 -0
  23. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/fan.py +0 -0
  24. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/hub2.py +0 -0
  25. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/hub3.py +0 -0
  26. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  27. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/humidifier.py +0 -0
  28. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/keypad.py +0 -0
  29. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/keypad_vision.py +0 -0
  30. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/leak.py +0 -0
  31. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/light_strip.py +0 -0
  32. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/lock.py +0 -0
  33. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/meter.py +0 -0
  34. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/motion.py +0 -0
  35. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/plug.py +0 -0
  36. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
  37. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  38. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/remote.py +0 -0
  39. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/roller_shade.py +0 -0
  40. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
  41. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/vacuum.py +0 -0
  42. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/adv_parsers/weather_station.py +0 -0
  43. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/api_config.py +0 -0
  44. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/__init__.py +0 -0
  45. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/air_purifier.py +0 -0
  46. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/climate.py +0 -0
  47. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/evaporative_humidifier.py +0 -0
  48. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/fan.py +0 -0
  49. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/hub2.py +0 -0
  50. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/hub3.py +0 -0
  51. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/light.py +0 -0
  52. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/lock.py +0 -0
  53. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/const/presence_sensor.py +0 -0
  54. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/__init__.py +0 -0
  55. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/air_purifier.py +0 -0
  56. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/art_frame.py +0 -0
  57. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/base_cover.py +0 -0
  58. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/base_light.py +0 -0
  59. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/blind_tilt.py +0 -0
  60. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/bot.py +0 -0
  61. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/bulb.py +0 -0
  62. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/ceiling_light.py +0 -0
  63. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/contact.py +0 -0
  64. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/curtain.py +0 -0
  65. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  66. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/fan.py +0 -0
  67. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/humidifier.py +0 -0
  68. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/keypad.py +0 -0
  69. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/keypad_vision.py +0 -0
  70. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/light_strip.py +0 -0
  71. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/lock.py +0 -0
  72. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/meter.py +0 -0
  73. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/meter_pro.py +0 -0
  74. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/motion.py +0 -0
  75. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/plug.py +0 -0
  76. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/relay_switch.py +0 -0
  77. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/roller_shade.py +0 -0
  78. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
  79. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/universal_remote.py +0 -0
  80. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/devices/vacuum.py +0 -0
  81. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/discovery.py +0 -0
  82. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/enum.py +0 -0
  83. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/helpers.py +0 -0
  84. {pyswitchbot-2.8.0 → pyswitchbot-2.9.0}/switchbot/models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 2.8.0
3
+ Version: 2.9.0
4
4
  Summary: A library to communicate with Switchbot
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -41,6 +41,57 @@ source .venv/bin/activate
41
41
  pip install .
42
42
  ```
43
43
 
44
+ ## OAuth account access
45
+
46
+ pySwitchbot provides helpers for SwitchBot's authorization-code flow. The
47
+ calling application supplies a SwitchBot-issued client ID and its registered
48
+ redirect URI; neither value is tied to Home Assistant or embedded in the
49
+ library.
50
+
51
+ SwitchBot currently treats these integrations as public clients: the token
52
+ request does not use a client secret, and the authorization server does not
53
+ support PKCE. The caller must generate an unpredictable, single-use `state`,
54
+ store it for the duration of the flow, and reject callbacks whose state does
55
+ not match. State protects the callback from request forgery but does not
56
+ replace PKCE.
57
+
58
+ ```python
59
+ import secrets
60
+
61
+ from switchbot import (
62
+ build_oauth_authorize_url,
63
+ exchange_oauth_code,
64
+ fetch_cloud_devices_by_token,
65
+ )
66
+
67
+ state = secrets.token_urlsafe(32)
68
+ authorize_url = build_oauth_authorize_url(client_id, redirect_uri, state)
69
+
70
+ # Store state before sending the user to authorize_url. On callback:
71
+ if callback_state != state:
72
+ raise ValueError("OAuth state mismatch")
73
+
74
+ token = await exchange_oauth_code(
75
+ session,
76
+ client_id,
77
+ redirect_uri,
78
+ authorization_code,
79
+ )
80
+ devices = await fetch_cloud_devices_by_token(session, token["access_token"])
81
+ ```
82
+
83
+ The client ID and exact HTTPS redirect URI must be registered with SwitchBot;
84
+ arbitrary values and wildcard redirect URIs will not work. `exchange_oauth_code`
85
+ returns the provider's token mapping after validating the access token and
86
+ normalizing `expires_in` to an integer. The access token can then be passed to
87
+ `fetch_cloud_devices_by_token` or
88
+ `SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token`.
89
+
90
+ HTTP 401 and 403 responses from the SwitchBot account API raise
91
+ `SwitchbotAuthenticationError`. Other API failures raise `SwitchbotApiError`,
92
+ while transport and availability failures raise
93
+ `SwitchbotAccountConnectionError`.
94
+
44
95
  ## Obtaining encryption key for Switchbot Locks
45
96
 
46
97
  Using the script `scripts/get_encryption_key.py` you can manually obtain locks encryption key.
@@ -72,11 +123,11 @@ password. The most common failures are account-side, not bugs in this library:
72
123
  email/password account.
73
124
  - The username is an email but the account is registered to a phone number
74
125
  (or vice versa). Use the exact identifier you log in with.
75
- - **`Failed to retrieve encryption key from SwitchBot Account: ...`** —
76
- authentication succeeded but the key could not be read. Usually the account
77
- is not the device **owner**: keys are only returned to the owning account,
78
- not to shared/family members. Retrieve the key from the owner account, or
79
- transfer ownership in the app.
126
+ - **`..., status code: 190`** (`SwitchbotApiError`) — authentication succeeded
127
+ but the key could not be read. Usually the account is not the device
128
+ **owner**: keys are only returned to the owning account, not to shared/family
129
+ members. Retrieve the key from the owner account, or transfer ownership in
130
+ the app.
80
131
 
81
132
  The key only needs to be fetched once; store the `key_id` and encryption key
82
133
  and reuse them — there is no need to call the script on every connection.
@@ -10,6 +10,57 @@ source .venv/bin/activate
10
10
  pip install .
11
11
  ```
12
12
 
13
+ ## OAuth account access
14
+
15
+ pySwitchbot provides helpers for SwitchBot's authorization-code flow. The
16
+ calling application supplies a SwitchBot-issued client ID and its registered
17
+ redirect URI; neither value is tied to Home Assistant or embedded in the
18
+ library.
19
+
20
+ SwitchBot currently treats these integrations as public clients: the token
21
+ request does not use a client secret, and the authorization server does not
22
+ support PKCE. The caller must generate an unpredictable, single-use `state`,
23
+ store it for the duration of the flow, and reject callbacks whose state does
24
+ not match. State protects the callback from request forgery but does not
25
+ replace PKCE.
26
+
27
+ ```python
28
+ import secrets
29
+
30
+ from switchbot import (
31
+ build_oauth_authorize_url,
32
+ exchange_oauth_code,
33
+ fetch_cloud_devices_by_token,
34
+ )
35
+
36
+ state = secrets.token_urlsafe(32)
37
+ authorize_url = build_oauth_authorize_url(client_id, redirect_uri, state)
38
+
39
+ # Store state before sending the user to authorize_url. On callback:
40
+ if callback_state != state:
41
+ raise ValueError("OAuth state mismatch")
42
+
43
+ token = await exchange_oauth_code(
44
+ session,
45
+ client_id,
46
+ redirect_uri,
47
+ authorization_code,
48
+ )
49
+ devices = await fetch_cloud_devices_by_token(session, token["access_token"])
50
+ ```
51
+
52
+ The client ID and exact HTTPS redirect URI must be registered with SwitchBot;
53
+ arbitrary values and wildcard redirect URIs will not work. `exchange_oauth_code`
54
+ returns the provider's token mapping after validating the access token and
55
+ normalizing `expires_in` to an integer. The access token can then be passed to
56
+ `fetch_cloud_devices_by_token` or
57
+ `SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token`.
58
+
59
+ HTTP 401 and 403 responses from the SwitchBot account API raise
60
+ `SwitchbotAuthenticationError`. Other API failures raise `SwitchbotApiError`,
61
+ while transport and availability failures raise
62
+ `SwitchbotAccountConnectionError`.
63
+
13
64
  ## Obtaining encryption key for Switchbot Locks
14
65
 
15
66
  Using the script `scripts/get_encryption_key.py` you can manually obtain locks encryption key.
@@ -41,11 +92,11 @@ password. The most common failures are account-side, not bugs in this library:
41
92
  email/password account.
42
93
  - The username is an email but the account is registered to a phone number
43
94
  (or vice versa). Use the exact identifier you log in with.
44
- - **`Failed to retrieve encryption key from SwitchBot Account: ...`** —
45
- authentication succeeded but the key could not be read. Usually the account
46
- is not the device **owner**: keys are only returned to the owning account,
47
- not to shared/family members. Retrieve the key from the owner account, or
48
- transfer ownership in the app.
95
+ - **`..., status code: 190`** (`SwitchbotApiError`) — authentication succeeded
96
+ but the key could not be read. Usually the account is not the device
97
+ **owner**: keys are only returned to the owning account, not to shared/family
98
+ members. Retrieve the key from the owner account, or transfer ownership in
99
+ the app.
49
100
 
50
101
  The key only needs to be fetched once; store the `key_id` and encryption key
51
102
  and reuse them — there is no need to call the script on every connection.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "PySwitchbot"
3
- version = "2.8.0"
3
+ version = "2.9.0"
4
4
  description = "A library to communicate with Switchbot"
5
5
  authors = ["Daniel Hjelseth Hoyer"]
6
6
  license = "MIT"
@@ -47,6 +47,7 @@ from .devices.device import (
47
47
  SwitchbotEncryptedDevice,
48
48
  SwitchbotOperationError,
49
49
  fetch_cloud_devices,
50
+ fetch_cloud_devices_by_token,
50
51
  )
51
52
  from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
52
53
  from .devices.fan import (
@@ -79,8 +80,18 @@ from .devices.universal_remote import SwitchbotUniversalRemote
79
80
  from .devices.vacuum import SwitchbotVacuum
80
81
  from .discovery import GetSwitchbotDevices
81
82
  from .models import SwitchBotAdvertisement
83
+ from .oauth import (
84
+ OAUTH_AUTHORIZE_URL,
85
+ OAUTH_SCOPE,
86
+ OAUTH_TOKEN_URL,
87
+ build_oauth_authorize_url,
88
+ exchange_oauth_code,
89
+ )
82
90
 
83
91
  __all__ = [
92
+ "OAUTH_AUTHORIZE_URL",
93
+ "OAUTH_SCOPE",
94
+ "OAUTH_TOKEN_URL",
84
95
  "AirPurifierMode",
85
96
  "AirQualityLevel",
86
97
  "BulbColorMode",
@@ -142,9 +153,12 @@ __all__ = [
142
153
  "SwitchbotUniversalRemote",
143
154
  "SwitchbotVacuum",
144
155
  "VerticalOscillationAngle",
156
+ "build_oauth_authorize_url",
145
157
  "close_stale_connections",
146
158
  "close_stale_connections_by_address",
159
+ "exchange_oauth_code",
147
160
  "fetch_cloud_devices",
161
+ "fetch_cloud_devices_by_token",
148
162
  "get_device",
149
163
  "parse_advertisement_data",
150
164
  ]
@@ -0,0 +1 @@
1
+ __version__ = "2.9.0"
@@ -39,15 +39,25 @@ from ..const import (
39
39
  from ..discovery import GetSwitchbotDevices
40
40
  from ..helpers import create_background_task
41
41
  from ..models import SwitchBotAdvertisement
42
- from ..utils import format_mac_upper
42
+ from ..utils import extract_request_id, format_mac_upper
43
43
 
44
44
  _LOGGER = logging.getLogger(__name__)
45
45
 
46
46
 
47
+ def _masked_device_id(device_id: str) -> str:
48
+ """Mask a device identifier while retaining a useful suffix."""
49
+ normalized = device_id.replace(":", "").replace("-", "").upper()
50
+ if not normalized:
51
+ return "unknown"
52
+ return f"****{normalized[-4:]}"
53
+
54
+
47
55
  def _extract_region(userinfo: dict[str, Any]) -> str:
48
56
  """Extract region from user info, defaulting to 'us'."""
49
- if "botRegion" in userinfo and userinfo["botRegion"] != "":
50
- return userinfo["botRegion"]
57
+ region = userinfo.get("botRegion")
58
+ if isinstance(region, str) and region:
59
+ return region
60
+ _LOGGER.warning("SwitchBot account region missing; defaulting to us")
51
61
  return "us"
52
62
 
53
63
 
@@ -265,6 +275,10 @@ class SwitchbotBaseDevice:
265
275
  return await cls.api_request(
266
276
  session, "account", "account/api/v1/user/userinfo", {}, auth_headers
267
277
  )
278
+ except SwitchbotAuthenticationError:
279
+ raise
280
+ except SwitchbotApiError:
281
+ raise
268
282
  except Exception as err:
269
283
  raise SwitchbotAccountConnectionError(
270
284
  f"Failed to retrieve SwitchBot Account user details: {err}"
@@ -308,8 +322,45 @@ class SwitchbotBaseDevice:
308
322
  except Exception as err:
309
323
  raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
310
324
 
325
+ return await cls._async_get_devices(session, auth_headers)
326
+
327
+ @classmethod
328
+ async def get_devices_by_token(
329
+ cls,
330
+ session: aiohttp.ClientSession,
331
+ access_token: str,
332
+ ) -> dict[str, SwitchbotModel]:
333
+ """Get devices from SwitchBot API using an OAuth access token."""
334
+ started = time.monotonic()
335
+ _LOGGER.debug("Retrieving SwitchBot cloud devices using an OAuth token")
336
+ try:
337
+ devices = await cls._async_get_devices(
338
+ session, {"authorization": access_token}
339
+ )
340
+ except Exception:
341
+ _LOGGER.debug(
342
+ "SwitchBot OAuth cloud device retrieval failed; duration_ms=%s",
343
+ round((time.monotonic() - started) * 1000),
344
+ )
345
+ raise
346
+ _LOGGER.debug(
347
+ "SwitchBot OAuth cloud device retrieval finished; supported_devices=%s "
348
+ "duration_ms=%s",
349
+ len(devices),
350
+ round((time.monotonic() - started) * 1000),
351
+ )
352
+ return devices
353
+
354
+ @classmethod
355
+ async def _async_get_devices(
356
+ cls,
357
+ session: aiohttp.ClientSession,
358
+ auth_headers: dict[str, str],
359
+ ) -> dict[str, SwitchbotModel]:
360
+ """Get devices from SwitchBot API using authenticated headers."""
311
361
  userinfo = await cls._async_get_user_info(session, auth_headers)
312
362
  region = _extract_region(userinfo)
363
+ _LOGGER.debug("SwitchBot account region resolved to %s", region)
313
364
 
314
365
  try:
315
366
  device_info = await cls.api_request(
@@ -321,12 +372,21 @@ class SwitchbotBaseDevice:
321
372
  },
322
373
  auth_headers,
323
374
  )
375
+ except SwitchbotAuthenticationError:
376
+ raise
377
+ except SwitchbotApiError:
378
+ raise
324
379
  except Exception as err:
325
380
  raise SwitchbotAccountConnectionError(
326
381
  f"Failed to retrieve devices from SwitchBot Account: {err}"
327
382
  ) from err
328
383
 
329
- items: list[dict[str, Any]] = device_info["Items"]
384
+ items = device_info.get("Items")
385
+ if not isinstance(items, list) or not all(
386
+ isinstance(item, dict) for item in items
387
+ ):
388
+ raise SwitchbotApiError("Invalid device response from SwitchBot API")
389
+ _LOGGER.debug("SwitchBot cloud API returned %s device records", len(items))
330
390
  mac_to_model: dict[str, SwitchbotModel] = {}
331
391
 
332
392
  for item in items:
@@ -360,6 +420,7 @@ class SwitchbotBaseDevice:
360
420
  item,
361
421
  )
362
422
 
423
+ _LOGGER.debug("Mapped %s supported SwitchBot cloud devices", len(mac_to_model))
363
424
  return mac_to_model
364
425
 
365
426
  @classmethod
@@ -372,18 +433,46 @@ class SwitchbotBaseDevice:
372
433
  headers: dict | None = None,
373
434
  ) -> dict:
374
435
  url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
436
+ started = time.monotonic()
437
+ _LOGGER.debug("Requesting SwitchBot API endpoint %s", url)
375
438
  async with session.post(
376
439
  url,
377
440
  json=data,
378
441
  headers=headers,
379
442
  timeout=aiohttp.ClientTimeout(total=10),
380
443
  ) as result:
444
+ _LOGGER.debug(
445
+ "SwitchBot API endpoint %s returned HTTP status %s; duration_ms=%s "
446
+ "request_id=%s",
447
+ url,
448
+ result.status,
449
+ round((time.monotonic() - started) * 1000),
450
+ extract_request_id(result.headers) or "unavailable",
451
+ )
452
+ if result.status in (401, 403):
453
+ raise SwitchbotAuthenticationError(
454
+ "Authentication rejected by SwitchBot API"
455
+ )
381
456
  if result.status > 299:
382
457
  raise SwitchbotApiError(
383
458
  f"Unexpected status code returned by SwitchBot API: {result.status}"
384
459
  )
385
460
 
386
461
  response = await result.json()
462
+ body = response.get("body")
463
+ body_fields: list[str] | str = (
464
+ sorted(body) if isinstance(body, dict) else type(body).__name__
465
+ )
466
+ _LOGGER.debug(
467
+ (
468
+ "SwitchBot API endpoint %s returned API status %s; "
469
+ "response fields=%s; body fields=%s"
470
+ ),
471
+ url,
472
+ response.get("statusCode"),
473
+ sorted(response),
474
+ body_fields,
475
+ )
387
476
  if response["statusCode"] != 100:
388
477
  raise SwitchbotApiError(
389
478
  f"{response['message']}, status code: {response['statusCode']}"
@@ -1052,17 +1141,69 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
1052
1141
  password: str,
1053
1142
  ) -> dict:
1054
1143
  """Retrieve lock key from internal SwitchBot API."""
1055
- device_mac = device_mac.replace(":", "").replace("-", "").upper()
1056
-
1057
1144
  try:
1058
1145
  auth_result = await cls._get_auth_result(session, username, password)
1059
1146
  auth_headers = {"authorization": auth_result["access_token"]}
1060
1147
  except Exception as err:
1061
1148
  raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
1062
1149
 
1150
+ return await cls._async_retrieve_encryption_key(
1151
+ session, device_mac, auth_headers
1152
+ )
1153
+
1154
+ @classmethod
1155
+ async def async_retrieve_encryption_key_by_token(
1156
+ cls,
1157
+ session: aiohttp.ClientSession,
1158
+ device_mac: str,
1159
+ access_token: str,
1160
+ ) -> dict:
1161
+ """Retrieve an encryption key using an OAuth access token."""
1162
+ started = time.monotonic()
1163
+ masked_device = _masked_device_id(device_mac)
1164
+ _LOGGER.debug(
1165
+ "Retrieving a SwitchBot encryption key using an OAuth token; device=%s",
1166
+ masked_device,
1167
+ )
1168
+ try:
1169
+ key_details = await cls._async_retrieve_encryption_key(
1170
+ session, device_mac, {"authorization": access_token}
1171
+ )
1172
+ except Exception:
1173
+ _LOGGER.debug(
1174
+ "SwitchBot OAuth encryption key retrieval failed; device=%s "
1175
+ "duration_ms=%s",
1176
+ masked_device,
1177
+ round((time.monotonic() - started) * 1000),
1178
+ )
1179
+ raise
1180
+ _LOGGER.debug(
1181
+ "SwitchBot OAuth encryption key retrieval finished; device=%s "
1182
+ "duration_ms=%s",
1183
+ masked_device,
1184
+ round((time.monotonic() - started) * 1000),
1185
+ )
1186
+ return key_details
1187
+
1188
+ @classmethod
1189
+ async def _async_retrieve_encryption_key(
1190
+ cls,
1191
+ session: aiohttp.ClientSession,
1192
+ device_mac: str,
1193
+ auth_headers: dict[str, str],
1194
+ ) -> dict:
1195
+ """Retrieve an encryption key using authenticated headers."""
1196
+ device_mac = device_mac.replace(":", "").replace("-", "").upper()
1197
+
1063
1198
  userinfo = await cls._async_get_user_info(session, auth_headers)
1064
1199
  region = _extract_region(userinfo)
1200
+ masked_device = _masked_device_id(device_mac)
1065
1201
 
1202
+ _LOGGER.debug(
1203
+ "SwitchBot encryption key account region resolved; region=%s device=%s",
1204
+ region,
1205
+ masked_device,
1206
+ )
1066
1207
  try:
1067
1208
  device_info = await cls.api_request(
1068
1209
  session,
@@ -1074,16 +1215,33 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
1074
1215
  },
1075
1216
  auth_headers,
1076
1217
  )
1077
-
1078
- return {
1079
- "key_id": device_info["communicationKey"]["keyId"],
1080
- "encryption_key": device_info["communicationKey"]["key"],
1081
- }
1218
+ except SwitchbotAuthenticationError:
1219
+ raise
1220
+ except SwitchbotApiError:
1221
+ raise
1082
1222
  except Exception as err:
1083
1223
  raise SwitchbotAccountConnectionError(
1084
1224
  f"Failed to retrieve encryption key from SwitchBot Account: {err}"
1085
1225
  ) from err
1086
1226
 
1227
+ communication_key = device_info.get("communicationKey")
1228
+ if not isinstance(communication_key, dict):
1229
+ raise SwitchbotApiError(
1230
+ "Invalid encryption key response from SwitchBot API"
1231
+ )
1232
+ key_id = communication_key.get("keyId")
1233
+ encryption_key = communication_key.get("key")
1234
+ if not isinstance(key_id, str) or not isinstance(encryption_key, str):
1235
+ raise SwitchbotApiError(
1236
+ "Invalid encryption key response from SwitchBot API"
1237
+ )
1238
+
1239
+ _LOGGER.debug(
1240
+ "SwitchBot encryption key retrieved successfully; device=%s",
1241
+ masked_device,
1242
+ )
1243
+ return {"key_id": key_id, "encryption_key": encryption_key}
1244
+
1087
1245
  @classmethod
1088
1246
  async def verify_encryption_key(
1089
1247
  cls,
@@ -1336,3 +1494,11 @@ async def fetch_cloud_devices(
1336
1494
  """Fetch devices from SwitchBot API and return MAC to model mapping."""
1337
1495
  # Get devices from the API (which also populates the cache)
1338
1496
  return await SwitchbotBaseDevice.get_devices(session, username, password)
1497
+
1498
+
1499
+ async def fetch_cloud_devices_by_token(
1500
+ session: aiohttp.ClientSession,
1501
+ access_token: str,
1502
+ ) -> dict[str, SwitchbotModel]:
1503
+ """Fetch devices from SwitchBot API using an OAuth access token."""
1504
+ return await SwitchbotBaseDevice.get_devices_by_token(session, access_token)
@@ -0,0 +1,183 @@
1
+ """OAuth helpers for the SwitchBot account API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from time import monotonic
8
+ from typing import Any
9
+ from urllib.parse import urlencode, urlsplit
10
+
11
+ import aiohttp
12
+
13
+ from .const import (
14
+ SwitchbotAccountConnectionError,
15
+ SwitchbotApiError,
16
+ SwitchbotAuthenticationError,
17
+ )
18
+ from .utils import extract_request_id
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+ OAUTH_AUTHORIZE_URL = "https://sp.oauth.switchbot.net"
23
+ OAUTH_TOKEN_URL = "https://account.api.switchbot.net/merchant/v1/oauth/token"
24
+ OAUTH_SCOPE = "api_login"
25
+
26
+
27
+ def _oauth_error_field(
28
+ error_data: Any, field: str, authorization_code: str
29
+ ) -> str | None:
30
+ """Return a bounded OAuth error field with the authorization code redacted."""
31
+ if not isinstance(error_data, Mapping):
32
+ return None
33
+ value = error_data.get(field)
34
+ if not isinstance(value, str):
35
+ return None
36
+ value = " ".join(value.split())
37
+ if authorization_code:
38
+ value = value.replace(authorization_code, "<redacted>")
39
+ return value[:256] or None
40
+
41
+
42
+ def _raise_for_oauth_error(status: int, error: str | None, error_suffix: str) -> None:
43
+ """Raise the appropriate exception for an OAuth error response."""
44
+ if status in (401, 403) or (
45
+ status == 400 and error in {"invalid_client", "invalid_grant"}
46
+ ):
47
+ raise SwitchbotAuthenticationError(
48
+ f"SwitchBot OAuth token request rejected ({status}){error_suffix}"
49
+ )
50
+ if 400 <= status < 500 and status != 429:
51
+ raise SwitchbotApiError(
52
+ f"SwitchBot OAuth token request failed ({status}){error_suffix}"
53
+ )
54
+ if status == 429 or status >= 500:
55
+ raise SwitchbotAccountConnectionError(
56
+ f"SwitchBot OAuth token service unavailable ({status}){error_suffix}"
57
+ )
58
+
59
+
60
+ def build_oauth_authorize_url(
61
+ client_id: str,
62
+ redirect_uri: str,
63
+ state: str,
64
+ ) -> str:
65
+ """Build a SwitchBot OAuth authorization URL."""
66
+ _LOGGER.debug(
67
+ "Building SwitchBot OAuth authorization request; authorize_host=%s "
68
+ "redirect_host=%s",
69
+ urlsplit(OAUTH_AUTHORIZE_URL).hostname,
70
+ urlsplit(redirect_uri).hostname,
71
+ )
72
+ query = urlencode(
73
+ {
74
+ "client_id": client_id,
75
+ "redirect_uri": redirect_uri,
76
+ "response_type": "code",
77
+ "scope": OAUTH_SCOPE,
78
+ "state": state,
79
+ }
80
+ )
81
+ return f"{OAUTH_AUTHORIZE_URL}?{query}"
82
+
83
+
84
+ async def exchange_oauth_code(
85
+ session: aiohttp.ClientSession,
86
+ client_id: str,
87
+ redirect_uri: str,
88
+ code: str,
89
+ ) -> dict[str, Any]:
90
+ """Exchange an OAuth authorization code for a SwitchBot access token."""
91
+ started = monotonic()
92
+ _LOGGER.debug(
93
+ "Exchanging SwitchBot OAuth authorization code; token_host=%s",
94
+ urlsplit(OAUTH_TOKEN_URL).hostname,
95
+ )
96
+ error: str | None = None
97
+ error_description: str | None = None
98
+ token_data: Any = None
99
+ try:
100
+ async with session.post(
101
+ OAUTH_TOKEN_URL,
102
+ data={
103
+ "code": code,
104
+ "client_id": client_id,
105
+ "grant_type": "authorization_code",
106
+ "redirect_uri": redirect_uri,
107
+ },
108
+ timeout=aiohttp.ClientTimeout(total=10),
109
+ ) as response:
110
+ status = response.status
111
+ _LOGGER.debug(
112
+ "SwitchBot OAuth token endpoint returned HTTP status %s; "
113
+ "duration_ms=%s request_id=%s",
114
+ status,
115
+ round((monotonic() - started) * 1000),
116
+ extract_request_id(response.headers) or "unavailable",
117
+ )
118
+ if status >= 400:
119
+ try:
120
+ error_data = await response.json()
121
+ except (aiohttp.ClientError, ValueError, TypeError) as err:
122
+ _LOGGER.debug(
123
+ "SwitchBot OAuth token error response could not be parsed; "
124
+ "error_type=%s",
125
+ type(err).__name__,
126
+ )
127
+ error_data = None
128
+ error = _oauth_error_field(error_data, "error", code)
129
+ error_description = _oauth_error_field(
130
+ error_data, "error_description", code
131
+ )
132
+ _LOGGER.debug(
133
+ "SwitchBot OAuth token endpoint returned an error response; "
134
+ "error=%s error_description=%s",
135
+ error or "unavailable",
136
+ error_description or "unavailable",
137
+ )
138
+ else:
139
+ try:
140
+ token_data = await response.json()
141
+ except (aiohttp.ClientError, ValueError, TypeError) as err:
142
+ raise SwitchbotApiError(
143
+ "Invalid response from SwitchBot OAuth token API"
144
+ ) from err
145
+ except (aiohttp.ClientError, TimeoutError) as err:
146
+ raise SwitchbotAccountConnectionError(
147
+ f"Failed to connect to SwitchBot OAuth token API: {err}"
148
+ ) from err
149
+
150
+ error_detail = ": ".join(
151
+ value for value in (error, error_description) if value is not None
152
+ )
153
+ error_suffix = f": {error_detail}" if error_detail else ""
154
+ _raise_for_oauth_error(status, error, error_suffix)
155
+ if not isinstance(token_data, dict):
156
+ raise SwitchbotApiError("Invalid response from SwitchBot OAuth token API")
157
+
158
+ token: dict[str, Any] = token_data.copy()
159
+ _LOGGER.debug("SwitchBot OAuth token response fields: %s", sorted(token))
160
+ access_token = token.get("access_token")
161
+ expires_in = token.get("expires_in")
162
+ if (
163
+ not isinstance(access_token, str)
164
+ or not access_token
165
+ or isinstance(expires_in, bool)
166
+ or not isinstance(expires_in, int | str)
167
+ ):
168
+ raise SwitchbotApiError("Invalid token data from SwitchBot OAuth token API")
169
+ try:
170
+ normalized_expires_in = int(expires_in)
171
+ except ValueError as err:
172
+ raise SwitchbotApiError(
173
+ "Invalid token data from SwitchBot OAuth token API"
174
+ ) from err
175
+ token["expires_in"] = normalized_expires_in
176
+ _LOGGER.debug(
177
+ "SwitchBot OAuth token response validated; expires_in=%s "
178
+ "refresh_token_present=%s refresh_expires_in_present=%s",
179
+ normalized_expires_in,
180
+ bool(token.get("refresh_token")),
181
+ "refresh_expires_in" in token,
182
+ )
183
+ return token
@@ -1,7 +1,23 @@
1
1
  """Utility functions for switchbot."""
2
2
 
3
+ from collections.abc import Mapping
3
4
  from functools import lru_cache
4
5
 
6
+ _REQUEST_ID_HEADERS = ("x-request-id", "x-amzn-requestid", "cf-ray")
7
+
8
+
9
+ def extract_request_id(headers: Mapping[str, str]) -> str | None:
10
+ """Extract a provider request identifier for log correlation."""
11
+ normalized_headers = {name.lower(): value for name, value in headers.items()}
12
+ return next(
13
+ (
14
+ value
15
+ for name in _REQUEST_ID_HEADERS
16
+ if (value := normalized_headers.get(name))
17
+ ),
18
+ None,
19
+ )
20
+
5
21
 
6
22
  @lru_cache(maxsize=512)
7
23
  def format_mac_upper(mac: str) -> str:
@@ -1 +0,0 @@
1
- __version__ = "2.8.0"
File without changes