test_mqtt.py 16 KB

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