PySwitchbot 0.54.0__tar.gz → 0.55.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.
Files changed (58) hide show
  1. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PKG-INFO +2 -1
  2. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/PKG-INFO +2 -1
  3. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/SOURCES.txt +1 -0
  4. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/setup.py +2 -1
  5. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/__init__.py +2 -0
  6. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parser.py +7 -0
  7. pyswitchbot-0.55.1/switchbot/adv_parsers/leak.py +29 -0
  8. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/const.py +1 -0
  9. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/device.py +166 -3
  10. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/lock.py +9 -136
  11. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/relay_switch.py +16 -17
  12. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_adv_parser.py +148 -0
  13. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/LICENSE +0 -0
  14. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/MANIFEST.in +0 -0
  15. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  16. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/requires.txt +0 -0
  17. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/top_level.txt +0 -0
  18. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/README.md +0 -0
  19. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/setup.cfg +0 -0
  20. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/__init__.py +0 -0
  21. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/blind_tilt.py +0 -0
  22. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/bot.py +0 -0
  23. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/bulb.py +0 -0
  24. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/ceiling_light.py +0 -0
  25. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/contact.py +0 -0
  26. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/curtain.py +0 -0
  27. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/hub2.py +0 -0
  28. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/humidifier.py +0 -0
  29. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/keypad.py +0 -0
  30. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/light_strip.py +0 -0
  31. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/lock.py +0 -0
  32. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/meter.py +0 -0
  33. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/motion.py +0 -0
  34. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/plug.py +0 -0
  35. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/relay_switch.py +0 -0
  36. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/api_config.py +0 -0
  37. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/__init__.py +0 -0
  38. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/base_cover.py +0 -0
  39. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/base_light.py +0 -0
  40. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/blind_tilt.py +0 -0
  41. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/bot.py +0 -0
  42. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/bulb.py +0 -0
  43. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/ceiling_light.py +0 -0
  44. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/contact.py +0 -0
  45. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/curtain.py +0 -0
  46. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/humidifier.py +0 -0
  47. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/keypad.py +0 -0
  48. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/light_strip.py +0 -0
  49. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/meter.py +0 -0
  50. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/motion.py +0 -0
  51. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/plug.py +0 -0
  52. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/discovery.py +0 -0
  53. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/enum.py +0 -0
  54. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/models.py +0 -0
  55. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_base_cover.py +0 -0
  56. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_blind_tilt.py +0 -0
  57. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_curtain.py +0 -0
  58. {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_relay_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PySwitchbot
3
- Version: 0.54.0
3
+ Version: 0.55.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -12,6 +12,7 @@ Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python
13
13
  Classifier: Topic :: Home Automation
14
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.11
15
16
  Description-Content-Type: text/markdown
16
17
  License-File: LICENSE
17
18
  Requires-Dist: aiohttp>=3.9.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PySwitchbot
3
- Version: 0.54.0
3
+ Version: 0.55.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -12,6 +12,7 @@ Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python
13
13
  Classifier: Topic :: Home Automation
14
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.11
15
16
  Description-Content-Type: text/markdown
16
17
  License-File: LICENSE
17
18
  Requires-Dist: aiohttp>=3.9.5
@@ -24,6 +24,7 @@ switchbot/adv_parsers/curtain.py
24
24
  switchbot/adv_parsers/hub2.py
25
25
  switchbot/adv_parsers/humidifier.py
26
26
  switchbot/adv_parsers/keypad.py
27
+ switchbot/adv_parsers/leak.py
27
28
  switchbot/adv_parsers/light_strip.py
28
29
  switchbot/adv_parsers/lock.py
29
30
  switchbot/adv_parsers/meter.py
@@ -15,13 +15,14 @@ setup(
15
15
  "cryptography>=39.0.0",
16
16
  "pyOpenSSL>=23.0.0",
17
17
  ],
18
- version="0.54.0",
18
+ version="0.55.1",
19
19
  description="A library to communicate with Switchbot",
20
20
  long_description=long_description,
21
21
  long_description_content_type="text/markdown",
22
22
  author="Daniel Hjelseth Hoyer",
23
23
  url="https://github.com/sblibs/pySwitchbot/",
24
24
  license="MIT",
25
+ python_requires=">=3.11",
25
26
  classifiers=[
26
27
  "Development Status :: 3 - Alpha",
27
28
  "Environment :: Other Environment",
@@ -16,6 +16,7 @@ from .const import (
16
16
  SwitchbotAuthenticationError,
17
17
  SwitchbotModel,
18
18
  )
19
+ from .devices.device import SwitchbotEncryptedDevice
19
20
  from .devices.base_light import SwitchbotBaseLight
20
21
  from .devices.blind_tilt import SwitchbotBlindTilt
21
22
  from .devices.bot import Switchbot
@@ -41,6 +42,7 @@ __all__ = [
41
42
  "SwitchbotAccountConnectionError",
42
43
  "SwitchbotApiError",
43
44
  "SwitchbotAuthenticationError",
45
+ "SwitchbotEncryptedDevice",
44
46
  "ColorMode",
45
47
  "LockStatus",
46
48
  "SwitchbotBaseLight",
@@ -19,6 +19,7 @@ from .adv_parsers.curtain import process_wocurtain
19
19
  from .adv_parsers.hub2 import process_wohub2
20
20
  from .adv_parsers.humidifier import process_wohumidifier
21
21
  from .adv_parsers.keypad import process_wokeypad
22
+ from .adv_parsers.leak import process_leak
22
23
  from .adv_parsers.light_strip import process_wostrip
23
24
  from .adv_parsers.lock import process_wolock, process_wolock_pro
24
25
  from .adv_parsers.meter import process_wosensorth, process_wosensorth_c
@@ -178,6 +179,12 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
178
179
  "func": process_woblindtilt,
179
180
  "manufacturer_id": 2409,
180
181
  },
182
+ "3": {
183
+ "modelName": SwitchbotModel.LEAK,
184
+ "modelFriendlyName": "Leak Detector",
185
+ "func": process_leak,
186
+ "manufacturer_id": 2409,
187
+ },
181
188
  "y": {
182
189
  "modelName": SwitchbotModel.KEYPAD,
183
190
  "modelFriendlyName": "Keypad",
@@ -0,0 +1,29 @@
1
+ """Leak detector adv parser."""
2
+
3
+
4
+ def process_leak(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
5
+ """Process SwitchBot Water Leak Detector advertisement data."""
6
+ if data is None or len(data) < 3 or mfr_data is None or len(mfr_data) < 2:
7
+ return {}
8
+
9
+ water_leak_detected = None
10
+ device_tampered = None
11
+ battery_level = None
12
+ low_battery = None
13
+
14
+ # Byte 1: Event Flags
15
+ event_flags = mfr_data[8]
16
+ water_leak_detected = bool(event_flags & 0b00000001) # Bit 0
17
+ device_tampered = bool(event_flags & 0b00000010) # Bit 1
18
+
19
+ # Byte 2: Battery Info
20
+ battery_info = mfr_data[7]
21
+ battery_level = battery_info & 0b01111111 # Bits 0-6
22
+ low_battery = bool(battery_info & 0b10000000) # Bit 7
23
+
24
+ return {
25
+ "leak": water_leak_detected,
26
+ "tampered": device_tampered,
27
+ "battery": battery_level,
28
+ "low_battery": low_battery,
29
+ }
@@ -53,6 +53,7 @@ class SwitchbotModel(StrEnum):
53
53
  LOCK_PRO = "WoLockPro"
54
54
  BLIND_TILT = "WoBlindTilt"
55
55
  HUB2 = "WoHub2"
56
+ LEAK = "Leak Detector"
56
57
  KEYPAD = "WoKeypad"
57
58
  RELAY_SWITCH_1PM = "Relay Switch 1PM"
58
59
  RELAY_SWITCH_1 = "Relay Switch 1"
@@ -8,9 +8,11 @@ import logging
8
8
  import time
9
9
  from dataclasses import replace
10
10
  from enum import Enum
11
- from typing import Any, Callable, TypeVar, cast
11
+ from typing import Any, TypeVar, cast
12
+ from collections.abc import Callable
12
13
  from uuid import UUID
13
14
 
15
+ import aiohttp
14
16
  from bleak.backends.device import BLEDevice
15
17
  from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection
16
18
  from bleak.exc import BleakDBusError
@@ -22,7 +24,15 @@ from bleak_retry_connector import (
22
24
  establish_connection,
23
25
  )
24
26
 
25
- from ..const import DEFAULT_RETRY_COUNT, DEFAULT_SCAN_TIMEOUT
27
+ from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
28
+ from ..const import (
29
+ DEFAULT_RETRY_COUNT,
30
+ DEFAULT_SCAN_TIMEOUT,
31
+ SwitchbotAccountConnectionError,
32
+ SwitchbotApiError,
33
+ SwitchbotAuthenticationError,
34
+ SwitchbotModel,
35
+ )
26
36
  from ..discovery import GetSwitchbotDevices
27
37
  from ..models import SwitchBotAdvertisement
28
38
 
@@ -151,6 +161,35 @@ class SwitchbotBaseDevice:
151
161
  self._last_full_update: float = -PASSIVE_POLL_INTERVAL
152
162
  self._timed_disconnect_task: asyncio.Task[None] | None = None
153
163
 
164
+ @classmethod
165
+ async def api_request(
166
+ cls,
167
+ session: aiohttp.ClientSession,
168
+ subdomain: str,
169
+ path: str,
170
+ data: dict = None,
171
+ headers: dict = None,
172
+ ) -> dict:
173
+ url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
174
+ async with session.post(
175
+ url,
176
+ json=data,
177
+ headers=headers,
178
+ timeout=aiohttp.ClientTimeout(total=10),
179
+ ) as result:
180
+ if result.status > 299:
181
+ raise SwitchbotApiError(
182
+ f"Unexpected status code returned by SwitchBot API: {result.status}"
183
+ )
184
+
185
+ response = await result.json()
186
+ if response["statusCode"] != 100:
187
+ raise SwitchbotApiError(
188
+ f"{response['message']}, status code: {response['statusCode']}"
189
+ )
190
+
191
+ return response["body"]
192
+
154
193
  def advertisement_changed(self, advertisement: SwitchBotAdvertisement) -> bool:
155
194
  """Check if the advertisement has changed."""
156
195
  return bool(
@@ -470,7 +509,7 @@ class SwitchbotBaseDevice:
470
509
  timeout_expired = False
471
510
  try:
472
511
  notify_msg = await self._notify_future
473
- except asyncio.TimeoutError:
512
+ except TimeoutError:
474
513
  timeout_expired = True
475
514
  raise
476
515
  finally:
@@ -665,6 +704,130 @@ class SwitchbotDevice(SwitchbotBaseDevice):
665
704
  self._set_advertisement_data(advertisement)
666
705
 
667
706
 
707
+ class SwitchbotEncryptedDevice(SwitchbotDevice):
708
+ """A Switchbot device that uses encryption."""
709
+
710
+ def __init__(
711
+ self,
712
+ device: BLEDevice,
713
+ key_id: str,
714
+ encryption_key: str,
715
+ model: SwitchbotModel,
716
+ interface: int = 0,
717
+ **kwargs: Any,
718
+ ) -> None:
719
+ """Switchbot base class constructor for encrypted devices."""
720
+ if len(key_id) == 0:
721
+ raise ValueError("key_id is missing")
722
+ elif len(key_id) != 2:
723
+ raise ValueError("key_id is invalid")
724
+ if len(encryption_key) == 0:
725
+ raise ValueError("encryption_key is missing")
726
+ elif len(encryption_key) != 32:
727
+ raise ValueError("encryption_key is invalid")
728
+ self._key_id = key_id
729
+ self._encryption_key = bytearray.fromhex(encryption_key)
730
+ self._iv: bytes | None = None
731
+ self._cipher: bytes | None = None
732
+ self._model = model
733
+ super().__init__(device, None, interface, **kwargs)
734
+
735
+ # Old non-async method preserved for backwards compatibility
736
+ @classmethod
737
+ def retrieve_encryption_key(cls, device_mac: str, username: str, password: str):
738
+ async def async_fn():
739
+ async with aiohttp.ClientSession() as session:
740
+ return await cls.async_retrieve_encryption_key(
741
+ session, device_mac, username, password
742
+ )
743
+
744
+ return asyncio.run(async_fn())
745
+
746
+ @classmethod
747
+ async def async_retrieve_encryption_key(
748
+ cls,
749
+ session: aiohttp.ClientSession,
750
+ device_mac: str,
751
+ username: str,
752
+ password: str,
753
+ ) -> dict:
754
+ """Retrieve lock key from internal SwitchBot API."""
755
+ device_mac = device_mac.replace(":", "").replace("-", "").upper()
756
+
757
+ try:
758
+ auth_result = await cls.api_request(
759
+ session,
760
+ "account",
761
+ "account/api/v1/user/login",
762
+ {
763
+ "clientId": SWITCHBOT_APP_CLIENT_ID,
764
+ "username": username,
765
+ "password": password,
766
+ "grantType": "password",
767
+ "verifyCode": "",
768
+ },
769
+ )
770
+ auth_headers = {"authorization": auth_result["access_token"]}
771
+ except Exception as err:
772
+ raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
773
+
774
+ try:
775
+ userinfo = await cls.api_request(
776
+ session, "account", "account/api/v1/user/userinfo", {}, auth_headers
777
+ )
778
+ if "botRegion" in userinfo and userinfo["botRegion"] != "":
779
+ region = userinfo["botRegion"]
780
+ else:
781
+ region = "us"
782
+ except Exception as err:
783
+ raise SwitchbotAccountConnectionError(
784
+ f"Failed to retrieve SwitchBot Account user details: {err}"
785
+ ) from err
786
+
787
+ try:
788
+ device_info = await cls.api_request(
789
+ session,
790
+ f"wonderlabs.{region}",
791
+ "wonder/keys/v1/communicate",
792
+ {
793
+ "device_mac": device_mac,
794
+ "keyType": "user",
795
+ },
796
+ auth_headers,
797
+ )
798
+
799
+ return {
800
+ "key_id": device_info["communicationKey"]["keyId"],
801
+ "encryption_key": device_info["communicationKey"]["key"],
802
+ }
803
+ except Exception as err:
804
+ raise SwitchbotAccountConnectionError(
805
+ f"Failed to retrieve encryption key from SwitchBot Account: {err}"
806
+ ) from err
807
+
808
+ @classmethod
809
+ async def verify_encryption_key(
810
+ cls,
811
+ device: BLEDevice,
812
+ key_id: str,
813
+ encryption_key: str,
814
+ model: SwitchbotModel,
815
+ **kwargs: Any,
816
+ ) -> bool:
817
+ try:
818
+ device = cls(
819
+ device, key_id=key_id, encryption_key=encryption_key, model=model
820
+ )
821
+ except ValueError:
822
+ return False
823
+ try:
824
+ info = await device.get_basic_info()
825
+ except SwitchbotOperationError:
826
+ return False
827
+
828
+ return info is not None
829
+
830
+
668
831
  class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
669
832
  """Base Representation of a Switchbot Device.
670
833
 
@@ -2,24 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
5
  import logging
7
6
  import time
8
7
  from typing import Any
9
8
 
10
- import aiohttp
11
9
  from bleak.backends.device import BLEDevice
12
10
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
13
11
 
14
- from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
15
- from ..const import (
16
- LockStatus,
17
- SwitchbotAccountConnectionError,
18
- SwitchbotApiError,
19
- SwitchbotAuthenticationError,
20
- SwitchbotModel,
21
- )
22
- from .device import SwitchbotDevice, SwitchbotOperationError
12
+ from ..const import LockStatus, SwitchbotModel
13
+ from .device import SwitchbotEncryptedDevice
23
14
 
24
15
  COMMAND_HEADER = "57"
25
16
  COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
@@ -54,7 +45,7 @@ COMMAND_RESULT_EXPECTED_VALUES = {1, 6}
54
45
  # The return value of the command is 6 when the command is successful but the battery is low.
55
46
 
56
47
 
57
- class SwitchbotLock(SwitchbotDevice):
48
+ class SwitchbotLock(SwitchbotEncryptedDevice):
58
49
  """Representation of a Switchbot Lock."""
59
50
 
60
51
  def __init__(
@@ -66,141 +57,23 @@ class SwitchbotLock(SwitchbotDevice):
66
57
  model: SwitchbotModel = SwitchbotModel.LOCK,
67
58
  **kwargs: Any,
68
59
  ) -> None:
69
- if len(key_id) == 0:
70
- raise ValueError("key_id is missing")
71
- elif len(key_id) != 2:
72
- raise ValueError("key_id is invalid")
73
- if len(encryption_key) == 0:
74
- raise ValueError("encryption_key is missing")
75
- elif len(encryption_key) != 32:
76
- raise ValueError("encryption_key is invalid")
77
60
  if model not in (SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO):
78
61
  raise ValueError("initializing SwitchbotLock with a non-lock model")
79
- self._iv = None
80
- self._cipher = None
81
- self._key_id = key_id
82
- self._encryption_key = bytearray.fromhex(encryption_key)
83
62
  self._notifications_enabled: bool = False
84
- self._model: SwitchbotModel = model
85
- super().__init__(device, None, interface, **kwargs)
63
+ super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
86
64
 
87
- @staticmethod
65
+ @classmethod
88
66
  async def verify_encryption_key(
67
+ cls,
89
68
  device: BLEDevice,
90
69
  key_id: str,
91
70
  encryption_key: str,
92
71
  model: SwitchbotModel = SwitchbotModel.LOCK,
93
72
  **kwargs: Any,
94
73
  ) -> bool:
95
- try:
96
- lock = SwitchbotLock(
97
- device, key_id=key_id, encryption_key=encryption_key, model=model
98
- )
99
- except ValueError:
100
- return False
101
- try:
102
- lock_info = await lock.get_basic_info()
103
- except SwitchbotOperationError:
104
- return False
105
-
106
- return lock_info is not None
107
-
108
- @staticmethod
109
- async def api_request(
110
- session: aiohttp.ClientSession,
111
- subdomain: str,
112
- path: str,
113
- data: dict = None,
114
- headers: dict = None,
115
- ) -> dict:
116
- url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
117
- async with session.post(
118
- url,
119
- json=data,
120
- headers=headers,
121
- timeout=aiohttp.ClientTimeout(total=10),
122
- ) as result:
123
- if result.status > 299:
124
- raise SwitchbotApiError(
125
- f"Unexpected status code returned by SwitchBot API: {result.status}"
126
- )
127
-
128
- response = await result.json()
129
- if response["statusCode"] != 100:
130
- raise SwitchbotApiError(
131
- f"{response['message']}, status code: {response['statusCode']}"
132
- )
133
-
134
- return response["body"]
135
-
136
- # Old non-async method preserved for backwards compatibility
137
- @staticmethod
138
- def retrieve_encryption_key(device_mac: str, username: str, password: str):
139
- async def async_fn():
140
- async with aiohttp.ClientSession() as session:
141
- return await SwitchbotLock.async_retrieve_encryption_key(
142
- session, device_mac, username, password
143
- )
144
-
145
- return asyncio.run(async_fn())
146
-
147
- @staticmethod
148
- async def async_retrieve_encryption_key(
149
- session: aiohttp.ClientSession, device_mac: str, username: str, password: str
150
- ) -> dict:
151
- """Retrieve lock key from internal SwitchBot API."""
152
- device_mac = device_mac.replace(":", "").replace("-", "").upper()
153
-
154
- try:
155
- auth_result = await SwitchbotLock.api_request(
156
- session,
157
- "account",
158
- "account/api/v1/user/login",
159
- {
160
- "clientId": SWITCHBOT_APP_CLIENT_ID,
161
- "username": username,
162
- "password": password,
163
- "grantType": "password",
164
- "verifyCode": "",
165
- },
166
- )
167
- auth_headers = {"authorization": auth_result["access_token"]}
168
- except Exception as err:
169
- raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
170
-
171
- try:
172
- userinfo = await SwitchbotLock.api_request(
173
- session, "account", "account/api/v1/user/userinfo", {}, auth_headers
174
- )
175
- if "botRegion" in userinfo and userinfo["botRegion"] != "":
176
- region = userinfo["botRegion"]
177
- else:
178
- region = "us"
179
- except Exception as err:
180
- raise SwitchbotAccountConnectionError(
181
- f"Failed to retrieve SwitchBot Account user details: {err}"
182
- ) from err
183
-
184
- try:
185
- device_info = await SwitchbotLock.api_request(
186
- session,
187
- f"wonderlabs.{region}",
188
- "wonder/keys/v1/communicate",
189
- {
190
- "device_mac": device_mac,
191
- "keyType": "user",
192
- },
193
- auth_headers,
194
- )
195
-
196
- return {
197
- "key_id": device_info["communicationKey"]["keyId"],
198
- "encryption_key": device_info["communicationKey"]["key"],
199
- }
200
- except Exception as err:
201
- raise SwitchbotAccountConnectionError(
202
- f"Failed to retrieve encryption key from SwitchBot Account: {err}"
203
- ) from err
74
+ return await super().verify_encryption_key(
75
+ device, key_id, encryption_key, model, **kwargs
76
+ )
204
77
 
205
78
  async def lock(self) -> bool:
206
79
  """Send lock command."""
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  import logging
3
2
  import time
4
3
  from typing import Any
@@ -8,7 +7,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
8
7
 
9
8
  from ..const import SwitchbotModel
10
9
  from ..models import SwitchBotAdvertisement
11
- from .device import SwitchbotDevice
10
+ from .device import SwitchbotEncryptedDevice
12
11
 
13
12
  _LOGGER = logging.getLogger(__name__)
14
13
 
@@ -21,7 +20,7 @@ COMMAND_GET_VOLTAGE_AND_CURRENT = f"{COMMAND_HEADER}0f7106000000"
21
20
  PASSIVE_POLL_INTERVAL = 10 * 60
22
21
 
23
22
 
24
- class SwitchbotRelaySwitch(SwitchbotDevice):
23
+ class SwitchbotRelaySwitch(SwitchbotEncryptedDevice):
25
24
  """Representation of a Switchbot relay switch 1pm."""
26
25
 
27
26
  def __init__(
@@ -33,21 +32,21 @@ class SwitchbotRelaySwitch(SwitchbotDevice):
33
32
  model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM,
34
33
  **kwargs: Any,
35
34
  ) -> None:
36
- if len(key_id) == 0:
37
- raise ValueError("key_id is missing")
38
- elif len(key_id) != 2:
39
- raise ValueError("key_id is invalid")
40
- if len(encryption_key) == 0:
41
- raise ValueError("encryption_key is missing")
42
- elif len(encryption_key) != 32:
43
- raise ValueError("encryption_key is invalid")
44
- self._iv = None
45
- self._cipher = None
46
- self._key_id = key_id
47
- self._encryption_key = bytearray.fromhex(encryption_key)
48
- self._model: SwitchbotModel = model
49
35
  self._force_next_update = False
50
- super().__init__(device, None, interface, **kwargs)
36
+ super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
37
+
38
+ @classmethod
39
+ async def verify_encryption_key(
40
+ cls,
41
+ device: BLEDevice,
42
+ key_id: str,
43
+ encryption_key: str,
44
+ model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM,
45
+ **kwargs: Any,
46
+ ) -> bool:
47
+ return await super().verify_encryption_key(
48
+ device, key_id, encryption_key, model, **kwargs
49
+ )
51
50
 
52
51
  def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
53
52
  """Update device data from advertisement."""
@@ -1737,3 +1737,151 @@ def test_parse_advertisement_data_relay_switch_1():
1737
1737
  rssi=-67,
1738
1738
  active=True,
1739
1739
  )
1740
+
1741
+
1742
+ def test_leak_active():
1743
+ """Test parse_advertisement_data for the leak detector."""
1744
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1745
+ adv_data = generate_advertisement_data(
1746
+ manufacturer_data={2409: b"\xc4407Lz\x18N\x98g^\x94Q<\x05\x00\x00\x00\x00"},
1747
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00N"},
1748
+ rssi=-72,
1749
+ )
1750
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK)
1751
+ assert result == SwitchBotAdvertisement(
1752
+ address="aa:bb:cc:dd:ee:ff",
1753
+ data={
1754
+ "data": {
1755
+ "leak": False,
1756
+ "tampered": False,
1757
+ "battery": 78,
1758
+ "low_battery": False,
1759
+ },
1760
+ "isEncrypted": False,
1761
+ "model": "3",
1762
+ "modelFriendlyName": "Leak Detector",
1763
+ "modelName": SwitchbotModel.LEAK,
1764
+ "rawAdvData": b"&\x00N",
1765
+ },
1766
+ device=ble_device,
1767
+ rssi=-72,
1768
+ active=True,
1769
+ )
1770
+
1771
+
1772
+ def test_leak_passive():
1773
+ """Test parse_advertisement_data for the leak detector."""
1774
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1775
+ adv_data = generate_advertisement_data(
1776
+ manufacturer_data={2409: b"\xc4407Lz\x18N\x98g^\x94Q<\x05\x00\x00\x00\x00"},
1777
+ rssi=-72,
1778
+ )
1779
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK)
1780
+ assert result == SwitchBotAdvertisement(
1781
+ address="aa:bb:cc:dd:ee:ff",
1782
+ data={
1783
+ "data": {},
1784
+ "isEncrypted": False,
1785
+ "model": "3",
1786
+ "rawAdvData": None,
1787
+ },
1788
+ device=ble_device,
1789
+ rssi=-72,
1790
+ active=False,
1791
+ )
1792
+
1793
+
1794
+ def test_leak_no_leak_detected():
1795
+ """Test parse_advertisement_data for the leak detector."""
1796
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any")
1797
+ adv_data = generate_advertisement_data(
1798
+ manufacturer_data={
1799
+ 2409: b"\xc4407LzJd\x98ga\xc4\n<\x05\x00\x00\x00\x00"
1800
+ }, # no leak, batt
1801
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00d"},
1802
+ rssi=-73,
1803
+ )
1804
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK)
1805
+ assert result == SwitchBotAdvertisement(
1806
+ address="aa:bb:cc:dd:ee:ff",
1807
+ data={
1808
+ "data": {
1809
+ "leak": False,
1810
+ "tampered": False,
1811
+ "battery": 100,
1812
+ "low_battery": False,
1813
+ },
1814
+ "isEncrypted": False,
1815
+ "model": "3",
1816
+ "modelFriendlyName": "Leak Detector",
1817
+ "modelName": SwitchbotModel.LEAK,
1818
+ "rawAdvData": b"&\x00d",
1819
+ },
1820
+ device=ble_device,
1821
+ rssi=-73,
1822
+ active=True,
1823
+ )
1824
+
1825
+
1826
+ def test_leak_leak_detected():
1827
+ """Test parse_advertisement_data for the leak detector."""
1828
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any")
1829
+ adv_data = generate_advertisement_data(
1830
+ manufacturer_data={
1831
+ 2409: b"\xc4407LzGd\xf9ga\xc4\x08<\x05\x00\x00\x00\x00"
1832
+ }, # leak, batt
1833
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00d"},
1834
+ rssi=-73,
1835
+ )
1836
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK)
1837
+ assert result == SwitchBotAdvertisement(
1838
+ address="aa:bb:cc:dd:ee:ff",
1839
+ data={
1840
+ "data": {
1841
+ "leak": True,
1842
+ "tampered": False,
1843
+ "battery": 100,
1844
+ "low_battery": False,
1845
+ },
1846
+ "isEncrypted": False,
1847
+ "model": "3",
1848
+ "modelFriendlyName": "Leak Detector",
1849
+ "modelName": SwitchbotModel.LEAK,
1850
+ "rawAdvData": b"&\x00d",
1851
+ },
1852
+ device=ble_device,
1853
+ rssi=-73,
1854
+ active=True,
1855
+ )
1856
+
1857
+
1858
+ def test_leak_low_battery():
1859
+ """Test parse_advertisement_data for the leak detector."""
1860
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any")
1861
+ adv_data = generate_advertisement_data(
1862
+ manufacturer_data={
1863
+ 2409: b"\xc4407Lz\x02\t\x98\x00\x00\x00\x00<\x05\x00\x00\x00\x00"
1864
+ }, # no leak, low battery
1865
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00d"},
1866
+ rssi=-73,
1867
+ )
1868
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK)
1869
+ assert result == SwitchBotAdvertisement(
1870
+ address="aa:bb:cc:dd:ee:ff",
1871
+ data={
1872
+ "data": {
1873
+ "leak": False,
1874
+ "tampered": False,
1875
+ "battery": 9,
1876
+ "low_battery": False,
1877
+ },
1878
+ "isEncrypted": False,
1879
+ "model": "3",
1880
+ "modelFriendlyName": "Leak Detector",
1881
+ "modelName": SwitchbotModel.LEAK,
1882
+ "rawAdvData": b"&\x00d",
1883
+ },
1884
+ device=ble_device,
1885
+ rssi=-73,
1886
+ active=True,
1887
+ )
File without changes
File without changes
File without changes
File without changes