test_lock.py 49 KB

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