Skip to content

Commit 60c2725

Browse files
authored
Fix device discovery when Bluetooth adapter is in passive scanning mode (#397)
1 parent 2201c90 commit 60c2725

File tree

7 files changed

+688
-25
lines changed

7 files changed

+688
-25
lines changed

switchbot/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
SwitchbotDevice,
3737
SwitchbotEncryptedDevice,
3838
SwitchbotOperationError,
39+
fetch_cloud_devices,
3940
)
4041
from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
4142
from .devices.fan import SwitchbotFan
@@ -104,6 +105,7 @@
104105
"SwitchbotVacuum",
105106
"close_stale_connections",
106107
"close_stale_connections_by_address",
108+
"fetch_cloud_devices",
107109
"get_device",
108110
"parse_advertisement_data",
109111
]

switchbot/adv_parser.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from .adv_parsers.vacuum import process_vacuum, process_vacuum_k
4646
from .const import SwitchbotModel
4747
from .models import SwitchBotAdvertisement
48+
from .utils import format_mac_upper
4849

4950
_LOGGER = logging.getLogger(__name__)
5051

@@ -54,6 +55,8 @@
5455
)
5556
MFR_DATA_ORDER = (2409, 741, 89)
5657

58+
_MODEL_TO_MAC_CACHE: dict[str, SwitchbotModel] = {}
59+
5760

5861
class SwitchbotSupportedType(TypedDict):
5962
"""Supported type of Switchbot."""
@@ -383,6 +386,10 @@ def parse_advertisement_data(
383386
model: SwitchbotModel | None = None,
384387
) -> SwitchBotAdvertisement | None:
385388
"""Parse advertisement data."""
389+
upper_mac = format_mac_upper(device.address)
390+
if model is None and upper_mac in _MODEL_TO_MAC_CACHE:
391+
model = _MODEL_TO_MAC_CACHE[upper_mac]
392+
386393
service_data = advertisement_data.service_data
387394

388395
_service_data = None
@@ -470,3 +477,8 @@ def _parse_data(
470477
)
471478

472479
return data
480+
481+
482+
def populate_model_to_mac_cache(mac: str, model: SwitchbotModel) -> None:
483+
"""Populate the model to MAC address cache."""
484+
_MODEL_TO_MAC_CACHE[mac] = model

switchbot/devices/device.py

Lines changed: 155 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
2626

27+
from ..adv_parser import populate_model_to_mac_cache
2728
from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
2829
from ..const import (
2930
DEFAULT_RETRY_COUNT,
@@ -37,9 +38,43 @@
3738
from ..discovery import GetSwitchbotDevices
3839
from ..helpers import create_background_task
3940
from ..models import SwitchBotAdvertisement
41+
from ..utils import format_mac_upper
4042

4143
_LOGGER = logging.getLogger(__name__)
4244

45+
46+
def _extract_region(userinfo: dict[str, Any]) -> str:
47+
"""Extract region from user info, defaulting to 'us'."""
48+
if "botRegion" in userinfo and userinfo["botRegion"] != "":
49+
return userinfo["botRegion"]
50+
return "us"
51+
52+
53+
# Mapping from API model names to SwitchbotModel enum values
54+
API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
55+
"WoHand": SwitchbotModel.BOT,
56+
"WoCurtain": SwitchbotModel.CURTAIN,
57+
"WoHumi": SwitchbotModel.HUMIDIFIER,
58+
"WoPlug": SwitchbotModel.PLUG_MINI,
59+
"WoPlugUS": SwitchbotModel.PLUG_MINI,
60+
"WoContact": SwitchbotModel.CONTACT_SENSOR,
61+
"WoStrip": SwitchbotModel.LIGHT_STRIP,
62+
"WoSensorTH": SwitchbotModel.METER,
63+
"WoMeter": SwitchbotModel.METER,
64+
"WoMeterPlus": SwitchbotModel.METER_PRO,
65+
"WoPresence": SwitchbotModel.MOTION_SENSOR,
66+
"WoBulb": SwitchbotModel.COLOR_BULB,
67+
"WoCeiling": SwitchbotModel.CEILING_LIGHT,
68+
"WoLock": SwitchbotModel.LOCK,
69+
"WoBlindTilt": SwitchbotModel.BLIND_TILT,
70+
"WoIOSensor": SwitchbotModel.IO_METER, # Outdoor Meter
71+
"WoButton": SwitchbotModel.REMOTE, # Remote button
72+
"WoLinkMini": SwitchbotModel.HUBMINI_MATTER, # Hub Mini
73+
"W1083002": SwitchbotModel.RELAY_SWITCH_1, # Relay Switch 1
74+
"W1079000": SwitchbotModel.METER_PRO, # Meter Pro (another variant)
75+
"W1102001": SwitchbotModel.STRIP_LIGHT_3, # RGBWW Strip Light 3
76+
}
77+
4378
REQ_HEADER = "570f"
4479

