1
0

test_mqtt.py 25 KB

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