test_lock.py 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569
  1. import logging
  2. from typing import Any
  3. from unittest.mock import AsyncMock, Mock, call, patch
  4. import pytest
  5. from switchbot import SwitchbotLockUltra, SwitchbotModel
  6. from switchbot.const.lock import LockStatus, QuickKeyFunction
  7. from switchbot.devices import lock
  8. from switchbot.devices.device import SwitchbotOperationError
  9. from switchbot.models import SwitchBotAdvertisement
  10. from .test_adv_parser import generate_ble_device
  11. @pytest.mark.asyncio
  12. @pytest.mark.parametrize(
  13. ("replies", "valid"),
  14. [
  15. (
  16. [bytes.fromhex("01 88 00 00 00 00 00"), bytes.fromhex("01 85 28 21 14")],
  17. True,
  18. ),
  19. ([None], False),
  20. ([bytes.fromhex("01 88 00 00 00 00 00"), None], False),
  21. ],
  22. )
  23. async def test_uwb_lock_key_verification(replies, valid):
  24. device = generate_ble_device("aa:bb:cc:dd:ee:ff", "UWB Lock")
  25. with patch.object(SwitchbotLockUltra, "_send_command", side_effect=replies) as send:
  26. assert (
  27. await SwitchbotLockUltra.verify_encryption_key(
  28. device, "ff", "ff" * 16, SwitchbotModel.UWB_LOCK
  29. )
  30. is valid
  31. )
  32. assert [args.kwargs["key"] for args in send.await_args_list] == [
  33. "570f4f8107",
  34. "5702",
  35. ][: len(replies)]
  36. @pytest.mark.asyncio
  37. async def test_nano_update_reads_lock_and_basic_info():
  38. device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
  39. device.update_from_advertisement(
  40. SwitchBotAdvertisement(
  41. device._device.address, {"data": {"battery": 95}}, device._device, -60
  42. )
  43. )
  44. replies = [bytes.fromhex("01 90 08 5f 00 00 40"), bytes.fromhex("01 4b 21 14")]
  45. with patch.object(device, "_send_command", side_effect=replies) as send:
  46. await device.update()
  47. assert send.await_args_list == [
  48. call(key="570f4f810c", retry=device._retry_count),
  49. call(key="5702", retry=device._retry_count),
  50. ]
  51. assert device.get_lock_status() is LockStatus.LOCKING
  52. assert device.is_calibrated()
  53. assert device.is_auto_lock_paused()
  54. assert device.is_unlocked_alarm_on()
  55. assert device.get_battery_percent() == 75
  56. assert device.parsed_data["firmware"] == 3.3
  57. @pytest.mark.asyncio
  58. async def test_nano_key_verification_without_response():
  59. device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Lock Nano")
  60. with patch.object(lock.SwitchbotLock, "_send_command", return_value=None):
  61. assert not await lock.SwitchbotLock.verify_encryption_key(
  62. device, "ff", "ff" * 16, SwitchbotModel.LOCK_NANO
  63. )
  64. @pytest.mark.asyncio
  65. async def test_nano_lock_info_command():
  66. device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
  67. with patch.object(device, "_send_command", return_value=b"\x01\x00") as send:
  68. assert await device._get_lock_info() == b"\x01\x00"
  69. send.assert_awaited_once_with(key="570f4f810c", retry=device._retry_count)
  70. @pytest.mark.asyncio
  71. async def test_nano_notification_command():
  72. device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
  73. with patch.object(device, "_send_command", return_value=b"\x01\x00") as send:
  74. assert await device._enable_notifications()
  75. send.assert_awaited_once_with("570e01001e0000810c")
  76. def create_device_for_command_testing(model: str, cls=lock.SwitchbotLock):
  77. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  78. return cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model)
  79. def test_nano_status_notification_updates_device():
  80. device = create_device_for_command_testing(SwitchbotModel.LOCK_NANO)
  81. advertisement = SwitchBotAdvertisement(
  82. device._device.address,
  83. {"data": {"status": LockStatus.UNLOCKED}},
  84. device._device,
  85. -60,
  86. )
  87. device.update_from_advertisement(advertisement)
  88. received = []
  89. device.subscribe(lambda: received.append(device.get_lock_status()))
  90. payload = bytes.fromhex("90 00 00 00 00 00")
  91. with (
  92. patch.object(device, "_decrypt", return_value=payload) as decrypt,
  93. patch.object(device, "_reset_disconnect_timer"),
  94. ):
  95. frame = bytearray.fromhex("0f 00 00 00 12 34 56 78 9a bc")
  96. device._notification_handler(0, frame)
  97. decrypt.assert_called_once_with(frame[4:])
  98. assert received == [LockStatus.LOCKING]
  99. assert device.get_lock_status() is LockStatus.LOCKING
  100. @pytest.mark.parametrize(
  101. "model",
  102. [
  103. SwitchbotModel.LOCK,
  104. SwitchbotModel.LOCK_LITE,
  105. SwitchbotModel.LOCK_PRO,
  106. SwitchbotModel.LOCK_ULTRA,
  107. SwitchbotModel.LOCK_ULTRA_MAX,
  108. SwitchbotModel.UWB_LOCK,
  109. SwitchbotModel.LOCK_NANO,
  110. SwitchbotModel.LOCK_VISION,
  111. SwitchbotModel.LOCK_VISION_PRO,
  112. SwitchbotModel.LOCK_PRO_WIFI,
  113. ],
  114. )
  115. def test_lock_init(model: str):
  116. """Test the initialization of the lock device."""
  117. device = create_device_for_command_testing(model)
  118. assert device._model == model
  119. @pytest.mark.parametrize(
  120. "model",
  121. [
  122. SwitchbotModel.AIR_PURIFIER_JP,
  123. ],
  124. )
  125. def test_lock_init_with_invalid_model(model: str):
  126. """Test that initializing with an invalid model raises ValueError."""
  127. with pytest.raises(
  128. ValueError, match="initializing SwitchbotLock with a non-lock model"
  129. ):
  130. create_device_for_command_testing(model)
  131. def test_ultra_direct_construction():
  132. device = generate_ble_device("aa:bb:cc:dd:ee:ff", "lock")
  133. assert (
  134. SwitchbotLockUltra(device, "ff", "ff" * 16)._model is SwitchbotModel.LOCK_ULTRA
  135. )
  136. def test_default_model_classvar():
  137. """The classvar default is SwitchbotModel.LOCK."""
  138. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  139. device = lock.SwitchbotLock(ble_device, "ff", "ffffffffffffffffffffffffffffffff")
  140. assert device._model == SwitchbotModel.LOCK
  141. @pytest.mark.asyncio
  142. @pytest.mark.parametrize(
  143. ("model", "command"),
  144. [
  145. (SwitchbotModel.LOCK, b"W\x0fN\x01\x01\x10\x80"),
  146. (SwitchbotModel.LOCK_LITE, b"W\x0fN\x01\x01\x10\x81"),
  147. (SwitchbotModel.LOCK_PRO, b"W\x0fN\x01\x01\x10\x85"),
  148. (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x86"),
  149. (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x00"),
  150. (SwitchbotModel.UWB_LOCK, b"W\x0fN\x01\x01\x00\x00"),
  151. (SwitchbotModel.LOCK_NANO, b"W\x0fN\x01\x01\x00\x00"),
  152. (SwitchbotModel.LOCK_VISION, b"W\x0fN\x01\x01\x00\x80"),
  153. (SwitchbotModel.LOCK_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
  154. (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x82"),
  155. ],
  156. )
  157. async def test_lock(model: str, command: bytes):
  158. """Test lock method."""
  159. device = create_device_for_command_testing(model)
  160. device._get_adv_value = Mock(return_value=LockStatus.UNLOCKED)
  161. with (
  162. patch.object(
  163. device, "_send_command", return_value=b"\x01\x00"
  164. ) as mock_send_command,
  165. patch.object(device, "_enable_notifications", return_value=True),
  166. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  167. ):
  168. result = await device.lock()
  169. assert result is True
  170. mock_send_command.assert_any_call(lock.COMMAND_LOCK[model])
  171. @pytest.mark.asyncio
  172. @pytest.mark.parametrize(
  173. ("model", "command"),
  174. [
  175. (SwitchbotModel.LOCK, b"W\x0fN\x01\x01\x10\x80"),
  176. (SwitchbotModel.LOCK_LITE, b"W\x0fN\x01\x01\x10\x81"),
  177. (SwitchbotModel.LOCK_PRO, b"W\x0fN\x01\x01\x10\x84"),
  178. (SwitchbotModel.LOCK_ULTRA, b"W\x0fN\x01\x01\x10\x83"),
  179. (SwitchbotModel.LOCK_ULTRA_MAX, b"W\x0fN\x01\x01\x00\x80"),
  180. (SwitchbotModel.UWB_LOCK, b"W\x0fN\x01\x01\x00\x80"),
  181. (SwitchbotModel.LOCK_NANO, b"W\x0fN\x01\x01\x00\x80"),
  182. (SwitchbotModel.LOCK_VISION, b"W\x0fN\x01\x01\x00\x80"),
  183. (SwitchbotModel.LOCK_VISION_PRO, b"W\x0fN\x01\x01\x00\x80"),
  184. (SwitchbotModel.LOCK_PRO_WIFI, b"W\x0fN\x01\x01\x10\x81"),
  185. ],
  186. )
  187. async def test_unlock(model: str, command: bytes):
  188. """Test unlock method."""
  189. device = create_device_for_command_testing(model)
  190. device._get_adv_value = Mock(return_value=LockStatus.LOCKED)
  191. with (
  192. patch.object(
  193. device, "_send_command", return_value=b"\x01\x00"
  194. ) as mock_send_command,
  195. patch.object(device, "_enable_notifications", return_value=True),
  196. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  197. ):
  198. result = await device.unlock()
  199. assert result is True
  200. mock_send_command.assert_any_call(lock.COMMAND_UNLOCK[model])
  201. @pytest.mark.asyncio
  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_ULTRA_MAX,
  210. SwitchbotModel.UWB_LOCK,
  211. SwitchbotModel.LOCK_NANO,
  212. SwitchbotModel.LOCK_VISION,
  213. SwitchbotModel.LOCK_VISION_PRO,
  214. SwitchbotModel.LOCK_PRO_WIFI,
  215. ],
  216. )
  217. async def test_unlock_without_unlatch(model: str):
  218. """Test unlock_without_unlatch method."""
  219. device = create_device_for_command_testing(model)
  220. device._get_adv_value = Mock(return_value=LockStatus.LOCKED)
  221. with (
  222. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  223. patch.object(device, "_enable_notifications", return_value=True),
  224. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  225. ):
  226. result = await device.unlock_without_unlatch()
  227. assert result is True
  228. @pytest.mark.asyncio
  229. @pytest.mark.parametrize(
  230. "model",
  231. [
  232. SwitchbotModel.LOCK,
  233. SwitchbotModel.LOCK_LITE,
  234. SwitchbotModel.LOCK_NANO,
  235. SwitchbotModel.LOCK_PRO,
  236. SwitchbotModel.LOCK_ULTRA,
  237. SwitchbotModel.LOCK_ULTRA_MAX,
  238. SwitchbotModel.LOCK_VISION,
  239. SwitchbotModel.LOCK_VISION_PRO,
  240. SwitchbotModel.LOCK_PRO_WIFI,
  241. ],
  242. )
  243. async def test_get_basic_info(model: str):
  244. """Test get_basic_info method."""
  245. device = create_device_for_command_testing(model)
  246. lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
  247. basic_data = bytes.fromhex("01 64 01 21 14")
  248. with (
  249. patch.object(device, "_get_lock_info", return_value=lock_data),
  250. patch.object(device, "_get_basic_info", return_value=basic_data),
  251. ):
  252. result = await device.get_basic_info()
  253. assert result is not None
  254. assert "battery" in result
  255. assert "firmware" in result
  256. assert "calibration" in result
  257. assert "status" in result
  258. @pytest.mark.asyncio
  259. @pytest.mark.parametrize(
  260. "model",
  261. [
  262. SwitchbotModel.LOCK,
  263. SwitchbotModel.LOCK_LITE,
  264. SwitchbotModel.LOCK_NANO,
  265. SwitchbotModel.LOCK_PRO,
  266. SwitchbotModel.LOCK_ULTRA,
  267. SwitchbotModel.LOCK_VISION,
  268. SwitchbotModel.LOCK_VISION_PRO,
  269. SwitchbotModel.LOCK_PRO_WIFI,
  270. ],
  271. )
  272. async def test_get_basic_info_no_lock_data(model: str):
  273. """Test get_basic_info when no lock data is returned."""
  274. device = create_device_for_command_testing(model)
  275. with patch.object(device, "_get_lock_info", return_value=None):
  276. result = await device.get_basic_info()
  277. assert result is None
  278. @pytest.mark.asyncio
  279. @pytest.mark.parametrize(
  280. "model",
  281. [
  282. SwitchbotModel.LOCK,
  283. SwitchbotModel.LOCK_LITE,
  284. SwitchbotModel.LOCK_NANO,
  285. SwitchbotModel.LOCK_PRO,
  286. SwitchbotModel.LOCK_ULTRA,
  287. SwitchbotModel.LOCK_VISION,
  288. SwitchbotModel.LOCK_VISION_PRO,
  289. SwitchbotModel.LOCK_PRO_WIFI,
  290. ],
  291. )
  292. async def test_get_basic_info_no_basic_data(model: str):
  293. """Test get_basic_info when no basic data is returned."""
  294. device = create_device_for_command_testing(model)
  295. lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
  296. with (
  297. patch.object(device, "_get_lock_info", return_value=lock_data),
  298. patch.object(device, "_get_basic_info", return_value=None),
  299. ):
  300. result = await device.get_basic_info()
  301. assert result is None
  302. @pytest.mark.parametrize("battery", [100, 255])
  303. def test_parse_basic_data(battery):
  304. """Test _parse_basic_data method."""
  305. device = create_device_for_command_testing(SwitchbotModel.LOCK)
  306. basic_data = bytes([0, battery, 1])
  307. result = device._parse_basic_data(basic_data)
  308. assert result["battery"] == battery
  309. assert result["firmware"] == 0.1
  310. @pytest.mark.asyncio
  311. @pytest.mark.parametrize(
  312. "model",
  313. [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX, SwitchbotModel.UWB_LOCK],
  314. )
  315. @pytest.mark.parametrize(
  316. ("basic_info", "battery", "firmware"),
  317. [
  318. ("01 4b 28 21 14", 75, 3.3), # Main battery in use.
  319. ("01 85 28 21 14", 40, 3.3), # Backup battery in use.
  320. ("01 f3 28 21 14", 40, 3.3), # Byte 1 must never appear as 243%.
  321. ("01 80 ff 21 14", 255, 3.3), # Report the backup battery byte as received.
  322. ("01 4b 28", 60, 2.1), # Truncated reply: keep the last readings.
  323. ],
  324. )
  325. async def test_lock2_basic_info(model, basic_info, battery, firmware):
  326. device = create_device_for_command_testing(model, SwitchbotLockUltra)
  327. device.update_from_advertisement(
  328. SwitchBotAdvertisement(
  329. device._device.address,
  330. {"data": {"battery": 60, "firmware": 2.1}},
  331. device._device,
  332. -60,
  333. )
  334. )
  335. with (
  336. patch.object(
  337. device, "_get_lock_info", return_value=bytes.fromhex("01 80 00 00 00 00 00")
  338. ),
  339. patch.object(device, "_get_basic_info", return_value=bytes.fromhex(basic_info)),
  340. ):
  341. await device.update()
  342. assert device.get_battery_percent() == battery
  343. assert device.parsed_data["firmware"] == firmware
  344. assert device.get_lock_status() is LockStatus.LOCKED
  345. @pytest.mark.asyncio
  346. @pytest.mark.parametrize(
  347. "model",
  348. [SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA_MAX, SwitchbotModel.UWB_LOCK],
  349. )
  350. async def test_lock2_battery_after_lock(model):
  351. device = create_device_for_command_testing(model, SwitchbotLockUltra)
  352. device.update_from_advertisement(
  353. SwitchBotAdvertisement(
  354. device._device.address,
  355. {"data": {"status": LockStatus.UNLOCKED}},
  356. device._device,
  357. -60,
  358. )
  359. )
  360. with (
  361. patch.object(device, "_enable_notifications", return_value=True),
  362. patch.object(device, "_send_command", return_value=bytes.fromhex("01")),
  363. patch.object(
  364. device, "_get_basic_info", return_value=bytes.fromhex("01 f3 28 21 14")
  365. ),
  366. ):
  367. assert await device.lock()
  368. assert device.get_battery_percent() == 40
  369. assert device.parsed_data["firmware"] == 3.3
  370. @pytest.mark.asyncio
  371. async def test_lock_ultra_max_verifies_encryption_key():
  372. """Lock Ultra Max verifies a valid encryption key."""
  373. lock_data = b"\x00\x80\x00\x00\x00\x00\x00\x00"
  374. basic_data = bytes.fromhex("01 64 01 21 14")
  375. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  376. with (
  377. patch.object(lock.SwitchbotLock, "_get_lock_info", return_value=lock_data),
  378. patch.object(lock.SwitchbotLock, "_get_basic_info", return_value=basic_data),
  379. ):
  380. assert await SwitchbotLockUltra.verify_encryption_key(
  381. ble_device,
  382. "ff",
  383. "ffffffffffffffffffffffffffffffff",
  384. SwitchbotModel.LOCK_ULTRA_MAX,
  385. )
  386. @pytest.mark.parametrize(
  387. "model",
  388. [
  389. SwitchbotModel.LOCK,
  390. SwitchbotModel.LOCK_LITE,
  391. SwitchbotModel.LOCK_PRO,
  392. SwitchbotModel.LOCK_ULTRA,
  393. SwitchbotModel.LOCK_VISION,
  394. SwitchbotModel.LOCK_VISION_PRO,
  395. SwitchbotModel.LOCK_PRO_WIFI,
  396. ],
  397. )
  398. def test_is_calibrated(model: str):
  399. """Test is_calibrated method."""
  400. device = create_device_for_command_testing(model)
  401. device._get_adv_value = Mock(return_value=True)
  402. assert device.is_calibrated() is True
  403. @pytest.mark.parametrize(
  404. "model",
  405. [
  406. SwitchbotModel.LOCK,
  407. SwitchbotModel.LOCK_LITE,
  408. SwitchbotModel.LOCK_PRO,
  409. SwitchbotModel.LOCK_ULTRA,
  410. SwitchbotModel.LOCK_VISION,
  411. SwitchbotModel.LOCK_VISION_PRO,
  412. SwitchbotModel.LOCK_PRO_WIFI,
  413. ],
  414. )
  415. def test_get_lock_status(model: str):
  416. """Test get_lock_status method."""
  417. device = create_device_for_command_testing(model)
  418. device._get_adv_value = Mock(return_value=LockStatus.LOCKED)
  419. assert device.get_lock_status() == LockStatus.LOCKED
  420. @pytest.mark.parametrize(
  421. "model",
  422. [
  423. SwitchbotModel.LOCK,
  424. SwitchbotModel.LOCK_PRO,
  425. SwitchbotModel.LOCK_ULTRA,
  426. SwitchbotModel.LOCK_VISION_PRO,
  427. SwitchbotModel.LOCK_PRO_WIFI,
  428. ],
  429. )
  430. def test_is_door_open(model: str):
  431. """Test is_door_open method."""
  432. device = create_device_for_command_testing(model)
  433. device._get_adv_value = Mock(return_value=True)
  434. assert device.is_door_open() is True
  435. @pytest.mark.parametrize(
  436. "model",
  437. [
  438. SwitchbotModel.LOCK,
  439. SwitchbotModel.LOCK_PRO,
  440. SwitchbotModel.LOCK_ULTRA,
  441. SwitchbotModel.LOCK_VISION_PRO,
  442. SwitchbotModel.LOCK_PRO_WIFI,
  443. ],
  444. )
  445. def test_is_unclosed_alarm_on(model: str):
  446. """Test is_unclosed_alarm_on method."""
  447. device = create_device_for_command_testing(model)
  448. device._get_adv_value = Mock(return_value=True)
  449. assert device.is_unclosed_alarm_on() is True
  450. @pytest.mark.parametrize(
  451. "model",
  452. [
  453. SwitchbotModel.LOCK,
  454. SwitchbotModel.LOCK_LITE,
  455. SwitchbotModel.LOCK_PRO,
  456. SwitchbotModel.LOCK_ULTRA,
  457. SwitchbotModel.LOCK_VISION,
  458. SwitchbotModel.LOCK_VISION_PRO,
  459. SwitchbotModel.LOCK_PRO_WIFI,
  460. ],
  461. )
  462. def test_is_unlocked_alarm_on(model: str):
  463. """Test is_unlocked_alarm_on method."""
  464. device = create_device_for_command_testing(model)
  465. device._get_adv_value = Mock(return_value=True)
  466. assert device.is_unlocked_alarm_on() is True
  467. @pytest.mark.parametrize(
  468. "model",
  469. [
  470. SwitchbotModel.LOCK,
  471. ],
  472. )
  473. def test_is_auto_lock_paused(model: str):
  474. """Test is_auto_lock_paused method."""
  475. device = create_device_for_command_testing(model)
  476. device._get_adv_value = Mock(return_value=True)
  477. assert device.is_auto_lock_paused() is True
  478. @pytest.mark.parametrize(
  479. "model",
  480. [
  481. SwitchbotModel.LOCK,
  482. SwitchbotModel.LOCK_LITE,
  483. SwitchbotModel.LOCK_PRO,
  484. SwitchbotModel.LOCK_ULTRA,
  485. SwitchbotModel.LOCK_VISION,
  486. SwitchbotModel.LOCK_VISION_PRO,
  487. SwitchbotModel.LOCK_PRO_WIFI,
  488. ],
  489. )
  490. def test_is_night_latch_enabled(model: str):
  491. """Test is_night_latch_enabled method."""
  492. device = create_device_for_command_testing(model)
  493. device._get_adv_value = Mock(return_value=True)
  494. assert device.is_night_latch_enabled() is True
  495. @pytest.mark.asyncio
  496. @pytest.mark.parametrize(
  497. "model",
  498. [
  499. SwitchbotModel.LOCK,
  500. SwitchbotModel.LOCK_LITE,
  501. SwitchbotModel.LOCK_PRO,
  502. SwitchbotModel.LOCK_ULTRA,
  503. SwitchbotModel.LOCK_VISION,
  504. SwitchbotModel.LOCK_VISION_PRO,
  505. SwitchbotModel.LOCK_PRO_WIFI,
  506. ],
  507. )
  508. async def test_get_lock_info(model: str):
  509. """Test _get_lock_info method."""
  510. device = create_device_for_command_testing(model)
  511. expected_data = b"\x01\x00\x80\x00\x00\x00\x00\x00"
  512. with patch.object(device, "_send_command", return_value=expected_data):
  513. result = await device._get_lock_info()
  514. assert result == expected_data
  515. @pytest.mark.asyncio
  516. @pytest.mark.parametrize(
  517. "model",
  518. [
  519. SwitchbotModel.LOCK,
  520. SwitchbotModel.LOCK_LITE,
  521. SwitchbotModel.LOCK_PRO,
  522. SwitchbotModel.LOCK_ULTRA,
  523. SwitchbotModel.LOCK_VISION,
  524. SwitchbotModel.LOCK_VISION_PRO,
  525. SwitchbotModel.LOCK_PRO_WIFI,
  526. ],
  527. )
  528. async def test_get_lock_info_failure(model: str):
  529. """Test _get_lock_info method when command fails."""
  530. device = create_device_for_command_testing(model)
  531. with patch.object(device, "_send_command", return_value=b"\x00\x00"):
  532. result = await device._get_lock_info()
  533. assert result is None
  534. @pytest.mark.asyncio
  535. @pytest.mark.parametrize(
  536. "model",
  537. [
  538. SwitchbotModel.LOCK,
  539. SwitchbotModel.LOCK_LITE,
  540. SwitchbotModel.LOCK_PRO,
  541. SwitchbotModel.LOCK_ULTRA,
  542. SwitchbotModel.LOCK_VISION,
  543. SwitchbotModel.LOCK_VISION_PRO,
  544. SwitchbotModel.LOCK_PRO_WIFI,
  545. ],
  546. )
  547. async def test_enable_notifications(model: str):
  548. """Test _enable_notifications method."""
  549. device = create_device_for_command_testing(model)
  550. with patch.object(device, "_send_command", return_value=b"\x01\x00"):
  551. result = await device._enable_notifications()
  552. assert result is True
  553. @pytest.mark.asyncio
  554. @pytest.mark.parametrize(
  555. "model",
  556. [
  557. SwitchbotModel.LOCK,
  558. SwitchbotModel.LOCK_LITE,
  559. SwitchbotModel.LOCK_PRO,
  560. SwitchbotModel.LOCK_ULTRA,
  561. SwitchbotModel.LOCK_VISION,
  562. SwitchbotModel.LOCK_VISION_PRO,
  563. SwitchbotModel.LOCK_PRO_WIFI,
  564. ],
  565. )
  566. async def test_disable_notifications(model: str):
  567. """Test _disable_notifications method."""
  568. device = create_device_for_command_testing(model)
  569. device._notifications_enabled = True
  570. with patch.object(device, "_send_command", return_value=b"\x01\x00"):
  571. result = await device._disable_notifications()
  572. assert result is True
  573. assert device._notifications_enabled is False
  574. @pytest.mark.asyncio
  575. @pytest.mark.parametrize(
  576. "model",
  577. [
  578. SwitchbotModel.LOCK,
  579. SwitchbotModel.LOCK_LITE,
  580. SwitchbotModel.LOCK_PRO,
  581. SwitchbotModel.LOCK_ULTRA,
  582. SwitchbotModel.LOCK_VISION,
  583. SwitchbotModel.LOCK_VISION_PRO,
  584. SwitchbotModel.LOCK_PRO_WIFI,
  585. ],
  586. )
  587. async def test_disable_notifications_already_disabled(model: str):
  588. """Test _disable_notifications when already disabled."""
  589. device = create_device_for_command_testing(model)
  590. device._notifications_enabled = False
  591. with patch.object(device, "_send_command") as mock_send:
  592. result = await device._disable_notifications()
  593. assert result is True
  594. mock_send.assert_not_called()
  595. @pytest.mark.parametrize(
  596. "model",
  597. [
  598. SwitchbotModel.LOCK,
  599. SwitchbotModel.LOCK_LITE,
  600. SwitchbotModel.LOCK_PRO,
  601. SwitchbotModel.LOCK_ULTRA,
  602. SwitchbotModel.LOCK_VISION,
  603. SwitchbotModel.LOCK_VISION_PRO,
  604. SwitchbotModel.LOCK_PRO_WIFI,
  605. ],
  606. )
  607. def test_notification_handler(model: str):
  608. """Test _notification_handler method."""
  609. device = create_device_for_command_testing(model)
  610. device._notifications_enabled = True
  611. data = bytearray(b"\x0f\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  612. with patch.object(device, "_update_lock_status") as mock_update:
  613. device._notification_handler(0, data)
  614. mock_update.assert_called_once_with(data)
  615. @pytest.mark.parametrize(
  616. "model",
  617. [
  618. SwitchbotModel.LOCK,
  619. SwitchbotModel.LOCK_LITE,
  620. SwitchbotModel.LOCK_PRO,
  621. SwitchbotModel.LOCK_ULTRA,
  622. SwitchbotModel.LOCK_VISION,
  623. SwitchbotModel.LOCK_VISION_PRO,
  624. SwitchbotModel.LOCK_PRO_WIFI,
  625. ],
  626. )
  627. def test_notification_handler_not_enabled(model: str):
  628. """Test _notification_handler when notifications not enabled."""
  629. device = create_device_for_command_testing(model)
  630. device._notifications_enabled = False
  631. data = bytearray(b"\x01\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  632. with (
  633. patch.object(device, "_update_lock_status") as mock_update,
  634. patch.object(
  635. lock.SwitchbotSequenceDevice, "_notification_handler"
  636. ) as mock_super,
  637. ):
  638. device._notification_handler(0, data)
  639. mock_update.assert_not_called()
  640. mock_super.assert_called_once()
  641. @pytest.mark.parametrize(
  642. "model",
  643. [
  644. SwitchbotModel.LOCK,
  645. SwitchbotModel.LOCK_LITE,
  646. SwitchbotModel.LOCK_PRO,
  647. SwitchbotModel.LOCK_ULTRA,
  648. SwitchbotModel.LOCK_VISION,
  649. SwitchbotModel.LOCK_VISION_PRO,
  650. SwitchbotModel.LOCK_PRO_WIFI,
  651. ],
  652. )
  653. def test_notification_handler_during_disconnect(
  654. model: str, caplog: pytest.LogCaptureFixture
  655. ) -> None:
  656. """Test _notification_handler during expected disconnect."""
  657. device = create_device_for_command_testing(model)
  658. device._notifications_enabled = True
  659. device._expected_disconnect = True
  660. data = bytearray(b"\x0f\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  661. with (
  662. patch.object(device, "_update_lock_status") as mock_update,
  663. caplog.at_level(logging.DEBUG),
  664. ):
  665. device._notification_handler(0, data)
  666. # Should not update lock status during disconnect
  667. mock_update.assert_not_called()
  668. # Should log debug message
  669. assert "Ignoring lock notification during expected disconnect" in caplog.text
  670. @pytest.mark.parametrize(
  671. "model",
  672. [
  673. SwitchbotModel.LOCK,
  674. SwitchbotModel.LOCK_LITE,
  675. SwitchbotModel.LOCK_PRO,
  676. SwitchbotModel.LOCK_ULTRA,
  677. SwitchbotModel.LOCK_VISION,
  678. SwitchbotModel.LOCK_VISION_PRO,
  679. SwitchbotModel.LOCK_PRO_WIFI,
  680. ],
  681. )
  682. def test_update_lock_status(model: str):
  683. """Test _update_lock_status method."""
  684. device = create_device_for_command_testing(model)
  685. data = bytearray(b"\x0f\x00\x00\x00\x80\x00\x00\x00\x00\x00")
  686. with (
  687. patch.object(device, "_decrypt", return_value=b"\x80\x00\x00\x00\x00\x00"),
  688. patch.object(device, "_update_parsed_data", return_value=True),
  689. patch.object(device, "_reset_disconnect_timer"),
  690. patch.object(device, "_fire_callbacks"),
  691. ):
  692. device._update_lock_status(data)
  693. @pytest.mark.parametrize(
  694. ("model", "data", "expected"),
  695. [
  696. (
  697. SwitchbotModel.LOCK,
  698. b"\x80\x00\x00\x00\x00\x00",
  699. {
  700. "calibration": True,
  701. "status": LockStatus.LOCKED, # (0x80 & 0b01110000) >> 4 = 0 = LOCKED
  702. "door_open": False,
  703. "unclosed_alarm": False,
  704. "unlocked_alarm": False,
  705. },
  706. ),
  707. (
  708. SwitchbotModel.LOCK_LITE,
  709. b"\x80\x00\x00\x00\x00\x00",
  710. {
  711. "calibration": True,
  712. "status": LockStatus.LOCKED, # (0x80 & 0b01110000) >> 4 = 0 = LOCKED
  713. "unlocked_alarm": False,
  714. },
  715. ),
  716. (
  717. SwitchbotModel.LOCK_PRO,
  718. b"\x80\x00\x00\x00\x00\x00",
  719. {
  720. "calibration": True,
  721. "status": LockStatus.LOCKED, # (0x80 & 0b01111000) >> 3 = 0 = LOCKED
  722. "door_open": False,
  723. "unclosed_alarm": False,
  724. "unlocked_alarm": False,
  725. },
  726. ),
  727. (
  728. SwitchbotModel.LOCK_ULTRA,
  729. b"\x88\x10\x00\x00\x00\xc0",
  730. {
  731. "calibration": True,
  732. "status": LockStatus.UNLOCKED, # (0x88 & 0b01111000) >> 3 = 0x08 >> 3 = 1 = UNLOCKED
  733. "door_open": True,
  734. "unclosed_alarm": True,
  735. "unlocked_alarm": True,
  736. "half_lock_calibration": False,
  737. },
  738. ),
  739. (
  740. SwitchbotModel.LOCK_VISION,
  741. b"\x80\x00\x00\x00\x00\x00",
  742. {
  743. "calibration": True,
  744. "status": LockStatus.LOCKED, # (0x80 & 0b01110000) >> 4 = 0 = LOCKED
  745. "unlocked_alarm": False,
  746. },
  747. ),
  748. (
  749. SwitchbotModel.LOCK_VISION_PRO,
  750. b"\x80\x00\x00\x00\x00\x00",
  751. {
  752. "calibration": True,
  753. "status": LockStatus.LOCKED, # (0x80 & 0b01111000) >> 3 = 0 = LOCKED
  754. "door_open": False,
  755. "unclosed_alarm": False,
  756. "unlocked_alarm": False,
  757. },
  758. ),
  759. (
  760. SwitchbotModel.LOCK_PRO_WIFI,
  761. b"\x80\x00\x00\x00\x00\x00",
  762. {
  763. "calibration": True,
  764. "status": LockStatus.LOCKED, # (0x80 & 0b01111000) >> 3 = 0 = LOCKED
  765. "door_open": False,
  766. "unclosed_alarm": False,
  767. "unlocked_alarm": False,
  768. },
  769. ),
  770. ],
  771. )
  772. def test_parse_lock_data(model: str, data: bytes, expected: dict):
  773. """Test _parse_lock_data static method."""
  774. result = lock.SwitchbotLock._parse_lock_data(data, model)
  775. assert result == expected
  776. @pytest.mark.parametrize(
  777. ("model", "data", "expected"),
  778. [
  779. # Test LOCK with different status bits and flags
  780. (
  781. SwitchbotModel.LOCK,
  782. b"\x94\x00\x00\x00\x00\x00", # Unlocked status (0x10 >> 4 = 1) with door open
  783. {
  784. "calibration": True,
  785. "status": LockStatus.UNLOCKED,
  786. "door_open": True,
  787. "unclosed_alarm": False,
  788. "unlocked_alarm": False,
  789. },
  790. ),
  791. # Test LOCK_LITE without door_open field
  792. (
  793. SwitchbotModel.LOCK_LITE,
  794. b"\x90\x10\x00\x00\x00\x00", # Unlocked with unlocked alarm
  795. {
  796. "calibration": True,
  797. "status": LockStatus.UNLOCKED,
  798. "unlocked_alarm": True,
  799. },
  800. ),
  801. # Test LOCK_PRO with new bit positions
  802. (
  803. SwitchbotModel.LOCK_PRO,
  804. b"\x90\x10\x00\x00\x00\xc0", # New format: status bits 3-6, door open bit 4 of byte 1
  805. {
  806. "calibration": True,
  807. "status": LockStatus.LOCKING, # (0x90 & 0b01111000) >> 3 = 0x10 >> 3 = 2 (LOCKING)
  808. "door_open": True, # bit 4 of byte 1 (0x10)
  809. "unclosed_alarm": True, # bit 7 of byte 5 (0xc0)
  810. "unlocked_alarm": True, # bit 6 of byte 5 (0xc0)
  811. },
  812. ),
  813. # Test LOCK_ULTRA with same format as PRO
  814. (
  815. SwitchbotModel.LOCK_ULTRA,
  816. b"\x88\x00\x00\x00\x00\x40", # Unlocked with unlocked alarm only
  817. {
  818. "calibration": True,
  819. "status": LockStatus.UNLOCKED, # (0x88 & 0b01111000) >> 3 = 0x08 >> 3 = 1
  820. "door_open": False,
  821. "unclosed_alarm": False,
  822. "unlocked_alarm": True, # bit 6 of byte 5
  823. "half_lock_calibration": False,
  824. },
  825. ),
  826. (
  827. SwitchbotModel.LOCK_PRO_WIFI,
  828. b"\x90\x10\x00\x00\x00\xc0", # New format: status bits 3-6, door open bit 4 of byte 1
  829. {
  830. "calibration": True,
  831. "status": LockStatus.LOCKING, # (0x90 & 0b01111000) >> 3 = 0x10 >> 3 = 2 (LOCKING)
  832. "door_open": True, # bit 4 of byte 1 (0x10)
  833. "unclosed_alarm": True, # bit 7 of byte 5 (0xc0)
  834. "unlocked_alarm": True, # bit 6 of byte 5 (0xc0)
  835. },
  836. ),
  837. ],
  838. )
  839. def test_parse_lock_data_new_formats(model: str, data: bytes, expected: dict):
  840. """Test _parse_lock_data with new format changes."""
  841. result = lock.SwitchbotLock._parse_lock_data(data, model)
  842. assert result == expected
  843. @pytest.mark.asyncio
  844. @pytest.mark.parametrize(
  845. "model",
  846. [
  847. SwitchbotModel.LOCK,
  848. SwitchbotModel.LOCK_LITE,
  849. SwitchbotModel.LOCK_PRO,
  850. SwitchbotModel.LOCK_ULTRA,
  851. SwitchbotModel.LOCK_VISION,
  852. SwitchbotModel.LOCK_VISION_PRO,
  853. SwitchbotModel.LOCK_PRO_WIFI,
  854. ],
  855. )
  856. async def test_lock_with_update(model: str):
  857. """Test lock method with status update."""
  858. device = create_device_for_command_testing(model)
  859. device._get_adv_value = Mock(side_effect=[None, LockStatus.UNLOCKED])
  860. with (
  861. patch.object(device, "update", new_callable=AsyncMock),
  862. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  863. patch.object(device, "_enable_notifications", return_value=True),
  864. patch.object(device, "_get_basic_info", return_value=b"\x00\x64\x01"),
  865. ):
  866. result = await device.lock()
  867. assert result is True
  868. def test_is_half_lock_calibrated():
  869. """Test is_half_lock_calibrated method."""
  870. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  871. device._get_adv_value = Mock(return_value=True)
  872. assert device.is_half_lock_calibrated() is True
  873. device._get_adv_value = Mock(return_value=False)
  874. assert device.is_half_lock_calibrated() is False
  875. @pytest.mark.asyncio
  876. async def test_half_lock_calibrated():
  877. """Test half_lock succeeds when calibrated."""
  878. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  879. device._get_adv_value = Mock(side_effect=[True, LockStatus.LOCKED])
  880. with (
  881. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  882. patch.object(device, "_enable_notifications", return_value=True),
  883. patch.object(device, "_get_basic_info", return_value=b"\x01\x64\x01"),
  884. ):
  885. result = await device.half_lock()
  886. assert result is True
  887. @pytest.mark.asyncio
  888. async def test_half_lock_not_calibrated():
  889. """Test half_lock raises SwitchbotOperationError when not calibrated."""
  890. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  891. device._get_adv_value = Mock(return_value=False)
  892. with pytest.raises(SwitchbotOperationError, match="not calibrated"):
  893. await device.half_lock()
  894. @pytest.mark.asyncio
  895. @pytest.mark.parametrize(
  896. "model",
  897. [
  898. SwitchbotModel.LOCK,
  899. SwitchbotModel.LOCK_LITE,
  900. SwitchbotModel.LOCK_PRO,
  901. SwitchbotModel.LOCK_ULTRA_MAX,
  902. SwitchbotModel.UWB_LOCK,
  903. SwitchbotModel.LOCK_NANO,
  904. SwitchbotModel.LOCK_VISION,
  905. SwitchbotModel.LOCK_VISION_PRO,
  906. SwitchbotModel.LOCK_PRO_WIFI,
  907. ],
  908. )
  909. async def test_half_lock_unsupported_model(model: str):
  910. """Test half_lock raises SwitchbotOperationError on unsupported models."""
  911. device = create_device_for_command_testing(model)
  912. with pytest.raises(SwitchbotOperationError, match="not supported"):
  913. await device.half_lock()
  914. @pytest.mark.asyncio
  915. async def test_half_lock():
  916. """Test half_lock method."""
  917. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  918. device._get_adv_value = Mock(side_effect=[True, LockStatus.LOCKED])
  919. with (
  920. patch.object(device, "_send_command", return_value=b"\x01\x00") as mock_send,
  921. patch.object(device, "_enable_notifications", return_value=True),
  922. patch.object(device, "_get_basic_info", return_value=b"\x01\x64\x01"),
  923. ):
  924. result = await device.half_lock()
  925. assert result is True
  926. mock_send.assert_awaited_once_with(
  927. lock.COMMAND_HALF_LOCK[SwitchbotModel.LOCK_ULTRA]
  928. )
  929. @pytest.mark.asyncio
  930. @pytest.mark.parametrize(
  931. ("model", "status"),
  932. [
  933. (SwitchbotModel.LOCK, LockStatus.LOCKED),
  934. (SwitchbotModel.LOCK_LITE, LockStatus.LOCKING),
  935. (SwitchbotModel.LOCK_PRO, LockStatus.LOCKED),
  936. (SwitchbotModel.LOCK_ULTRA, LockStatus.LOCKING),
  937. (SwitchbotModel.LOCK_VISION, LockStatus.LOCKED),
  938. (SwitchbotModel.LOCK_VISION_PRO, LockStatus.LOCKED),
  939. (SwitchbotModel.LOCK_PRO_WIFI, LockStatus.LOCKED),
  940. ],
  941. )
  942. async def test_lock_already_locked(model: str, status: LockStatus):
  943. """Test lock method when already locked."""
  944. device = create_device_for_command_testing(model)
  945. device._get_adv_value = Mock(return_value=status)
  946. with patch.object(device, "_send_command") as mock_send:
  947. result = await device.lock()
  948. assert result is True
  949. mock_send.assert_not_called()
  950. @pytest.mark.asyncio
  951. @pytest.mark.parametrize(
  952. "model",
  953. [
  954. SwitchbotModel.LOCK,
  955. SwitchbotModel.LOCK_LITE,
  956. SwitchbotModel.LOCK_PRO,
  957. SwitchbotModel.LOCK_ULTRA,
  958. SwitchbotModel.LOCK_VISION,
  959. SwitchbotModel.LOCK_VISION_PRO,
  960. SwitchbotModel.LOCK_PRO_WIFI,
  961. ],
  962. )
  963. async def test_lock_with_invalid_basic_data(model: str):
  964. """Test lock method with invalid basic data."""
  965. device = create_device_for_command_testing(model)
  966. device._get_adv_value = Mock(return_value=LockStatus.UNLOCKED)
  967. with (
  968. patch.object(device, "_send_command", return_value=b"\x01\x00"),
  969. patch.object(device, "_enable_notifications", return_value=True),
  970. patch.object(device, "_get_basic_info", return_value=b"\x00"),
  971. ):
  972. result = await device.lock()
  973. assert result is True
  974. @pytest.mark.asyncio
  975. async def test_get_quick_key():
  976. """Reading the Quick Key parses the config byte (Lock Ultra)."""
  977. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  978. # 0xca = enabled, single press, Lock & Unlock
  979. with patch.object(
  980. device, "_send_command", return_value=b"\x01\xca\x00\x00\x00\x80"
  981. ) as mock_send_command:
  982. result = await device.get_quick_key()
  983. mock_send_command.assert_any_call(
  984. key="570f4f0401",
  985. retry=device._retry_count,
  986. )
  987. assert result == {
  988. "raw": 0xCA,
  989. "light": True,
  990. "sound": True,
  991. "manual_unlock_linkage": False,
  992. "enabled": True,
  993. "double_press": False,
  994. "function": QuickKeyFunction.LOCK_AND_UNLOCK,
  995. "motor_speed": 0,
  996. }
  997. @pytest.mark.asyncio
  998. async def test_get_quick_key_failure():
  999. """A non-success status returns None."""
  1000. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1001. with patch.object(device, "_send_command", return_value=b"\x00"):
  1002. assert await device.get_quick_key() is None
  1003. @pytest.mark.asyncio
  1004. async def test_get_quick_key_zero_properties():
  1005. """A zero property byte means disabled, single press, and lock only."""
  1006. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1007. with patch.object(device, "_send_command", return_value=b"\x01\x00"):
  1008. assert await device.get_quick_key() == {
  1009. "raw": 0,
  1010. "light": False,
  1011. "sound": False,
  1012. "manual_unlock_linkage": False,
  1013. "enabled": False,
  1014. "double_press": False,
  1015. "function": QuickKeyFunction.LOCK_ONLY,
  1016. "motor_speed": None,
  1017. }
  1018. @pytest.mark.asyncio
  1019. async def test_set_quick_key_function_update_flag():
  1020. """Bit 1 selects the entire two-bit function field for writing."""
  1021. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1022. with patch.object(
  1023. device,
  1024. "_send_command",
  1025. return_value=b"\x01\xc9", # echo 0xc9 = Unlock Only
  1026. ) as mock_send_command:
  1027. result = await device.set_quick_key(function=QuickKeyFunction.UNLOCK_ONLY)
  1028. mock_send_command.assert_awaited_once_with(
  1029. key="570f4e0401000201ff", retry=device._retry_count
  1030. )
  1031. assert result is True
  1032. @pytest.mark.asyncio
  1033. async def test_set_quick_key_multiple_fields():
  1034. """Update all Quick Key fields while keeping the default target and speed."""
  1035. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1036. with patch.object(
  1037. device, "_send_command", return_value=b"\x01\xce"
  1038. ) as mock_send_command:
  1039. result = await device.set_quick_key(
  1040. enabled=True, double_press=True, function=QuickKeyFunction.LOCK_AND_UNLOCK
  1041. )
  1042. mock_send_command.assert_awaited_once_with(
  1043. key="570f4e0401000e0eff", retry=device._retry_count
  1044. )
  1045. assert result is True
  1046. @pytest.mark.asyncio
  1047. @pytest.mark.parametrize(
  1048. ("kwargs", "flags_and_value"),
  1049. [
  1050. ({"enabled": False}, "0800"),
  1051. ({"double_press": False}, "0400"),
  1052. ({"function": QuickKeyFunction.LOCK_ONLY}, "0200"),
  1053. (
  1054. {
  1055. "enabled": False,
  1056. "double_press": False,
  1057. "function": QuickKeyFunction.LOCK_ONLY,
  1058. },
  1059. "0e00",
  1060. ),
  1061. ],
  1062. )
  1063. async def test_set_quick_key_false_and_zero_values(
  1064. kwargs: dict[str, Any], flags_and_value: str
  1065. ):
  1066. """Explicit false and zero values still select their fields for writing."""
  1067. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1068. with patch.object(
  1069. device, "_send_command", return_value=b"\x01\xc0"
  1070. ) as mock_send_command:
  1071. result = await device.set_quick_key(**kwargs)
  1072. mock_send_command.assert_awaited_once_with(
  1073. key=f"570f4e040100{flags_and_value}ff", retry=device._retry_count
  1074. )
  1075. assert result is True
  1076. @pytest.mark.asyncio
  1077. async def test_set_quick_key_requires_a_field():
  1078. """Calling with nothing to change raises ValueError."""
  1079. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1080. with pytest.raises(ValueError, match="at least one setting"):
  1081. await device.set_quick_key()
  1082. @pytest.mark.asyncio
  1083. async def test_quick_key_unsupported_model():
  1084. """Quick Key is Lock Ultra only; other models raise."""
  1085. device = create_device_for_command_testing(SwitchbotModel.LOCK)
  1086. with pytest.raises(SwitchbotOperationError):
  1087. await device.get_quick_key()
  1088. with pytest.raises(SwitchbotOperationError):
  1089. await device.set_quick_key(enabled=True)
  1090. @pytest.mark.asyncio
  1091. async def test_get_quick_key_short_response():
  1092. """A success status but a truncated payload returns None."""
  1093. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1094. with patch.object(device, "_send_command", return_value=b"\x01"):
  1095. assert await device.get_quick_key() is None
  1096. @pytest.mark.asyncio
  1097. async def test_get_quick_key_unknown_function():
  1098. """An undefined 2-bit function value keeps the bits that are unambiguous."""
  1099. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1100. # 0xcb = enabled, single press, function bits 0b11 (undefined)
  1101. with patch.object(device, "_send_command", return_value=b"\x01\xcb"):
  1102. assert await device.get_quick_key() == {
  1103. "raw": 0xCB,
  1104. "light": True,
  1105. "sound": True,
  1106. "manual_unlock_linkage": False,
  1107. "enabled": True,
  1108. "double_press": False,
  1109. "function": None,
  1110. "motor_speed": None,
  1111. }
  1112. @pytest.mark.asyncio
  1113. @pytest.mark.parametrize(
  1114. ("properties", "light", "sound"),
  1115. [
  1116. (0x0A, False, False),
  1117. (0x4A, False, True),
  1118. (0x8A, True, False),
  1119. (0xCA, True, True),
  1120. ],
  1121. )
  1122. async def test_get_quick_key_light_and_sound_settings(
  1123. properties: int, light: bool, sound: bool
  1124. ):
  1125. """Read light and sound independently of the original Quick Key fields."""
  1126. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1127. with patch.object(
  1128. device, "_send_command", return_value=bytes([1, properties, 0, 0, 0, 0])
  1129. ):
  1130. assert await device.get_quick_key() == {
  1131. "raw": properties,
  1132. "light": light,
  1133. "sound": sound,
  1134. "manual_unlock_linkage": False,
  1135. "enabled": True,
  1136. "double_press": False,
  1137. "function": QuickKeyFunction.LOCK_AND_UNLOCK,
  1138. "motor_speed": 0,
  1139. }
  1140. @pytest.mark.asyncio
  1141. @pytest.mark.parametrize(
  1142. ("response", "expected"),
  1143. [
  1144. (b"\x01", False),
  1145. (b"\x06", False),
  1146. (b"\x01\x08", True),
  1147. (b"\x06\x08", True),
  1148. (b"\x01\x00", False),
  1149. (b"\x06\x00", False),
  1150. (b"\x00", False),
  1151. (b"\x02", False),
  1152. (b"\x03", False),
  1153. (b"\x05", False),
  1154. ],
  1155. )
  1156. async def test_set_quick_key_command_status(response: bytes, expected: bool):
  1157. """Require a successful status and matching config without another read."""
  1158. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1159. with patch.object(
  1160. device, "_send_command", return_value=response
  1161. ) as mock_send_command:
  1162. assert await device.set_quick_key(enabled=True) is expected
  1163. mock_send_command.assert_awaited_once_with(
  1164. key="570f4e0401000808ff", retry=device._retry_count
  1165. )
  1166. @pytest.mark.asyncio
  1167. async def test_get_quick_key_manual_unlock_linkage_and_motor_speed():
  1168. """Read primary properties and speed without using the secondary values."""
  1169. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1170. with patch.object(
  1171. device, "_send_command", return_value=bytes([1, 0xD9, 0xFF, 2, 0, 0])
  1172. ):
  1173. assert await device.get_quick_key() == {
  1174. "raw": 0xD9,
  1175. "light": True,
  1176. "sound": True,
  1177. "manual_unlock_linkage": True,
  1178. "enabled": True,
  1179. "double_press": False,
  1180. "function": QuickKeyFunction.UNLOCK_ONLY,
  1181. "motor_speed": 2,
  1182. }
  1183. @pytest.mark.asyncio
  1184. @pytest.mark.parametrize(
  1185. ("kwargs", "command", "config"),
  1186. [
  1187. ({"light": True}, "570f4e0401008080ff", 0xCA),
  1188. ({"light": False}, "570f4e0401008000ff", 0x4A),
  1189. ({"sound": True}, "570f4e0401004040ff", 0xCA),
  1190. ({"sound": False}, "570f4e0401004000ff", 0x8A),
  1191. ({"manual_unlock_linkage": True}, "570f4e0401001010ff", 0xDA),
  1192. ({"manual_unlock_linkage": False}, "570f4e0401001000ff", 0xCA),
  1193. ],
  1194. )
  1195. async def test_set_quick_key_independent_properties(
  1196. kwargs: dict[str, bool], command: str, config: int
  1197. ):
  1198. """Each basic property can be changed while retaining the Quick Key fields."""
  1199. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1200. with patch.object(
  1201. device, "_send_command", return_value=bytes([1, config])
  1202. ) as mock_send_command:
  1203. assert await device.set_quick_key(**kwargs) is True
  1204. mock_send_command.assert_awaited_once_with(key=command, retry=device._retry_count)
  1205. @pytest.mark.asyncio
  1206. @pytest.mark.parametrize(
  1207. ("kwargs", "command", "config"),
  1208. [
  1209. (
  1210. {
  1211. "light": True,
  1212. "sound": False,
  1213. "manual_unlock_linkage": True,
  1214. "enabled": False,
  1215. "double_press": True,
  1216. "function": QuickKeyFunction.UNLOCK_ONLY,
  1217. },
  1218. "570f4e040100de95ff",
  1219. 0x95,
  1220. ),
  1221. (
  1222. {
  1223. "light": False,
  1224. "sound": False,
  1225. "manual_unlock_linkage": False,
  1226. "enabled": False,
  1227. "double_press": False,
  1228. "function": QuickKeyFunction.LOCK_ONLY,
  1229. },
  1230. "570f4e040100de00ff",
  1231. 0x20,
  1232. ),
  1233. ],
  1234. )
  1235. async def test_set_quick_key_combines_all_properties(
  1236. kwargs: dict[str, Any], command: str, config: int
  1237. ):
  1238. """Combine all writable config fields without selecting reserved bits."""
  1239. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1240. with patch.object(
  1241. device, "_send_command", return_value=bytes([1, config])
  1242. ) as mock_send_command:
  1243. assert await device.set_quick_key(**kwargs) is True
  1244. mock_send_command.assert_awaited_once_with(key=command, retry=device._retry_count)
  1245. @pytest.mark.asyncio
  1246. @pytest.mark.parametrize("motor_speed", [0, 1, 2])
  1247. async def test_set_quick_key_motor_speed(motor_speed: int):
  1248. """Change speed alone with a single write command."""
  1249. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1250. with patch.object(
  1251. device,
  1252. "_send_command",
  1253. return_value=b"\x01\xca",
  1254. ) as mock_send_command:
  1255. assert await device.set_quick_key(motor_speed=motor_speed) is True
  1256. mock_send_command.assert_awaited_once_with(
  1257. key=f"570f4e0401000000{motor_speed:02x}", retry=device._retry_count
  1258. )
  1259. @pytest.mark.asyncio
  1260. async def test_set_quick_key_motor_speed_with_other_properties():
  1261. """Write speed and properties together without an additional read."""
  1262. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1263. with patch.object(
  1264. device,
  1265. "_send_command",
  1266. return_value=b"\x01\x5d",
  1267. ) as mock_send_command:
  1268. assert (
  1269. await device.set_quick_key(
  1270. light=False,
  1271. sound=True,
  1272. manual_unlock_linkage=True,
  1273. enabled=True,
  1274. double_press=True,
  1275. function=QuickKeyFunction.UNLOCK_ONLY,
  1276. motor_speed=2,
  1277. )
  1278. is True
  1279. )
  1280. mock_send_command.assert_awaited_once_with(
  1281. key="570f4e040100de5d02", retry=device._retry_count
  1282. )
  1283. @pytest.mark.asyncio
  1284. @pytest.mark.parametrize("motor_speed", [-1, 3, 0x100])
  1285. async def test_set_quick_key_invalid_motor_speed(motor_speed: int):
  1286. """Invalid speeds fail before Bluetooth commands are sent."""
  1287. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1288. with (
  1289. patch.object(device, "_send_command") as mock_send_command,
  1290. pytest.raises(ValueError, match="motor_speed must be"),
  1291. ):
  1292. await device.set_quick_key(motor_speed=motor_speed)
  1293. mock_send_command.assert_not_called()
  1294. @pytest.mark.asyncio
  1295. async def test_set_quick_key_motor_speed_keep():
  1296. """Explicit 0xFF preserves speed without an additional read."""
  1297. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1298. with patch.object(
  1299. device, "_send_command", return_value=b"\x01\xca"
  1300. ) as mock_send_command:
  1301. assert await device.set_quick_key(light=True, motor_speed=0xFF) is True
  1302. mock_send_command.assert_awaited_once_with(
  1303. key="570f4e0401008080ff", retry=device._retry_count
  1304. )
  1305. @pytest.mark.asyncio
  1306. @pytest.mark.parametrize(
  1307. ("kwargs", "config", "reported"),
  1308. [
  1309. ({"light": True}, 0x00, False),
  1310. ({"light": False}, 0x80, True),
  1311. ({"sound": True}, 0x00, False),
  1312. ({"manual_unlock_linkage": True}, 0x00, False),
  1313. ({"enabled": False}, 0x08, True),
  1314. ({"double_press": True}, 0x00, False),
  1315. ({"function": QuickKeyFunction.LOCK_ONLY}, 0x01, QuickKeyFunction.UNLOCK_ONLY),
  1316. ({"function": QuickKeyFunction.UNLOCK_ONLY}, 0x00, QuickKeyFunction.LOCK_ONLY),
  1317. ({"function": QuickKeyFunction.LOCK_AND_UNLOCK}, 0x03, None),
  1318. ],
  1319. )
  1320. async def test_set_quick_key_config_mismatch(
  1321. kwargs: dict[str, Any],
  1322. config: int,
  1323. reported: bool | QuickKeyFunction | None,
  1324. caplog: pytest.LogCaptureFixture,
  1325. ):
  1326. """Identify the rejected field, requested value, and reported value."""
  1327. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1328. with patch.object(
  1329. device, "_send_command", return_value=bytes([1, config])
  1330. ) as mock_send_command:
  1331. assert await device.set_quick_key(**kwargs) is False
  1332. mock_send_command.assert_awaited_once()
  1333. setting, expected = next(iter(kwargs.items()))
  1334. assert (
  1335. f"Quick Key {setting} was not applied "
  1336. f"(requested={expected}, reported={reported}, config={config:#04x})"
  1337. ) in caplog.text
  1338. @pytest.mark.asyncio
  1339. async def test_set_quick_key_multiple_config_mismatches(
  1340. caplog: pytest.LogCaptureFixture,
  1341. ):
  1342. """Report every mismatching field in a combined write."""
  1343. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1344. with patch.object(device, "_send_command", return_value=b"\x01\x00"):
  1345. assert (
  1346. await device.set_quick_key(
  1347. light=True,
  1348. sound=True,
  1349. manual_unlock_linkage=True,
  1350. enabled=True,
  1351. double_press=True,
  1352. function=QuickKeyFunction.UNLOCK_ONLY,
  1353. )
  1354. is False
  1355. )
  1356. messages = [
  1357. record.message
  1358. for record in caplog.records
  1359. if "was not applied" in record.message
  1360. ]
  1361. assert len(messages) == 6
  1362. for setting in (
  1363. "light",
  1364. "sound",
  1365. "manual_unlock_linkage",
  1366. "enabled",
  1367. "double_press",
  1368. "function",
  1369. ):
  1370. assert any(
  1371. f"Quick Key {setting} was not applied" in message for message in messages
  1372. )
  1373. @pytest.mark.asyncio
  1374. @pytest.mark.parametrize("status", [0x00, 0x02, 0x03, 0x04, 0x05, 0x09, 0x0E])
  1375. async def test_set_quick_key_failed_status_logs_code(
  1376. status: int, caplog: pytest.LogCaptureFixture
  1377. ):
  1378. """Keep the device status code in diagnostics for rejected commands."""
  1379. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1380. with patch.object(device, "_send_command", return_value=bytes([status])):
  1381. assert await device.set_quick_key(enabled=True) is False
  1382. assert (
  1383. f"Quick Key command failed (status={status:#04x}, response={status:02x})"
  1384. in caplog.text
  1385. )
  1386. @pytest.mark.asyncio
  1387. @pytest.mark.parametrize("status", [0x01, 0x06])
  1388. async def test_set_quick_key_missing_config_logs_status(
  1389. status: int, caplog: pytest.LogCaptureFixture
  1390. ):
  1391. """Distinguish a missing config from a rejected command."""
  1392. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1393. with patch.object(device, "_send_command", return_value=bytes([status])):
  1394. assert await device.set_quick_key(enabled=True) is False
  1395. assert (
  1396. f"Quick Key write response is missing config (status={status:#04x}, response={status:02x})"
  1397. ) in caplog.text
  1398. @pytest.mark.asyncio
  1399. @pytest.mark.parametrize("response", [None, b""])
  1400. async def test_set_quick_key_absent_response_raises(response: bytes | None):
  1401. """Preserve the existing operation error for an absent response."""
  1402. device = create_device_for_command_testing(SwitchbotModel.LOCK_ULTRA)
  1403. with (
  1404. patch.object(device, "_send_command", return_value=response),
  1405. pytest.raises(SwitchbotOperationError, match="Sending command failed"),
  1406. ):
  1407. await device.set_quick_key(enabled=True)