test_mqtt.py 21 KB

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