PySwitchbot 0.53.2__tar.gz → 0.55.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 (58) hide show
  1. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/PKG-INFO +2 -1
  2. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/PySwitchbot.egg-info/PKG-INFO +2 -1
  3. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/PySwitchbot.egg-info/SOURCES.txt +3 -1
  4. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/setup.py +2 -1
  5. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/__init__.py +1 -0
  6. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parser.py +10 -3
  7. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/blind_tilt.py +1 -0
  8. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/bot.py +1 -0
  9. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/bulb.py +1 -0
  10. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/ceiling_light.py +1 -0
  11. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/contact.py +1 -0
  12. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/curtain.py +1 -0
  13. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/humidifier.py +1 -0
  14. pyswitchbot-0.55.0/switchbot/adv_parsers/leak.py +29 -0
  15. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/light_strip.py +1 -0
  16. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/lock.py +1 -0
  17. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/meter.py +1 -0
  18. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/motion.py +1 -0
  19. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/plug.py +1 -0
  20. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/relay_switch.py +2 -1
  21. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/const.py +2 -1
  22. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/base_cover.py +1 -0
  23. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/blind_tilt.py +1 -0
  24. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/bot.py +1 -0
  25. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/curtain.py +1 -0
  26. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/device.py +167 -3
  27. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/humidifier.py +1 -0
  28. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/lock.py +10 -136
  29. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/plug.py +1 -0
  30. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/relay_switch.py +47 -18
  31. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/enum.py +1 -0
  32. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/models.py +1 -0
  33. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/tests/test_adv_parser.py +213 -0
  34. pyswitchbot-0.55.0/tests/test_relay_switch.py +60 -0
  35. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/LICENSE +0 -0
  36. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/MANIFEST.in +0 -0
  37. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  38. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/PySwitchbot.egg-info/requires.txt +0 -0
  39. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/PySwitchbot.egg-info/top_level.txt +0 -0
  40. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/README.md +0 -0
  41. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/setup.cfg +0 -0
  42. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/__init__.py +0 -0
  43. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/hub2.py +0 -0
  44. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/adv_parsers/keypad.py +0 -0
  45. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/api_config.py +0 -0
  46. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/__init__.py +0 -0
  47. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/base_light.py +0 -0
  48. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/bulb.py +0 -0
  49. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/ceiling_light.py +0 -0
  50. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/contact.py +0 -0
  51. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/keypad.py +0 -0
  52. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/light_strip.py +0 -0
  53. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/meter.py +0 -0
  54. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/devices/motion.py +0 -0
  55. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/switchbot/discovery.py +0 -0
  56. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/tests/test_base_cover.py +0 -0
  57. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/tests/test_blind_tilt.py +0 -0
  58. {pyswitchbot-0.53.2 → pyswitchbot-0.55.0}/tests/test_curtain.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PySwitchbot
3
- Version: 0.53.2
3
+ Version: 0.55.0
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.53.2
3
+ Version: 0.55.0
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
@@ -51,4 +52,5 @@ switchbot/devices/relay_switch.py
51
52
  tests/test_adv_parser.py
52
53
  tests/test_base_cover.py
53
54
  tests/test_blind_tilt.py
54
- tests/test_curtain.py
55
+ tests/test_curtain.py
56
+ tests/test_relay_switch.py
@@ -15,13 +15,14 @@ setup(
15
15
  "cryptography>=39.0.0",
16
16
  "pyOpenSSL>=23.0.0",
17
17
  ],
18
- version="0.53.2",
18
+ version="0.55.0",
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",
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  from bleak_retry_connector import (
@@ -19,13 +19,14 @@ 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
25
26
  from .adv_parsers.motion import process_wopresence
26
27
  from .adv_parsers.plug import process_woplugmini
27
28
  from .adv_parsers.relay_switch import (
28
- process_worelay_switch_1plus,
29
+ process_worelay_switch_1,
29
30
  process_worelay_switch_1pm,
30
31
  )
31
32
  from .const import SwitchbotModel
@@ -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",
@@ -191,9 +198,9 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
191
198
  "manufacturer_id": 2409,
192
199
  },
