test_mqtt.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  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 logging
  19. import socket
  20. import ssl
  21. import typing
  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() -> typing.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: typing.Tuple[typing.Union[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: typing.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: typing.Optional[str],
  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. def _mock_actor_class(
  327. *,
  328. command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
  329. request_info_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
  330. ) -> typing.Type:
  331. class _ActorMock(_MQTTControlledActor):
  332. MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
  333. _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS = request_info_levels
  334. def __init__(
  335. self,
  336. device: bleak.backends.device.BLEDevice,
  337. retry_count: int,
  338. password: typing.Optional[str],
  339. ) -> None:
  340. super().__init__(device=device, retry_count=retry_count, password=password)
  341. async def execute_command(
  342. self,
  343. *,
  344. mqtt_message_payload: bytes,
  345. mqtt_client: aiomqtt.Client,
  346. update_device_info: bool,
  347. mqtt_topic_prefix: str,
  348. ) -> None:
  349. pass
  350. def _get_device(self) -> None:
  351. return None
  352. return _ActorMock
  353. @pytest.mark.asyncio
  354. @pytest.mark.parametrize(
  355. ("topic_levels", "topic", "expected_mac_address"),
  356. [
  357. (
  358. switchbot_mqtt._actors._ButtonAutomator._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  359. "prfx/switch/switchbot/aa:bb:cc:dd:ee:ff/request-device-info",
  360. "aa:bb:cc:dd:ee:ff",
  361. ),
  362. ],
  363. )
  364. @pytest.mark.parametrize("payload", [b"", b"whatever"])
  365. async def test__mqtt_update_device_info_callback(
  366. caplog: _pytest.logging.LogCaptureFixture,
  367. topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  368. topic: str,
  369. expected_mac_address: str,
  370. payload: bytes,
  371. ) -> None:
  372. ActorMock = _mock_actor_class(request_info_levels=topic_levels)
  373. message = aiomqtt.Message(
  374. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  375. )
  376. device = unittest.mock.Mock()
  377. with unittest.mock.patch.object(
  378. bleak.BleakScanner, "find_device_by_address", return_value=device
  379. ) as find_device_mock, unittest.mock.patch.object(
  380. ActorMock, "__init__", return_value=None
  381. ) as init_mock, unittest.mock.patch.object(
  382. ActorMock, "_update_and_report_device_info"
  383. ) as update_mock, caplog.at_level(
  384. logging.DEBUG
  385. ):
  386. await ActorMock._mqtt_update_device_info_callback(
  387. mqtt_client="client_dummy",
  388. message=message,
  389. mqtt_topic_prefix="prfx/",
  390. retry_count=21, # tested in test__mqtt_command_callback
  391. device_passwords={},
  392. fetch_device_info=True,
  393. )
  394. find_device_mock.assert_awaited_once_with(expected_mac_address)
  395. init_mock.assert_called_once_with(device=device, retry_count=21, password=None)
  396. update_mock.assert_called_once_with(
  397. mqtt_client="client_dummy", mqtt_topic_prefix="prfx/"
  398. )
  399. assert caplog.record_tuples == [
  400. (
  401. "switchbot_mqtt._actors.base",
  402. logging.DEBUG,
  403. f"received topic={topic} payload={payload!r}",
  404. )
  405. ]
  406. @pytest.mark.asyncio
  407. async def test__mqtt_update_device_info_callback_ignore_retained(
  408. caplog: _pytest.logging.LogCaptureFixture,
  409. ) -> None:
  410. ActorMock = _mock_actor_class(
  411. request_info_levels=(_MQTTTopicPlaceholder.MAC_ADDRESS, "request")
  412. )
  413. message = aiomqtt.Message(
  414. topic="aa:bb:cc:dd:ee:ff/request",
  415. payload=b"",
  416. qos=0,
  417. retain=True,
  418. mid=0,
  419. properties=None,
  420. )
  421. with unittest.mock.patch.object(
  422. ActorMock, "__init__", return_value=None
  423. ) as init_mock, unittest.mock.patch.object(
  424. ActorMock, "execute_command"
  425. ) as execute_command_mock, caplog.at_level(
  426. logging.DEBUG
  427. ):
  428. await ActorMock._mqtt_update_device_info_callback(
  429. mqtt_client="client_dummy",
  430. message=message,
  431. mqtt_topic_prefix="ignored",
  432. retry_count=21,
  433. device_passwords={},
  434. fetch_device_info=True,
  435. )
  436. init_mock.assert_not_called()
  437. execute_command_mock.assert_not_called()
  438. execute_command_mock.assert_not_awaited()
  439. assert caplog.record_tuples == [
  440. (
  441. "switchbot_mqtt._actors.base",
  442. logging.DEBUG,
  443. "received topic=aa:bb:cc:dd:ee:ff/request payload=b''",
  444. ),
  445. ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
  446. ]
  447. @pytest.mark.asyncio
  448. @pytest.mark.parametrize(
  449. (
  450. "topic_prefix",
  451. "command_topic_levels",
  452. "topic",
  453. "payload",
  454. "expected_mac_address",
  455. ),
  456. [
  457. (
  458. "homeassistant/",
  459. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  460. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  461. b"ON",
  462. "aa:bb:cc:dd:ee:ff",
  463. ),
  464. (
  465. "homeassistant/",
  466. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  467. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  468. b"OFF",
  469. "aa:bb:cc:dd:ee:ff",
  470. ),
  471. (
  472. "homeassistant/",
  473. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  474. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  475. b"on",
  476. "aa:bb:cc:dd:ee:ff",
  477. ),
  478. (
  479. "homeassistant/",
  480. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  481. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  482. b"off",
  483. "aa:bb:cc:dd:ee:ff",
  484. ),
  485. (
  486. "prefix-",
  487. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  488. "prefix-switch/switchbot/aa:01:23:45:67:89/set",
  489. b"ON",
  490. "aa:01:23:45:67:89",
  491. ),
  492. (
  493. "",
  494. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS],
  495. "switchbot/aa:01:23:45:67:89",
  496. b"ON",
  497. "aa:01:23:45:67:89",
  498. ),
  499. (
  500. "homeassistant/",
  501. _CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
  502. "homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
  503. b"OPEN",
  504. "aa:01:23:45:67:89",
  505. ),
  506. ],
  507. )
  508. @pytest.mark.parametrize("retry_count", (3, 42))
  509. @pytest.mark.parametrize("fetch_device_info", [True, False])
  510. async def test__mqtt_command_callback(
  511. caplog: _pytest.logging.LogCaptureFixture,
  512. topic_prefix: str,
  513. command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  514. topic: str,
  515. payload: bytes,
  516. expected_mac_address: str,
  517. retry_count: int,
  518. fetch_device_info: bool,
  519. ) -> None:
  520. ActorMock = _mock_actor_class(command_topic_levels=command_topic_levels)
  521. message = aiomqtt.Message(
  522. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  523. )
  524. device = unittest.mock.Mock()
  525. device.address = expected_mac_address
  526. with unittest.mock.patch.object(
  527. bleak.BleakScanner, "find_device_by_address", return_value=device
  528. ) as find_device_mock, unittest.mock.patch.object(
  529. ActorMock, "__init__", return_value=None
  530. ) as init_mock, unittest.mock.patch.object(
  531. ActorMock, "execute_command"
  532. ) as execute_command_mock, caplog.at_level(
  533. logging.DEBUG
  534. ):
  535. await ActorMock._mqtt_command_callback(
  536. mqtt_client="client_dummy",
  537. message=message,
  538. retry_count=retry_count,
  539. device_passwords={},
  540. fetch_device_info=fetch_device_info,
  541. mqtt_topic_prefix=topic_prefix,
  542. )
  543. find_device_mock.assert_awaited_once_with(expected_mac_address)
  544. init_mock.assert_called_once_with(
  545. device=device, retry_count=retry_count, password=None
  546. )
  547. execute_command_mock.assert_awaited_once_with(
  548. mqtt_client="client_dummy",
  549. mqtt_message_payload=payload,
  550. update_device_info=fetch_device_info,
  551. mqtt_topic_prefix=topic_prefix,
  552. )
  553. assert caplog.record_tuples == [
  554. (
  555. "switchbot_mqtt._actors.base",
  556. logging.DEBUG,
  557. f"received topic={topic} payload={payload!r}",
  558. )
  559. ]
  560. @pytest.mark.asyncio
  561. @pytest.mark.parametrize(
  562. ("mac_address", "expected_password"),
  563. [
  564. ("11:22:33:44:55:66", None),
  565. ("aa:bb:cc:dd:ee:ff", "secret"),
  566. ("11:22:33:dd:ee:ff", "äöü"),
  567. ],
  568. )
  569. async def test__mqtt_command_callback_password(
  570. mac_address: str, expected_password: typing.Optional[str]
  571. ) -> None:
  572. ActorMock = _mock_actor_class(
  573. command_topic_levels=("switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS)
  574. )
  575. message = aiomqtt.Message(
  576. topic="prefix-switchbot/" + mac_address,
  577. payload=b"whatever",
  578. qos=0,
  579. retain=False,
  580. mid=0,
  581. properties=None,
  582. )
  583. device = unittest.mock.Mock()
  584. device.address = mac_address
  585. with unittest.mock.patch.object(
  586. bleak.BleakScanner, "find_device_by_address", return_value=device
  587. ) as find_device_mock, unittest.mock.patch.object(
  588. ActorMock, "__init__", return_value=None
  589. ) as init_mock, unittest.mock.patch.object(
  590. ActorMock, "execute_command"
  591. ) as execute_command_mock:
  592. await ActorMock._mqtt_command_callback(
  593. mqtt_client="client_dummy",
  594. message=message,
  595. retry_count=3,
  596. device_passwords={
  597. "11:22:33:44:55:77": "test",
  598. "aa:bb:cc:dd:ee:ff": "secret",
  599. "11:22:33:dd:ee:ff": "äöü",
  600. },
  601. fetch_device_info=True,
  602. mqtt_topic_prefix="prefix-",
  603. )
  604. find_device_mock.assert_awaited_once_with(mac_address)
  605. init_mock.assert_called_once_with(
  606. device=device, retry_count=3, password=expected_password
  607. )
  608. execute_command_mock.assert_awaited_once_with(
  609. mqtt_client="client_dummy",
  610. mqtt_message_payload=b"whatever",
  611. update_device_info=True,
  612. mqtt_topic_prefix="prefix-",
  613. )
  614. @pytest.mark.asyncio
  615. @pytest.mark.parametrize(
  616. ("topic", "payload"),
  617. [
  618. ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
  619. ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
  620. ("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
  621. ],
  622. )
  623. async def test__mqtt_command_callback_unexpected_topic(
  624. caplog: _pytest.logging.LogCaptureFixture, topic: str, payload: bytes
  625. ) -> None:
  626. ActorMock = _mock_actor_class(
  627. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  628. )
  629. message = aiomqtt.Message(
  630. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  631. )
  632. with unittest.mock.patch.object(
  633. ActorMock, "__init__", return_value=None
  634. ) as init_mock, unittest.mock.patch.object(
  635. ActorMock, "execute_command"
  636. ) as execute_command_mock, caplog.at_level(
  637. logging.DEBUG
  638. ):
  639. await ActorMock._mqtt_command_callback(
  640. mqtt_client="client_dummy",
  641. message=message,
  642. retry_count=3,
  643. device_passwords={},
  644. fetch_device_info=True,
  645. mqtt_topic_prefix="homeassistant/",
  646. )
  647. init_mock.assert_not_called()
  648. execute_command_mock.assert_not_called()
  649. execute_command_mock.assert_not_awaited()
  650. assert caplog.record_tuples == [
  651. (
  652. "switchbot_mqtt._actors.base",
  653. logging.DEBUG,
  654. f"received topic={topic} payload={payload!r}",
  655. ),
  656. (
  657. "switchbot_mqtt._actors.base",
  658. logging.WARNING,
  659. f"unexpected topic {topic}",
  660. ),
  661. ]
  662. @pytest.mark.asyncio
  663. @pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
  664. async def test__mqtt_command_callback_invalid_mac_address(
  665. caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
  666. ) -> None:
  667. ActorMock = _mock_actor_class(
  668. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  669. )
  670. topic = f"mqttprefix-switch/switchbot/{mac_address}/set"
  671. message = aiomqtt.Message(
  672. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  673. )
  674. with unittest.mock.patch.object(
  675. ActorMock, "__init__", return_value=None
  676. ) as init_mock, unittest.mock.patch.object(
  677. ActorMock, "execute_command"
  678. ) as execute_command_mock, caplog.at_level(
  679. logging.DEBUG
  680. ):
  681. await ActorMock._mqtt_command_callback(
  682. mqtt_client="client_dummy",
  683. message=message,
  684. retry_count=3,
  685. device_passwords={},
  686. fetch_device_info=True,
  687. mqtt_topic_prefix="mqttprefix-",
  688. )
  689. init_mock.assert_not_called()
  690. execute_command_mock.assert_not_called()
  691. assert caplog.record_tuples == [
  692. (
  693. "switchbot_mqtt._actors.base",
  694. logging.DEBUG,
  695. f"received topic={topic} payload={payload!r}",
  696. ),
  697. (
  698. "switchbot_mqtt._actors.base",
  699. logging.WARNING,
  700. f"invalid mac address {mac_address}",
  701. ),
  702. ]
  703. @pytest.mark.asyncio
  704. @pytest.mark.parametrize("mac_address", ["00:11:22:33:44:55", "aa:bb:cc:dd:ee:ff"])
  705. @pytest.mark.parametrize("payload", [b"ON"])
  706. async def test__mqtt_command_callback_device_not_found(
  707. caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
  708. ) -> None:
  709. ActorMock = _mock_actor_class(
  710. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  711. )
  712. topic = f"prefix/switch/switchbot/{mac_address}/set"
  713. message = aiomqtt.Message(
  714. topic=topic, payload=payload, qos=0, retain=False, mid=0, properties=None
  715. )
  716. with unittest.mock.patch.object(
  717. bleak.BleakScanner, "find_device_by_address", return_value=None
  718. ), unittest.mock.patch.object(
  719. ActorMock, "__init__", return_value=None
  720. ) as init_mock, unittest.mock.patch.object(
  721. ActorMock, "execute_command"
  722. ) as execute_command_mock, caplog.at_level(
  723. logging.DEBUG
  724. ):
  725. await ActorMock._mqtt_command_callback(
  726. mqtt_client="client_dummy",
  727. message=message,
  728. retry_count=3,
  729. device_passwords={},
  730. fetch_device_info=True,
  731. mqtt_topic_prefix="prefix/",
  732. )
  733. init_mock.assert_not_called()
  734. execute_command_mock.assert_not_called()
  735. assert caplog.record_tuples == [
  736. (
  737. "switchbot_mqtt._actors.base",
  738. logging.DEBUG,
  739. f"received topic={topic} payload={payload!r}",
  740. ),
  741. (
  742. "switchbot_mqtt._actors.base",
  743. logging.ERROR,
  744. f"failed to find bluetooth low energy device with mac address {mac_address}",
  745. ),
  746. ]
  747. @pytest.mark.asyncio
  748. @pytest.mark.parametrize(
  749. ("topic", "payload"),
  750. [("homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
  751. )
  752. async def test__mqtt_command_callback_ignore_retained(
  753. caplog: _pytest.logging.LogCaptureFixture, topic: str, payload: bytes
  754. ) -> None:
  755. ActorMock = _mock_actor_class(
  756. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  757. )
  758. message = aiomqtt.Message(
  759. topic=topic, payload=payload, qos=0, retain=True, mid=0, properties=None
  760. )
  761. with unittest.mock.patch.object(
  762. ActorMock, "__init__", return_value=None
  763. ) as init_mock, unittest.mock.patch.object(
  764. ActorMock, "execute_command"
  765. ) as execute_command_mock, caplog.at_level(
  766. logging.DEBUG
  767. ):
  768. await ActorMock._mqtt_command_callback(
  769. mqtt_client="client_dummy",
  770. message=message,
  771. retry_count=4,
  772. device_passwords={},
  773. fetch_device_info=True,
  774. mqtt_topic_prefix="homeassistant/",
  775. )
  776. init_mock.assert_not_called()
  777. execute_command_mock.assert_not_called()
  778. execute_command_mock.assert_not_awaited()
  779. assert caplog.record_tuples == [
  780. (
  781. "switchbot_mqtt._actors.base",
  782. logging.DEBUG,
  783. f"received topic={topic} payload={payload!r}",
  784. ),
  785. ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
  786. ]
  787. @pytest.mark.asyncio
  788. @pytest.mark.parametrize(
  789. ("topic_prefix", "state_topic_levels", "mac_address", "expected_topic"),
  790. # https://www.home-assistant.io/docs/mqtt/discovery/#switches
  791. [
  792. (
  793. "homeassistant/",
  794. _ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
  795. "aa:bb:cc:dd:ee:ff",
  796. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
  797. ),
  798. (
  799. "",
  800. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
  801. "aa:bb:cc:dd:ee:gg",
  802. "switchbot/aa:bb:cc:dd:ee:gg/state",
  803. ),
  804. ],
  805. )
  806. @pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
  807. @pytest.mark.parametrize("mqtt_publish_fails", [False, True])
  808. async def test__report_state(
  809. caplog: _pytest.logging.LogCaptureFixture,
  810. topic_prefix: str,
  811. state_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  812. mac_address: str,
  813. expected_topic: str,
  814. state: bytes,
  815. mqtt_publish_fails: bool,
  816. ) -> None:
  817. # pylint: disable=too-many-arguments
  818. class _ActorMock(_MQTTControlledActor):
  819. MQTT_STATE_TOPIC_LEVELS = state_topic_levels
  820. def __init__(
  821. self,
  822. device: bleak.backends.device.BLEDevice,
  823. retry_count: int,
  824. password: typing.Optional[str],
  825. ) -> None:
  826. super().__init__(device=device, retry_count=retry_count, password=password)
  827. async def execute_command(
  828. self,
  829. *,
  830. mqtt_message_payload: bytes,
  831. mqtt_client: aiomqtt.Client,
  832. update_device_info: bool,
  833. mqtt_topic_prefix: str,
  834. ) -> None:
  835. pass
  836. def _get_device(self) -> None:
  837. return None
  838. mqtt_client_mock = unittest.mock.AsyncMock()
  839. if mqtt_publish_fails:
  840. # https://github.com/sbtinstruments/aiomqtt/blob/v1.2.1/aiomqtt/client.py#L678
  841. mqtt_client_mock.publish.side_effect = aiomqtt.MqttCodeError(
  842. MQTT_ERR_NO_CONN, "Could not publish message"
  843. )
  844. device = unittest.mock.Mock()
  845. device.address = mac_address
  846. with caplog.at_level(logging.DEBUG):
  847. actor = _ActorMock(device=device, retry_count=3, password=None)
  848. await actor.report_state(
  849. state=state, mqtt_client=mqtt_client_mock, mqtt_topic_prefix=topic_prefix
  850. )
  851. mqtt_client_mock.publish.assert_awaited_once_with(
  852. topic=expected_topic, payload=state, retain=True
  853. )
  854. assert caplog.record_tuples[0] == (
  855. "switchbot_mqtt._actors.base",
  856. logging.DEBUG,
  857. f"publishing topic={expected_topic} payload={state!r}",
  858. )
  859. if not mqtt_publish_fails:
  860. assert not caplog.records[1:]
  861. else:
  862. assert caplog.record_tuples[1:] == [
  863. (
  864. "switchbot_mqtt._actors.base",
  865. logging.ERROR,
  866. f"Failed to publish MQTT message on topic {expected_topic}:"
  867. " aiomqtt.MqttCodeError [code:4] The client is not currently connected.",
  868. )
  869. ]