test_mqtt.py 26 KB

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