193
200
  ";": {
194
- "modelName": SwitchbotModel.RELAY_SWITCH_1_PLUS,
201
+ "modelName": SwitchbotModel.RELAY_SWITCH_1,
195
202
  "modelFriendlyName": "Relay Switch 1",
196
- "func": process_worelay_switch_1plus,
203
+ "func": process_worelay_switch_1,
197
204
  "manufacturer_id": 2409,
198
205
  },
199
206
  }
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Bulb parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Ceiling Light adv parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -1,4 +1,5 @@
1
1
  """Contact sensor parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Humidifier adv parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  """Light strip adv parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Lock parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -1,4 +1,5 @@
1
1
  """Meter parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import struct
@@ -1,4 +1,5 @@
1
1
  """Motion sensor parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Relay Switch adv parser."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -18,7 +19,7 @@ def process_worelay_switch_1pm(
18
19
  }
19
20
 
20
21
 
21
- def process_worelay_switch_1plus(
22
+ def process_worelay_switch_1(
22
23
  data: bytes | None, mfr_data: bytes | None
23
24
  ) -> dict[str, bool | int]:
24
25
  """Process WoStrip services data."""
@@ -53,9 +53,10 @@ 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
- RELAY_SWITCH_1_PLUS = "Relay Switch 1"
59
+ RELAY_SWITCH_1 = "Relay Switch 1"
59
60
 
60
61
 
61
62
  class LockStatus(Enum):
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import logging
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import asyncio
@@ -7,9 +8,11 @@ import logging
7
8
  import time
8
9
  from dataclasses import replace
9
10
  from enum import Enum
10
- from typing import Any, Callable, TypeVar, cast
11
+ from typing import Any, TypeVar, cast
12
+ from collections.abc import Callable
11
13
  from uuid import UUID
12
14
 
15
+ import aiohttp
13
16
  from bleak.backends.device import BLEDevice
14
17
  from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection
15
18
  from bleak.exc import BleakDBusError
@@ -21,7 +24,15 @@ from bleak_retry_connector import (
21
24
  establish_connection,
22
25
  )
23
26
 
24
- 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
+ )
25
36
  from ..discovery import GetSwitchbotDevices
26
37
  from ..models import SwitchBotAdvertisement
27
38
 
@@ -150,6 +161,35 @@ class SwitchbotBaseDevice:
150
161
  self._last_full_update: float = -PASSIVE_POLL_INTERVAL
151
162
  self._timed_disconnect_task: asyncio.Task[None] | None = None
152
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
+
153
193
  def advertisement_changed(self, advertisement: SwitchBotAdvertisement) -> bool:
154
194
  """Check if the advertisement has changed."""
