test_mqtt.py 17 KB

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