test_mqtt.py 26 KB

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