test_mqtt.py 13 KB

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