155
195
  return bool(
@@ -469,7 +509,7 @@ class SwitchbotBaseDevice:
469
509
  timeout_expired = False
470
510
  try:
471
511
  notify_msg = await self._notify_future
472
- except asyncio.TimeoutError:
512
+ except TimeoutError:
473
513
  timeout_expired = True
474
514
  raise
475
515
  finally:
@@ -664,6 +704,130 @@ class SwitchbotDevice(SwitchbotBaseDevice):
664
704
  self._set_advertisement_data(advertisement)
665
705
 
666
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
+
667
831
  class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
668
832
  """Base Representation of a Switchbot Device.
669
833
 
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import time
@@ -1,24 +1,16 @@
1
1
  """Library to handle connection with Switchbot Lock."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
- import asyncio
5
5
  import logging
6
6
  import time
7
7
  from typing import Any
8
8
 
9
- import aiohttp
10
9
  from bleak.backends.device import BLEDevice
11
10
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
12
11
 
13
- from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
14
- from ..const import (
15
- LockStatus,
16
- SwitchbotAccountConnectionError,
17
- SwitchbotApiError,
18
- SwitchbotAuthenticationError,
19
- SwitchbotModel,
20
- )
21
- from .device import SwitchbotDevice, SwitchbotOperationError
12
+ from ..const import LockStatus, SwitchbotModel
13
+ from .device import SwitchbotEncryptedDevice
22
14
 
23
15
  COMMAND_HEADER = "57"
24
16
  COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
@@ -53,7 +45,7 @@ COMMAND_RESULT_EXPECTED_VALUES = {1, 6}
53
45
  # The return value of the command is 6 when the command is successful but the battery is low.
54
46
 
55
47
 
56
- class SwitchbotLock(SwitchbotDevice):
48
+ class SwitchbotLock(SwitchbotEncryptedDevice):
57
49
  """Representation of a Switchbot Lock."""
58
50
 
59
51
  def __init__(
@@ -65,141 +57,23 @@ class SwitchbotLock(SwitchbotDevice):
65
57
  model: SwitchbotModel = SwitchbotModel.LOCK,
66
58
  **kwargs: Any,
67
59
  ) -> None:
68
- if len(key_id) == 0:
69
- raise ValueError("key_id is missing")
70
- elif len(key_id) != 2:
71
- raise ValueError("key_id is invalid")
72
- if len(encryption_key) == 0:
73
- raise ValueError("encryption_key is missing")
74
- elif len(encryption_key) != 32:
75
- raise ValueError("encryption_key is invalid")
76
60
  if model not in (SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO):
77
61
  raise ValueError("initializing SwitchbotLock with a non-lock model")
78
- self._iv = None
79
- self._cipher = None
80
- self._key_id = key_id
81
- self._encryption_key = bytearray.fromhex(encryption_key)
82
62
  self._notifications_enabled: bool = False
83
- self._model: SwitchbotModel = model
84
- super().__init__(device, None, interface, **kwargs)
63
+ super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
85
64
 
86
- @staticmethod
65
+ @classmethod
87
66
  async def verify_encryption_key(
67
+ cls,
88
68
  device: BLEDevice,
89
69
  key_id: str,
90
70
  encryption_key: str,
91
71
  model: SwitchbotModel = SwitchbotModel.LOCK,
92
72
  **kwargs: Any,
93
73
  ) -> bool:
94
- try:
95
- lock = SwitchbotLock(
96
- device, key_id=key_id, encryption_key=encryption_key, model=model
97
- )
98
- except ValueError:
99
- return False
100
- try:
101
- lock_info = await lock.get_basic_info()
102
- except SwitchbotOperationError:
103
- return False
104
-
105
- return lock_info is not None
106
-
107
- @staticmethod
108
- async def api_request(
109
- session: aiohttp.ClientSession,
110
- subdomain: str,
111
- path: str,
112
- data: dict = None,
113
- headers: dict = None,
114
- ) -> dict:
115
- url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
116
- async with session.post(
117
- url,
118
- json=data,
119
- headers=headers,
120
- timeout=aiohttp.ClientTimeout(total=10),
121
- ) as result:
122
- if result.status > 299:
123
- raise SwitchbotApiError(
124
- f"Unexpected status code returned by SwitchBot API: {result.status}"
125
- )
126
-
127
- response = await result.json()
128
- if response["statusCode"] != 100:
129
- raise SwitchbotApiError(
130
- f"{response['message']}, status code: {response['statusCode']}"
131
- )
132
-
133
- return response["body"]
134
-
135
- # Old non-async method preserved for backwards compatibility
136
- @staticmethod
137
- def retrieve_encryption_key(device_mac: str, username: str, password: str):
138
- async def async_fn():
139
- async with aiohttp.ClientSession() as session:
140
- return await SwitchbotLock.async_retrieve_encryption_key(
141
- session, device_mac, username, password
142
- )
143
-
144
- return asyncio.run(async_fn())
145
-
146
- @staticmethod
147
- async def async_retrieve_encryption_key(
148
- session: aiohttp.ClientSession, device_mac: str, username: str, password: str
149
- ) -> dict:
150
- """Retrieve lock key from internal SwitchBot API."""
151
- device_mac = device_mac.replace(":", "").replace("-", "").upper()
152
-
153
- try:
154
- auth_result = await SwitchbotLock.api_request(
155
- session,
156
- "account",
157
- "account/api/v1/user/login",
158
- {
159
- "clientId": SWITCHBOT_APP_CLIENT_ID,
160
- "username": username,
161
- "password": password,
162
- "grantType": "password",
163
- "verifyCode": "",
164
- },
165
- )
166
- auth_headers = {"authorization": auth_result["access_token"]}
167
- except Exception as err:
168
- raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
169
-
170
- try:
171
- userinfo = await SwitchbotLock.api_request(
172
- session, "account", "account/api/v1/user/userinfo", {}, auth_headers
173
- )
174
- if "botRegion" in userinfo and userinfo["botRegion"] != "":
175
- region = userinfo["botRegion"]
176
- else:
177
- region = "us"
178
- except Exception as err:
179
- raise SwitchbotAccountConnectionError(
180
- f"Failed to retrieve SwitchBot Account user details: {err}"
181
- ) from err
182
-
183
- try:
184
- device_info = await SwitchbotLock.api_request(
185
- session,
186
- f"wonderlabs.{region}",
187
- "wonder/keys/v1/communicate",
188
- {
189
- "device_mac": device_mac,
190
- "keyType": "user",
191
- },
192
- auth_headers,
193
- )
194
-
195
- return {
196
- "key_id": device_info["communicationKey"]["keyId"],
197
- "encryption_key": device_info["communicationKey"]["key"],
198
- }
199
- except Exception as err:
200
- raise SwitchbotAccountConnectionError(
201
- f"Failed to retrieve encryption key from SwitchBot Account: {err}"
202
- ) from err
74
+ return super().verify_encryption_key(
75
+ device, key_id, encryption_key, model, **kwargs
76
+ )
203
77
 
204
78
  async def lock(self) -> bool:
205
79
  """Send lock command."""
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import time
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import time
2
3
  from typing import Any
3
4
 
@@ -5,7 +6,10 @@ from bleak.backends.device import BLEDevice
5
6
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
6
7
 
7
8
  from ..const import SwitchbotModel
8
- from .device import SwitchbotSequenceDevice
9
+ from ..models import SwitchBotAdvertisement
10
+ from .device import SwitchbotEncryptedDevice
11
+
12
+ _LOGGER = logging.getLogger(__name__)
9
13
 
10
14
  COMMAND_HEADER = "57"
11
15
  COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
@@ -13,10 +17,10 @@ COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f70010000"
13
17
  COMMAND_TURN_ON = f"{COMMAND_HEADER}0f70010100"
14
18
  COMMAND_TOGGLE = f"{COMMAND_HEADER}0f70010200"
15
19
  COMMAND_GET_VOLTAGE_AND_CURRENT = f"{COMMAND_HEADER}0f7106000000"
16
- PASSIVE_POLL_INTERVAL = 1 * 60
20
+ PASSIVE_POLL_INTERVAL = 10 * 60
17
21
 
18
22
 
19
- class SwitchbotRelaySwitch(SwitchbotSequenceDevice):
23
+ class SwitchbotRelaySwitch(SwitchbotEncryptedDevice):
20
24
  """Representation of a Switchbot relay switch 1pm."""
21
25
 
22
26
  def __init__(
@@ -28,20 +32,42 @@ class SwitchbotRelaySwitch(SwitchbotSequenceDevice):
28
32
  model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM,
29
33
  **kwargs: Any,
30
34
  ) -> None:
31
- if len(key_id) == 0:
32
- raise ValueError("key_id is missing")
33
- elif len(key_id) != 2:
34
- raise ValueError("key_id is invalid")
35
- if len(encryption_key) == 0:
36
- raise ValueError("encryption_key is missing")
37
- elif len(encryption_key) != 32:
38
- raise ValueError("encryption_key is invalid")
39
- self._iv = None
40
- self._cipher = None
41
- self._key_id = key_id
42
- self._encryption_key = bytearray.fromhex(encryption_key)
43
- self._model: SwitchbotModel = model
44
- super().__init__(device, None, interface, **kwargs)
35
+ self._force_next_update = False
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 super().verify_encryption_key(
48
+ device, key_id, encryption_key, model, **kwargs
49
+ )
50
+
51
+ def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
52
+ """Update device data from advertisement."""
53
+ # Obtain voltage and current through command.
54
+ adv_data = advertisement.data["data"]
55
+ if previous_voltage := self._get_adv_value("voltage"):
56
+ adv_data["voltage"] = previous_voltage
57
+ if previous_current := self._get_adv_value("current"):
58
+ adv_data["current"] = previous_current
59
+ current_state = self._get_adv_value("sequence_number")
60
+ super().update_from_advertisement(advertisement)
61
+ new_state = self._get_adv_value("sequence_number")
62
+ _LOGGER.debug(
63
+ "%s: update advertisement: %s (seq before: %s) (seq after: %s)",
64
+ self.name,
65
+ advertisement,
66
+ current_state,
67
+ new_state,
68
+ )
69
+ if current_state != new_state:
70
+ self._force_next_update = True
45
71
 
46
72
  async def update(self, interface: int | None = None) -> None:
47
73
  """Update state of device."""
@@ -56,13 +82,16 @@ class SwitchbotRelaySwitch(SwitchbotSequenceDevice):
56
82
  ok = self._check_command_result(result, 0, {1})
57
83
  if ok:
58
84
  return {
59
- "voltage": (result[9] << 8) + result[10],
85
+ "voltage": ((result[9] << 8) + result[10]) / 10,
60
86
  "current": (result[11] << 8) + result[12],
61
87
  }
62
88
  return None
63
89
 
64
90
  def poll_needed(self, seconds_since_last_poll: float | None) -> bool:
65
91
  """Return if device needs polling."""
92
+ if self._force_next_update:
93
+ self._force_next_update = False
94
+ return True
66
95
  if (
67
96
  seconds_since_last_poll is not None
68
97
  and seconds_since_last_poll < PASSIVE_POLL_INTERVAL
@@ -1,4 +1,5 @@
1
1
  """Enum backports from standard lib."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  from enum import Enum
@@ -1,4 +1,5 @@
1
1
  """Library to handle connection with Switchbot."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  from dataclasses import dataclass
@@ -1672,3 +1672,216 @@ def test_parse_advertisement_data_keypad():
1672
1672
  rssi=-67,
1673
1673
  active=True,
1674
1674
  )
1675
+
1676
+
1677
+ def test_parse_advertisement_data_relay_switch_1pm():
1678
+ """Test parse_advertisement_data for the keypad."""
1679
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1680
+ adv_data = generate_advertisement_data(
1681
+ manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"},
1682
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"<\x00\x00\x00"},
1683
+ rssi=-67,
1684
+ )
1685
+ result = parse_advertisement_data(
1686
+ ble_device, adv_data, SwitchbotModel.RELAY_SWITCH_1PM
1687
+ )
1688
+ assert result == SwitchBotAdvertisement(
1689
+ address="aa:bb:cc:dd:ee:ff",
1690
+ data={
1691
+ "data": {
1692
+ "switchMode": True,
1693
+ "sequence_number": 71,
1694
+ "isOn": True,
1695
+ "power": 4.9,
1696
+ "voltage": 0,
1697
+ "current": 0,
1698
+ },
1699
+ "isEncrypted": False,
1700
+ "model": "<",
1701
+ "modelFriendlyName": "Relay Switch 1PM",
1702
+ "modelName": SwitchbotModel.RELAY_SWITCH_1PM,
1703
+ "rawAdvData": b"<\x00\x00\x00",
1704
+ },
1705
+ device=ble_device,
1706
+ rssi=-67,
1707
+ active=True,
1708
+ )
1709
+
1710
+
1711
+ def test_parse_advertisement_data_relay_switch_1():
1712
+ """Test parse_advertisement_data for the keypad."""
1713
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1714
+ adv_data = generate_advertisement_data(
1715
+ manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"},
1716
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b";\x00\x00\x00"},
1717
+ rssi=-67,
1718
+ )
1719
+ result = parse_advertisement_data(
1720
+ ble_device, adv_data, SwitchbotModel.RELAY_SWITCH_1
1721
+ )
1722
+ assert result == SwitchBotAdvertisement(
1723
+ address="aa:bb:cc:dd:ee:ff",
1724
+ data={
1725
+ "data": {
1726
+ "switchMode": True,
1727
+ "sequence_number": 71,
1728
+ "isOn": True,
1729
+ },
1730
+ "isEncrypted": False,
1731
+ "model": ";",
1732
+ "modelFriendlyName": "Relay Switch 1",
1733
+ "modelName": SwitchbotModel.RELAY_SWITCH_1,
1734
+ "rawAdvData": b";\x00\x00\x00",
1735
+ },
1736
+ device=ble_device,
1737
+ rssi=-67,
1738
+ active=True,
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
+ )
@@ -0,0 +1,60 @@
1
+ from unittest.mock import AsyncMock
2
+
3
+ import pytest
4
+ from bleak.backends.device import BLEDevice
5
+
6
+ from switchbot import SwitchBotAdvertisement, SwitchbotModel
7
+ from switchbot.devices import relay_switch
8
+
9
+ from .test_adv_parser import generate_ble_device
10
+
11
+
12
+ def create_device_for_command_testing(calibration=True, reverse_mode=False):
13
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
14
+ relay_switch_device = relay_switch.SwitchbotRelaySwitch(
15
+ ble_device, "ff", "ffffffffffffffffffffffffffffffff"
16
+ )
17
+ relay_switch_device.update_from_advertisement(make_advertisement_data(ble_device))
18
+ return relay_switch_device
19
+
20
+
21
+ def make_advertisement_data(ble_device: BLEDevice):
22
+ """Set advertisement data with defaults."""
23
+
24
+ return SwitchBotAdvertisement(
25
+ address="aa:bb:cc:dd:ee:ff",
26
+ data={
27
+ "rawAdvData": b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00",
28
+ "data": {
29
+ "switchMode": True,
30
+ "sequence_number": 71,
31
+ "isOn": True,
32
+ "power": 4.9,
33
+ "voltage": 0,
34
+ "current": 0,
35
+ },
36
+ "isEncrypted": False,
37
+ "model": "<",
38
+ "modelFriendlyName": "Relay Switch 1PM",
39
+ "modelName": SwitchbotModel.RELAY_SWITCH_1PM,
40
+ },
41
+ device=ble_device,
42
+ rssi=-80,
43
+ active=True,
44
+ )
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_turn_on():
49
+ relay_switch_device = create_device_for_command_testing()
50
+ relay_switch_device._send_command = AsyncMock(return_value=b"\x01")
51
+ await relay_switch_device.turn_on()
52
+ assert relay_switch_device.is_on() is True
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_trun_off():
57
+ relay_switch_device = create_device_for_command_testing()
58
+ relay_switch_device._send_command = AsyncMock(return_value=b"\x01")
59
+ await relay_switch_device.turn_off()
60
+ assert relay_switch_device.is_on() is False
File without changes
File without changes
File without changes
File without changes