test_mqtt.py 23 KB

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