test_switchbot_curtain_motor.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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 unittest.mock
  20. import bluepy.btle
  21. import pytest
  22. import switchbot_mqtt
  23. # pylint: disable=protected-access,
  24. # pylint: disable=too-many-arguments; these are tests, no API
  25. @pytest.mark.parametrize("mac_address", ["{MAC_ADDRESS}", "aa:bb:cc:dd:ee:ff"])
  26. def test_get_mqtt_battery_percentage_topic(mac_address):
  27. assert (
  28. switchbot_mqtt._CurtainMotor.get_mqtt_battery_percentage_topic(
  29. mac_address=mac_address
  30. )
  31. == f"homeassistant/cover/switchbot-curtain/{mac_address}/battery-percentage"
  32. )
  33. @pytest.mark.parametrize("mac_address", ["{MAC_ADDRESS}", "aa:bb:cc:dd:ee:ff"])
  34. def test_get_mqtt_position_topic(mac_address):
  35. assert (
  36. switchbot_mqtt._CurtainMotor.get_mqtt_position_topic(mac_address=mac_address)
  37. == f"homeassistant/cover/switchbot-curtain/{mac_address}/position"
  38. )
  39. @pytest.mark.parametrize(
  40. "mac_address",
  41. ("aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:gg"),
  42. )
  43. @pytest.mark.parametrize(
  44. ("position", "expected_payload"), [(0, b"0"), (100, b"100"), (42, b"42")]
  45. )
  46. def test__report_position(
  47. caplog, mac_address: str, position: int, expected_payload: bytes
  48. ):
  49. with unittest.mock.patch(
  50. "switchbot.SwitchbotCurtain.__init__", return_value=None
  51. ) as device_init_mock, caplog.at_level(logging.DEBUG):
  52. actor = switchbot_mqtt._CurtainMotor(
  53. mac_address=mac_address, retry_count=7, password=None
  54. )
  55. device_init_mock.assert_called_once_with(
  56. mac=mac_address,
  57. retry_count=7,
  58. password=None,
  59. # > The position of the curtain is saved in self._pos with 0 = open and 100 = closed.
  60. # > [...] The parameter 'reverse_mode' reverse these values, [...]
  61. # > The parameter is default set to True so that the definition of position
  62. # > is the same as in Home Assistant.
  63. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L150
  64. reverse_mode=True,
  65. )
  66. with unittest.mock.patch.object(
  67. actor, "_mqtt_publish"
  68. ) as publish_mock, unittest.mock.patch(
  69. "switchbot.SwitchbotCurtain.get_position", return_value=position
  70. ):
  71. actor._report_position(mqtt_client="dummy")
  72. publish_mock.assert_called_once_with(
  73. topic_levels=[
  74. "homeassistant",
  75. "cover",
  76. "switchbot-curtain",
  77. switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS,
  78. "position",
  79. ],
  80. payload=expected_payload,
  81. mqtt_client="dummy",
  82. )
  83. assert not caplog.record_tuples
  84. @pytest.mark.parametrize("position", ("", 'lambda: print("")'))
  85. def test__report_position_invalid(caplog, position):
  86. with unittest.mock.patch(
  87. "switchbot.SwitchbotCurtain.__init__", return_value=None
  88. ), caplog.at_level(logging.DEBUG):
  89. actor = switchbot_mqtt._CurtainMotor(
  90. mac_address="aa:bb:cc:dd:ee:ff", retry_count=3, password=None
  91. )
  92. with unittest.mock.patch.object(
  93. actor, "_mqtt_publish"
  94. ) as publish_mock, unittest.mock.patch(
  95. "switchbot.SwitchbotCurtain.get_position", return_value=position
  96. ), pytest.raises(
  97. ValueError
  98. ):
  99. actor._report_position(mqtt_client="dummy")
  100. publish_mock.assert_not_called()
  101. @pytest.mark.parametrize(("battery_percent", "battery_percent_encoded"), [(42, b"42")])
  102. @pytest.mark.parametrize("report_position", [True, False])
  103. @pytest.mark.parametrize(("position", "position_encoded"), [(21, b"21")])
  104. def test__update_and_report_device_info(
  105. report_position: bool,
  106. battery_percent: int,
  107. battery_percent_encoded: bytes,
  108. position: int,
  109. position_encoded: bytes,
  110. ):
  111. with unittest.mock.patch("switchbot.SwitchbotCurtain.__init__", return_value=None):
  112. actor = switchbot_mqtt._CurtainMotor(
  113. mac_address="dummy", retry_count=21, password=None
  114. )
  115. actor._get_device()._battery_percent = battery_percent
  116. actor._get_device()._pos = position
  117. mqtt_client_mock = unittest.mock.MagicMock()
  118. with unittest.mock.patch("switchbot.SwitchbotCurtain.update") as update_mock:
  119. actor._update_and_report_device_info(
  120. mqtt_client=mqtt_client_mock, report_position=report_position
  121. )
  122. update_mock.assert_called_once_with()
  123. assert mqtt_client_mock.publish.call_count == (1 + report_position)
  124. assert (
  125. unittest.mock.call(
  126. topic="homeassistant/cover/switchbot-curtain/dummy/battery-percentage",
  127. payload=battery_percent_encoded,
  128. retain=True,
  129. )
  130. in mqtt_client_mock.publish.call_args_list
  131. )
  132. if report_position:
  133. assert (
  134. unittest.mock.call(
  135. topic="homeassistant/cover/switchbot-curtain/dummy/position",
  136. payload=position_encoded,
  137. retain=True,
  138. )
  139. in mqtt_client_mock.publish.call_args_list
  140. )
  141. @pytest.mark.parametrize(
  142. "exception",
  143. [
  144. PermissionError("bluepy-helper failed to enable low energy mode..."),
  145. bluepy.btle.BTLEManagementError("test"),
  146. ],
  147. )
  148. def test__update_and_report_device_info_update_error(exception):
  149. actor = switchbot_mqtt._CurtainMotor(
  150. mac_address="dummy", retry_count=21, password=None
  151. )
  152. mqtt_client_mock = unittest.mock.MagicMock()
  153. with unittest.mock.patch.object(
  154. actor._get_device(), "update", side_effect=exception
  155. ), pytest.raises(type(exception)):
  156. actor._update_and_report_device_info(mqtt_client_mock, report_position=True)
  157. mqtt_client_mock.publish.assert_not_called()
  158. @pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff", "aa:bb:cc:11:22:33"])
  159. @pytest.mark.parametrize("password", ["pa$$word", None])
  160. @pytest.mark.parametrize("retry_count", (2, 3))
  161. @pytest.mark.parametrize(
  162. ("message_payload", "action_name"),
  163. [
  164. (b"open", "switchbot.SwitchbotCurtain.open"),
  165. (b"OPEN", "switchbot.SwitchbotCurtain.open"),
  166. (b"Open", "switchbot.SwitchbotCurtain.open"),
  167. (b"close", "switchbot.SwitchbotCurtain.close"),
  168. (b"CLOSE", "switchbot.SwitchbotCurtain.close"),
  169. (b"Close", "switchbot.SwitchbotCurtain.close"),
  170. (b"stop", "switchbot.SwitchbotCurtain.stop"),
  171. (b"STOP", "switchbot.SwitchbotCurtain.stop"),
  172. (b"Stop", "switchbot.SwitchbotCurtain.stop"),
  173. ],
  174. )
  175. @pytest.mark.parametrize("update_device_info", [True, False])
  176. @pytest.mark.parametrize("command_successful", [True, False])
  177. def test_execute_command(
  178. caplog,
  179. mac_address,
  180. password,
  181. retry_count,
  182. message_payload,
  183. action_name,
  184. update_device_info,
  185. command_successful,
  186. ):
  187. with unittest.mock.patch(
  188. "switchbot.SwitchbotCurtain.__init__", return_value=None
  189. ) as device_init_mock, caplog.at_level(logging.INFO):
  190. actor = switchbot_mqtt._CurtainMotor(
  191. mac_address=mac_address, retry_count=retry_count, password=password
  192. )
  193. with unittest.mock.patch.object(
  194. actor, "report_state"
  195. ) as report_mock, unittest.mock.patch(
  196. action_name, return_value=command_successful
  197. ) as action_mock, unittest.mock.patch.object(
  198. actor, "_update_and_report_device_info"
  199. ) as update_device_info_mock:
  200. actor.execute_command(
  201. mqtt_client="dummy",
  202. mqtt_message_payload=message_payload,
  203. update_device_info=update_device_info,
  204. )
  205. device_init_mock.assert_called_once_with(
  206. mac=mac_address, password=password, retry_count=retry_count, reverse_mode=True
  207. )
  208. action_mock.assert_called_once_with()
  209. if command_successful:
  210. state_str = {b"open": "opening", b"close": "closing", b"stop": "stopped"}[
  211. message_payload.lower()
  212. ]
  213. assert caplog.record_tuples == [
  214. (
  215. "switchbot_mqtt",
  216. logging.INFO,
  217. f"switchbot curtain {mac_address} {state_str}",
  218. )
  219. ]
  220. report_mock.assert_called_once_with(
  221. mqtt_client="dummy",
  222. # https://www.home-assistant.io/integrations/cover.mqtt/#state_opening
  223. state={b"open": b"opening", b"close": b"closing", b"stop": b""}[
  224. message_payload.lower()
  225. ],
  226. )
  227. else:
  228. assert caplog.record_tuples == [
  229. (
  230. "switchbot_mqtt",
  231. logging.ERROR,
  232. f"failed to {message_payload.decode().lower()} switchbot curtain {mac_address}",
  233. )
  234. ]
  235. report_mock.assert_not_called()
  236. if update_device_info and command_successful:
  237. update_device_info_mock.assert_called_once_with(
  238. mqtt_client="dummy",
  239. report_position=(action_name == "switchbot.SwitchbotCurtain.stop"),
  240. )
  241. else:
  242. update_device_info_mock.assert_not_called()
  243. @pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
  244. @pytest.mark.parametrize("password", ["secret"])
  245. @pytest.mark.parametrize("message_payload", [b"OEFFNEN", b""])
  246. def test_execute_command_invalid_payload(
  247. caplog, mac_address, password, message_payload
  248. ):
  249. with unittest.mock.patch(
  250. "switchbot.SwitchbotCurtain"
  251. ) as device_mock, caplog.at_level(logging.INFO):
  252. actor = switchbot_mqtt._CurtainMotor(
  253. mac_address=mac_address, retry_count=7, password=password
  254. )
  255. with unittest.mock.patch.object(actor, "report_state") as report_mock:
  256. actor.execute_command(
  257. mqtt_client="dummy",
  258. mqtt_message_payload=message_payload,
  259. update_device_info=True,
  260. )
  261. device_mock.assert_called_once_with(
  262. mac=mac_address, password=password, retry_count=7, reverse_mode=True
  263. )
  264. assert not device_mock().mock_calls # no methods called
  265. report_mock.assert_not_called()
  266. assert caplog.record_tuples == [
  267. (
  268. "switchbot_mqtt",
  269. logging.WARNING,
  270. f"unexpected payload {message_payload!r} (expected 'OPEN', 'CLOSE', or 'STOP')",
  271. )
  272. ]
  273. @pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
  274. @pytest.mark.parametrize("message_payload", [b"OPEN", b"CLOSE", b"STOP"])
  275. def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
  276. """
  277. paho.mqtt.python>=1.5.1 no longer implicitly suppresses exceptions in callbacks.
  278. verify pySwitchbot catches exceptions raised in bluetooth stack.
  279. https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L48
  280. https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L94
  281. """
  282. with unittest.mock.patch(
  283. "bluepy.btle.Peripheral",
  284. side_effect=bluepy.btle.BTLEDisconnectError(
  285. f"Failed to connect to peripheral {mac_address}, addr type: random"
  286. ),
  287. ), caplog.at_level(logging.ERROR):
  288. switchbot_mqtt._CurtainMotor(
  289. mac_address=mac_address, retry_count=10, password="secret"
  290. ).execute_command(
  291. mqtt_client="dummy",
  292. mqtt_message_payload=message_payload,
  293. update_device_info=True,
  294. )
  295. assert caplog.record_tuples == [
  296. (
  297. "switchbot",
  298. logging.ERROR,
  299. "Switchbot communication failed. Stopping trying.",
  300. ),
  301. (
  302. "switchbot_mqtt",
  303. logging.ERROR,
  304. f"failed to {message_payload.decode().lower()} switchbot curtain {mac_address}",
  305. ),
  306. ]