test_mqtt.py 24 KB

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