4580

@@ -164,6 +199,113 @@ def __init__(
164199
self._last_full_update: float = -PASSIVE_POLL_INTERVAL
165200
self._timed_disconnect_task: asyncio.Task[None] | None = None
166201

202+
@classmethod
203+
async def _async_get_user_info(
204+
cls,
205+
session: aiohttp.ClientSession,
206+
auth_headers: dict[str, str],
207+
) -> dict[str, Any]:
208+
try:
209+
return await cls.api_request(
210+
session, "account", "account/api/v1/user/userinfo", {}, auth_headers
211+
)
212+
except Exception as err:
213+
raise SwitchbotAccountConnectionError(
214+
f"Failed to retrieve SwitchBot Account user details: {err}"
215+
) from err
216+
217+
@classmethod
218+
async def _get_auth_result(
219+
cls,
220+
session: aiohttp.ClientSession,
221+
username: str,
222+
password: str,
223+
) -> dict[str, Any]:
224+
"""Authenticate with SwitchBot API."""
225+
try:
226+
return await cls.api_request(
227+
session,
228+
"account",
229+
"account/api/v1/user/login",
230+
{
231+
"clientId": SWITCHBOT_APP_CLIENT_ID,
232+
"username": username,
233+
"password": password,
234+
"grantType": "password",
235+
"verifyCode": "",
236+
},
237+
)
238+
except Exception as err:
239+
raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
240+
241+
@classmethod
242+
async def get_devices(
243+
cls,
244+
session: aiohttp.ClientSession,
245+
username: str,
246+
password: str,
247+
) -> dict[str, SwitchbotModel]:
248+
"""Get devices from SwitchBot API and return formatted MAC to model mapping."""
249+
try:
250+
auth_result = await cls._get_auth_result(session, username, password)
251+
auth_headers = {"authorization": auth_result["access_token"]}
252+
except Exception as err:
253+
raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
254+
255+
userinfo = await cls._async_get_user_info(session, auth_headers)
256+
region = _extract_region(userinfo)
257+
258+
try:
259+
device_info = await cls.api_request(
260+
session,
261+
f"wonderlabs.{region}",
262+
"wonder/device/v3/getdevice",
263+
{
264+
"required_type": "All",
265+
},
266+
auth_headers,
267+
)
268+
except Exception as err:
269+
raise SwitchbotAccountConnectionError(
270+
f"Failed to retrieve devices from SwitchBot Account: {err}"
271+
) from err
272+
273+
items: list[dict[str, Any]] = device_info["Items"]
274+
mac_to_model: dict[str, SwitchbotModel] = {}
275+
276+
for item in items:
277+
if "device_mac" not in item:
278+
continue
279+
280+
if (
281+
"device_detail" not in item
282+
or "device_type" not in item["device_detail"]
283+
):
284+
continue
285+
286+
mac = item["device_mac"]
287+
model_name = item["device_detail"]["device_type"]
288+
289+
# Format MAC to uppercase with colons
290+
formatted_mac = format_mac_upper(mac)
291+
292+
# Map API model name to SwitchbotModel enum if possible
293+
if model_name in API_MODEL_TO_ENUM:
294+
model = API_MODEL_TO_ENUM[model_name]
295+
mac_to_model[formatted_mac] = model
296+
# Populate the cache
297+
populate_model_to_mac_cache(formatted_mac, model)
298+
else:
299+
# Log the full item payload for unknown models
300+
_LOGGER.debug(
301+
"Unknown model %s for device %s, full item: %s",
302+
model_name,
303+
formatted_mac,
304+
item,
305+
)
306+
307+
return mac_to_model
308+
167309
@classmethod
168310
async def api_request(
169311
cls,
@@ -809,34 +951,13 @@ async def async_retrieve_encryption_key(
809951
device_mac = device_mac.replace(":", "").replace("-", "").upper()
810952

811953
try:
812-
auth_result = await cls.api_request(
813-
session,
814-
"account",
815-
"account/api/v1/user/login",
816-
{
817-
"clientId": SWITCHBOT_APP_CLIENT_ID,
818-
"username": username,
819-
"password": password,
820-
"grantType": "password",
821-
"verifyCode": "",
822-
},
823-
)
954+
auth_result = await cls._get_auth_result(session, username, password)
824955
auth_headers = {"authorization": auth_result["access_token"]}
825956
except Exception as err:
826957
raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
827958

828-
try:
829-
userinfo = await cls.api_request(
830-
session, "account", "account/api/v1/user/userinfo", {}, auth_headers
831-
)
832-
if "botRegion" in userinfo and userinfo["botRegion"] != "":
833-
region = userinfo["botRegion"]
834-
else:
835-
region = "us"
836-
except Exception as err:
837-
raise SwitchbotAccountConnectionError(
838-
f"Failed to retrieve SwitchBot Account user details: {err}"
839-
) from err
959+
userinfo = await cls._async_get_user_info(session, auth_headers)
960+
region = _extract_region(userinfo)
840961

841962
try:
842963
device_info = await cls.api_request(
@@ -1023,3 +1144,13 @@ def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> No
10231144
)
10241145
if current_state != new_state:
10251146
create_background_task(self.update())
1147+
1148+
1149+
async def fetch_cloud_devices(
1150+
session: aiohttp.ClientSession,
1151+
username: str,
1152+
password: str,
1153+
) -> dict[str, SwitchbotModel]:
1154+
"""Fetch devices from SwitchBot API and return MAC to model mapping."""
1155+
# Get devices from the API (which also populates the cache)
1156+
return await SwitchbotBaseDevice.get_devices(session, username, password)

switchbot/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Utility functions for switchbot."""
2+
3+
from functools import lru_cache
4+
5+
6+
@lru_cache(maxsize=512)
7+
def format_mac_upper(mac: str) -> str:
8+
"""Format the mac address string to uppercase with colons."""
9+
to_test = mac
10+
11+
if len(to_test) == 17 and to_test.count(":") == 5:
12+
return to_test.upper()
13+
14+
if len(to_test) == 17 and to_test.count("-") == 5:
15+
to_test = to_test.replace("-", "")
16+
elif len(to_test) == 14 and to_test.count(".") == 2:
17+
to_test = to_test.replace(".", "")
18+
19+
if len(to_test) == 12:
20+
# no : included
21+
return ":".join(to_test.upper()[i : i + 2] for i in range(0, 12, 2))
22+
23+
# Not sure how formatted, return original
24+
return mac.upper()

tests/test_adv_parser.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from bleak.backends.scanner import AdvertisementData
99

1010
from switchbot import HumidifierMode, SwitchbotModel
11-
from switchbot.adv_parser import parse_advertisement_data
11+
from switchbot.adv_parser import (
12+
_MODEL_TO_MAC_CACHE,
13+
parse_advertisement_data,
14+
populate_model_to_mac_cache,
15+
)
1216
from switchbot.const.lock import LockStatus
1317
from switchbot.models import SwitchBotAdvertisement
1418

@@ -3790,3 +3794,73 @@ def test_adv_with_empty_data(test_case: AdvTestCase) -> None:
37903794
rssi=-97,
37913795
active=True,
37923796
)
3797+
3798+
3799+
def test_parse_advertisement_with_mac_cache() -> None:
3800+
"""Test that populating the MAC cache helps identify unknown passive devices."""
3801+
# Clear the cache to ensure clean test
3802+
_MODEL_TO_MAC_CACHE.clear()
3803+
3804+
# Create a passive lock device with manufacturer data only (no service data)
3805+
# This would normally not be identifiable without the cache
3806+
mac_address = "C1:64:B8:7D:06:05"
3807+
ble_device = generate_ble_device(mac_address, "WoLock")
3808+
3809+
# Lock passive advertisement with manufacturer data
3810+
# This is real data from a WoLock device in passive mode
3811+
adv_data = generate_advertisement_data(
3812+
manufacturer_data={2409: b"\xc1d\xb8}\x06\x05\x00\x00\x00\x00\x00"},
3813+
service_data={},
3814+
rssi=-70,
3815+
)
3816+
3817+
# First attempt: Without cache, parser cannot identify the device model
3818+
result_without_cache = parse_advertisement_data(ble_device, adv_data)
3819+
assert result_without_cache is None, "Should not decode without model hint"
3820+
3821+
# Now populate the cache with the device's MAC and model
3822+
populate_model_to_mac_cache(mac_address, SwitchbotModel.LOCK)
3823+
3824+
# Second attempt: With cache, parser can now identify and decode the device
3825+
result_with_cache = parse_advertisement_data(ble_device, adv_data)
3826+
assert result_with_cache is not None, "Should decode with MAC cache"
3827+
assert result_with_cache.data["modelName"] == SwitchbotModel.LOCK
3828+
assert result_with_cache.data["modelFriendlyName"] == "Lock"
3829+
assert result_with_cache.active is False # Passive advertisement
3830+
3831+
# Clean up
3832+
_MODEL_TO_MAC_CACHE.clear()
3833+
3834+
3835+
def test_parse_advertisement_with_mac_cache_curtain() -> None:
3836+
"""Test MAC cache with a passive curtain device."""
3837+
# Clear the cache
3838+
_MODEL_TO_MAC_CACHE.clear()
3839+
3840+
# Create a passive curtain device
3841+
mac_address = "CC:F4:C4:F9:AC:6C"
3842+
ble_device = generate_ble_device(mac_address, None)
3843+
3844+
# Curtain passive advertisement with only manufacturer data
3845+
adv_data = generate_advertisement_data(
3846+
manufacturer_data={2409: b"\xccOLG\x00c\x00\x00\x11\x00\x00"},
3847+
service_data={},
3848+
rssi=-85,
3849+
)
3850+
3851+
# Without cache, cannot identify
3852+
result_without_cache = parse_advertisement_data(ble_device, adv_data)
3853+
assert result_without_cache is None
3854+
3855+
# Populate cache
3856+
populate_model_to_mac_cache(mac_address, SwitchbotModel.CURTAIN)
3857+
3858+
# With cache, can identify and parse
3859+
result_with_cache = parse_advertisement_data(ble_device, adv_data)
3860+
assert result_with_cache is not None
3861+
assert result_with_cache.data["modelName"] == SwitchbotModel.CURTAIN
3862+
assert result_with_cache.data["modelFriendlyName"] == "Curtain"
3863+
assert result_with_cache.active is False
3864+
3865+
# Clean up
3866+
_MODEL_TO_MAC_CACHE.clear()

0 commit comments

Comments
 (0)