test_lock.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959
  1. import logging
  2. from unittest.mock import AsyncMock, Mock, patch
  3. import pytest
  4. from switchbot import SwitchbotLockUltra, SwitchbotModel
  5. from switchbot.const.lock import LockStatus
  6. from switchbot.devices import lock
  7. from switchbot.devices.device import SwitchbotOperationError
  8. from switchbot.models import SwitchBotAdvertisement
  9. from .test_adv_parser import generate_ble_device
  10. def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
  11. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  12. return cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model)
  13. @pytest.mark.parametrize(
  14. "model",
  15. [
  16. SwitchbotModel.LOCK,
  17. SwitchbotModel.LOCK_LITE,
  18. SwitchbotModel.LOCK_PRO,
  19. SwitchbotModel.LOCK_ULTRA,
  20. SwitchbotModel.LOCK_ULTRA_MAX,
  21. SwitchbotModel.LOCK_VISION,
  22. SwitchbotModel.LOCK_VISION_PRO,
  23. SwitchbotModel.LOCK_PRO_WIFI,
  24. ],
  25. )
  26. def test_lock_init(model: str):
  27. """Test the initialization of the lock device."""
  28. device = create_device_for_command_testing(model)
  29. assert device._model == model
  30. @pytest.mark.parametrize(
  31. "model",
  32. [
  33. SwitchbotModel.AIR_PURIFIER_JP,
  34. ],
  35. )
  36. def test_lock_init_with_invalid_model(model: str):
  37. """Test that initializing with an invalid model raises ValueError."""
  38. with pytest.raises(
  39. ValueError, match="initializing SwitchbotLock with a non-lock model"
  40. ):
  41. create_device_for_command_testing(model)
  42. def test_ultra_direct_construction():
  43. device = generate_ble_device("aa:bb:cc:dd:ee:ff", "lock")
  44. assert (
  45. SwitchbotLockUltra(device, "ff", "ff" * 16)._model is SwitchbotModel.LOCK_ULTRA
  46. )
  47. def test_default_model_classvar():
  48. """The classvar default is SwitchbotModel.LOCK."""
  49. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  50. device = lock.SwitchbotLock(ble_device, "ff", "ffffffffffffffffffffffffffffffff")
  51. assert device._model == SwitchbotModel.LOCK
  52. @pytest.mark.asyncio
  53. @pytest.mark.parametrize(
  54. ("model", "command"),
  55. [
  56. (SwitchbotModel.LOCK, b"W\x0fN\x01\x01\x10\x80"),
  57. (SwitchbotModel.LOCK_LITE, b"W\x0fN\x01\x01\x10\x81"),
  58. (SwitchbotModel.LOCK_PRO, b"W\x0fN\x01\x01\x10\x85"),
  59. (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x86"),
  60. (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x00"),
  61. (SwitchbotModel.LOCK_VISION, b"W\x0fN\x01\x01\x00\x80"),
  62. (SwitchbotModel.LOCK_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
  63. (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x82"),
  64. ],
  65. )
  66. async def test_lock(model: str, command: bytes):
  67. """Test lock method."""
  68. device = create_device_for_command_testing(model)
  69. device._get_adv_value = Mock(return_value=LockStatus.UNLOCKED)
  70. with (
  71. patch.object(
  72. device, "_send_command", return_value=b"\x01\x00"
  73. ) as mock_send_command,
  74. patch.object(device, "_enable_notifications", return_value=True),
  75. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  76. ):
  77. result = await device.lock()
  78. assert result is True
  79. mock_send_command.assert_any_call(lock.COMMAND_LOCK[model])
  80. @pytest.mark.asyncio
  81. @pytest.mark.parametrize(
  82. ("model", "command"),
  83. [
  84. (SwitchbotModel.LOCK, b"W\x0fN\x01\x01\x10\x80"),
  85. (SwitchbotModel.LOCK_LITE, b"W\x0fN\x01\x01\x10\x81"),
  86. (SwitchbotModel.LOCK_PRO, b"W\x0fN\x01\x01\x10\x84"),
  87. (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x83"),
  88. (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x80"),
  89. (SwitchbotModel.LOCK_VISION, b"W\x0fN\x01\x01\x00\x80"),
  90. (SwitchbotModel.LOCK_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
  91. (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x81"),
  92. ],
  93. )
  94. async def test_unlock(model: str, command: bytes):
  95. """Test unlock method."""
  96. device = create_device_for_command_testing(model)
  97. device._get_adv_value = Mock(return_value=LockStatus.LOCKED)
  98. with (
  99. patch.object(
  100. device, "_send_command", return_value=b"\x01\x00"
  101. ) as mock_send_command,
  102. patch.object(device, "_enable_notifications", return_value=True),
  103. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  104. ):
  105. result = await device.unlock()
  106. assert result is True
  107. mock_send_command.assert_any_call(lock.COMMAND_UNLOCK[model])
  108. @pytest.mark.asyncio
  109. @pytest.mark.parametrize(
  110. "model",
  111. [
  112. SwitchbotModel.LOCK,
  113. SwitchbotModel.LOCK_LITE,
  114. SwitchbotModel.LOCK_PRO,
  115. SwitchbotModel.LOCK_ULTRA,
  116. SwitchbotModel.LOCK_ULTRA_MAX,
  117. SwitchbotModel.LOCK_VISION,
  118. SwitchbotModel.LOCK_VISION_PRO,
  119. SwitchbotModel.LOCK_PRO_WIFI,
  120. ],
  121. )
  122. async def test_unlock_without_unlatch(model: str):
  123. """Test unlock_without_unlatch method."""
  124. device = create_device_for_command_testing(model)
  125. device._get_adv_value = Mock(return_value=LockStatus.LOCKED)
  126. with (
  127. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  128. patch.object(device, "_enable_notifications", return_value=True),
  129. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  130. ):
  131. result = await device.unlock_without_unlatch()
  132. assert result is True
  133. @pytest.mark.asyncio
  134. @pytest.mark.parametrize(
  135. "model",
  136. [
  137. SwitchbotModel.LOCK,
  138. SwitchbotModel.LOCK_LITE,
  139. SwitchbotModel.LOCK_PRO,
  140. SwitchbotModel.LOCK_ULTRA,
  141. SwitchbotModel.LOCK_ULTRA_MAX,
  142. SwitchbotModel.LOCK_VISION,
  143. SwitchbotModel.LOCK_VISION_PRO,
  144. SwitchbotModel.LOCK_PRO_WIFI,
  145. ],
  146. )
  147. async def test_get_basic_info(model: str):
  148. """Test get_basic_info method."""
  149. device = create_device_for_command_testing(model)
  150. lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
  151. basic_data = bytes.fromhex("01 64 01 21 14")
  152. with (
  153. patch.object(device, "_get_lock_info", return_value=lock_data),
  154. patch.object(device, "_get_basic_info", return_value=basic_data),
  155. ):
  156. result = await device.get_basic_info()
  157. assert result is not None
  158. assert "battery" in result
  159. assert "firmware" in result
  160. assert "calibration" in result
  161. assert "status" in result
  162. @pytest.mark.asyncio
  163. @pytest.mark.parametrize(
  164. "model",
  165. [
  166. SwitchbotModel.LOCK,
  167. SwitchbotModel.LOCK_LITE,
  168. SwitchbotModel.LOCK_PRO,
  169. SwitchbotModel.LOCK_ULTRA,
  170. SwitchbotModel.LOCK_VISION,
  171. SwitchbotModel.LOCK_VISION_PRO,
  172. SwitchbotModel.LOCK_PRO_WIFI,
  173. ],
  174. )
  175. async def test_get_basic_info_no_lock_data(model: str):
  176. """Test get_basic_info when no lock data is returned."""
  177. device = create_device_for_command_testing(model)
  178. with patch.object(device, "_get_lock_info", return_value=None):
  179. result = await device.get_basic_info()
  180. assert result is None
  181. @pytest.mark.asyncio
  182. @pytest.mark.parametrize(
  183. "model",
  184. [
  185. SwitchbotModel.LOCK,
  186. SwitchbotModel.LOCK_LITE,
  187. SwitchbotModel.LOCK_PRO,
  188. SwitchbotModel.LOCK_ULTRA,
  189. SwitchbotModel.LOCK_VISION,
  190. SwitchbotModel.LOCK_VISION_PRO,
  191. SwitchbotModel.LOCK_PRO_WIFI,
  192. ],
  193. )
  194. async def test_get_basic_info_no_basic_data(model: str):
  195. """Test get_basic_info when no basic data is returned."""
  196. device = create_device_for_command_testing(model)
  197. lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
  198. with (
  199. patch.object(device, "_get_lock_info", return_value=lock_data),
  200. patch.object(device, "_get_basic_info", return_value=None),
  201. ):
  202. result = await device.get_basic_info()
  203. assert result is None
  204. @pytest.mark.parametrize("battery", [100, 255])
  205. def test_parse_basic_data(battery):
  206. """Test _parse_basic_data method."""
  207. device = create_device_for_command_testing(SwitchbotModel.LOCK)
  208. basic_data = bytes([0, battery, 1])
  209. result = device._parse_basic_data(basic_data)
  210. assert result["battery"] == battery
  211. assert result["firmware"] == 0.1
  212. @pytest.mark.asyncio
  213. @pytest.mark.parametrize(
  214. "model", [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX]
  215. )
  216. @pytest.mark.parametrize(
  217. ("basic_info", "battery", "firmware"),
  218. [
  219. ("01 4b 28 21 14", 75, 3.3), # Main battery in use.
  220. ("01 85 28 21 14", 40, 3.3), # Backup battery in use.
  221. ("01 f3 28 21 14", 40, 3.3), # Byte 1 must never appear as 243%.
  222. ("01 80 ff 21 14", 255, 3.3), # Report the backup battery byte as received.
  223. ("01 4b 28", 60, 2.1), # Truncated reply: keep the last readings.
  224. ],
  225. )
  226. async def test_lock2_basic_info(model, basic_info, battery, firmware):
  227. device = create_device_for_command_testing(model, SwitchbotLockUltra)
  228. device.update_from_advertisement(
  229. SwitchBotAdvertisement(
  230. device._device.address,
  231. {"data": {"battery": 60, "firmware": 2.1}},
  232. device._device,
  233. -60,
  234. )
  235. )
  236. with (
  237. patch.object(
  238. device, "_get_lock_info", return_value=bytes.fromhex("01 80 00 00 00 00 00")
  239. ),
  240. patch.object(device, "_get_basic_info", return_value=bytes.fromhex(basic_info)),
  241. ):
  242. await device.update()
  243. assert device.get_battery_percent() == battery
  244. assert device.parsed_data["firmware"] == firmware
  245. assert device.get_lock_status() is LockStatus.LOCKED
  246. @pytest.mark.asyncio
  247. @pytest.mark.parametrize(
  248. "model", [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX]
  249. )
  250. async def test_lock2_battery_after_lock(model):
  251. device = create_device_for_command_testing(model, SwitchbotLockUltra)
  252. device.update_from_advertisement(
  253. SwitchBotAdvertisement(
  254. device._device.address,
  255. {"data": {"status": LockStatus.UNLOCKED}},
  256. device._device,
  257. -60,
  258. )
  259. )
  260. with (
  261. patch.object(device, "_enable_notifications", return_value=True),
  262. patch.object(device, "_send_command", return_value=bytes.fromhex("01")),
  263. patch.object(
  264. device, "_get_basic_info", return_value=bytes.fromhex("01 f3 28 21 14")
  265. ),
  266. ):
  267. assert await device.lock()
  268. assert device.get_battery_percent() == 40
  269. assert device.parsed_data["firmware"] == 3.3
  270. @pytest.mark.asyncio
  271. async def test_lock_ultra_max_verifies_encryption_key():
  272. """Lock Ultra Max verifies a valid encryption key."""
  273. lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
  274. basic_data = bytes.fromhex("01 64 01 21 14")
  275. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  276. with (
  277. patch.object(lock.SwitchbotLock, "_get_lock_info", return_value=lock_data),
  278. patch.object(lock.SwitchbotLock, "_get_basic_info", return_value=basic_data),
  279. ):
  280. assert await SwitchbotLockUltra.verify_encryption_key(
  281. ble_device,
  282. "ff",
  283. "ffffffffffffffffffffffffffffffff",
  284. SwitchbotModel.LOCK_ULTRA_MAX,
  285. )
  286. @pytest.mark.parametrize(
  287. "model",
  288. [
  289. SwitchbotModel.LOCK,
  290. SwitchbotModel.LOCK_LITE,
  291. SwitchbotModel.LOCK_PRO,
  292. SwitchbotModel.LOCK_ULTRA,
  293. SwitchbotModel.LOCK_VISION,
  294. SwitchbotModel.LOCK_VISION_PRO,
  295. SwitchbotModel.LOCK_PRO_WIFI,
  296. ],
  297. )
  298. def test_is_calibrated(model: str):
  299. """Test is_calibrated method."""
  300. device = create_device_for_command_testing(model)
  301. device._get_adv_value = Mock(return_value=True)
  302. assert device.is_calibrated() is True
  303. @pytest.mark.parametrize(
  304. "model",
  305. [
  306. SwitchbotModel.LOCK,
  307. SwitchbotModel.LOCK_LITE,
  308. SwitchbotModel.LOCK_PRO,
  309. SwitchbotModel.LOCK_ULTRA,
  310. SwitchbotModel.LOCK_VISION,
  311. SwitchbotModel.LOCK_VISION_PRO,
  312. SwitchbotModel.LOCK_PRO_WIFI,
  313. ],
  314. )
  315. def test_get_lock_status(model: str):
  316. """Test get_lock_status method."""
  317. device = create_device_for_command_testing(model)
  318. device._get_adv_value = Mock(return_value=LockStatus.LOCKED)
  319. assert device.get_lock_status() == LockStatus.LOCKED
  320. @pytest.mark.parametrize(
  321. "model",
  322. [
  323. SwitchbotModel.LOCK,
  324. SwitchbotModel.LOCK_PRO,
  325. SwitchbotModel.LOCK_ULTRA,
  326. SwitchbotModel.LOCK_VISION_PRO,
  327. SwitchbotModel.LOCK_PRO_WIFI,
  328. ],
  329. )
  330. def test_is_door_open(model: str):
  331. """Test is_door_open method."""
  332. device = create_device_for_command_testing(model)
  333. device._get_adv_value = Mock(return_value=True)
  334. assert device.is_door_open() is True
  335. @pytest.mark.parametrize(
  336. "model",
  337. [
  338. SwitchbotModel.LOCK,
  339. SwitchbotModel.LOCK_PRO,
  340. SwitchbotModel.LOCK_ULTRA,
  341. SwitchbotModel.LOCK_VISION_PRO,
  342. SwitchbotModel.LOCK_PRO_WIFI,
  343. ],
  344. )
  345. def test_is_unclosed_alarm_on(model: str):
  346. """Test is_unclosed_alarm_on method."""
  347. device = create_device_for_command_testing(model)
  348. device._get_adv_value = Mock(return_value=True)
  349. assert device.is_unclosed_alarm_on() is True
  350. @pytest.mark.parametrize(
  351. "model",
  352. [
  353. SwitchbotModel.LOCK,
  354. SwitchbotModel.LOCK_LITE,
  355. SwitchbotModel.LOCK_PRO,
  356. SwitchbotModel.LOCK_ULTRA,
  357. SwitchbotModel.LOCK_VISION,
  358. SwitchbotModel.LOCK_VISION_PRO,
  359. SwitchbotModel.LOCK_PRO_WIFI,
  360. ],
  361. )
  362. def test_is_unlocked_alarm_on(model: str):
  363. """Test is_unlocked_alarm_on method."""
  364. device = create_device_for_command_testing(model)
  365. device._get_adv_value = Mock(return_value=True)
  366. assert device.is_unlocked_alarm_on() is True
  367. @pytest.mark.parametrize(
  368. "model",
  369. [
  370. SwitchbotModel.LOCK,
  371. ],
  372. )
  373. def test_is_auto_lock_paused(model: str):
  374. """Test is_auto_lock_paused method."""
  375. device = create_device_for_command_testing(model)
  376. device._get_adv_value = Mock(return_value=True)
  377. assert device.is_auto_lock_paused() is True
  378. @pytest.mark.parametrize(
  379. "model",
  380. [
  381. SwitchbotModel.LOCK,
  382. SwitchbotModel.LOCK_LITE,
  383. SwitchbotModel.LOCK_PRO,
  384. SwitchbotModel.LOCK_ULTRA,
  385. SwitchbotModel.LOCK_VISION,
  386. SwitchbotModel.LOCK_VISION_PRO,
  387. SwitchbotModel.LOCK_PRO_WIFI,
  388. ],
  389. )
  390. def test_is_night_latch_enabled(model: str):
  391. """Test is_night_latch_enabled method."""
  392. device = create_device_for_command_testing(model)
  393. device._get_adv_value = Mock(return_value=True)
  394. assert device.is_night_latch_enabled() is True
  395. @pytest.mark.asyncio
  396. @pytest.mark.parametrize(
  397. "model",
  398. [
  399. SwitchbotModel.LOCK,
  400. SwitchbotModel.LOCK_LITE,
  401. SwitchbotModel.LOCK_PRO,
  402. SwitchbotModel.LOCK_ULTRA,
  403. SwitchbotModel.LOCK_VISION,
  404. SwitchbotModel.LOCK_VISION_PRO,
  405. SwitchbotModel.LOCK_PRO_WIFI,
  406. ],
  407. )
  408. async def test_get_lock_info(model: str):
  409. """Test _get_lock_info method."""
  410. device = create_device_for_command_testing(model)
  411. expected_data = b"\x01\x00\x80\x00\x00\x00\x00\x00"
  412. with patch.object(device, "_send_command", return_value=expected_data):
  413. result = await device._get_lock_info()
  414. assert result == expected_data
  415. @pytest.mark.asyncio
  416. @pytest.mark.parametrize(
  417. "model",
  418. [
  419. SwitchbotModel.LOCK,
  420. SwitchbotModel.LOCK_LITE,
  421. SwitchbotModel.LOCK_PRO,
  422. SwitchbotModel.LOCK_ULTRA,
  423. SwitchbotModel.LOCK_VISION,
  424. SwitchbotModel.LOCK_VISION_PRO,
  425. SwitchbotModel.LOCK_PRO_WIFI,
  426. ],
  427. )
  428. async def test_get_lock_info_failure(model: str):
  429. """Test _get_lock_info method when command fails."""
  430. device = create_device_for_command_testing(model)
  431. with patch.object(device, "_send_command", return_value=b"\x00\x00"):
  432. result = await device._get_lock_info()
  433. assert result is None
  434. @pytest.mark.asyncio
  435. @pytest.mark.parametrize(
  436. "model",
  437. [
  438. SwitchbotModel.LOCK,
  439. SwitchbotModel.LOCK_LITE,
  440. SwitchbotModel.LOCK_PRO,
  441. SwitchbotModel.LOCK_ULTRA,
  442. SwitchbotModel.LOCK_VISION,
  443. SwitchbotModel.LOCK_VISION_PRO,
  444. SwitchbotModel.LOCK_PRO_WIFI,
  445. ],
  446. )
  447. async def test_enable_notifications(model: str):
  448. """Test _enable_notifications method."""
  449. device = create_device_for_command_testing(model)
  450. with patch.object(device, "_send_command", return_value=b"\x01\x00"):
  451. result = await device._enable_notifications()
  452. assert result is True
  453. @pytest.mark.asyncio
  454. @pytest.mark.parametrize(
  455. "model",
  456. [
  457. SwitchbotModel.LOCK,
  458. SwitchbotModel.LOCK_LITE,
  459. SwitchbotModel.LOCK_PRO,
  460. SwitchbotModel.LOCK_ULTRA,
  461. SwitchbotModel.LOCK_VISION,
  462. SwitchbotModel.LOCK_VISION_PRO,
  463. SwitchbotModel.LOCK_PRO_WIFI,
  464. ],
  465. )
  466. async def test_disable_notifications(model: str):
  467. """Test _disable_notifications method."""
  468. device = create_device_for_command_testing(model)
  469. device._notifications_enabled = True
  470. with patch.object(device, "_send_command", return_value=b"\x01\x00"):
  471. result = await device._disable_notifications()
  472. assert result is True
  473. assert device._notifications_enabled is False
  474. @pytest.mark.asyncio
  475. @pytest.mark.parametrize(
  476. "model",
  477. [
  478. SwitchbotModel.LOCK,
  479. SwitchbotModel.LOCK_LITE,
  480. SwitchbotModel.LOCK_PRO,
  481. SwitchbotModel.LOCK_ULTRA,
  482. SwitchbotModel.LOCK_VISION,
  483. SwitchbotModel.LOCK_VISION_PRO,
  484. SwitchbotModel.LOCK_PRO_WIFI,
  485. ],
  486. )
  487. async def test_disable_notifications_already_disabled(model: str):
  488. """Test _disable_notifications when already disabled."""
  489. device = create_device_for_command_testing(model)
  490. device._notifications_enabled = False
  491. with patch.object(device, "_send_command") as mock_send:
  492. result = await device._disable_notifications()
  493. assert result is True
  494. mock_send.assert_not_called()
  495. @pytest.mark.parametrize(
  496. "model",
  497. [
  498. SwitchbotModel.LOCK,
  499. SwitchbotModel.LOCK_LITE,
  500. SwitchbotModel.LOCK_PRO,
  501. SwitchbotModel.LOCK_ULTRA,
  502. SwitchbotModel.LOCK_VISION,
  503. SwitchbotModel.LOCK_VISION_PRO,
  504. SwitchbotModel.LOCK_PRO_WIFI,
  505. ],
  506. )
  507. def test_notification_handler(model: str):
  508. """Test _notification_handler method."""
  509. device = create_device_for_command_testing(model)
  510. device._notifications_enabled = True
  511. data = bytearray(b"\x0f\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  512. with patch.object(device, "_update_lock_status") as mock_update:
  513. device._notification_handler(0, data)
  514. mock_update.assert_called_once_with(data)
  515. @pytest.mark.parametrize(
  516. "model",
  517. [
  518. SwitchbotModel.LOCK,
  519. SwitchbotModel.LOCK_LITE,
  520. SwitchbotModel.LOCK_PRO,
  521. SwitchbotModel.LOCK_ULTRA,
  522. SwitchbotModel.LOCK_VISION,
  523. SwitchbotModel.LOCK_VISION_PRO,
  524. SwitchbotModel.LOCK_PRO_WIFI,
  525. ],
  526. )
  527. def test_notification_handler_not_enabled(model: str):
  528. """Test _notification_handler when notifications not enabled."""
  529. device = create_device_for_command_testing(model)
  530. device._notifications_enabled = False
  531. data = bytearray(b"\x01\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  532. with (
  533. patch.object(device, "_update_lock_status") as mock_update,
  534. patch.object(
  535. lock.SwitchbotSequenceDevice, "_notification_handler"
  536. ) as mock_super,
  537. ):
  538. device._notification_handler(0, data)
  539. mock_update.assert_not_called()
  540. mock_super.assert_called_once()
  541. @pytest.mark.parametrize(
  542. "model",
  543. [
  544. SwitchbotModel.LOCK,
  545. SwitchbotModel.LOCK_LITE,
  546. SwitchbotModel.LOCK_PRO,
  547. SwitchbotModel.LOCK_ULTRA,
  548. SwitchbotModel.LOCK_VISION,
  549. SwitchbotModel.LOCK_VISION_PRO,
  550. SwitchbotModel.LOCK_PRO_WIFI,
  551. ],
  552. )
  553. def test_notification_handler_during_disconnect(
  554. model: str, caplog: pytest.LogCaptureFixture
  555. ) -> None:
  556. """Test _notification_handler during expected disconnect."""
  557. device = create_device_for_command_testing(model)
  558. device._notifications_enabled = True
  559. device._expected_disconnect = True
  560. data = bytearray(b"\x0f\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  561. with (
  562. patch.object(device, "_update_lock_status") as mock_update,
  563. caplog.at_level(logging.DEBUG),
  564. ):
  565. device._notification_handler(0, data)
  566. # Should not update lock status during disconnect
  567. mock_update.assert_not_called()
  568. # Should log debug message
  569. assert "Ignoring lock notification during expected disconnect" in caplog.text
  570. @pytest.mark.parametrize(
  571. "model",
  572. [
  573. SwitchbotModel.LOCK,
  574. SwitchbotModel.LOCK_LITE,
  575. SwitchbotModel.LOCK_PRO,
  576. SwitchbotModel.LOCK_ULTRA,
  577. SwitchbotModel.LOCK_VISION,
  578. SwitchbotModel.LOCK_VISION_PRO,
  579. SwitchbotModel.LOCK_PRO_WIFI,
  580. ],
  581. )
  582. def test_update_lock_status(model: str):
  583. """Test _update_lock_status method."""
  584. device = create_device_for_command_testing(model)
  585. data = bytearray(b"\x0f\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  586. with (
  587. patch.object(device, "_decrypt", return_value=b"\x80\x00\x00\x00\x00\x00"),
  588. patch.object(device, "_update_parsed_data", return_value=True),
  589. patch.object(device, "_reset_disconnect_timer"),
  590. patch.object(device, "_fire_callbacks"),
  591. ):
  592. device._update_lock_status(data)
  593. @pytest.mark.parametrize(
  594. ("model", "data", "expected"),
  595. [
  596. (
  597. SwitchbotModel.LOCK,
  598. b"\x80\x00\x00\x00\x00\x00",
  599. {
  600. "calibration": True,
  601. "status": LockStatus.LOCKED, # (0x80 & 0b01110000) >> 4 = 0 = LOCKED
  602. "door_open": False,
  603. "unclosed_alarm": False,
  604. "unlocked_alarm": False,
  605. },
  606. ),
  607. (
  608. SwitchbotModel.LOCK_LITE,
  609. b"\x80\x00\x00\x00\x00\x00",
  610. {
  611. "calibration": True,
  612. "status": LockStatus.LOCKED, # (0x80 & 0b01110000) >> 4 = 0 = LOCKED
  613. "unlocked_alarm": False,
  614. },
  615. ),
  616. (
  617. SwitchbotModel.LOCK_PRO,
  618. b"\x80\x00\x00\x00\x00\x00",
  619. {
  620. "calibration": True,
  621. "status": LockStatus.LOCKED, # (0x80 & 0b01111000) >> 3 = 0 = LOCKED
  622. "door_open": False,
  623. "unclosed_alarm": False,
  624. "unlocked_alarm": False,
  625. },
  626. ),
  627. (
  628. SwitchbotModel.LOCK_ULTRA,
  629. b"\x88\x10\x00\x00\x00\xc0",
  630. {
  631. "calibration": True,
  632. "status": LockStatus.UNLOCKED, # (0x88 & 0b01111000) >> 3 = 0x08 >> 3 = 1 = UNLOCKED
  633. "door_open": True,
  634. "unclosed_alarm": True,
  635. "unlocked_alarm": True,
  636. "half_lock_calibration": False,
  637. },
  638. ),
  639. (
  640. SwitchbotModel.LOCK_VISION,
  641. b"\x80\x00\x00\x00\x00\x00",
  642. {
  643. "calibration": True,
  644. "status": LockStatus.LOCKED, # (0x80 & 0b01110000) >> 4 = 0 = LOCKED
  645. "unlocked_alarm": False,
  646. },
  647. ),
  648. (
  649. SwitchbotModel.LOCK_VISION_PRO,
  650. b"\x80\x00\x00\x00\x00\x00",
  651. {
  652. "calibration": True,
  653. "status": LockStatus.LOCKED, # (0x80 & 0b01111000) >> 3 = 0 = LOCKED
  654. "door_open": False,
  655. "unclosed_alarm": False,
  656. "unlocked_alarm": False,
  657. },
  658. ),
  659. (
  660. SwitchbotModel.LOCK_PRO_WIFI,
  661. b"\x80\x00\x00\x00\x00\x00",
  662. {
  663. "calibration": True,
  664. "status": LockStatus.LOCKED, # (0x80 & 0b01111000) >> 3 = 0 = LOCKED
  665. "door_open": False,
  666. "unclosed_alarm": False,
  667. "unlocked_alarm": False,
  668. },
  669. ),
  670. ],
  671. )
  672. def test_parse_lock_data(model: str, data: bytes, expected: dict):
  673. """Test _parse_lock_data static method."""
  674. result = lock.SwitchbotLock._parse_lock_data(data, model)
  675. assert result == expected
  676. @pytest.mark.parametrize(
  677. ("model", "data", "expected"),
  678. [
  679. # Test LOCK with different status bits and flags
  680. (
  681. SwitchbotModel.LOCK,
  682. b"\x94\x00\x00\x00\x00\x00", # Unlocked status (0x10 >> 4 = 1) with door open
  683. {
  684. "calibration": True,
  685. "status": LockStatus.UNLOCKED,
  686. "door_open": True,
  687. "unclosed_alarm": False,
  688. "unlocked_alarm": False,
  689. },
  690. ),
  691. # Test LOCK_LITE without door_open field
  692. (
  693. SwitchbotModel.LOCK_LITE,
  694. b"\x90\x10\x00\x00\x00\x00", # Unlocked with unlocked alarm
  695. {
  696. "calibration": True,
  697. "status": LockStatus.UNLOCKED,
  698. "unlocked_alarm": True,
  699. },
  700. ),
  701. # Test LOCK_PRO with new bit positions
  702. (
  703. SwitchbotModel.LOCK_PRO,
  704. b"\x90\x10\x00\x00\x00\xc0", # New format: status bits 3-6, door open bit 4 of byte 1
  705. {
  706. "calibration": True,
  707. "status": LockStatus.LOCKING, # (0x90 & 0b01111000) >> 3 = 0x10 >> 3 = 2 (LOCKING)
  708. "door_open": True, # bit 4 of byte 1 (0x10)
  709. "unclosed_alarm": True, # bit 7 of byte 5 (0xc0)
  710. "unlocked_alarm": True, # bit 6 of byte 5 (0xc0)
  711. },
  712. ),
  713. # Test LOCK_ULTRA with same format as PRO
  714. (
  715. SwitchbotModel.LOCK_ULTRA,
  716. b"\x88\x00\x00\x00\x00\x40", # Unlocked with unlocked alarm only
  717. {
  718. "calibration": True,
  719. "status": LockStatus.UNLOCKED, # (0x88 & 0b01111000) >> 3 = 0x08 >> 3 = 1
  720. "door_open": False,
  721. "unclosed_alarm": False,
  722. "unlocked_alarm": True, # bit 6 of byte 5
  723. "half_lock_calibration": False,
  724. },
  725. ),
  726. (
  727. SwitchbotModel.LOCK_PRO_WIFI,
  728. b"\x90\x10\x00\x00\x00\xc0", # New format: status bits 3-6, door open bit 4 of byte 1
  729. {
  730. "calibration": True,
  731. "status": LockStatus.LOCKING, # (0x90 & 0b01111000) >> 3 = 0x10 >> 3 = 2 (LOCKING)
  732. "door_open": True, # bit 4 of byte 1 (0x10)
  733. "unclosed_alarm": True, # bit 7 of byte 5 (0xc0)
  734. "unlocked_alarm": True, # bit 6 of byte 5 (0xc0)
  735. },
  736. ),
  737. ],
  738. )
  739. def test_parse_lock_data_new_formats(model: str, data: bytes, expected: dict):
  740. """Test _parse_lock_data with new format changes."""
  741. result = lock.SwitchbotLock._parse_lock_data(data, model)
  742. assert result == expected
  743. @pytest.mark.asyncio
  744. @pytest.mark.parametrize(
  745. "model",
  746. [
  747. SwitchbotModel.LOCK,
  748. SwitchbotModel.LOCK_LITE,
  749. SwitchbotModel.LOCK_PRO,
  750. SwitchbotModel.LOCK_ULTRA,
  751. SwitchbotModel.LOCK_VISION,
  752. SwitchbotModel.LOCK_VISION_PRO,
  753. SwitchbotModel.LOCK_PRO_WIFI,
  754. ],
  755. )
  756. async def test_lock_with_update(model: str):
  757. """Test lock method with status update."""
  758. device = create_device_for_command_testing(model)
  759. device._get_adv_value = Mock(side_effect=[None, LockStatus.UNLOCKED])
  760. with (
  761. patch.object(device, "update", new_callable=AsyncMock),
  762. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  763. patch.object(device, "_enable_notifications", return_value=True),
  764. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  765. ):
  766. result = await device.lock()
  767. assert result is True
  768. def test_is_half_lock_calibrated():
  769. """Test is_half_lock_calibrated method."""
  770. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  771. device._get_adv_value = Mock(return_value=True)
  772. assert device.is_half_lock_calibrated() is True
  773. device._get_adv_value = Mock(return_value=False)
  774. assert device.is_half_lock_calibrated() is False
  775. @pytest.mark.asyncio
  776. async def test_half_lock_calibrated():
  777. """Test half_lock succeeds when calibrated."""
  778. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  779. device._get_adv_value = Mock(side_effect=[True, LockStatus.LOCKED])
  780. with (
  781. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  782. patch.object(device, "_enable_notifications", return_value=True),
  783. patch.object(device, "_get_basic_info", return_value=b"\x01\x64\x01"),
  784. ):
  785. result = await device.half_lock()
  786. assert result is True
  787. @pytest.mark.asyncio
  788. async def test_half_lock_not_calibrated():
  789. """Test half_lock raises SwitchbotOperationError when not calibrated."""
  790. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  791. device._get_adv_value = Mock(return_value=False)
  792. with pytest.raises(SwitchbotOperationError, match="not calibrated"):
  793. await device.half_lock()
  794. @pytest.mark.asyncio
  795. @pytest.mark.parametrize(
  796. "model",
  797. [
  798. SwitchbotModel.LOCK,
  799. SwitchbotModel.LOCK_LITE,
  800. SwitchbotModel.LOCK_PRO,
  801. SwitchbotModel.LOCK_ULTRA_MAX,
  802. SwitchbotModel.LOCK_VISION,
  803. SwitchbotModel.LOCK_VISION_PRO,
  804. SwitchbotModel.LOCK_PRO_WIFI,
  805. ],
  806. )
  807. async def test_half_lock_unsupported_model(model: str):
  808. """Test half_lock raises SwitchbotOperationError on unsupported models."""
  809. device = create_device_for_command_testing(model)
  810. with pytest.raises(SwitchbotOperationError, match="not supported"):
  811. await device.half_lock()
  812. @pytest.mark.asyncio
  813. async def test_half_lock():
  814. """Test half_lock method."""
  815. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  816. device._get_adv_value = Mock(side_effect=[True, LockStatus.LOCKED])
  817. with (
  818. patch.object(device, "_send_command", return_value=b"\x01\x00") as mock_send,
  819. patch.object(device, "_enable_notifications", return_value=True),
  820. patch.object(device, "_get_basic_info", return_value=b"\x01\x64\x01"),
  821. ):
  822. result = await device.half_lock()
  823. assert result is True
  824. mock_send.assert_awaited_once_with(
  825. lock.COMMAND_HALF_LOCK[SwitchbotModel.LOCK_ULTRA]
  826. )
  827. @pytest.mark.asyncio
  828. @pytest.mark.parametrize(
  829. ("model", "status"),
  830. [
  831. (SwitchbotModel.LOCK, LockStatus.LOCKED),
  832. (SwitchbotModel.LOCK_LITE, LockStatus.LOCKING),
  833. (SwitchbotModel.LOCK_PRO, LockStatus.LOCKED),
  834. (SwitchbotModel.LOCK_ULTRA, LockStatus.LOCKING),
  835. (SwitchbotModel.LOCK_VISION, LockStatus.LOCKED),
  836. (SwitchbotModel.LOCK_VISION_PRO, LockStatus.LOCKED),
  837. (SwitchbotModel.LOCK_PRO_WIFI, LockStatus.LOCKED),
  838. ],
  839. )
  840. async def test_lock_already_locked(model: str, status: LockStatus):
  841. """Test lock method when already locked."""
  842. device = create_device_for_command_testing(model)
  843. device._get_adv_value = Mock(return_value=status)
  844. with patch.object(device, "_send_command") as mock_send:
  845. result = await device.lock()
  846. assert result is True
  847. mock_send.assert_not_called()
  848. @pytest.mark.asyncio
  849. @pytest.mark.parametrize(
  850. "model",
  851. [
  852. SwitchbotModel.LOCK,
  853. SwitchbotModel.LOCK_LITE,
  854. SwitchbotModel.LOCK_PRO,
  855. SwitchbotModel.LOCK_ULTRA,
  856. SwitchbotModel.LOCK_VISION,
  857. SwitchbotModel.LOCK_VISION_PRO,
  858. SwitchbotModel.LOCK_PRO_WIFI,
  859. ],
  860. )
  861. async def test_lock_with_invalid_basic_data(model: str):
  862. """Test lock method with invalid basic data."""
  863. device = create_device_for_command_testing(model)
  864. device._get_adv_value = Mock(return_value=LockStatus.UNLOCKED)
  865. with (
  866. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  867. patch.object(device, "_enable_notifications", return_value=True),
  868. patch.object(device, "_get_basic_info", return_value=b"\x00"),
  869. ):
  870. result = await device.lock()
  871. assert result is True