test_mqtt.py 26 KB

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