test_mqtt.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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
  22. from paho.mqtt.client import MQTT_ERR_QUEUE_SIZE, MQTT_ERR_SUCCESS, MQTTMessage, Client
  23. import switchbot_mqtt
  24. # pylint: disable=protected-access
  25. # pylint: disable=too-many-arguments; these are tests, no API
  26. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  27. @pytest.mark.parametrize("mqtt_port", [1833])
  28. @pytest.mark.parametrize("retry_count", [3, 21])
  29. @pytest.mark.parametrize(
  30. "device_passwords",
  31. [{}, {"11:22:33:44:55:66": "password", "aa:bb:cc:dd:ee:ff": "secret"}],
  32. )
  33. @pytest.mark.parametrize("fetch_device_info", [True, False])
  34. def test__run(
  35. caplog, mqtt_host, mqtt_port, retry_count, device_passwords, fetch_device_info
  36. ):
  37. with unittest.mock.patch(
  38. "paho.mqtt.client.Client"
  39. ) as mqtt_client_mock, caplog.at_level(logging.DEBUG):
  40. switchbot_mqtt._run(
  41. mqtt_host=mqtt_host,
  42. mqtt_port=mqtt_port,
  43. mqtt_username=None,
  44. mqtt_password=None,
  45. retry_count=retry_count,
  46. device_passwords=device_passwords,
  47. fetch_device_info=fetch_device_info,
  48. )
  49. mqtt_client_mock.assert_called_once_with(
  50. userdata=switchbot_mqtt._MQTTCallbackUserdata(
  51. retry_count=retry_count,
  52. device_passwords=device_passwords,
  53. fetch_device_info=fetch_device_info,
  54. )
  55. )
  56. assert not mqtt_client_mock().username_pw_set.called
  57. mqtt_client_mock().connect.assert_called_once_with(host=mqtt_host, port=mqtt_port)
  58. mqtt_client_mock().socket().getpeername.return_value = (mqtt_host, mqtt_port)
  59. with caplog.at_level(logging.DEBUG):
  60. mqtt_client_mock().on_connect(mqtt_client_mock(), None, {}, 0)
  61. assert mqtt_client_mock().subscribe.call_args_list == [
  62. unittest.mock.call("homeassistant/switch/switchbot/+/set"),
  63. unittest.mock.call("homeassistant/cover/switchbot-curtain/+/set"),
  64. ]
  65. assert mqtt_client_mock().message_callback_add.call_args_list == [
  66. unittest.mock.call(
  67. sub="homeassistant/switch/switchbot/+/set",
  68. callback=switchbot_mqtt._ButtonAutomator._mqtt_command_callback,
  69. ),
  70. unittest.mock.call(
  71. sub="homeassistant/cover/switchbot-curtain/+/set",
  72. callback=switchbot_mqtt._CurtainMotor._mqtt_command_callback,
  73. ),
  74. ]
  75. mqtt_client_mock().loop_forever.assert_called_once_with()
  76. assert caplog.record_tuples == [
  77. (
  78. "switchbot_mqtt",
  79. logging.INFO,
  80. f"connecting to MQTT broker {mqtt_host}:{mqtt_port}",
  81. ),
  82. (
  83. "switchbot_mqtt",
  84. logging.DEBUG,
  85. f"connected to MQTT broker {mqtt_host}:{mqtt_port}",
  86. ),
  87. (
  88. "switchbot_mqtt",
  89. logging.INFO,
  90. "subscribing to MQTT topic 'homeassistant/switch/switchbot/+/set'",
  91. ),
  92. (
  93. "switchbot_mqtt",
  94. logging.INFO,
  95. "subscribing to MQTT topic 'homeassistant/cover/switchbot-curtain/+/set'",
  96. ),
  97. ]
  98. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  99. @pytest.mark.parametrize("mqtt_port", [1833])
  100. @pytest.mark.parametrize("mqtt_username", ["me"])
  101. @pytest.mark.parametrize("mqtt_password", [None, "secret"])
  102. def test__run_authentication(mqtt_host, mqtt_port, mqtt_username, mqtt_password):
  103. with unittest.mock.patch("paho.mqtt.client.Client") as mqtt_client_mock:
  104. switchbot_mqtt._run(
  105. mqtt_host=mqtt_host,
  106. mqtt_port=mqtt_port,
  107. mqtt_username=mqtt_username,
  108. mqtt_password=mqtt_password,
  109. retry_count=7,
  110. device_passwords={},
  111. fetch_device_info=True,
  112. )
  113. mqtt_client_mock.assert_called_once_with(
  114. userdata=switchbot_mqtt._MQTTCallbackUserdata(
  115. retry_count=7, device_passwords={}, fetch_device_info=True
  116. )
  117. )
  118. mqtt_client_mock().username_pw_set.assert_called_once_with(
  119. username=mqtt_username, password=mqtt_password
  120. )
  121. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  122. @pytest.mark.parametrize("mqtt_port", [1833])
  123. @pytest.mark.parametrize("mqtt_password", ["secret"])
  124. def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_password):
  125. with unittest.mock.patch("paho.mqtt.client.Client"):
  126. with pytest.raises(ValueError):
  127. switchbot_mqtt._run(
  128. mqtt_host=mqtt_host,
  129. mqtt_port=mqtt_port,
  130. mqtt_username=None,
  131. mqtt_password=mqtt_password,
  132. retry_count=3,
  133. device_passwords={},
  134. fetch_device_info=True,
  135. )
  136. def _mock_actor_class(
  137. command_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
  138. ) -> typing.Type:
  139. class _ActorMock(switchbot_mqtt._MQTTControlledActor):
  140. MQTT_COMMAND_TOPIC_LEVELS = command_topic_levels
  141. def __init__(self, mac_address, retry_count, password):
  142. super().__init__(
  143. mac_address=mac_address, retry_count=retry_count, password=password
  144. )
  145. def execute_command(
  146. self,
  147. mqtt_message_payload: bytes,
  148. mqtt_client: Client,
  149. update_device_info: bool,
  150. ):
  151. pass
  152. def _get_device(self):
  153. return None
  154. return _ActorMock
  155. @pytest.mark.parametrize(
  156. ("command_topic_levels", "topic", "payload", "expected_mac_address"),
  157. [
  158. (
  159. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  160. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  161. b"ON",
  162. "aa:bb:cc:dd:ee:ff",
  163. ),
  164. (
  165. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  166. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  167. b"OFF",
  168. "aa:bb:cc:dd:ee:ff",
  169. ),
  170. (
  171. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  172. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  173. b"on",
  174. "aa:bb:cc:dd:ee:ff",
  175. ),
  176. (
  177. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  178. b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set",
  179. b"off",
  180. "aa:bb:cc:dd:ee:ff",
  181. ),
  182. (
  183. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS,
  184. b"homeassistant/switch/switchbot/aa:01:23:45:67:89/set",
  185. b"ON",
  186. "aa:01:23:45:67:89",
  187. ),
  188. (
  189. ["switchbot", switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS],
  190. b"switchbot/aa:01:23:45:67:89",
  191. b"ON",
  192. "aa:01:23:45:67:89",
  193. ),
  194. (
  195. switchbot_mqtt._CurtainMotor.MQTT_COMMAND_TOPIC_LEVELS,
  196. b"homeassistant/cover/switchbot-curtain/aa:01:23:45:67:89/set",
  197. b"OPEN",
  198. "aa:01:23:45:67:89",
  199. ),
  200. ],
  201. )
  202. @pytest.mark.parametrize("retry_count", (3, 42))
  203. @pytest.mark.parametrize("fetch_device_info", [True, False])
  204. def test__mqtt_command_callback(
  205. caplog,
  206. command_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
  207. topic: bytes,
  208. payload: bytes,
  209. expected_mac_address: str,
  210. retry_count: int,
  211. fetch_device_info: bool,
  212. ):
  213. ActorMock = _mock_actor_class(command_topic_levels)
  214. message = MQTTMessage(topic=topic)
  215. message.payload = payload
  216. callback_userdata = switchbot_mqtt._MQTTCallbackUserdata(
  217. retry_count=retry_count,
  218. device_passwords={},
  219. fetch_device_info=fetch_device_info,
  220. )
  221. with unittest.mock.patch.object(
  222. ActorMock, "__init__", return_value=None
  223. ) as init_mock, unittest.mock.patch.object(
  224. ActorMock, "execute_command"
  225. ) as execute_command_mock, caplog.at_level(
  226. logging.DEBUG
  227. ):
  228. ActorMock._mqtt_command_callback("client_dummy", callback_userdata, message)
  229. init_mock.assert_called_once_with(
  230. mac_address=expected_mac_address, retry_count=retry_count, password=None
  231. )
  232. execute_command_mock.assert_called_once_with(
  233. mqtt_client="client_dummy",
  234. mqtt_message_payload=payload,
  235. update_device_info=fetch_device_info,
  236. )
  237. assert caplog.record_tuples == [
  238. (
  239. "switchbot_mqtt",
  240. logging.DEBUG,
  241. f"received topic={topic.decode()} payload={payload!r}",
  242. )
  243. ]
  244. @pytest.mark.parametrize(
  245. ("mac_address", "expected_password"),
  246. [
  247. ("11:22:33:44:55:66", None),
  248. ("aa:bb:cc:dd:ee:ff", "secret"),
  249. ("11:22:33:dd:ee:ff", "äöü"),
  250. ],
  251. )
  252. def test__mqtt_command_callback_password(mac_address, expected_password):
  253. ActorMock = _mock_actor_class(
  254. [
  255. "switchbot",
  256. switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS,
  257. ]
  258. )
  259. message = MQTTMessage(topic=b"switchbot/" + mac_address.encode())
  260. message.payload = b"whatever"
  261. callback_userdata = switchbot_mqtt._MQTTCallbackUserdata(
  262. retry_count=3,
  263. device_passwords={
  264. "11:22:33:44:55:77": "test",
  265. "aa:bb:cc:dd:ee:ff": "secret",
  266. "11:22:33:dd:ee:ff": "äöü",
  267. },
  268. fetch_device_info=True,
  269. )
  270. with unittest.mock.patch.object(
  271. ActorMock, "__init__", return_value=None
  272. ) as init_mock, unittest.mock.patch.object(
  273. ActorMock, "execute_command"
  274. ) as execute_command_mock:
  275. ActorMock._mqtt_command_callback("client_dummy", callback_userdata, message)
  276. init_mock.assert_called_once_with(
  277. mac_address=mac_address, retry_count=3, password=expected_password
  278. )
  279. execute_command_mock.assert_called_once_with(
  280. mqtt_client="client_dummy",
  281. mqtt_message_payload=b"whatever",
  282. update_device_info=True,
  283. )
  284. @pytest.mark.parametrize(
  285. ("topic", "payload"),
  286. [
  287. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff", b"on"),
  288. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/change", b"ON"),
  289. (b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set/suffix", b"ON"),
  290. ],
  291. )
  292. def test__mqtt_command_callback_unexpected_topic(caplog, topic: bytes, payload: bytes):
  293. ActorMock = _mock_actor_class(
  294. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  295. )
  296. message = MQTTMessage(topic=topic)
  297. message.payload = payload
  298. with unittest.mock.patch.object(
  299. ActorMock, "__init__", return_value=None
  300. ) as init_mock, unittest.mock.patch.object(
  301. ActorMock, "execute_command"
  302. ) as execute_command_mock, caplog.at_level(
  303. logging.DEBUG
  304. ):
  305. ActorMock._mqtt_command_callback(
  306. "client_dummy",
  307. switchbot_mqtt._MQTTCallbackUserdata(
  308. retry_count=3, device_passwords={}, fetch_device_info=True
  309. ),
  310. message,
  311. )
  312. init_mock.assert_not_called()
  313. execute_command_mock.assert_not_called()
  314. assert caplog.record_tuples == [
  315. (
  316. "switchbot_mqtt",
  317. logging.DEBUG,
  318. f"received topic={topic.decode()} payload={payload!r}",
  319. ),
  320. (
  321. "switchbot_mqtt",
  322. logging.WARNING,
  323. f"unexpected topic {topic.decode()}",
  324. ),
  325. ]
  326. @pytest.mark.parametrize(("mac_address", "payload"), [("aa:01:23:4E:RR:OR", b"ON")])
  327. def test__mqtt_command_callback_invalid_mac_address(
  328. caplog, mac_address: str, payload: bytes
  329. ):
  330. ActorMock = _mock_actor_class(
  331. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  332. )
  333. topic = f"homeassistant/switch/switchbot/{mac_address}/set".encode()
  334. message = MQTTMessage(topic=topic)
  335. message.payload = payload
  336. with unittest.mock.patch.object(
  337. ActorMock, "__init__", return_value=None
  338. ) as init_mock, unittest.mock.patch.object(
  339. ActorMock, "execute_command"
  340. ) as execute_command_mock, caplog.at_level(
  341. logging.DEBUG
  342. ):
  343. ActorMock._mqtt_command_callback(
  344. "client_dummy",
  345. switchbot_mqtt._MQTTCallbackUserdata(
  346. retry_count=3, device_passwords={}, fetch_device_info=True
  347. ),
  348. message,
  349. )
  350. init_mock.assert_not_called()
  351. execute_command_mock.assert_not_called()
  352. assert caplog.record_tuples == [
  353. (
  354. "switchbot_mqtt",
  355. logging.DEBUG,
  356. f"received topic={topic.decode()} payload={payload!r}",
  357. ),
  358. (
  359. "switchbot_mqtt",
  360. logging.WARNING,
  361. f"invalid mac address {mac_address}",
  362. ),
  363. ]
  364. @pytest.mark.parametrize(
  365. ("topic", "payload"),
  366. [(b"homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/set", b"ON")],
  367. )
  368. def test__mqtt_command_callback_ignore_retained(caplog, topic: bytes, payload: bytes):
  369. ActorMock = _mock_actor_class(
  370. switchbot_mqtt._ButtonAutomator.MQTT_COMMAND_TOPIC_LEVELS
  371. )
  372. message = MQTTMessage(topic=topic)
  373. message.payload = payload
  374. message.retain = True
  375. with unittest.mock.patch.object(
  376. ActorMock, "__init__", return_value=None
  377. ) as init_mock, unittest.mock.patch.object(
  378. ActorMock, "execute_command"
  379. ) as execute_command_mock, caplog.at_level(
  380. logging.DEBUG
  381. ):
  382. ActorMock._mqtt_command_callback(
  383. "client_dummy",
  384. switchbot_mqtt._MQTTCallbackUserdata(
  385. retry_count=4, device_passwords={}, fetch_device_info=True
  386. ),
  387. message,
  388. )
  389. init_mock.assert_not_called()
  390. execute_command_mock.assert_not_called()
  391. assert caplog.record_tuples == [
  392. (
  393. "switchbot_mqtt",
  394. logging.DEBUG,
  395. f"received topic={topic.decode()} payload={payload!r}",
  396. ),
  397. ("switchbot_mqtt", logging.INFO, "ignoring retained message"),
  398. ]
  399. @pytest.mark.parametrize(
  400. ("state_topic_levels", "mac_address", "expected_topic"),
  401. # https://www.home-assistant.io/docs/mqtt/discovery/#switches
  402. [
  403. (
  404. switchbot_mqtt._ButtonAutomator.MQTT_STATE_TOPIC_LEVELS,
  405. "aa:bb:cc:dd:ee:ff",
  406. "homeassistant/switch/switchbot/aa:bb:cc:dd:ee:ff/state",
  407. ),
  408. (
  409. ["switchbot", switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS, "state"],
  410. "aa:bb:cc:dd:ee:gg",
  411. "switchbot/aa:bb:cc:dd:ee:gg/state",
  412. ),
  413. ],
  414. )
  415. @pytest.mark.parametrize("state", [b"ON", b"CLOSE"])
  416. @pytest.mark.parametrize("return_code", [MQTT_ERR_SUCCESS, MQTT_ERR_QUEUE_SIZE])
  417. def test__report_state(
  418. caplog,
  419. state_topic_levels: typing.List[switchbot_mqtt._MQTTTopicLevel],
  420. mac_address: str,
  421. expected_topic: str,
  422. state: bytes,
  423. return_code: int,
  424. ):
  425. # pylint: disable=too-many-arguments
  426. class _ActorMock(switchbot_mqtt._MQTTControlledActor):
  427. MQTT_STATE_TOPIC_LEVELS = state_topic_levels
  428. def __init__(self, mac_address, retry_count, password):
  429. super().__init__(
  430. mac_address=mac_address, retry_count=retry_count, password=password
  431. )
  432. def execute_command(
  433. self,
  434. mqtt_message_payload: bytes,
  435. mqtt_client: Client,
  436. update_device_info: bool,
  437. ):
  438. pass
  439. def _get_device(self):
  440. return None
  441. mqtt_client_mock = unittest.mock.MagicMock()
  442. mqtt_client_mock.publish.return_value.rc = return_code
  443. with caplog.at_level(logging.DEBUG):
  444. actor = _ActorMock(mac_address=mac_address, retry_count=3, password=None)
  445. actor.report_state(state=state, mqtt_client=mqtt_client_mock)
  446. mqtt_client_mock.publish.assert_called_once_with(
  447. topic=expected_topic, payload=state, retain=True
  448. )
  449. assert caplog.record_tuples[0] == (
  450. "switchbot_mqtt",
  451. logging.DEBUG,
  452. f"publishing topic={expected_topic} payload={state!r}",
  453. )
  454. if return_code == MQTT_ERR_SUCCESS:
  455. assert not caplog.records[1:]
  456. else:
  457. assert caplog.record_tuples[1:] == [
  458. (
  459. "switchbot_mqtt",
  460. logging.ERROR,
  461. f"Failed to publish MQTT message on topic {expected_topic} (rc={return_code})",
  462. )
  463. ]