test_mqtt.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  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 typing
  20. import unittest.mock
  21. import _pytest.logging
  22. import pytest
  23. from paho.mqtt.client import MQTT_ERR_QUEUE_SIZE, MQTT_ERR_SUCCESS, MQTTMessage, Client
  24. import switchbot_mqtt
  25. import switchbot_mqtt._actors
  26. from switchbot_mqtt._actors import _ButtonAutomator, _CurtainMotor
  27. from switchbot_mqtt._actors.base import _MQTTCallbackUserdata, _MQTTControlledActor
  28. from switchbot_mqtt._utils import _MQTTTopicLevel, _MQTTTopicPlaceholder
  29. # pylint: disable=protected-access
  30. # pylint: disable=too-many-arguments; these are tests, no API
  31. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  32. @pytest.mark.parametrize("mqtt_port", [1833])
  33. @pytest.mark.parametrize("retry_count", [3, 21])
  34. @pytest.mark.parametrize(
  35. "device_passwords",
  36. [{}, {"11:22:33:44:55:66": "password", "aa:bb:cc:dd:ee:ff": "secret"}],
  37. )
  38. @pytest.mark.parametrize("fetch_device_info", [True, False])
  39. def test__run(
  40. caplog: _pytest.logging.LogCaptureFixture,
  41. mqtt_host: str,
  42. mqtt_port: int,
  43. retry_count: int,
  44. device_passwords: typing.Dict[str, str],
  45. fetch_device_info: bool,
  46. ) -> None:
  47. with unittest.mock.patch(
  48. "paho.mqtt.client.Client"
  49. ) as mqtt_client_mock, caplog.at_level(logging.DEBUG):
  50. switchbot_mqtt._run(
  51. mqtt_host=mqtt_host,
  52. mqtt_port=mqtt_port,
  53. mqtt_username=None,
  54. mqtt_password=None,
  55. mqtt_disable_tls=False,
  56. retry_count=retry_count,
  57. device_passwords=device_passwords,
  58. fetch_device_info=fetch_device_info,
  59. )
  60. mqtt_client_mock.assert_called_once()
  61. assert not mqtt_client_mock.call_args[0]
  62. assert set(mqtt_client_mock.call_args[1].keys()) == {"userdata"}
  63. userdata = mqtt_client_mock.call_args[1]["userdata"]
  64. assert userdata == _MQTTCallbackUserdata(
  65. retry_count=retry_count,
  66. device_passwords=device_passwords,
  67. fetch_device_info=fetch_device_info,
  68. )
  69. assert not mqtt_client_mock().username_pw_set.called
  70. mqtt_client_mock().tls_set.assert_called_once_with(ca_certs=None)
  71. mqtt_client_mock().connect.assert_called_once_with(host=mqtt_host, port=mqtt_port)
  72. mqtt_client_mock().socket().getpeername.return_value = (mqtt_host, mqtt_port)
  73. with caplog.at_level(logging.DEBUG):
  74. mqtt_client_mock().on_connect(mqtt_client_mock(), userdata, {}, 0)
  75. subscribe_mock = mqtt_client_mock().subscribe
  76. assert subscribe_mock.call_count == (5 if fetch_device_info else 3)
  77. for topic in [
  78. "homeassistant/switch/switchbot/+/set",
  79. "homeassistant/cover/switchbot-curtain/+/set",
  80. "homeassistant/cover/switchbot-curtain/+/position/set-percent",
  81. ]:
  82. assert unittest.mock.call(topic) in subscribe_mock.call_args_list
  83. for topic in [
  84. "homeassistant/switch/switchbot/+/request-device-info",
  85. "homeassistant/cover/switchbot-curtain/+/request-device-info",
  86. ]:
  87. assert (
  88. unittest.mock.call(topic) in subscribe_mock.call_args_list
  89. ) == fetch_device_info
  90. callbacks = {
  91. c[1]["sub"]: c[1]["callback"]
  92. for c in mqtt_client_mock().message_callback_add.call_args_list
  93. }
  94. assert ( # pylint: disable=comparison-with-callable; intended
  95. callbacks["homeassistant/cover/switchbot-curtain/+/position/set-percent"]
  96. == _CurtainMotor._mqtt_set_position_callback
  97. )
  98. mqtt_client_mock().loop_forever.assert_called_once_with()
  99. assert caplog.record_tuples[:2] == [
  100. (
  101. "switchbot_mqtt",
  102. logging.INFO,
  103. f"connecting to MQTT broker {mqtt_host}:{mqtt_port} (TLS enabled)",
  104. ),
  105. (
  106. "switchbot_mqtt",
  107. logging.DEBUG,
  108. f"connected to MQTT broker {mqtt_host}:{mqtt_port}",
  109. ),
  110. ]
  111. assert len(caplog.record_tuples) == (7 if fetch_device_info else 5)
  112. assert (
  113. "switchbot_mqtt._actors.base",
  114. logging.INFO,
  115. "subscribing to MQTT topic 'homeassistant/switch/switchbot/+/set'",
  116. ) in caplog.record_tuples
  117. assert (
  118. "switchbot_mqtt._actors.base",
  119. logging.INFO,
  120. "subscribing to MQTT topic 'homeassistant/cover/switchbot-curtain/+/set'",
  121. ) in caplog.record_tuples
  122. @pytest.mark.parametrize("mqtt_disable_tls", [True, False])
  123. def test__run_tls(
  124. caplog: _pytest.logging.LogCaptureFixture, mqtt_disable_tls: bool
  125. ) -> None:
  126. with unittest.mock.patch(
  127. "paho.mqtt.client.Client"
  128. ) as mqtt_client_mock, caplog.at_level(logging.INFO):
  129. switchbot_mqtt._run(
  130. mqtt_host="mqtt.local",
  131. mqtt_port=1234,
  132. mqtt_username=None,
  133. mqtt_password=None,
  134. mqtt_disable_tls=mqtt_disable_tls,
  135. retry_count=21,
  136. device_passwords={},
  137. fetch_device_info=True,
  138. )
  139. if mqtt_disable_tls:
  140. mqtt_client_mock().tls_set.assert_not_called()
  141. else:
  142. mqtt_client_mock().tls_set.assert_called_once_with(ca_certs=None)
  143. if mqtt_disable_tls:
  144. assert caplog.record_tuples[0][2].endswith(" (TLS disabled)")
  145. else:
  146. assert caplog.record_tuples[0][2].endswith(" (TLS enabled)")
  147. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  148. @pytest.mark.parametrize("mqtt_port", [1833])
  149. @pytest.mark.parametrize("mqtt_username", ["me"])
  150. @pytest.mark.parametrize("mqtt_password", [None, "secret"])
  151. def test__run_authentication(
  152. mqtt_host: str,
  153. mqtt_port: int,
  154. mqtt_username: str,
  155. mqtt_password: typing.Optional[str],
  156. ) -> None:
  157. with unittest.mock.patch("paho.mqtt.client.Client") as mqtt_client_mock:
  158. switchbot_mqtt._run(
  159. mqtt_host=mqtt_host,
  160. mqtt_port=mqtt_port,
  161. mqtt_username=mqtt_username,
  162. mqtt_password=mqtt_password,
  163. mqtt_disable_tls=True,
  164. retry_count=7,
  165. device_passwords={},
  166. fetch_device_info=True,
  167. )
  168. mqtt_client_mock.assert_called_once_with(
  169. userdata=_MQTTCallbackUserdata(
  170. retry_count=7, device_passwords={}, fetch_device_info=True
  171. )
  172. )
  173. mqtt_client_mock().username_pw_set.assert_called_once_with(
  174. username=mqtt_username, password=mqtt_password
  175. )
  176. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  177. @pytest.mark.parametrize("mqtt_port", [1833])
  178. @pytest.mark.parametrize("mqtt_password", ["secret"])
  179. def test__run_authentication_missing_username(
  180. mqtt_host: str, mqtt_port: int, mqtt_password: str
  181. ) -> None:
  182. with unittest.mock.patch("paho.mqtt.client.Client"):
  183. with pytest.raises(ValueError):
  184. switchbot_mqtt._run(
  185. mqtt_host=mqtt_host,
  186. mqtt_port=mqtt_port,
  187. mqtt_username=None,
  188. mqtt_password=mqtt_password,
  189. mqtt_disable_tls=True,
  190. retry_count=3,
  191. device_passwords={},
  192. fetch_device_info=True,
  193. )
  194. def _mock_actor_class(
  195. *,
  196. command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
  197. request_info_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
  198. ) -> typing.Type:
  199. class _ActorMock(_MQTTControlledActor):
  200. MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
  201. _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS = request_info_levels
  202. def __init__(
  203. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  204. ) -> None:
  205. super().__init__(
  206. mac_address=mac_address, retry_count=retry_count, password=password
  207. )
  208. def execute_command(
  209. self,
  210. *,
  211. mqtt_message_payload: bytes,
  212. mqtt_client: Client,
  213. update_device_info: bool,
  214. mqtt_topic_prefix: str,
  215. ) -> None:
  216. pass
  217. def _get_device(self) -> None:
  218. return None
  219. return _ActorMock
  220. @pytest.mark.parametrize(
  221. ("topic_levels", "topic", "expected_mac_address"),
  222. [
  223. (
  224. switchbot_mqtt._actors._ButtonAutomator._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  225. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/request-device-info",
  226. "aa:bb:cc:dd:ee:ff",
  227. ),
  228. ],
  229. )
  230. @pytest.mark.parametrize("payload", [b"", b"whatever"])
  231. def test__mqtt_update_device_info_callback(
  232. caplog: _pytest.logging.LogCaptureFixture,
  233. topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  234. topic: bytes,
  235. expected_mac_address: str,
  236. payload: bytes,
  237. ) -> None:
  238. ActorMock = _mock_actor_class(request_info_levels=topic_levels)
  239. message = MQTTMessage(topic=topic)
  240. message.payload = payload
  241. callback_userdata = _MQTTCallbackUserdata(
  242. retry_count=21, # tested in test__mqtt_command_callback
  243. device_passwords={},
  244. fetch_device_info=True,
  245. )
  246. with unittest.mock.patch.object(
  247. ActorMock, "__init__", return_value=None
  248. ) as init_mock, unittest.mock.patch.object(
  249. ActorMock, "_update_and_report_device_info"
  250. ) as update_mock, caplog.at_level(
  251. logging.DEBUG
  252. ):
  253. ActorMock._mqtt_update_device_info_callback(
  254. "client_dummy", callback_userdata, message
  255. )
  256. init_mock.assert_called_once_with(
  257. mac_address=expected_mac_address, retry_count=21, password=None
  258. )
  259. update_mock.assert_called_once_with(
  260. mqtt_client="client_dummy", mqtt_topic_prefix="homeassistant/"
  261. )
  262. assert caplog.record_tuples == [
  263. (
  264. "switchbot_mqtt._actors.base",
  265. logging.DEBUG,
  266. f"received topic={topic.decode()} payload={payload!r}",
  267. )
  268. ]
  269. def test__mqtt_update_device_info_callback_ignore_retained(
  270. caplog: _pytest.logging.LogCaptureFixture,
  271. ) -> None:
  272. ActorMock = _mock_actor_class(
  273. request_info_levels=(_MQTTTopicPlaceholder.MAC_ADDRESS, "request")
  274. )
  275. message = MQTTMessage(topic=b"aa:bb:cc:dd:ee:ff/request")
  276. message.payload = b""
  277. message.retain = True
  278. with unittest.mock.patch.object(
  279. ActorMock, "__init__", return_value=None
  280. ) as init_mock, unittest.mock.patch.object(
  281. ActorMock, "execute_command"
  282. ) as execute_command_mock, caplog.at_level(
  283. logging.DEBUG
  284. ):
  285. ActorMock._mqtt_update_device_info_callback(
  286. "client_dummy",
  287. _MQTTCallbackUserdata(
  288. retry_count=21, device_passwords={}, fetch_device_info=True
  289. ),
  290. message,
  291. )
  292. init_mock.assert_not_called()
  293. execute_command_mock.assert_not_called()
  294. assert caplog.record_tuples == [
  295. (
  296. "switchbot_mqtt._actors.base",
  297. logging.DEBUG,
  298. "received topic=aa:bb:cc:dd:ee:ff/request payload=b''",
  299. ),
  300. ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
  301. ]
  302. @pytest.mark.parametrize(
  303. (
  304. "topic_prefix",
  305. "command_topic_levels",
  306. "topic",
  307. "payload",
  308. "expected_mac_address",
  309. ),
  310. [
  311. (
  312. "homeassistant/",
  313. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  314. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  315. b"ON",
  316. "aa:bb:cc:dd:ee:ff",
  317. ),
  318. (
  319. "homeassistant/",
  320. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  321. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  322. b"OFF",
  323. "aa:bb:cc:dd:ee:ff",
  324. ),
  325. (
  326. "homeassistant/",
  327. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  328. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  329. b"on",
  330. "aa:bb:cc:dd:ee:ff",
  331. ),
  332. (
  333. "homeassistant/",
  334. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  335. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  336. b"off",
  337. "aa:bb:cc:dd:ee:ff",
  338. ),
  339. (
  340. "prefix-",
  341. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  342. b"prefix-switch/switchbot/aa:01:23:45:67:89/set",
  343. b"ON",
  344. "aa:01:23:45:67:89",
  345. ),
  346. (
  347. "",
  348. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS],
  349. b"switchbot/aa:01:23:45:67:89",
  350. b"ON",
  351. "aa:01:23:45:67:89",
  352. ),
  353. (
  354. "homeassistant/",
  355. _CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
  356. b"homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
  357. b"OPEN",
  358. "aa:01:23:45:67:89",
  359. ),
  360. ],
  361. )
  362. @pytest.mark.parametrize("retry_count", (3, 42))
  363. @pytest.mark.parametrize("fetch_device_info", [True, False])
  364. def test__mqtt_command_callback(
  365. caplog: _pytest.logging.LogCaptureFixture,
  366. topic_prefix: str,
  367. command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  368. topic: bytes,
  369. payload: bytes,
  370. expected_mac_address: str,
  371. retry_count: int,
  372. fetch_device_info: bool,
  373. ) -> None:
  374. ActorMock = _mock_actor_class(command_topic_levels=command_topic_levels)
  375. message = MQTTMessage(topic=topic)
  376. message.payload = payload
  377. callback_userdata = _MQTTCallbackUserdata(
  378. retry_count=retry_count,
  379. device_passwords={},
  380. fetch_device_info=fetch_device_info,
  381. mqtt_topic_prefix=topic_prefix,
  382. )
  383. with unittest.mock.patch.object(
  384. ActorMock, "__init__", return_value=None
  385. ) as init_mock, unittest.mock.patch.object(
  386. ActorMock, "execute_command"
  387. ) as execute_command_mock, caplog.at_level(
  388. logging.DEBUG
  389. ):
  390. ActorMock._mqtt_command_callback("client_dummy", callback_userdata, message)
  391. init_mock.assert_called_once_with(
  392. mac_address=expected_mac_address, retry_count=retry_count, password=None
  393. )
  394. execute_command_mock.assert_called_once_with(
  395. mqtt_client="client_dummy",
  396. mqtt_message_payload=payload,
  397. update_device_info=fetch_device_info,
  398. mqtt_topic_prefix=topic_prefix,
  399. )
  400. assert caplog.record_tuples == [
  401. (
  402. "switchbot_mqtt._actors.base",
  403. logging.DEBUG,
  404. f"received topic={topic.decode()} payload={payload!r}",
  405. )
  406. ]
  407. @pytest.mark.parametrize(
  408. ("mac_address", "expected_password"),
  409. [
  410. ("11:22:33:44:55:66", None),
  411. ("aa:bb:cc:dd:ee:ff", "secret"),
  412. ("11:22:33:dd:ee:ff", "äöü"),
  413. ],
  414. )
  415. def test__mqtt_command_callback_password(
  416. mac_address: str, expected_password: typing.Optional[str]
  417. ) -> None:
  418. ActorMock = _mock_actor_class(
  419. command_topic_levels=("switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS)
  420. )
  421. message = MQTTMessage(topic=b"prefix-switchbot/" + mac_address.encode())
  422. message.payload = b"whatever"
  423. callback_userdata = _MQTTCallbackUserdata(
  424. retry_count=3,
  425. device_passwords={
  426. "11:22:33:44:55:77": "test",
  427. "aa:bb:cc:dd:ee:ff": "secret",
  428. "11:22:33:dd:ee:ff": "äöü",
  429. },
  430. fetch_device_info=True,
  431. mqtt_topic_prefix="prefix-",
  432. )
  433. with unittest.mock.patch.object(
  434. ActorMock, "__init__", return_value=None
  435. ) as init_mock, unittest.mock.patch.object(
  436. ActorMock, "execute_command"
  437. ) as execute_command_mock:
  438. ActorMock._mqtt_command_callback("client_dummy", callback_userdata, message)
  439. init_mock.assert_called_once_with(
  440. mac_address=mac_address, retry_count=3, password=expected_password
  441. )
  442. execute_command_mock.assert_called_once_with(
  443. mqtt_client="client_dummy",
  444. mqtt_message_payload=b"whatever",
  445. update_device_info=True,
  446. mqtt_topic_prefix="prefix-",
  447. )
  448. @pytest.mark.parametrize(
  449. ("topic", "payload"),
  450. [
  451. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
  452. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
  453. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
  454. ],
  455. )
  456. def test__mqtt_command_callback_unexpected_topic(
  457. caplog: _pytest.logging.LogCaptureFixture, topic: bytes, payload: bytes
  458. ) -> None:
  459. ActorMock = _mock_actor_class(
  460. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  461. )
  462. message = MQTTMessage(topic=topic)
  463. message.payload = payload
  464. with unittest.mock.patch.object(
  465. ActorMock, "__init__", return_value=None
  466. ) as init_mock, unittest.mock.patch.object(
  467. ActorMock, "execute_command"
  468. ) as execute_command_mock, caplog.at_level(
  469. logging.DEBUG
  470. ):
  471. ActorMock._mqtt_command_callback(
  472. "client_dummy",
  473. _MQTTCallbackUserdata(
  474. retry_count=3, device_passwords={}, fetch_device_info=True
  475. ),
  476. message,
  477. )
  478. init_mock.assert_not_called()
  479. execute_command_mock.assert_not_called()
  480. assert caplog.record_tuples == [
  481. (
  482. "switchbot_mqtt._actors.base",
  483. logging.DEBUG,
  484. f"received topic={topic.decode()} payload={payload!r}",
  485. ),
  486. (
  487. "switchbot_mqtt._actors.base",
  488. logging.WARNING,
  489. f"unexpected topic {topic.decode()}",
  490. ),
  491. ]
  492. @pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
  493. def test__mqtt_command_callback_invalid_mac_address(
  494. caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
  495. ) -> None:
  496. ActorMock = _mock_actor_class(
  497. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  498. )
  499. topic = f"homeassistant/switch/switchbot/{mac_address}/set".encode()
  500. message = MQTTMessage(topic=topic)
  501. message.payload = payload
  502. with unittest.mock.patch.object(
  503. ActorMock, "__init__", return_value=None
  504. ) as init_mock, unittest.mock.patch.object(
  505. ActorMock, "execute_command"
  506. ) as execute_command_mock, caplog.at_level(
  507. logging.DEBUG
  508. ):
  509. ActorMock._mqtt_command_callback(
  510. "client_dummy",
  511. _MQTTCallbackUserdata(
  512. retry_count=3, device_passwords={}, fetch_device_info=True
  513. ),
  514. message,
  515. )
  516. init_mock.assert_not_called()
  517. execute_command_mock.assert_not_called()
  518. assert caplog.record_tuples == [
  519. (
  520. "switchbot_mqtt._actors.base",
  521. logging.DEBUG,
  522. f"received topic={topic.decode()} payload={payload!r}",
  523. ),
  524. (
  525. "switchbot_mqtt._actors.base",
  526. logging.WARNING,
  527. f"invalid mac address {mac_address}",
  528. ),
  529. ]
  530. @pytest.mark.parametrize(
  531. ("topic", "payload"),
  532. [(b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
  533. )
  534. def test__mqtt_command_callback_ignore_retained(
  535. caplog: _pytest.logging.LogCaptureFixture, topic: bytes, payload: bytes
  536. ) -> None:
  537. ActorMock = _mock_actor_class(
  538. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  539. )
  540. message = MQTTMessage(topic=topic)
  541. message.payload = payload
  542. message.retain = True
  543. with unittest.mock.patch.object(
  544. ActorMock, "__init__", return_value=None
  545. ) as init_mock, unittest.mock.patch.object(
  546. ActorMock, "execute_command"
  547. ) as execute_command_mock, caplog.at_level(
  548. logging.DEBUG
  549. ):
  550. ActorMock._mqtt_command_callback(
  551. "client_dummy",
  552. _MQTTCallbackUserdata(
  553. retry_count=4, device_passwords={}, fetch_device_info=True
  554. ),
  555. message,
  556. )
  557. init_mock.assert_not_called()
  558. execute_command_mock.assert_not_called()
  559. assert caplog.record_tuples == [
  560. (
  561. "switchbot_mqtt._actors.base",
  562. logging.DEBUG,
  563. f"received topic={topic.decode()} payload={payload!r}",
  564. ),
  565. ("switchbot_mqtt._actors.base", logging.INFO, "ignoring retained message"),
  566. ]
  567. @pytest.mark.parametrize(
  568. ("topic_prefix", "state_topic_levels", "mac_address", "expected_topic"),
  569. # https://www.home-assistant.io/docs/mqtt/discovery/#switches
  570. [
  571. (
  572. "homeassistant/",
  573. _ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
  574. "aa:bb:cc:dd:ee:ff",
  575. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
  576. ),
  577. (
  578. "",
  579. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
  580. "aa:bb:cc:dd:ee:gg",
  581. "switchbot/aa:bb:cc:dd:ee:gg/state",
  582. ),
  583. ],
  584. )
  585. @pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
  586. @pytest.mark.parametrize("return_code", [MQTT_ERR_SUCCESS, MQTT_ERR_QUEUE_SIZE])
  587. def test__report_state(
  588. caplog: _pytest.logging.LogCaptureFixture,
  589. topic_prefix: str,
  590. state_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  591. mac_address: str,
  592. expected_topic: str,
  593. state: bytes,
  594. return_code: int,
  595. ) -> None:
  596. # pylint: disable=too-many-arguments
  597. class _ActorMock(_MQTTControlledActor):
  598. MQTT_STATE_TOPIC_LEVELS = state_topic_levels
  599. def __init__(
  600. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  601. ) -> None:
  602. super().__init__(
  603. mac_address=mac_address, retry_count=retry_count, password=password
  604. )
  605. def execute_command(
  606. self,
  607. *,
  608. mqtt_message_payload: bytes,
  609. mqtt_client: Client,
  610. update_device_info: bool,
  611. mqtt_topic_prefix: str,
  612. ) -> None:
  613. pass
  614. def _get_device(self) -> None:
  615. return None
  616. mqtt_client_mock = unittest.mock.MagicMock()
  617. mqtt_client_mock.publish.return_value.rc = return_code
  618. with caplog.at_level(logging.DEBUG):
  619. actor = _ActorMock(mac_address=mac_address, retry_count=3, password=None)
  620. actor.report_state(
  621. state=state,
  622. mqtt_client=mqtt_client_mock,
  623. mqtt_topic_prefix=topic_prefix,
  624. )
  625. mqtt_client_mock.publish.assert_called_once_with(
  626. topic=expected_topic, payload=state, retain=True
  627. )
  628. assert caplog.record_tuples[0] == (
  629. "switchbot_mqtt._actors.base",
  630. logging.DEBUG,
  631. f"publishing topic={expected_topic} payload={state!r}",
  632. )
  633. if return_code == MQTT_ERR_SUCCESS:
  634. assert not caplog.records[1:]
  635. else:
  636. assert caplog.record_tuples[1:] == [
  637. (
  638. "switchbot_mqtt._actors.base",
  639. logging.ERROR,
  640. f"Failed to publish MQTT message on topic {expected_topic} (rc={return_code})",
  641. )
  642. ]