test_mqtt.py 29 KB

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