Skip to content

Commit 9f939ce

Browse files
Move encryption and api functions into the base class (#277)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8f3172a commit 9f939ce

File tree

3 files changed

+188
-153
lines changed

3 files changed

+188
-153
lines changed

switchbot/devices/device.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from collections.abc import Callable
1313
from uuid import UUID
1414

15+
import aiohttp
1516
from bleak.backends.device import BLEDevice
1617
from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection
1718
from bleak.exc import BleakDBusError
@@ -23,7 +24,15 @@
2324
establish_connection,
2425
)
2526

26-
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+
)
2736
from ..discovery import GetSwitchbotDevices
2837
from ..models import SwitchBotAdvertisement
2938

@@ -152,6 +161,35 @@ def __init__(
152161
self._last_full_update: float = -PASSIVE_POLL_INTERVAL
153162
self._timed_disconnect_task: asyncio.Task[None] | None = None
154163

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+
155193
def advertisement_changed(self, advertisement: SwitchBotAdvertisement) -> bool:
156194
"""Check if the advertisement has changed."""
157195
return bool(
@@ -666,6 +704,130 @@ def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> No
666704
self._set_advertisement_data(advertisement)
667705

668706

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+
669831
class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
670832
"""Base Representation of a Switchbot Device.
671833

switchbot/devices/lock.py

Lines changed: 9 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,15 @@
22

33
from __future__ import annotations
44

5-
import asyncio
65
import logging
76
import time
87
from typing import Any
98

10-
import aiohttp
119
from bleak.backends.device import BLEDevice
1210
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
1311

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
2314

2415
COMMAND_HEADER = "57"
2516
COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
@@ -54,7 +45,7 @@
5445
# The return value of the command is 6 when the command is successful but the battery is low.
5546

5647

57-
class SwitchbotLock(SwitchbotDevice):
48+
class SwitchbotLock(SwitchbotEncryptedDevice):
5849
"""Representation of a Switchbot Lock."""
5950

6051
def __init__(
@@ -66,141 +57,23 @@ def __init__(
6657
model: SwitchbotModel = SwitchbotModel.LOCK,
6758
**kwargs: Any,
6859
) -> 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")
7760
if model not in (SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO):
7861
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)
8362
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)
8664

87-
@staticmethod
65+
@classmethod
8866
async def verify_encryption_key(
67+
cls,
8968
device: BLEDevice,
9069
key_id: str,
9170
encryption_key: str,
9271
model: SwitchbotModel = SwitchbotModel.LOCK,
9372
**kwargs: Any,
9473
) -> 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 super().verify_encryption_key(
75+
device, key_id, encryption_key, model, **kwargs
76+
)
20477

20578
async def lock(self) -> bool:
20679
"""Send lock command."""

0 commit comments

Comments
 (0)