test_mqtt.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. # switchbot-mqtt - MQTT client controlling SwitchBot button & curtain automators,
  2. # compatible with home-assistant.io's MQTT Switch & Cover platform
  3. #
  4. # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. import collections.abc
  19. import logging
  20. import socket
  21. import ssl
  22. import unittest.mock
  23. import _pytest.logging # pylint: disable=import-private-name; typing
  24. import pytest
  25. import aiomqtt
  26. import bleak
  27. import bleak.backends.device
  28. from paho.mqtt.client import MQTT_ERR_NO_CONN
  29. # pylint: disable=import-private-name; internal
  30. import switchbot_mqtt
  31. import switchbot_mqtt._actors
  32. from switchbot_mqtt._actors import _ButtonAutomator, _CurtainMotor
  33. from switchbot_mqtt._actors.base import _MQTTControlledActor
  34. from switchbot_mqtt._utils import _MQTTTopicLevel, _MQTTTopicPlaceholder
  35. # pylint: disable=protected-access,too-many-positional-arguments
  36. # pylint: disable=too-many-arguments; these are tests, no API
  37. @pytest.mark.asyncio
  38. async def test__listen(caplog: _pytest.logging.LogCaptureFixture) -> None:
  39. mqtt_client = unittest.mock.AsyncMock()
  40. messages_mock = unittest.mock.AsyncMock()
  41. async def _msg_iter() -> collections.abc.AsyncIterator[aiomqtt.Message]:
  42. for topic, payload in [
  43. ("/foo", b"foo1"),
  44. ("/baz/21/bar", b"42/2"),
  45. ("/baz/bar", b"nope"),
  46. ("/foo", b"foo2"),
  47. ]:
  48. yield aiomqtt.Message(
  49. topic=topic,
  50. payload=payload,
  51. qos=0,
  52. retain=False,
  53. mid=0,
  54. properties=None,
  55. )
  56. messages_mock.__aenter__.return_value.__aiter__.side_effect = _msg_iter
  57. mqtt_client.messages = lambda: messages_mock
  58. callback_foo = unittest.mock.AsyncMock()
  59. callback_bar = unittest.mock.AsyncMock()
  60. with caplog.at_level(logging.DEBUG):
  61. await switchbot_mqtt._listen(
  62. mqtt_client=mqtt_client,
  63. topic_callbacks=(("/foo", callback_foo), ("/baz/+/bar", callback_bar)),
  64. mqtt_topic_prefix="whatever/",
  65. retry_count=3,
  66. device_passwords={},
  67. fetch_device_info=False,
  68. )
  69. mqtt_client.publish.assert_awaited_once_with(
  70. topic="whatever/switchbot-mqtt/status", payload="online", retain=True
  71. )
  72. messages_mock.__aenter__.assert_awaited_once_with()
  73. assert callback_foo.await_count == 2
  74. assert not callback_foo.await_args_list[0].args
  75. kwargs = callback_foo.await_args_list[0].kwargs
  76. assert kwargs["message"].topic.value == "/foo"
  77. assert kwargs["message"].payload == b"foo1"
  78. del kwargs["message"] # type: ignore
  79. assert kwargs == {
  80. "mqtt_client": mqtt_client,
  81. "mqtt_topic_prefix": "whatever/",
  82. "retry_count": 3,
  83. "device_passwords": {},
  84. "fetch_device_info": False,
  85. }
  86. assert callback_foo.await_args_list[1].kwargs["message"].payload == b"foo2"
  87. assert callback_bar.await_count == 1
  88. assert (
  89. callback_bar.await_args_list[0].kwargs["message"].topic.value == "/baz/21/bar"
  90. )
  91. assert callback_bar.await_args_list[0].kwargs["message"].payload == b"42/2"
  92. @pytest.mark.parametrize(
  93. ("socket_family", "peername", "peername_log"),
  94. [
  95. (socket.AF_INET, ("mqtt-broker.local", 1883), "mqtt-broker.local:1883"),
  96. # https://github.com/fphammerle/switchbot-mqtt/issues/42#issuecomment-1173909335
  97. (socket.AF_INET6, ("::1", 1883, 0, 0), "[::1]:1883"),
  98. ],
  99. )
  100. def test__log_mqtt_connected(
  101. caplog: _pytest.logging.LogCaptureFixture,
  102. socket_family: int, # socket.AddressFamily,
  103. peername: tuple[str | int],
  104. peername_log: str,
  105. ) -> None:
  106. mqtt_client = unittest.mock.MagicMock()
  107. mqtt_client._client.socket().family = socket_family
  108. mqtt_client._client.socket().getpeername.return_value = peername
  109. with caplog.at_level(logging.INFO):
  110. switchbot_mqtt._log_mqtt_connected(mqtt_client)
  111. assert not caplog.records
  112. with caplog.at_level(logging.DEBUG):
  113. switchbot_mqtt._log_mqtt_connected(mqtt_client)
  114. assert caplog.record_tuples[0] == (
  115. "switchbot_mqtt",
  116. logging.DEBUG,
  117. f"connected to MQTT broker {peername_log}",
  118. )
  119. @pytest.mark.asyncio()
  120. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  121. @pytest.mark.parametrize("mqtt_port", [1234])
  122. @pytest.mark.parametrize("retry_count", [3, 21])
  123. @pytest.mark.parametrize(
  124. "device_passwords",
  125. [{}, {"11:22:33:44:55:66": "password", "aa:bb:cc:dd:ee:ff": "secret"}],
  126. )
  127. @pytest.mark.parametrize("fetch_device_info", [True, False])
  128. async def test__run(
  129. caplog: _pytest.logging.LogCaptureFixture,
  130. mqtt_host: str,
  131. mqtt_port: int,
  132. retry_count: int,
  133. device_passwords: dict[str, str],
  134. fetch_device_info: bool,
  135. ) -> None:
  136. with unittest.mock.patch("aiomqtt.Client") as mqtt_client_mock, unittest.mock.patch(
  137. "switchbot_mqtt._log_mqtt_connected"
  138. ) as log_connected_mock, unittest.mock.patch(
  139. "switchbot_mqtt._listen"
  140. ) as listen_mock, caplog.at_level(
  141. logging.DEBUG
  142. ):
  143. await switchbot_mqtt._run(
  144. mqtt_host=mqtt_host,
  145. mqtt_port=mqtt_port,
  146. mqtt_disable_tls=False,
  147. mqtt_username=None,
  148. mqtt_password=None,
  149. mqtt_topic_prefix="home/",
  150. retry_count=retry_count,
  151. device_passwords=device_passwords,
  152. fetch_device_info=fetch_device_info,
  153. )
  154. mqtt_client_mock.assert_called_once()
  155. assert not mqtt_client_mock.call_args.args
  156. init_kwargs = mqtt_client_mock.call_args.kwargs
  157. assert isinstance(init_kwargs, dict) # for mypy
  158. assert isinstance(init_kwargs.pop("tls_context"), ssl.SSLContext)
  159. assert init_kwargs.pop("will") == aiomqtt.Will(
  160. topic="home/switchbot-mqtt/status",
  161. payload="offline",
  162. qos=0,
  163. retain=True,
  164. properties=None,
  165. )
  166. assert init_kwargs == {
  167. "hostname": mqtt_host,
  168. "port": mqtt_port,
  169. "username": None,
  170. "password": None,
  171. }
  172. log_connected_mock.assert_called_once()
  173. subscribe_mock = mqtt_client_mock().__aenter__.return_value.subscribe
  174. assert subscribe_mock.await_count == (5 if fetch_device_info else 3)
  175. subscribe_mock.assert_has_awaits(
  176. (
  177. unittest.mock.call(topic)
  178. for topic in [
  179. "home/switch/switchbot/+/set",
  180. "home/cover/switchbot-curtain/+/set",
  181. "home/cover/switchbot-curtain/+/position/set-percent",
  182. ]
  183. ),
  184. any_order=True,
  185. )
  186. if fetch_device_info:
  187. subscribe_mock.assert_has_awaits(
  188. (
  189. unittest.mock.call("home/switch/switchbot/+/request-device-info"),
  190. unittest.mock.call(
  191. "home/cover/switchbot-curtain/+/request-device-info"
  192. ),
  193. ),
  194. any_order=True,
  195. )
  196. listen_mock.assert_awaited_once()
  197. assert listen_mock.await_args is not None # for mypy
  198. assert not listen_mock.await_args.args
  199. listen_kwargs = listen_mock.await_args.kwargs
  200. assert (
  201. listen_kwargs.pop("mqtt_client") # type: ignore
  202. == mqtt_client_mock().__aenter__.return_value
  203. )
  204. topic_callbacks = listen_kwargs.pop("topic_callbacks") # type: ignore
  205. assert len(topic_callbacks) == (5 if fetch_device_info else 3)
  206. assert (
  207. "home/switch/switchbot/+/set",
  208. switchbot_mqtt._actors._ButtonAutomator._mqtt_command_callback,
  209. ) in topic_callbacks
  210. assert (
  211. "home/cover/switchbot-curtain/+/set",
  212. switchbot_mqtt._actors._CurtainMotor._mqtt_command_callback,
  213. ) in topic_callbacks
  214. assert (
  215. "home/cover/switchbot-curtain/+/position/set-percent",
  216. switchbot_mqtt._actors._CurtainMotor._mqtt_set_position_callback,
  217. ) in topic_callbacks
  218. if fetch_device_info:
  219. assert (
  220. "home/switch/switchbot/+/request-device-info",
  221. switchbot_mqtt._actors._ButtonAutomator._mqtt_update_device_info_callback,
  222. ) in topic_callbacks
  223. assert (
  224. "home/cover/switchbot-curtain/+/request-device-info",
  225. switchbot_mqtt._actors._CurtainMotor._mqtt_update_device_info_callback,
  226. ) in topic_callbacks
  227. assert listen_kwargs == {
  228. "device_passwords": device_passwords,
  229. "fetch_device_info": fetch_device_info,
  230. "mqtt_topic_prefix": "home/",
  231. "retry_count": retry_count,
  232. }
  233. assert caplog.record_tuples[0] == (
  234. "switchbot_mqtt",
  235. logging.INFO,
  236. f"connecting to MQTT broker {mqtt_host}:{mqtt_port} (TLS enabled)",
  237. )
  238. assert len(caplog.record_tuples) == (5 if fetch_device_info else 3) + 1
  239. assert (
  240. "switchbot_mqtt._actors.base",
  241. logging.INFO,
  242. "subscribing to MQTT topic 'home/switch/switchbot/+/set'",
  243. ) in caplog.record_tuples
  244. assert (
  245. "switchbot_mqtt._actors.base",
  246. logging.INFO,
  247. "subscribing to MQTT topic 'home/cover/switchbot-curtain/+/set'",
  248. ) in caplog.record_tuples
  249. @pytest.mark.asyncio
  250. @pytest.mark.parametrize("mqtt_disable_tls", [True, False])
  251. async def test__run_tls(
  252. caplog: _pytest.logging.LogCaptureFixture, mqtt_disable_tls: bool
  253. ) -> None:
  254. with unittest.mock.patch("aiomqtt.Client") as mqtt_client_mock, unittest.mock.patch(
  255. "switchbot_mqtt._listen"
  256. ), caplog.at_level(logging.INFO):
  257. await switchbot_mqtt._run(
  258. mqtt_host="mqtt.local",
  259. mqtt_port=1234,
  260. mqtt_disable_tls=mqtt_disable_tls,
  261. mqtt_username=None,
  262. mqtt_password=None,
  263. mqtt_topic_prefix="prfx",
  264. retry_count=21,
  265. device_passwords={},
  266. fetch_device_info=True,
  267. )
  268. mqtt_client_mock.assert_called_once()
  269. assert not mqtt_client_mock.call_args.args
  270. kwargs = mqtt_client_mock.call_args.kwargs
  271. if mqtt_disable_tls:
  272. assert kwargs["tls_context"] is None
  273. assert caplog.record_tuples[0][2].endswith(" (TLS disabled)")
  274. else:
  275. assert isinstance(kwargs["tls_context"], ssl.SSLContext)
  276. assert caplog.record_tuples[0][2].endswith(" (TLS enabled)")
  277. @pytest.mark.asyncio
  278. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  279. @pytest.mark.parametrize("mqtt_port", [1833])
  280. @pytest.mark.parametrize("mqtt_username", ["me"])
  281. @pytest.mark.parametrize("mqtt_password", [None, "secret"])
  282. async def test__run_authentication(
  283. mqtt_host: str,
  284. mqtt_port: int,
  285. mqtt_username: str,
  286. mqtt_password: str | None,
  287. ) -> None:
  288. with unittest.mock.patch("aiomqtt.Client") as mqtt_client_mock, unittest.mock.patch(
  289. "switchbot_mqtt._listen"
  290. ):
  291. await switchbot_mqtt._run(
  292. mqtt_host=mqtt_host,
  293. mqtt_port=mqtt_port,
  294. mqtt_disable_tls=True,
  295. mqtt_username=mqtt_username,
  296. mqtt_password=mqtt_password,
  297. mqtt_topic_prefix="prfx",
  298. retry_count=7,
  299. device_passwords={},
  300. fetch_device_info=True,
  301. )
  302. mqtt_client_mock.assert_called_once()
  303. assert not mqtt_client_mock.call_args.args
  304. kwargs = mqtt_client_mock.call_args.kwargs
  305. assert kwargs["username"] == mqtt_username
  306. assert kwargs["password"] == mqtt_password
  307. @pytest.mark.asyncio
  308. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  309. @pytest.mark.parametrize("mqtt_port", [1833])
  310. @pytest.mark.parametrize("mqtt_password", ["secret"])
  311. async def test__run_authentication_missing_username(
  312. mqtt_host: str, mqtt_port: int, mqtt_password: str
  313. ) -> None:
  314. with pytest.raises(ValueError, match=r"^Missing MQTT username$"):
  315. await switchbot_mqtt._run(
  316. mqtt_host=mqtt_host,
  317. mqtt_port=mqtt_port,
  318. mqtt_disable_tls=True,
  319. mqtt_username=None,
  320. mqtt_password=mqtt_password,
  321. mqtt_topic_prefix="whatever",
  322. retry_count=3,
  323. device_passwords={},
  324. fetch_device_info=True,
  325. )
  326. class _ActorMockBase(_MQTTControlledActor):
  327. def __init__(
  328. self,
  329. device: bleak.backends.device.BLEDevice,
  330. retry_count: int,
  331. password: str | None,
  332. ) -> None:
  333. super().__init__(device=device, retry_count=retry_count, password=password)
  334. async def execute_command(
  335. self,
  336. *,
  337. mqtt_message_payload: bytes,
  338. mqtt_client: aiomqtt.Client,
  339. update_device_info: bool,
  340. mqtt_topic_prefix: str,
  341. ) -> None:
  342. pass
  343. def _get_device(self) -> None:
  344. return None
  345. def _mock_actor_class(
  346. *,
  347. command_topic_levels: tuple[_MQTTTopicLevel, ...] = NotImplemented,
  348. request_info_levels: tuple[_MQTTTopicLevel, ...] = NotImplemented,
  349. ) -> type[_ActorMockBase]:
  350. class _ActorMock(_ActorMockBase):
  351. MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
  352. _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS = request_info_levels
  353. return _ActorMock
  354. @pytest.mark.asyncio
  355. @pytest.mark.parametrize(
  356. ("topic_levels", "topic", "expected_mac_address"),
  357. [
  358. (
  359. switchbot_mqtt._actors._ButtonAutomator._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  360. "prfx/switch/switchbot/aa:bb:cc:dd:ee:ff/request-device-info",
  361. "aa:bb:cc:dd:ee:ff",
  362. ),
  363. ],
  364. )
  365. @pytest.mark.parametrize("payload", [b"", b"whatever"])
  366. async def test__mqtt_update_device_info_callback(
  367. caplog: _pytest.logging.LogCaptureFixture,
  368. topic_levels: tuple[_MQTTTopicLevel, ...],
  369. topic: str,
  370. expected_mac_address: str,
  371. payload: bytes,
  372. ) -> None:
  373. ActorMock = _mock_actor_class(request_info_levels=topic_levels)
  374. mqtt_client = unittest.mock.Mock()
  375. message = aiomqtt.Message(
  376. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  377. )
  378. device = unittest.mock.Mock()
  379. with unittest.mock.patch.object(
  380. bleak.BleakScanner, "find_device_by_address", return_value=device
  381. ) as find_device_mock, unittest.mock.patch.object(
  382. ActorMock, "__init__", return_value=None
  383. ) as init_mock, unittest.mock.patch.object(
  384. ActorMock, "_update_and_report_device_info"
  385. ) as update_mock, caplog.at_level(
  386. logging.DEBUG
  387. ):
  388. await ActorMock._mqtt_update_device_info_callback(
  389. mqtt_client=mqtt_client,
  390. message=message,
  391. mqtt_topic_prefix="prfx/",
  392. retry_count=21, # tested in test__mqtt_command_callback
  393. device_passwords={},
  394. fetch_device_info=True,
  395. )
  396. find_device_mock.assert_awaited_once_with(expected_mac_address)
  397. init_mock.assert_called_once_with(device=device, retry_count=21, password=None)
  398. update_mock.assert_called_once_with(
  399. mqtt_client=mqtt_client, mqtt_topic_prefix="prfx/"
  400. )
  401. assert caplog.record_tuples == [
  402. (
  403. "switchbot_mqtt._actors.base",
  404. logging.DEBUG,
  405. f"received topic={topic} payload={payload!r}",
  406. )
  407. ]
  408. @pytest.mark.asyncio
  409. async def test__mqtt_update_device_info_callback_ignore_retained(
  410. caplog: _pytest.logging.LogCaptureFixture,
  411. ) -> None:
  412. ActorMock = _mock_actor_class(
  413. request_info_levels=(_MQTTTopicPlaceholder.MAC_ADDRESS, "request")
  414. )
  415. message = aiomqtt.Message(
  416. topic="aa:bb:cc:dd:ee:ff/request",
  417. payload=b"",
  418. qos=0,
  419. retain=True,
  420. mid=0,
  421. properties=None,
  422. )
  423. with unittest.mock.patch.object(
  424. ActorMock, "__init__", return_value=None
  425. ) as init_mock, unittest.mock.patch.object(
  426. ActorMock, "execute_command"
  427. ) as execute_command_mock, caplog.at_level(
  428. logging.DEBUG
  429. ):
  430. await ActorMock._mqtt_update_device_info_callback(
  431. mqtt_client=unittest.mock.Mock(),
  432. message=message,
  433. mqtt_topic_prefix="ignored",
  434. retry_count=21,
  435. device_passwords={},
  436. fetch_device_info=True,
  437. )
  438. init_mock.assert_not_called()
  439. execute_command_mock.assert_not_called()
  440. execute_command_mock.assert_not_awaited()
  441. assert caplog.record_tuples == [
  442. (
  443. "switchbot_mqtt._actors.base",
  444. logging.DEBUG,
  445. "received topic=aa:bb:cc:dd:ee:ff/request payload=b''",
  446. ),
  447. ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
  448. ]
  449. @pytest.mark.asyncio
  450. @pytest.mark.parametrize(
  451. (
  452. "topic_prefix",
  453. "command_topic_levels",
  454. "topic",
  455. "payload",
  456. "expected_mac_address",
  457. ),
  458. [
  459. (
  460. "homeassistant/",
  461. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  462. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  463. b"ON",
  464. "aa:bb:cc:dd:ee:ff",
  465. ),
  466. (
  467. "homeassistant/",
  468. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  469. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  470. b"OFF",
  471. "aa:bb:cc:dd:ee:ff",
  472. ),
  473. (
  474. "homeassistant/",
  475. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  476. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  477. b"on",
  478. "aa:bb:cc:dd:ee:ff",
  479. ),
  480. (
  481. "homeassistant/",
  482. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  483. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  484. b"off",
  485. "aa:bb:cc:dd:ee:ff",
  486. ),
  487. (
  488. "prefix-",
  489. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  490. "prefix-switch/switchbot/aa:01:23:45:67:89/set",
  491. b"ON",
  492. "aa:01:23:45:67:89",
  493. ),
  494. (
  495. "",
  496. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS],
  497. "switchbot/aa:01:23:45:67:89",
  498. b"ON",
  499. "aa:01:23:45:67:89",
  500. ),
  501. (
  502. "homeassistant/",
  503. _CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
  504. "homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
  505. b"OPEN",
  506. "aa:01:23:45:67:89",
  507. ),
  508. ],
  509. )
  510. @pytest.mark.parametrize("retry_count", (3, 42))
  511. @pytest.mark.parametrize("fetch_device_info", [True, False])
  512. async def test__mqtt_command_callback(
  513. caplog: _pytest.logging.LogCaptureFixture,
  514. topic_prefix: str,
  515. command_topic_levels: tuple[_MQTTTopicLevel, ...],
  516. topic: str,
  517. payload: bytes,
  518. expected_mac_address: str,
  519. retry_count: int,
  520. fetch_device_info: bool,
  521. ) -> None:
  522. ActorMock = _mock_actor_class(command_topic_levels=command_topic_levels)
  523. mqtt_client = unittest.mock.Mock()
  524. message = aiomqtt.Message(
  525. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  526. )
  527. device = unittest.mock.Mock()
  528. device.address = expected_mac_address
  529. with unittest.mock.patch.object(
  530. bleak.BleakScanner, "find_device_by_address", return_value=device
  531. ) as find_device_mock, unittest.mock.patch.object(
  532. ActorMock, "__init__", return_value=None
  533. ) as init_mock, unittest.mock.patch.object(
  534. ActorMock, "execute_command"
  535. ) as execute_command_mock, caplog.at_level(
  536. logging.DEBUG
  537. ):
  538. await ActorMock._mqtt_command_callback(
  539. mqtt_client=mqtt_client,
  540. message=message,
  541. retry_count=retry_count,
  542. device_passwords={},
  543. fetch_device_info=fetch_device_info,
  544. mqtt_topic_prefix=topic_prefix,
  545. )
  546. find_device_mock.assert_awaited_once_with(expected_mac_address)
  547. init_mock.assert_called_once_with(
  548. device=device, retry_count=retry_count, password=None
  549. )
  550. execute_command_mock.assert_awaited_once_with(
  551. mqtt_client=mqtt_client,
  552. mqtt_message_payload=payload,
  553. update_device_info=fetch_device_info,
  554. mqtt_topic_prefix=topic_prefix,
  555. )
  556. assert caplog.record_tuples == [
  557. (
  558. "switchbot_mqtt._actors.base",
  559. logging.DEBUG,
  560. f"received topic={topic} payload={payload!r}",
  561. )
  562. ]
  563. @pytest.mark.asyncio
  564. @pytest.mark.parametrize(
  565. ("mac_address", "expected_password"),
  566. [
  567. ("11:22:33:44:55:66", None),
  568. ("aa:bb:cc:dd:ee:ff", "secret"),
  569. ("11:22:33:dd:ee:ff", "äöü"),
  570. ],
  571. )
  572. async def test__mqtt_command_callback_password(
  573. mac_address: str, expected_password: str | None
  574. ) -> None:
  575. ActorMock = _mock_actor_class(
  576. command_topic_levels=("switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS)
  577. )
  578. mqtt_client = unittest.mock.Mock()
  579. message = aiomqtt.Message(
  580. topic="prefix-switchbot/" + mac_address,
  581. payload=b"whatever",
  582. qos=0,
  583. retain=False,
  584. mid=0,
  585. properties=None,
  586. )
  587. device = unittest.mock.Mock()
  588. device.address = mac_address
  589. with unittest.mock.patch.object(
  590. bleak.BleakScanner, "find_device_by_address", return_value=device
  591. ) as find_device_mock, unittest.mock.patch.object(
  592. ActorMock, "__init__", return_value=None
  593. ) as init_mock, unittest.mock.patch.object(
  594. ActorMock, "execute_command"
  595. ) as execute_command_mock:
  596. await ActorMock._mqtt_command_callback(
  597. mqtt_client=mqtt_client,
  598. message=message,
  599. retry_count=3,
  600. device_passwords={
  601. "11:22:33:44:55:77": "test",
  602. "aa:bb:cc:dd:ee:ff": "secret",
  603. "11:22:33:dd:ee:ff": "äöü",
  604. },
  605. fetch_device_info=True,
  606. mqtt_topic_prefix="prefix-",
  607. )
  608. find_device_mock.assert_awaited_once_with(mac_address)
  609. init_mock.assert_called_once_with(
  610. device=device, retry_count=3, password=expected_password
  611. )
  612. execute_command_mock.assert_awaited_once_with(
  613. mqtt_client=mqtt_client,
  614. mqtt_message_payload=b"whatever",
  615. update_device_info=True,
  616. mqtt_topic_prefix="prefix-",
  617. )
  618. @pytest.mark.asyncio
  619. @pytest.mark.parametrize(
  620. ("topic", "payload"),
  621. [
  622. ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
  623. ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
  624. ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
  625. ],
  626. )
  627. async def test__mqtt_command_callback_unexpected_topic(
  628. caplog: _pytest.logging.LogCaptureFixture, topic: str, payload: bytes
  629. ) -> None:
  630. ActorMock = _mock_actor_class(
  631. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  632. )
  633. message = aiomqtt.Message(
  634. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  635. )
  636. with unittest.mock.patch.object(
  637. ActorMock, "__init__", return_value=None
  638. ) as init_mock, unittest.mock.patch.object(
  639. ActorMock, "execute_command"
  640. ) as execute_command_mock, caplog.at_level(
  641. logging.DEBUG
  642. ):
  643. await ActorMock._mqtt_command_callback(
  644. mqtt_client=unittest.mock.Mock(),
  645. message=message,
  646. retry_count=3,
  647. device_passwords={},
  648. fetch_device_info=True,
  649. mqtt_topic_prefix="homeassistant/",
  650. )
  651. init_mock.assert_not_called()
  652. execute_command_mock.assert_not_called()
  653. execute_command_mock.assert_not_awaited()
  654. assert caplog.record_tuples == [
  655. (
  656. "switchbot_mqtt._actors.base",
  657. logging.DEBUG,
  658. f"received topic={topic} payload={payload!r}",
  659. ),
  660. (
  661. "switchbot_mqtt._actors.base",
  662. logging.WARNING,
  663. f"unexpected topic {topic}",
  664. ),
  665. ]
  666. @pytest.mark.asyncio
  667. @pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
  668. async def test__mqtt_command_callback_invalid_mac_address(
  669. caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
  670. ) -> None:
  671. ActorMock = _mock_actor_class(
  672. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  673. )
  674. topic = f"mqttprefix-switch/switchbot/{mac_address}/set"
  675. message = aiomqtt.Message(
  676. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  677. )
  678. with unittest.mock.patch.object(
  679. ActorMock, "__init__", return_value=None
  680. ) as init_mock, unittest.mock.patch.object(
  681. ActorMock, "execute_command"
  682. ) as execute_command_mock, caplog.at_level(
  683. logging.DEBUG
  684. ):
  685. await ActorMock._mqtt_command_callback(
  686. mqtt_client=unittest.mock.Mock(),
  687. message=message,
  688. retry_count=3,
  689. device_passwords={},
  690. fetch_device_info=True,
  691. mqtt_topic_prefix="mqttprefix-",
  692. )
  693. init_mock.assert_not_called()
  694. execute_command_mock.assert_not_called()
  695. assert caplog.record_tuples == [
  696. (
  697. "switchbot_mqtt._actors.base",
  698. logging.DEBUG,
  699. f"received topic={topic} payload={payload!r}",
  700. ),
  701. (
  702. "switchbot_mqtt._actors.base",
  703. logging.WARNING,
  704. f"invalid mac address {mac_address}",
  705. ),
  706. ]
  707. @pytest.mark.asyncio
  708. @pytest.mark.parametrize("mac_address", ["00:11:22:33:44:55", "aa:bb:cc:dd:ee:ff"])
  709. @pytest.mark.parametrize("payload", [b"ON"])
  710. async def test__mqtt_command_callback_device_not_found(
  711. caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
  712. ) -> None:
  713. ActorMock = _mock_actor_class(
  714. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  715. )
  716. topic = f"prefix/switch/switchbot/{mac_address}/set"
  717. message = aiomqtt.Message(
  718. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  719. )
  720. with unittest.mock.patch.object(
  721. bleak.BleakScanner, "find_device_by_address", return_value=None
  722. ), unittest.mock.patch.object(
  723. ActorMock, "__init__", return_value=None
  724. ) as init_mock, unittest.mock.patch.object(
  725. ActorMock, "execute_command"
  726. ) as execute_command_mock, caplog.at_level(
  727. logging.DEBUG
  728. ):
  729. await ActorMock._mqtt_command_callback(
  730. mqtt_client=unittest.mock.Mock(),
  731. message=message,
  732. retry_count=3,
  733. device_passwords={},
  734. fetch_device_info=True,
  735. mqtt_topic_prefix="prefix/",
  736. )
  737. init_mock.assert_not_called()
  738. execute_command_mock.assert_not_called()
  739. assert caplog.record_tuples == [
  740. (
  741. "switchbot_mqtt._actors.base",
  742. logging.DEBUG,
  743. f"received topic={topic} payload={payload!r}",
  744. ),
  745. (
  746. "switchbot_mqtt._actors.base",
  747. logging.ERROR,
  748. f"failed to find bluetooth low energy device with mac address {mac_address}",
  749. ),
  750. ]
  751. @pytest.mark.asyncio
  752. @pytest.mark.parametrize(
  753. ("topic", "payload"),
  754. [("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
  755. )
  756. async def test__mqtt_command_callback_ignore_retained(
  757. caplog: _pytest.logging.LogCaptureFixture, topic: str, payload: bytes
  758. ) -> None:
  759. ActorMock = _mock_actor_class(
  760. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  761. )
  762. message = aiomqtt.Message(
  763. topic=topic, payload=payload, qos=0, retain=True, mid=0, properties=None
  764. )
  765. with unittest.mock.patch.object(
  766. ActorMock, "__init__", return_value=None
  767. ) as init_mock, unittest.mock.patch.object(
  768. ActorMock, "execute_command"
  769. ) as execute_command_mock, caplog.at_level(
  770. logging.DEBUG
  771. ):
  772. await ActorMock._mqtt_command_callback(
  773. mqtt_client=unittest.mock.Mock(),
  774. message=message,
  775. retry_count=4,
  776. device_passwords={},
  777. fetch_device_info=True,
  778. mqtt_topic_prefix="homeassistant/",
  779. )
  780. init_mock.assert_not_called()
  781. execute_command_mock.assert_not_called()
  782. execute_command_mock.assert_not_awaited()
  783. assert caplog.record_tuples == [
  784. (
  785. "switchbot_mqtt._actors.base",
  786. logging.DEBUG,
  787. f"received topic={topic} payload={payload!r}",
  788. ),
  789. ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
  790. ]
  791. @pytest.mark.asyncio
  792. @pytest.mark.parametrize(
  793. ("topic_prefix", "state_topic_levels", "mac_address", "expected_topic"),
  794. # https://www.home-assistant.io/docs/mqtt/discovery/#switches
  795. [
  796. (
  797. "homeassistant/",
  798. _ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
  799. "aa:bb:cc:dd:ee:ff",
  800. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
  801. ),
  802. (
  803. "",
  804. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
  805. "aa:bb:cc:dd:ee:gg",
  806. "switchbot/aa:bb:cc:dd:ee:gg/state",
  807. ),
  808. ],
  809. )
  810. @pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
  811. @pytest.mark.parametrize("mqtt_publish_fails", [False, True])
  812. async def test__report_state(
  813. caplog: _pytest.logging.LogCaptureFixture,
  814. topic_prefix: str,
  815. state_topic_levels: tuple[_MQTTTopicLevel, ...],
  816. mac_address: str,
  817. expected_topic: str,
  818. state: bytes,
  819. mqtt_publish_fails: bool,
  820. ) -> None:
  821. # pylint: disable=too-many-arguments
  822. class _ActorMock(_MQTTControlledActor):
  823. MQTT_STATE_TOPIC_LEVELS = state_topic_levels
  824. def __init__(
  825. self,
  826. device: bleak.backends.device.BLEDevice,
  827. retry_count: int,
  828. password: str | None,
  829. ) -> None:
  830. super().__init__(device=device, retry_count=retry_count, password=password)
  831. async def execute_command(
  832. self,
  833. *,
  834. mqtt_message_payload: bytes,
  835. mqtt_client: aiomqtt.Client,
  836. update_device_info: bool,
  837. mqtt_topic_prefix: str,
  838. ) -> None:
  839. pass
  840. def _get_device(self) -> None:
  841. return None
  842. mqtt_client_mock = unittest.mock.AsyncMock()
  843. if mqtt_publish_fails:
  844. # https://github.com/sbtinstruments/aiomqtt/blob/v1.2.1/aiomqtt/client.py#L678
  845. mqtt_client_mock.publish.side_effect = aiomqtt.MqttCodeError(
  846. MQTT_ERR_NO_CONN, "Could not publish message"
  847. )
  848. device = unittest.mock.Mock()
  849. device.address = mac_address
  850. with caplog.at_level(logging.DEBUG):
  851. actor = _ActorMock(device=device, retry_count=3, password=None)
  852. await actor.report_state(
  853. state=state, mqtt_client=mqtt_client_mock, mqtt_topic_prefix=topic_prefix
  854. )
  855. mqtt_client_mock.publish.assert_awaited_once_with(
  856. topic=expected_topic, payload=state, retain=True
  857. )
  858. assert caplog.record_tuples[0] == (
  859. "switchbot_mqtt._actors.base",
  860. logging.DEBUG,
  861. f"publishing topic={expected_topic} payload={state!r}",
  862. )
  863. if not mqtt_publish_fails:
  864. assert not caplog.records[1:]
  865. else:
  866. assert caplog.record_tuples[1:] == [
  867. (
  868. "switchbot_mqtt._actors.base",
  869. logging.ERROR,
  870. f"Failed to publish MQTT message on topic {expected_topic}:"
  871. " aiomqtt.MqttCodeError [code:4] The client is not currently connected.",
  872. )
  873. ]