test_device.py 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099
  1. """Tests for device.py functionality."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import aiohttp
  7. import pytest
  8. from switchbot import fetch_cloud_devices, fetch_cloud_devices_by_token
  9. from switchbot.adv_parser import (
  10. _MODEL_TO_MAC_CACHE,
  11. parse_advertisement_data,
  12. populate_model_to_mac_cache,
  13. )
  14. from switchbot.const import (
  15. SwitchbotAccountConnectionError,
  16. SwitchbotApiError,
  17. SwitchbotAuthenticationError,
  18. SwitchbotModel,
  19. )
  20. from switchbot.devices.device import (
  21. API_MODEL_TO_ENUM,
  22. SwitchbotBaseDevice,
  23. SwitchbotDevice,
  24. SwitchbotEncryptedDevice,
  25. _extract_region,
  26. _masked_device_id,
  27. )
  28. from .test_adv_parser import generate_advertisement_data, generate_ble_device
  29. @pytest.mark.parametrize(
  30. ("api_model", "expected_model"),
  31. [
  32. ("WoCurtain", SwitchbotModel.CURTAIN),
  33. ("WoCurtain3", SwitchbotModel.CURTAIN_3),
  34. ("WoPlugUS", SwitchbotModel.PLUG_MINI_US),
  35. ("WoPlugJP", SwitchbotModel.PLUG_MINI_JP),
  36. ("WoMeter", SwitchbotModel.METER),
  37. ("WoMeterPlus", SwitchbotModel.METER_PLUS),
  38. ("W1079000", SwitchbotModel.METER_PRO),
  39. ("W1079001", SwitchbotModel.METER_PRO_CO2),
  40. ("WoIOSensor", SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER),
  41. ("WoCeiling", SwitchbotModel.CEILING_LIGHT),
  42. ("WoCeilingPro", SwitchbotModel.CEILING_LIGHT_PRO),
  43. ("WoLinkMini", SwitchbotModel.HUB_MINI),
  44. ("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER),
  45. ],
  46. )
  47. def test_api_model_mapping(api_model: str, expected_model: SwitchbotModel) -> None:
  48. """Test API model names map to the exact physical product."""
  49. assert API_MODEL_TO_ENUM[api_model] is expected_model
  50. @pytest.mark.asyncio
  51. async def test_cloud_maps_uwb_lock(mock_user_info: dict[str, Any]) -> None:
  52. """Recognize the UWB Lock product ID in the account device list."""
  53. response = {
  54. "Items": [
  55. {"device_mac": "aabbccddeeff", "device_detail": {"device_type": "W1184000"}}
  56. ]
  57. }
  58. with (
  59. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  60. patch.object(
  61. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  62. ),
  63. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  64. ):
  65. devices = await fetch_cloud_devices_by_token(
  66. MagicMock(spec=aiohttp.ClientSession), "test-token"
  67. )
  68. assert devices == {"AA:BB:CC:DD:EE:FF": SwitchbotModel.UWB_LOCK}
  69. assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] is SwitchbotModel.UWB_LOCK
  70. def test_legacy_plug_is_not_mapped_to_plug_mini() -> None:
  71. """Test the legacy Plug is not identified as a Plug Mini."""
  72. assert "WoPlug" not in API_MODEL_TO_ENUM
  73. assert SwitchbotModel.PLUG_MINI not in API_MODEL_TO_ENUM.values()
  74. def test_deprecated_model_aliases() -> None:
  75. """Test old model names remain as compatibility aliases."""
  76. assert SwitchbotModel.IO_METER is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
  77. assert SwitchbotModel.METER_PRO_C is SwitchbotModel.METER_PRO_CO2
  78. assert SwitchbotModel.IO_METER.name == "INDOOR_OUTDOOR_THERMO_HYGROMETER"
  79. assert SwitchbotModel.METER_PRO_C.name == "METER_PRO_CO2"
  80. assert SwitchbotModel["IO_METER"] is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
  81. assert SwitchbotModel["METER_PRO_C"] is SwitchbotModel.METER_PRO_CO2
  82. @pytest.mark.asyncio
  83. @pytest.mark.parametrize(
  84. ("api_model", "expected_model", "can_parse"),
  85. [
  86. pytest.param("WoLinkMini", SwitchbotModel.HUB_MINI, False, id="ordinary"),
  87. pytest.param("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER, True, id="matter"),
  88. ],
  89. )
  90. async def test_hub_mini_cloud_cache_parsing(
  91. mock_user_info: dict[str, Any],
  92. api_model: str,
  93. expected_model: SwitchbotModel,
  94. can_parse: bool,
  95. ) -> None:
  96. """Cloud hints distinguish ordinary Hub Mini from Matter during parsing."""
  97. mac_address = "E6:A1:CD:1F:5B:65"
  98. device_response = {
  99. "Items": [
  100. {
  101. "device_mac": "e6a1cd1f5b65",
  102. "device_detail": {"device_type": api_model},
  103. }
  104. ]
  105. }
  106. ble_device = generate_ble_device(mac_address)
  107. adv_data = generate_advertisement_data(
  108. manufacturer_data={
  109. 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00"
  110. },
  111. )
  112. with (
  113. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  114. patch.object(
  115. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  116. ),
  117. patch.object(SwitchbotBaseDevice, "api_request", return_value=device_response),
  118. ):
  119. session = MagicMock(spec=aiohttp.ClientSession)
  120. devices = await fetch_cloud_devices_by_token(session, "test-token")
  121. assert devices == {mac_address: expected_model}
  122. assert _MODEL_TO_MAC_CACHE[mac_address] is expected_model
  123. assert (parse_advertisement_data(ble_device, adv_data) is not None) is can_parse
  124. def test_hubmini_matter_model_name() -> None:
  125. """Preserve the existing Hub Mini Matter enum name."""
  126. assert SwitchbotModel.HUBMINI_MATTER.name == "HUBMINI_MATTER"
  127. @pytest.fixture
  128. def mock_auth_response() -> dict[str, Any]:
  129. """Mock authentication response."""
  130. return {
  131. "access_token": "test_token_123",
  132. "refresh_token": "refresh_token_123",
  133. "expires_in": 3600,
  134. }
  135. @pytest.mark.asyncio
  136. async def test_cloud_maps_lock_nano(mock_user_info: dict[str, Any]) -> None:
  137. """Recognize the Lock Nano product ID in the account device list."""
  138. response = {
  139. "Items": [
  140. {"device_mac": "aabbccddeeff", "device_detail": {"device_type": "W1189000"}}
  141. ]
  142. }
  143. with (
  144. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  145. patch.object(
  146. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  147. ),
  148. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  149. ):
  150. devices = await fetch_cloud_devices_by_token(
  151. MagicMock(spec=aiohttp.ClientSession), "test-token"
  152. )
  153. assert devices == {"AA:BB:CC:DD:EE:FF": SwitchbotModel.LOCK_NANO}
  154. assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] is SwitchbotModel.LOCK_NANO
  155. @pytest.fixture
  156. def mock_user_info() -> dict[str, Any]:
  157. """Mock user info response."""
  158. return {
  159. "botRegion": "us",
  160. "country": "us",
  161. "email": "test@example.com",
  162. }
  163. @pytest.fixture
  164. def mock_device_response() -> dict[str, Any]:
  165. """Mock device list response."""
  166. return {
  167. "Items": [
  168. {
  169. "device_mac": "aabbccddeeff",
  170. "device_name": "Test Bot",
  171. "device_detail": {
  172. "device_type": "WoHand",
  173. "version": "1.0.0",
  174. },
  175. },
  176. {
  177. "device_mac": "112233445566",
  178. "device_name": "Test Curtain",
  179. "device_detail": {
  180. "device_type": "WoCurtain",
  181. "version": "2.0.0",
  182. },
  183. },
  184. {
  185. "device_mac": "778899aabbcc",
  186. "device_name": "Test Lock",
  187. "device_detail": {
  188. "device_type": "WoLock",
  189. "version": "1.5.0",
  190. },
  191. },
  192. {
  193. "device_mac": "ddeeff001122",
  194. "device_name": "Unknown Device",
  195. "device_detail": {
  196. "device_type": "WoUnknown",
  197. "version": "1.0.0",
  198. "extra_field": "extra_value",
  199. },
  200. },
  201. {
  202. "device_mac": "invalid_device",
  203. # Missing device_detail
  204. },
  205. {
  206. "device_mac": "another_invalid",
  207. "device_detail": {
  208. # Missing device_type
  209. "version": "1.0.0",
  210. },
  211. },
  212. ]
  213. }
  214. @pytest.mark.asyncio
  215. async def test_get_devices(
  216. mock_auth_response: dict[str, Any],
  217. mock_user_info: dict[str, Any],
  218. mock_device_response: dict[str, Any],
  219. caplog: pytest.LogCaptureFixture,
  220. ) -> None:
  221. """Test get_devices method."""
  222. caplog.set_level(logging.DEBUG)
  223. with (
  224. patch.object(
  225. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  226. ),
  227. patch.object(
  228. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  229. ),
  230. patch.object(
  231. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  232. ) as mock_api_request,
  233. patch(
  234. "switchbot.devices.device.populate_model_to_mac_cache"
  235. ) as mock_populate_cache,
  236. ):
  237. session = MagicMock(spec=aiohttp.ClientSession)
  238. result = await SwitchbotBaseDevice.get_devices(
  239. session, "test@example.com", "password123"
  240. )
  241. # Check that api_request was called with correct parameters
  242. mock_api_request.assert_called_once_with(
  243. session,
  244. "wonderlabs.us",
  245. "wonder/device/v3/getdevice",
  246. {"required_type": "All"},
  247. {"authorization": "test_token_123"},
  248. )
  249. # Check returned dictionary
  250. assert len(result) == 3 # Only valid devices with known models
  251. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  252. assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  253. assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
  254. # Check that cache was populated
  255. assert mock_populate_cache.call_count == 3
  256. mock_populate_cache.assert_any_call("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
  257. mock_populate_cache.assert_any_call("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
  258. mock_populate_cache.assert_any_call("77:88:99:AA:BB:CC", SwitchbotModel.LOCK)
  259. # Check that unknown model was logged
  260. assert "Unknown model WoUnknown for device DD:EE:FF:00:11:22" in caplog.text
  261. assert "extra_field" in caplog.text
  262. assert "extra_value" in caplog.text
  263. @pytest.mark.asyncio
  264. async def test_get_devices_with_region(
  265. mock_auth_response: dict[str, Any],
  266. mock_device_response: dict[str, Any],
  267. caplog: pytest.LogCaptureFixture,
  268. ) -> None:
  269. """Test get_devices with different region."""
  270. mock_user_info_eu = {
  271. "botRegion": "eu",
  272. "country": "de",
  273. "email": "test@example.com",
  274. }
  275. with (
  276. patch.object(
  277. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  278. ),
  279. patch.object(
  280. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info_eu
  281. ),
  282. patch.object(
  283. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  284. ) as mock_api_request,
  285. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  286. ):
  287. session = MagicMock(spec=aiohttp.ClientSession)
  288. await SwitchbotBaseDevice.get_devices(
  289. session, "test@example.com", "password123"
  290. )
  291. # Check that EU region was used
  292. mock_api_request.assert_called_once_with(
  293. session,
  294. "wonderlabs.eu",
  295. "wonder/device/v3/getdevice",
  296. {"required_type": "All"},
  297. {"authorization": "test_token_123"},
  298. )
  299. @pytest.mark.asyncio
  300. async def test_get_devices_no_region(
  301. mock_auth_response: dict[str, Any],
  302. mock_device_response: dict[str, Any],
  303. ) -> None:
  304. """Test get_devices with no region specified (defaults to us)."""
  305. mock_user_info_no_region = {
  306. "email": "test@example.com",
  307. }
  308. with (
  309. patch.object(
  310. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  311. ),
  312. patch.object(
  313. SwitchbotBaseDevice,
  314. "_async_get_user_info",
  315. return_value=mock_user_info_no_region,
  316. ),
  317. patch.object(
  318. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  319. ) as mock_api_request,
  320. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  321. ):
  322. session = MagicMock(spec=aiohttp.ClientSession)
  323. await SwitchbotBaseDevice.get_devices(
  324. session, "test@example.com", "password123"
  325. )
  326. # Check that default US region was used
  327. mock_api_request.assert_called_once_with(
  328. session,
  329. "wonderlabs.us",
  330. "wonder/device/v3/getdevice",
  331. {"required_type": "All"},
  332. {"authorization": "test_token_123"},
  333. )
  334. @pytest.mark.asyncio
  335. async def test_get_devices_empty_region(
  336. mock_auth_response: dict[str, Any],
  337. mock_device_response: dict[str, Any],
  338. ) -> None:
  339. """Test get_devices with empty region string (defaults to us)."""
  340. mock_user_info_empty_region = {
  341. "botRegion": "",
  342. "email": "test@example.com",
  343. }
  344. with (
  345. patch.object(
  346. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  347. ),
  348. patch.object(
  349. SwitchbotBaseDevice,
  350. "_async_get_user_info",
  351. return_value=mock_user_info_empty_region,
  352. ),
  353. patch.object(
  354. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  355. ) as mock_api_request,
  356. patch("switchbot.devices.device.populate_model_to_mac_cache"),
  357. ):
  358. session = MagicMock(spec=aiohttp.ClientSession)
  359. await SwitchbotBaseDevice.get_devices(
  360. session, "test@example.com", "password123"
  361. )
  362. # Check that default US region was used
  363. mock_api_request.assert_called_once_with(
  364. session,
  365. "wonderlabs.us",
  366. "wonder/device/v3/getdevice",
  367. {"required_type": "All"},
  368. {"authorization": "test_token_123"},
  369. )
  370. @pytest.mark.asyncio
  371. async def test_fetch_cloud_devices(
  372. mock_auth_response: dict[str, Any],
  373. mock_user_info: dict[str, Any],
  374. mock_device_response: dict[str, Any],
  375. ) -> None:
  376. """Test fetch_cloud_devices wrapper function."""
  377. with (
  378. patch.object(
  379. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  380. ),
  381. patch.object(
  382. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  383. ),
  384. patch.object(
  385. SwitchbotBaseDevice, "api_request", return_value=mock_device_response
  386. ),
  387. patch(
  388. "switchbot.devices.device.populate_model_to_mac_cache"
  389. ) as mock_populate_cache,
  390. ):
  391. session = MagicMock(spec=aiohttp.ClientSession)
  392. result = await fetch_cloud_devices(session, "test@example.com", "password123")
  393. # Check returned dictionary
  394. assert len(result) == 3
  395. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  396. assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  397. assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
  398. # Check that cache was populated
  399. assert mock_populate_cache.call_count == 3
  400. @pytest.mark.asyncio
  401. @pytest.mark.parametrize("region", ["us", "eu", "jp"])
  402. async def test_fetch_cloud_devices_by_token(
  403. mock_user_info: dict[str, Any],
  404. mock_device_response: dict[str, Any],
  405. region: str,
  406. caplog: pytest.LogCaptureFixture,
  407. ) -> None:
  408. """Test fetching cloud devices with an OAuth access token."""
  409. caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
  410. with (
  411. patch.object(SwitchbotBaseDevice, "_get_auth_result") as mock_get_auth_result,
  412. patch.object(
  413. SwitchbotBaseDevice,
  414. "_async_get_user_info",
  415. return_value={**mock_user_info, "botRegion": region},
  416. ) as mock_get_user_info,
  417. patch.object(
  418. SwitchbotBaseDevice,
  419. "api_request",
  420. return_value=mock_device_response,
  421. ) as mock_api_request,
  422. patch(
  423. "switchbot.devices.device.populate_model_to_mac_cache"
  424. ) as mock_populate_cache,
  425. ):
  426. session = MagicMock(spec=aiohttp.ClientSession)
  427. result = await fetch_cloud_devices_by_token(session, "oauth-access-token")
  428. mock_get_auth_result.assert_not_called()
  429. mock_get_user_info.assert_awaited_once_with(
  430. session, {"authorization": "oauth-access-token"}
  431. )
  432. mock_api_request.assert_awaited_once_with(
  433. session,
  434. f"wonderlabs.{region}",
  435. "wonder/device/v3/getdevice",
  436. {"required_type": "All"},
  437. {"authorization": "oauth-access-token"},
  438. )
  439. assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  440. assert mock_populate_cache.call_count == 3
  441. assert "retrieval finished; supported_devices=3 duration_ms=" in caplog.text
  442. assert f"region resolved to {region}" in caplog.text
  443. assert "oauth-access-token" not in caplog.text
  444. @pytest.mark.asyncio
  445. async def test_fetch_cloud_devices_by_token_connection_error(
  446. mock_user_info: dict[str, Any],
  447. ) -> None:
  448. """Test an API error while fetching cloud devices with an OAuth token."""
  449. with (
  450. patch.object(
  451. SwitchbotBaseDevice,
  452. "_async_get_user_info",
  453. return_value=mock_user_info,
  454. ),
  455. patch.object(
  456. SwitchbotBaseDevice,
  457. "api_request",
  458. side_effect=Exception("Network error"),
  459. ),
  460. ):
  461. session = MagicMock(spec=aiohttp.ClientSession)
  462. with pytest.raises(
  463. SwitchbotAccountConnectionError, match="Failed to retrieve devices"
  464. ):
  465. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  466. @pytest.mark.asyncio
  467. async def test_fetch_cloud_devices_by_token_authentication_error() -> None:
  468. """Test an authentication error while fetching devices with an OAuth token."""
  469. with patch.object(
  470. SwitchbotBaseDevice,
  471. "_async_get_user_info",
  472. side_effect=SwitchbotAuthenticationError("invalid token"),
  473. ):
  474. session = MagicMock(spec=aiohttp.ClientSession)
  475. with pytest.raises(SwitchbotAuthenticationError, match="invalid token"):
  476. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  477. @pytest.mark.asyncio
  478. async def test_get_devices_preserves_authentication_error_after_user_info(
  479. mock_user_info: dict[str, Any],
  480. ) -> None:
  481. """Test device retrieval preserves authentication errors."""
  482. with (
  483. patch.object(
  484. SwitchbotBaseDevice,
  485. "_async_get_user_info",
  486. return_value=mock_user_info,
  487. ),
  488. patch.object(
  489. SwitchbotBaseDevice,
  490. "api_request",
  491. side_effect=SwitchbotAuthenticationError("expired token"),
  492. ),
  493. ):
  494. session = MagicMock(spec=aiohttp.ClientSession)
  495. with pytest.raises(SwitchbotAuthenticationError, match="expired token"):
  496. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  497. @pytest.mark.asyncio
  498. async def test_get_devices_preserves_api_error_after_user_info(
  499. mock_user_info: dict[str, Any],
  500. ) -> None:
  501. """Test device retrieval preserves API errors."""
  502. with (
  503. patch.object(
  504. SwitchbotBaseDevice,
  505. "_async_get_user_info",
  506. return_value=mock_user_info,
  507. ),
  508. patch.object(
  509. SwitchbotBaseDevice,
  510. "api_request",
  511. side_effect=SwitchbotApiError("API error"),
  512. ),
  513. ):
  514. session = MagicMock(spec=aiohttp.ClientSession)
  515. with pytest.raises(SwitchbotApiError, match="API error"):
  516. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  517. @pytest.mark.asyncio
  518. @pytest.mark.parametrize(
  519. "device_info",
  520. [
  521. pytest.param({}, id="missing-items"),
  522. pytest.param({"Items": None}, id="invalid-items"),
  523. pytest.param({"Items": {}}, id="items-not-list"),
  524. pytest.param({"Items": ["invalid"]}, id="invalid-item"),
  525. ],
  526. )
  527. async def test_get_devices_rejects_invalid_response(
  528. mock_user_info: dict[str, Any], device_info: dict[str, Any]
  529. ) -> None:
  530. """Test malformed device responses retain their API error classification."""
  531. with (
  532. patch.object(
  533. SwitchbotBaseDevice,
  534. "_async_get_user_info",
  535. return_value=mock_user_info,
  536. ),
  537. patch.object(
  538. SwitchbotBaseDevice,
  539. "api_request",
  540. return_value=device_info,
  541. ),
  542. ):
  543. session = MagicMock(spec=aiohttp.ClientSession)
  544. with pytest.raises(SwitchbotApiError, match="Invalid device response"):
  545. await fetch_cloud_devices_by_token(session, "oauth-access-token")
  546. @pytest.mark.asyncio
  547. async def test_get_user_info_preserves_authentication_error() -> None:
  548. """Test user info retrieval preserves authentication errors."""
  549. with patch.object(
  550. SwitchbotBaseDevice,
  551. "api_request",
  552. side_effect=SwitchbotAuthenticationError("invalid token"),
  553. ):
  554. session = MagicMock(spec=aiohttp.ClientSession)
  555. with pytest.raises(SwitchbotAuthenticationError, match="invalid token"):
  556. await SwitchbotBaseDevice._async_get_user_info(
  557. session,
  558. {"authorization": "invalid-token"},
  559. )
  560. @pytest.mark.asyncio
  561. async def test_get_user_info_preserves_api_error() -> None:
  562. """Test user info retrieval preserves API errors."""
  563. with patch.object(
  564. SwitchbotBaseDevice,
  565. "api_request",
  566. side_effect=SwitchbotApiError("API error"),
  567. ):
  568. session = MagicMock(spec=aiohttp.ClientSession)
  569. with pytest.raises(SwitchbotApiError, match="API error"):
  570. await SwitchbotBaseDevice._async_get_user_info(
  571. session,
  572. {"authorization": "invalid-token"},
  573. )
  574. @pytest.mark.asyncio
  575. async def test_api_request_debug_logs_response_shape_without_values(
  576. caplog: pytest.LogCaptureFixture,
  577. ) -> None:
  578. """Test API debug logs contain response fields but no sensitive values."""
  579. response = MagicMock()
  580. response.status = 200
  581. response.headers = {"x-amzn-requestid": "api-request-id"}
  582. response.json = AsyncMock(
  583. return_value={
  584. "statusCode": 100,
  585. "message": "success",
  586. "body": {
  587. "access_token": "sensitive-access-token",
  588. "deviceId": "sensitive-device-id",
  589. "encryptionKey": "sensitive-encryption-key",
  590. },
  591. }
  592. )
  593. session = MagicMock(spec=aiohttp.ClientSession)
  594. session.post.return_value.__aenter__ = AsyncMock(return_value=response)
  595. session.post.return_value.__aexit__ = AsyncMock(return_value=None)
  596. caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
  597. result = await SwitchbotBaseDevice.api_request(
  598. session, "account", "account/api/v1/user/userinfo"
  599. )
  600. assert result["deviceId"] == "sensitive-device-id"
  601. assert "response fields=['body', 'message', 'statusCode']" in caplog.text
  602. assert "body fields=['access_token', 'deviceId', 'encryptionKey']" in caplog.text
  603. assert "duration_ms=" in caplog.text
  604. assert "request_id=api-request-id" in caplog.text
  605. for sensitive_value in (
  606. "sensitive-access-token",
  607. "sensitive-device-id",
  608. "sensitive-encryption-key",
  609. ):
  610. assert sensitive_value not in caplog.text
  611. @pytest.mark.asyncio
  612. async def test_api_request_authentication_error() -> None:
  613. """Test HTTP authentication errors retain their specific error type."""
  614. response = MagicMock()
  615. response.status = 401
  616. session = MagicMock(spec=aiohttp.ClientSession)
  617. session.post.return_value.__aenter__.return_value = response
  618. with pytest.raises(SwitchbotAuthenticationError, match="Authentication rejected"):
  619. await SwitchbotBaseDevice.api_request(
  620. session,
  621. "account",
  622. "account/api/v1/user/userinfo",
  623. {},
  624. {"authorization": "invalid-token"},
  625. )
  626. @pytest.mark.asyncio
  627. async def test_retrieve_encryption_key_with_password() -> None:
  628. """Test the password flow delegates with its access token."""
  629. key_details = {
  630. "key_id": "ff",
  631. "encryption_key": "ffffffffffffffffffffffffffffffff",
  632. }
  633. with (
  634. patch.object(
  635. SwitchbotEncryptedDevice,
  636. "_get_auth_result",
  637. return_value={"access_token": "password-access-token"},
  638. ) as mock_get_auth_result,
  639. patch.object(
  640. SwitchbotEncryptedDevice,
  641. "_async_retrieve_encryption_key",
  642. return_value=key_details,
  643. ) as mock_retrieve_key,
  644. ):
  645. session = MagicMock(spec=aiohttp.ClientSession)
  646. result = await SwitchbotEncryptedDevice.async_retrieve_encryption_key(
  647. session,
  648. "aa:bb:cc:dd:ee:ff",
  649. "test@example.com",
  650. "password",
  651. )
  652. mock_get_auth_result.assert_awaited_once_with(
  653. session, "test@example.com", "password"
  654. )
  655. mock_retrieve_key.assert_awaited_once_with(
  656. session,
  657. "aa:bb:cc:dd:ee:ff",
  658. {"authorization": "password-access-token"},
  659. )
  660. assert result == key_details
  661. @pytest.mark.asyncio
  662. async def test_retrieve_encryption_key_by_token_api_error(
  663. mock_user_info: dict[str, Any],
  664. ) -> None:
  665. """Test an API error while retrieving a key with an OAuth token."""
  666. with (
  667. patch.object(
  668. SwitchbotEncryptedDevice,
  669. "_async_get_user_info",
  670. return_value=mock_user_info,
  671. ),
  672. patch.object(
  673. SwitchbotEncryptedDevice,
  674. "api_request",
  675. side_effect=SwitchbotApiError("API error"),
  676. ),
  677. ):
  678. session = MagicMock(spec=aiohttp.ClientSession)
  679. with pytest.raises(SwitchbotApiError, match="API error"):
  680. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  681. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  682. )
  683. @pytest.mark.asyncio
  684. async def test_retrieve_encryption_key_by_token_authentication_error(
  685. mock_user_info: dict[str, Any],
  686. ) -> None:
  687. """Test key retrieval preserves authentication errors."""
  688. with (
  689. patch.object(
  690. SwitchbotEncryptedDevice,
  691. "_async_get_user_info",
  692. return_value=mock_user_info,
  693. ),
  694. patch.object(
  695. SwitchbotEncryptedDevice,
  696. "api_request",
  697. side_effect=SwitchbotAuthenticationError("expired token"),
  698. ),
  699. ):
  700. session = MagicMock(spec=aiohttp.ClientSession)
  701. with pytest.raises(SwitchbotAuthenticationError, match="expired token"):
  702. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  703. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  704. )
  705. @pytest.mark.asyncio
  706. async def test_retrieve_encryption_key_by_token_connection_error(
  707. mock_user_info: dict[str, Any],
  708. ) -> None:
  709. """Test key retrieval maps unexpected request errors to connection errors."""
  710. with (
  711. patch.object(
  712. SwitchbotEncryptedDevice,
  713. "_async_get_user_info",
  714. return_value=mock_user_info,
  715. ),
  716. patch.object(
  717. SwitchbotEncryptedDevice,
  718. "api_request",
  719. side_effect=Exception("network error"),
  720. ),
  721. ):
  722. session = MagicMock(spec=aiohttp.ClientSession)
  723. with pytest.raises(
  724. SwitchbotAccountConnectionError,
  725. match="Failed to retrieve encryption key",
  726. ):
  727. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  728. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  729. )
  730. @pytest.mark.asyncio
  731. @pytest.mark.parametrize(
  732. "device_info",
  733. [
  734. pytest.param({}, id="missing-communication-key"),
  735. pytest.param({"communicationKey": None}, id="invalid-communication-key"),
  736. pytest.param(
  737. {"communicationKey": {"key": "encryption-key"}}, id="missing-key-id"
  738. ),
  739. pytest.param(
  740. {"communicationKey": {"keyId": "ff"}}, id="missing-encryption-key"
  741. ),
  742. pytest.param(
  743. {"communicationKey": {"keyId": 1, "key": "encryption-key"}},
  744. id="invalid-key-id",
  745. ),
  746. ],
  747. )
  748. async def test_retrieve_encryption_key_by_token_invalid_response(
  749. mock_user_info: dict[str, Any], device_info: dict[str, Any]
  750. ) -> None:
  751. """Test malformed key responses retain their API error classification."""
  752. with (
  753. patch.object(
  754. SwitchbotEncryptedDevice,
  755. "_async_get_user_info",
  756. return_value=mock_user_info,
  757. ),
  758. patch.object(
  759. SwitchbotEncryptedDevice,
  760. "api_request",
  761. return_value=device_info,
  762. ),
  763. ):
  764. session = MagicMock(spec=aiohttp.ClientSession)
  765. with pytest.raises(SwitchbotApiError, match="Invalid encryption key response"):
  766. await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  767. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  768. )
  769. @pytest.mark.asyncio
  770. async def test_retrieve_encryption_key_by_token(
  771. mock_user_info: dict[str, Any],
  772. caplog: pytest.LogCaptureFixture,
  773. ) -> None:
  774. """Test retrieving an encryption key with an OAuth access token."""
  775. caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
  776. with (
  777. patch.object(
  778. SwitchbotEncryptedDevice,
  779. "_async_get_user_info",
  780. return_value=mock_user_info,
  781. ) as mock_get_user_info,
  782. patch.object(
  783. SwitchbotEncryptedDevice,
  784. "api_request",
  785. return_value={
  786. "communicationKey": {
  787. "keyId": "ff",
  788. "key": "ffffffffffffffffffffffffffffffff",
  789. }
  790. },
  791. ) as mock_api_request,
  792. ):
  793. session = MagicMock(spec=aiohttp.ClientSession)
  794. result = await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
  795. session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
  796. )
  797. auth_headers = {"authorization": "oauth-access-token"}
  798. mock_get_user_info.assert_awaited_once_with(session, auth_headers)
  799. mock_api_request.assert_awaited_once_with(
  800. session,
  801. "wonderlabs.us",
  802. "wonder/keys/v1/communicate",
  803. {"device_mac": "AABBCCDDEEFF", "keyType": "user"},
  804. auth_headers,
  805. )
  806. assert result == {
  807. "key_id": "ff",
  808. "encryption_key": "ffffffffffffffffffffffffffffffff",
  809. }
  810. assert "device=****EEFF" in caplog.text
  811. assert "retrieval finished; device=****EEFF duration_ms=" in caplog.text
  812. for sensitive_value in (
  813. "aa:bb:cc:dd:ee:ff",
  814. "oauth-access-token",
  815. "ffffffffffffffffffffffffffffffff",
  816. ):
  817. assert sensitive_value not in caplog.text
  818. @pytest.mark.asyncio
  819. async def test_get_devices_authentication_error() -> None:
  820. """Test get_devices with authentication error."""
  821. with patch.object(
  822. SwitchbotBaseDevice,
  823. "_get_auth_result",
  824. side_effect=Exception("Auth failed"),
  825. ):
  826. session = MagicMock(spec=aiohttp.ClientSession)
  827. with pytest.raises(SwitchbotAuthenticationError) as exc_info:
  828. await SwitchbotBaseDevice.get_devices(
  829. session, "test@example.com", "wrong_password"
  830. )
  831. assert "Authentication failed" in str(exc_info.value)
  832. @pytest.mark.asyncio
  833. async def test_get_devices_connection_error(
  834. mock_auth_response: dict[str, Any],
  835. mock_user_info: dict[str, Any],
  836. ) -> None:
  837. """Test get_devices with connection error."""
  838. with (
  839. patch.object(
  840. SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
  841. ),
  842. patch.object(
  843. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  844. ),
  845. patch.object(
  846. SwitchbotBaseDevice,
  847. "api_request",
  848. side_effect=Exception("Network error"),
  849. ),
  850. ):
  851. session = MagicMock(spec=aiohttp.ClientSession)
  852. with pytest.raises(SwitchbotAccountConnectionError) as exc_info:
  853. await SwitchbotBaseDevice.get_devices(
  854. session, "test@example.com", "password123"
  855. )
  856. assert "Failed to retrieve devices" in str(exc_info.value)
  857. @pytest.mark.asyncio
  858. async def test_populate_model_to_mac_cache() -> None:
  859. """Test the populate_model_to_mac_cache helper function."""
  860. # Clear the cache first
  861. _MODEL_TO_MAC_CACHE.clear()
  862. # Populate cache with test data
  863. populate_model_to_mac_cache("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
  864. populate_model_to_mac_cache("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
  865. # Check cache contents
  866. assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
  867. assert _MODEL_TO_MAC_CACHE["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
  868. assert len(_MODEL_TO_MAC_CACHE) == 2
  869. # Clear cache after test
  870. _MODEL_TO_MAC_CACHE.clear()
  871. def test_masked_device_id_empty() -> None:
  872. """Test an empty device identifier is represented safely."""
  873. assert _masked_device_id("") == "unknown"
  874. def test_extract_region(caplog: pytest.LogCaptureFixture) -> None:
  875. """Test the _extract_region helper function."""
  876. caplog.set_level(logging.WARNING, logger="switchbot.devices.device")
  877. # Test with botRegion present and not empty
  878. assert _extract_region({"botRegion": "eu", "country": "de"}) == "eu"
  879. assert _extract_region({"botRegion": "us", "country": "us"}) == "us"
  880. assert _extract_region({"botRegion": "jp", "country": "jp"}) == "jp"
  881. # Test with botRegion empty string
  882. assert _extract_region({"botRegion": "", "country": "de"}) == "us"
  883. # Test with botRegion missing
  884. assert _extract_region({"country": "de"}) == "us"
  885. # Test with empty dict
  886. assert _extract_region({}) == "us"
  887. assert "account region missing; defaulting to us" in caplog.text
  888. @pytest.mark.asyncio
  889. @pytest.mark.parametrize(
  890. ("commands", "results", "final_result"),
  891. [
  892. # All fail -> False
  893. (("command1", "command2"), [(b"\x01", False), (None, False)], False),
  894. # First fails -> False (short-circuits, second not called)
  895. (("command1", "command2"), [(b"\x01", False)], False),
  896. # First succeeds, second fails -> False
  897. (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], False),
  898. # All succeed -> True
  899. (("command1", "command2"), [(b"\x01", True), (b"\x01", True)], True),
  900. ],
  901. )
  902. async def test_send_command_sequence(
  903. commands: tuple[str, ...],
  904. results: list[tuple[bytes | None, bool]],
  905. final_result: bool,
  906. ) -> None:
  907. """Test sending command sequence where all must succeed."""
  908. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  909. device = SwitchbotDevice(ble_device)
  910. device._send_command = AsyncMock(side_effect=[r[0] for r in results])
  911. device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
  912. result = await device._send_command_sequence(list(commands))
  913. assert result is final_result
  914. def test_update_parsed_data_without_advertisement_does_not_log_exception(
  915. caplog: pytest.LogCaptureFixture,
  916. ) -> None:
  917. """
  918. Calling _update_parsed_data before any advertisement is a no-op, not an error.
  919. Regression for #285: previously emitted ``_LOGGER.exception(...)`` outside an
  920. ``except`` block, which logged "No advertisement data to update / NoneType: None"
  921. on every press()/turn_on()/update() until the first advertisement arrived.
  922. """
  923. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  924. device = SwitchbotDevice(ble_device)
  925. assert device._sb_adv_data is None
  926. with caplog.at_level(logging.DEBUG, logger="switchbot.devices.device"):
  927. result = device._update_parsed_data({"isOn": True})
  928. assert result is False
  929. exception_records = [
  930. record for record in caplog.records if record.levelno >= logging.WARNING
  931. ]
  932. assert exception_records == []
  933. assert all(record.exc_info is None for record in caplog.records)
  934. @pytest.mark.asyncio
  935. async def test_hub4_cloud_model_cache(mock_user_info: dict[str, Any]) -> None:
  936. """Map the backend product ID and populate the Bluetooth model cache."""
  937. response = {
  938. "Items": [
  939. {
  940. "device_mac": "e6a1cd1f5b65",
  941. "device_detail": {"device_type": "W1213000"},
  942. }
  943. ]
  944. }
  945. with (
  946. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  947. patch.object(
  948. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  949. ),
  950. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  951. ):
  952. devices = await fetch_cloud_devices_by_token(
  953. MagicMock(spec=aiohttp.ClientSession), "test-token"
  954. )
  955. assert devices == {"E6:A1:CD:1F:5B:65": SwitchbotModel.HUB4}
  956. assert _MODEL_TO_MAC_CACHE["E6:A1:CD:1F:5B:65"] is SwitchbotModel.HUB4
  957. @pytest.mark.asyncio
  958. async def test_plug_mini_it_cloud_model_cache(mock_user_info: dict[str, Any]) -> None:
  959. """Map the backend product ID and populate the Bluetooth model cache."""
  960. response = {
  961. "Items": [
  962. {
  963. "device_mac": "e6a1cd1f5b65",
  964. "device_detail": {"device_type": "W1197000"},
  965. }
  966. ]
  967. }
  968. with (
  969. patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
  970. patch.object(
  971. SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
  972. ),
  973. patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
  974. ):
  975. devices = await fetch_cloud_devices_by_token(
  976. MagicMock(spec=aiohttp.ClientSession), "test-token"
  977. )
  978. assert devices == {"E6:A1:CD:1F:5B:65": SwitchbotModel.PLUG_MINI_IT}
  979. assert _MODEL_TO_MAC_CACHE["E6:A1:CD:1F:5B:65"] is SwitchbotModel.PLUG_MINI_IT