test_mqtt.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  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}",
  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(mqtt_disable_tls: bool) -> None:
  124. with unittest.mock.patch("paho.mqtt.client.Client") as mqtt_client_mock:
  125. switchbot_mqtt._run(
  126. mqtt_host="mqtt.local",
  127. mqtt_port=1234,
  128. mqtt_username=None,
  129. mqtt_password=None,
  130. mqtt_disable_tls=mqtt_disable_tls,
  131. retry_count=21,
  132. device_passwords={},
  133. fetch_device_info=True,
  134. )
  135. if mqtt_disable_tls:
  136. mqtt_client_mock().tls_set.assert_not_called()
  137. else:
  138. mqtt_client_mock().tls_set.assert_called_once_with(ca_certs=None)
  139. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  140. @pytest.mark.parametrize("mqtt_port", [1833])
  141. @pytest.mark.parametrize("mqtt_username", ["me"])
  142. @pytest.mark.parametrize("mqtt_password", [None, "secret"])
  143. def test__run_authentication(
  144. mqtt_host: str,
  145. mqtt_port: int,
  146. mqtt_username: str,
  147. mqtt_password: typing.Optional[str],
  148. ) -> None:
  149. with unittest.mock.patch("paho.mqtt.client.Client") as mqtt_client_mock:
  150. switchbot_mqtt._run(
  151. mqtt_host=mqtt_host,
  152. mqtt_port=mqtt_port,
  153. mqtt_username=mqtt_username,
  154. mqtt_password=mqtt_password,
  155. mqtt_disable_tls=True,
  156. retry_count=7,
  157. device_passwords={},
  158. fetch_device_info=True,
  159. )
  160. mqtt_client_mock.assert_called_once_with(
  161. userdata=_MQTTCallbackUserdata(
  162. retry_count=7, device_passwords={}, fetch_device_info=True
  163. )
  164. )
  165. mqtt_client_mock().username_pw_set.assert_called_once_with(
  166. username=mqtt_username, password=mqtt_password
  167. )
  168. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  169. @pytest.mark.parametrize("mqtt_port", [1833])
  170. @pytest.mark.parametrize("mqtt_password", ["secret"])
  171. def test__run_authentication_missing_username(
  172. mqtt_host: str, mqtt_port: int, mqtt_password: str
  173. ) -> None:
  174. with unittest.mock.patch("paho.mqtt.client.Client"):
  175. with pytest.raises(ValueError):
  176. switchbot_mqtt._run(
  177. mqtt_host=mqtt_host,
  178. mqtt_port=mqtt_port,
  179. mqtt_username=None,
  180. mqtt_password=mqtt_password,
  181. mqtt_disable_tls=True,
  182. retry_count=3,
  183. device_passwords={},
  184. fetch_device_info=True,
  185. )
  186. def _mock_actor_class(
  187. *,
  188. command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
  189. request_info_levels: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented,
  190. ) -> typing.Type:
  191. class _ActorMock(_MQTTControlledActor):
  192. MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
  193. _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS = request_info_levels
  194. def __init__(
  195. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  196. ) -> None:
  197. super().__init__(
  198. mac_address=mac_address, retry_count=retry_count, password=password
  199. )
  200. def execute_command(
  201. self,
  202. mqtt_message_payload: bytes,
  203. mqtt_client: Client,
  204. update_device_info: bool,
  205. ) -> None:
  206. pass
  207. def _get_device(self) -> None:
  208. return None
  209. return _ActorMock
  210. @pytest.mark.parametrize(
  211. ("topic_levels", "topic", "expected_mac_address"),
  212. [
  213. (
  214. switchbot_mqtt._actors._ButtonAutomator._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  215. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/request-device-info",
  216. "aa:bb:cc:dd:ee:ff",
  217. ),
  218. ],
  219. )
  220. @pytest.mark.parametrize("payload", [b"", b"whatever"])
  221. def test__mqtt_update_device_info_callback(
  222. caplog: _pytest.logging.LogCaptureFixture,
  223. topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  224. topic: bytes,
  225. expected_mac_address: str,
  226. payload: bytes,
  227. ) -> None:
  228. ActorMock = _mock_actor_class(request_info_levels=topic_levels)
  229. message = MQTTMessage(topic=topic)
  230. message.payload = payload
  231. callback_userdata = _MQTTCallbackUserdata(
  232. retry_count=21, # tested in test__mqtt_command_callback
  233. device_passwords={},
  234. fetch_device_info=True,
  235. )
  236. with unittest.mock.patch.object(
  237. ActorMock, "__init__", return_value=None
  238. ) as init_mock, unittest.mock.patch.object(
  239. ActorMock, "_update_and_report_device_info"
  240. ) as update_mock, caplog.at_level(
  241. logging.DEBUG
  242. ):
  243. ActorMock._mqtt_update_device_info_callback(
  244. "client_dummy", callback_userdata, message
  245. )
  246. init_mock.assert_called_once_with(
  247. mac_address=expected_mac_address, retry_count=21, password=None
  248. )
  249. update_mock.assert_called_once_with("client_dummy")
  250. assert caplog.record_tuples == [
  251. (
  252. "switchbot_mqtt._actors._base",
  253. logging.DEBUG,
  254. f"received topic={topic.decode()} payload={payload!r}",
  255. )
  256. ]
  257. def test__mqtt_update_device_info_callback_ignore_retained(
  258. caplog: _pytest.logging.LogCaptureFixture,
  259. ) -> None:
  260. ActorMock = _mock_actor_class(
  261. request_info_levels=(_MQTTTopicPlaceholder.MAC_ADDRESS, "request")
  262. )
  263. message = MQTTMessage(topic=b"aa:bb:cc:dd:ee:ff/request")
  264. message.payload = b""
  265. message.retain = True
  266. with unittest.mock.patch.object(
  267. ActorMock, "__init__", return_value=None
  268. ) as init_mock, unittest.mock.patch.object(
  269. ActorMock, "execute_command"
  270. ) as execute_command_mock, caplog.at_level(
  271. logging.DEBUG
  272. ):
  273. ActorMock._mqtt_update_device_info_callback(
  274. "client_dummy",
  275. _MQTTCallbackUserdata(
  276. retry_count=21, device_passwords={}, fetch_device_info=True
  277. ),
  278. message,
  279. )
  280. init_mock.assert_not_called()
  281. execute_command_mock.assert_not_called()
  282. assert caplog.record_tuples == [
  283. (
  284. "switchbot_mqtt._actors._base",
  285. logging.DEBUG,
  286. "received topic=aa:bb:cc:dd:ee:ff/request payload=b''",
  287. ),
  288. ("switchbot_mqtt._actors._base", logging.INFO, "ignoring retained message"),
  289. ]
  290. @pytest.mark.parametrize(
  291. ("command_topic_levels", "topic", "payload", "expected_mac_address"),
  292. [
  293. (
  294. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  295. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  296. b"ON",
  297. "aa:bb:cc:dd:ee:ff",
  298. ),
  299. (
  300. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  301. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  302. b"OFF",
  303. "aa:bb:cc:dd:ee:ff",
  304. ),
  305. (
  306. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  307. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  308. b"on",
  309. "aa:bb:cc:dd:ee:ff",
  310. ),
  311. (
  312. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  313. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  314. b"off",
  315. "aa:bb:cc:dd:ee:ff",
  316. ),
  317. (
  318. _ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  319. b"homeassistant/switch/switchbot/aa:01:23:45:67:89/set",
  320. b"ON",
  321. "aa:01:23:45:67:89",
  322. ),
  323. (
  324. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS],
  325. b"switchbot/aa:01:23:45:67:89",
  326. b"ON",
  327. "aa:01:23:45:67:89",
  328. ),
  329. (
  330. _CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
  331. b"homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
  332. b"OPEN",
  333. "aa:01:23:45:67:89",
  334. ),
  335. ],
  336. )
  337. @pytest.mark.parametrize("retry_count", (3, 42))
  338. @pytest.mark.parametrize("fetch_device_info", [True, False])
  339. def test__mqtt_command_callback(
  340. caplog: _pytest.logging.LogCaptureFixture,
  341. command_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  342. topic: bytes,
  343. payload: bytes,
  344. expected_mac_address: str,
  345. retry_count: int,
  346. fetch_device_info: bool,
  347. ) -> None:
  348. ActorMock = _mock_actor_class(command_topic_levels=command_topic_levels)
  349. message = MQTTMessage(topic=topic)
  350. message.payload = payload
  351. callback_userdata = _MQTTCallbackUserdata(
  352. retry_count=retry_count,
  353. device_passwords={},
  354. fetch_device_info=fetch_device_info,
  355. )
  356. with unittest.mock.patch.object(
  357. ActorMock, "__init__", return_value=None
  358. ) as init_mock, unittest.mock.patch.object(
  359. ActorMock, "execute_command"
  360. ) as execute_command_mock, caplog.at_level(
  361. logging.DEBUG
  362. ):
  363. ActorMock._mqtt_command_callback("client_dummy", callback_userdata, message)
  364. init_mock.assert_called_once_with(
  365. mac_address=expected_mac_address, retry_count=retry_count, password=None
  366. )
  367. execute_command_mock.assert_called_once_with(
  368. mqtt_client="client_dummy",
  369. mqtt_message_payload=payload,
  370. update_device_info=fetch_device_info,
  371. )
  372. assert caplog.record_tuples == [
  373. (
  374. "switchbot_mqtt._actors._base",
  375. logging.DEBUG,
  376. f"received topic={topic.decode()} payload={payload!r}",
  377. )
  378. ]
  379. @pytest.mark.parametrize(
  380. ("mac_address", "expected_password"),
  381. [
  382. ("11:22:33:44:55:66", None),
  383. ("aa:bb:cc:dd:ee:ff", "secret"),
  384. ("11:22:33:dd:ee:ff", "äöü"),
  385. ],
  386. )
  387. def test__mqtt_command_callback_password(
  388. mac_address: str, expected_password: typing.Optional[str]
  389. ) -> None:
  390. ActorMock = _mock_actor_class(
  391. command_topic_levels=("switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS)
  392. )
  393. message = MQTTMessage(topic=b"switchbot/" + mac_address.encode())
  394. message.payload = b"whatever"
  395. callback_userdata = _MQTTCallbackUserdata(
  396. retry_count=3,
  397. device_passwords={
  398. "11:22:33:44:55:77": "test",
  399. "aa:bb:cc:dd:ee:ff": "secret",
  400. "11:22:33:dd:ee:ff": "äöü",
  401. },
  402. fetch_device_info=True,
  403. )
  404. with unittest.mock.patch.object(
  405. ActorMock, "__init__", return_value=None
  406. ) as init_mock, unittest.mock.patch.object(
  407. ActorMock, "execute_command"
  408. ) as execute_command_mock:
  409. ActorMock._mqtt_command_callback("client_dummy", callback_userdata, message)
  410. init_mock.assert_called_once_with(
  411. mac_address=mac_address, retry_count=3, password=expected_password
  412. )
  413. execute_command_mock.assert_called_once_with(
  414. mqtt_client="client_dummy",
  415. mqtt_message_payload=b"whatever",
  416. update_device_info=True,
  417. )
  418. @pytest.mark.parametrize(
  419. ("topic", "payload"),
  420. [
  421. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
  422. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
  423. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
  424. ],
  425. )
  426. def test__mqtt_command_callback_unexpected_topic(
  427. caplog: _pytest.logging.LogCaptureFixture, topic: bytes, payload: bytes
  428. ) -> None:
  429. ActorMock = _mock_actor_class(
  430. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  431. )
  432. message = MQTTMessage(topic=topic)
  433. message.payload = payload
  434. with unittest.mock.patch.object(
  435. ActorMock, "__init__", return_value=None
  436. ) as init_mock, unittest.mock.patch.object(
  437. ActorMock, "execute_command"
  438. ) as execute_command_mock, caplog.at_level(
  439. logging.DEBUG
  440. ):
  441. ActorMock._mqtt_command_callback(
  442. "client_dummy",
  443. _MQTTCallbackUserdata(
  444. retry_count=3, device_passwords={}, fetch_device_info=True
  445. ),
  446. message,
  447. )
  448. init_mock.assert_not_called()
  449. execute_command_mock.assert_not_called()
  450. assert caplog.record_tuples == [
  451. (
  452. "switchbot_mqtt._actors._base",
  453. logging.DEBUG,
  454. f"received topic={topic.decode()} payload={payload!r}",
  455. ),
  456. (
  457. "switchbot_mqtt._actors._base",
  458. logging.WARNING,
  459. f"unexpected topic {topic.decode()}",
  460. ),
  461. ]
  462. @pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
  463. def test__mqtt_command_callback_invalid_mac_address(
  464. caplog: _pytest.logging.LogCaptureFixture, mac_address: str, payload: bytes
  465. ) -> None:
  466. ActorMock = _mock_actor_class(
  467. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  468. )
  469. topic = f"homeassistant/switch/switchbot/{mac_address}/set".encode()
  470. message = MQTTMessage(topic=topic)
  471. message.payload = payload
  472. with unittest.mock.patch.object(
  473. ActorMock, "__init__", return_value=None
  474. ) as init_mock, unittest.mock.patch.object(
  475. ActorMock, "execute_command"
  476. ) as execute_command_mock, caplog.at_level(
  477. logging.DEBUG
  478. ):
  479. ActorMock._mqtt_command_callback(
  480. "client_dummy",
  481. _MQTTCallbackUserdata(
  482. retry_count=3, device_passwords={}, fetch_device_info=True
  483. ),
  484. message,
  485. )
  486. init_mock.assert_not_called()
  487. execute_command_mock.assert_not_called()
  488. assert caplog.record_tuples == [
  489. (
  490. "switchbot_mqtt._actors._base",
  491. logging.DEBUG,
  492. f"received topic={topic.decode()} payload={payload!r}",
  493. ),
  494. (
  495. "switchbot_mqtt._actors._base",
  496. logging.WARNING,
  497. f"invalid mac address {mac_address}",
  498. ),
  499. ]
  500. @pytest.mark.parametrize(
  501. ("topic", "payload"),
  502. [(b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
  503. )
  504. def test__mqtt_command_callback_ignore_retained(
  505. caplog: _pytest.logging.LogCaptureFixture, topic: bytes, payload: bytes
  506. ) -> None:
  507. ActorMock = _mock_actor_class(
  508. command_topic_levels=_ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  509. )
  510. message = MQTTMessage(topic=topic)
  511. message.payload = payload
  512. message.retain = True
  513. with unittest.mock.patch.object(
  514. ActorMock, "__init__", return_value=None
  515. ) as init_mock, unittest.mock.patch.object(
  516. ActorMock, "execute_command"
  517. ) as execute_command_mock, caplog.at_level(
  518. logging.DEBUG
  519. ):
  520. ActorMock._mqtt_command_callback(
  521. "client_dummy",
  522. _MQTTCallbackUserdata(
  523. retry_count=4, device_passwords={}, fetch_device_info=True
  524. ),
  525. message,
  526. )
  527. init_mock.assert_not_called()
  528. execute_command_mock.assert_not_called()
  529. assert caplog.record_tuples == [
  530. (
  531. "switchbot_mqtt._actors._base",
  532. logging.DEBUG,
  533. f"received topic={topic.decode()} payload={payload!r}",
  534. ),
  535. ("switchbot_mqtt._actors._base", logging.INFO, "ignoring retained message"),
  536. ]
  537. @pytest.mark.parametrize(
  538. ("state_topic_levels", "mac_address", "expected_topic"),
  539. # https://www.home-assistant.io/docs/mqtt/discovery/#switches
  540. [
  541. (
  542. _ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
  543. "aa:bb:cc:dd:ee:ff",
  544. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
  545. ),
  546. (
  547. ["switchbot", _MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
  548. "aa:bb:cc:dd:ee:gg",
  549. "switchbot/aa:bb:cc:dd:ee:gg/state",
  550. ),
  551. ],
  552. )
  553. @pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
  554. @pytest.mark.parametrize("return_code", [MQTT_ERR_SUCCESS, MQTT_ERR_QUEUE_SIZE])
  555. def test__report_state(
  556. caplog: _pytest.logging.LogCaptureFixture,
  557. state_topic_levels: typing.Tuple[_MQTTTopicLevel, ...],
  558. mac_address: str,
  559. expected_topic: str,
  560. state: bytes,
  561. return_code: int,
  562. ) -> None:
  563. # pylint: disable=too-many-arguments
  564. class _ActorMock(_MQTTControlledActor):
  565. MQTT_STATE_TOPIC_LEVELS = state_topic_levels
  566. def __init__(
  567. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  568. ) -> None:
  569. super().__init__(
  570. mac_address=mac_address, retry_count=retry_count, password=password
  571. )
  572. def execute_command(
  573. self,
  574. mqtt_message_payload: bytes,
  575. mqtt_client: Client,
  576. update_device_info: bool,
  577. ) -> None:
  578. pass
  579. def _get_device(self) -> None:
  580. return None
  581. mqtt_client_mock = unittest.mock.MagicMock()
  582. mqtt_client_mock.publish.return_value.rc = return_code
  583. with caplog.at_level(logging.DEBUG):
  584. actor = _ActorMock(mac_address=mac_address, retry_count=3, password=None)
  585. actor.report_state(state=state, mqtt_client=mqtt_client_mock)
  586. mqtt_client_mock.publish.assert_called_once_with(
  587. topic=expected_topic, payload=state, retain=True
  588. )
  589. assert caplog.record_tuples[0] == (
  590. "switchbot_mqtt._actors._base",
  591. logging.DEBUG,
  592. f"publishing topic={expected_topic} payload={state!r}",
  593. )
  594. if return_code == MQTT_ERR_SUCCESS:
  595. assert not caplog.records[1:]
  596. else:
  597. assert caplog.record_tuples[1:] == [
  598. (
  599. "switchbot_mqtt._actors._base",
  600. logging.ERROR,
  601. f"Failed to publish MQTT message on topic {expected_topic} (rc={return_code})",
  602. )
  603. ]