test_mqtt.py 16 KB

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