1
0

test_lock.py 29 KB

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