test_lock.py 28 KB

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