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.
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PKG-INFO +2 -1
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/PKG-INFO +2 -1
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/SOURCES.txt +1 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/setup.py +2 -1
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/__init__.py +2 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parser.py +7 -0
- pyswitchbot-0.55.1/switchbot/adv_parsers/leak.py +29 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/const.py +1 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/device.py +166 -3
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/lock.py +9 -136
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/relay_switch.py +16 -17
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_adv_parser.py +148 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/LICENSE +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/MANIFEST.in +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/dependency_links.txt +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/requires.txt +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/PySwitchbot.egg-info/top_level.txt +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/README.md +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/setup.cfg +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/__init__.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/blind_tilt.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/bot.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/bulb.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/ceiling_light.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/contact.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/curtain.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/hub2.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/humidifier.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/keypad.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/light_strip.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/lock.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/meter.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/motion.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/plug.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/adv_parsers/relay_switch.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/api_config.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/__init__.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/base_cover.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/base_light.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/blind_tilt.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/bot.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/bulb.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/ceiling_light.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/contact.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/curtain.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/humidifier.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/keypad.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/light_strip.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/meter.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/motion.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/devices/plug.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/discovery.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/enum.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/switchbot/models.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_base_cover.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_blind_tilt.py +0 -0
- {pyswitchbot-0.54.0 → pyswitchbot-0.55.1}/tests/test_curtain.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
|
@@ -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,
|
|
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 ..
|
|
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
|
|
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 ..
|
|
15
|
-
from
|
|
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(
|
|
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
|
-
|
|
85
|
-
super().__init__(device, None, interface, **kwargs)
|
|
63
|
+
super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
|
|
86
64
|
|
|
87
|
-
@
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|