test_switchbot_curtain_motor.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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(
  26. "mac_address",
  27. ("aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:gg"),
  28. )
  29. @pytest.mark.parametrize(
  30. ("position", "expected_payload"), [(0, b"0"), (100, b"100"), (42, b"42")]
  31. )
  32. def test__report_position(
  33. caplog, mac_address: str, position: int, expected_payload: bytes
  34. ):
  35. with unittest.mock.patch(
  36. "switchbot.SwitchbotCurtain.__init__", return_value=None
  37. ) as device_init_mock, caplog.at_level(logging.DEBUG):
  38. actor = switchbot_mqtt._CurtainMotor(
  39. mac_address=mac_address, retry_count=7, password=None
  40. )
  41. device_init_mock.assert_called_once_with(
  42. mac=mac_address,
  43. retry_count=7,
  44. password=None,
  45. # > The position of the curtain is saved in self._pos with 0 = open and 100 = closed.
  46. # > [...] The parameter 'reverse_mode' reverse these values, [...]
  47. # > The parameter is default set to True so that the definition of position
  48. # > is the same as in Home Assistant.
  49. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L150
  50. reverse_mode=True,
  51. )
  52. with unittest.mock.patch.object(
  53. actor, "_mqtt_publish"
  54. ) as publish_mock, unittest.mock.patch(
  55. "switchbot.SwitchbotCurtain.get_position", return_value=position
  56. ):
  57. actor._report_position(mqtt_client="dummy")
  58. publish_mock.assert_called_once_with(
  59. topic_levels=[
  60. "homeassistant",
  61. "cover",
  62. "switchbot-curtain",
  63. switchbot_mqtt._MQTTTopicPlaceholder.MAC_ADDRESS,
  64. "position",
  65. ],
  66. payload=expected_payload,
  67. mqtt_client="dummy",
  68. )
  69. assert not caplog.record_tuples
  70. @pytest.mark.parametrize("position", ("", 'lambda: print("")'))
  71. def test__report_position_invalid(caplog, position):
  72. with unittest.mock.patch(
  73. "switchbot.SwitchbotCurtain.__init__", return_value=None
  74. ), caplog.at_level(logging.DEBUG):
  75. actor = switchbot_mqtt._CurtainMotor(
  76. mac_address="aa:bb:cc:dd:ee:ff", retry_count=3, password=None
  77. )
  78. with unittest.mock.patch.object(
  79. actor, "_mqtt_publish"
  80. ) as publish_mock, unittest.mock.patch(
  81. "switchbot.SwitchbotCurtain.get_position", return_value=position
  82. ), pytest.raises(
  83. ValueError
  84. ):
  85. actor._report_position(mqtt_client="dummy")
  86. publish_mock.assert_not_called()
  87. def test__update_position():
  88. with unittest.mock.patch("switchbot.SwitchbotCurtain.__init__", return_value=None):
  89. actor = switchbot_mqtt._CurtainMotor(
  90. mac_address="dummy", retry_count=21, password=None
  91. )
  92. with unittest.mock.patch(
  93. "switchbot.SwitchbotCurtain.update"
  94. ) as update_mock, unittest.mock.patch.object(
  95. actor, "_report_position"
  96. ) as report_position_mock:
  97. actor._update_position(mqtt_client="client")
  98. update_mock.assert_called_once_with()
  99. report_position_mock.assert_called_once_with(mqtt_client="client")
  100. @pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff", "aa:bb:cc:11:22:33"])
  101. @pytest.mark.parametrize("password", ["pa$$word", None])
  102. @pytest.mark.parametrize("retry_count", (2, 3))
  103. @pytest.mark.parametrize(
  104. ("message_payload", "action_name"),
  105. [
  106. (b"open", "switchbot.SwitchbotCurtain.open"),
  107. (b"OPEN", "switchbot.SwitchbotCurtain.open"),
  108. (b"Open", "switchbot.SwitchbotCurtain.open"),
  109. (b"close", "switchbot.SwitchbotCurtain.close"),
  110. (b"CLOSE", "switchbot.SwitchbotCurtain.close"),
  111. (b"Close", "switchbot.SwitchbotCurtain.close"),
  112. (b"stop", "switchbot.SwitchbotCurtain.stop"),
  113. (b"STOP", "switchbot.SwitchbotCurtain.stop"),
  114. (b"Stop", "switchbot.SwitchbotCurtain.stop"),
  115. ],
  116. )
  117. @pytest.mark.parametrize("command_successful", [True, False])
  118. def test_execute_command(
  119. caplog,
  120. mac_address,
  121. password,
  122. retry_count,
  123. message_payload,
  124. action_name,
  125. command_successful,
  126. ):
  127. with unittest.mock.patch(
  128. "switchbot.SwitchbotCurtain.__init__", return_value=None
  129. ) as device_init_mock, caplog.at_level(logging.INFO):
  130. actor = switchbot_mqtt._CurtainMotor(
  131. mac_address=mac_address, retry_count=retry_count, password=password
  132. )
  133. with unittest.mock.patch.object(
  134. actor, "report_state"
  135. ) as report_mock, unittest.mock.patch(
  136. action_name, return_value=command_successful
  137. ) as action_mock, unittest.mock.patch.object(
  138. actor, "_update_position"
  139. ) as update_position_mock:
  140. actor.execute_command(
  141. mqtt_client="dummy", mqtt_message_payload=message_payload
  142. )
  143. device_init_mock.assert_called_once_with(
  144. mac=mac_address, password=password, retry_count=retry_count, reverse_mode=True
  145. )
  146. action_mock.assert_called_once_with()
  147. if command_successful:
  148. assert caplog.record_tuples == [
  149. (
  150. "switchbot_mqtt",
  151. logging.INFO,
  152. "switchbot curtain {} {}".format(
  153. mac_address,
  154. {b"open": "opening", b"close": "closing", b"stop": "stopped"}[
  155. message_payload.lower()
  156. ],
  157. ),
  158. )
  159. ]
  160. report_mock.assert_called_once_with(
  161. mqtt_client="dummy",
  162. # https://www.home-assistant.io/integrations/cover.mqtt/#state_opening
  163. state={b"open": b"opening", b"close": b"closing", b"stop": b""}[
  164. message_payload.lower()
  165. ],
  166. )
  167. else:
  168. assert caplog.record_tuples == [
  169. (
  170. "switchbot_mqtt",
  171. logging.ERROR,
  172. "failed to {} switchbot curtain {}".format(
  173. message_payload.decode().lower(), mac_address
  174. ),
  175. )
  176. ]
  177. report_mock.assert_not_called()
  178. if action_name == "switchbot.SwitchbotCurtain.stop" and command_successful:
  179. update_position_mock.assert_called_once_with(mqtt_client="dummy")
  180. else:
  181. update_position_mock.assert_not_called()
  182. @pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
  183. @pytest.mark.parametrize("password", ["secret"])
  184. @pytest.mark.parametrize("message_payload", [b"OEFFNEN", b""])
  185. def test_execute_command_invalid_payload(
  186. caplog, mac_address, password, message_payload
  187. ):
  188. with unittest.mock.patch(
  189. "switchbot.SwitchbotCurtain"
  190. ) as device_mock, caplog.at_level(logging.INFO):
  191. actor = switchbot_mqtt._CurtainMotor(
  192. mac_address=mac_address, retry_count=7, password=password
  193. )
  194. with unittest.mock.patch.object(actor, "report_state") as report_mock:
  195. actor.execute_command(
  196. mqtt_client="dummy", mqtt_message_payload=message_payload
  197. )
  198. device_mock.assert_called_once_with(
  199. mac=mac_address, password=password, retry_count=7, reverse_mode=True
  200. )
  201. assert not device_mock().mock_calls # no methods called
  202. report_mock.assert_not_called()
  203. assert caplog.record_tuples == [
  204. (
  205. "switchbot_mqtt",
  206. logging.WARNING,
  207. "unexpected payload {!r} (expected 'OPEN', 'CLOSE', or 'STOP')".format(
  208. message_payload
  209. ),
  210. )
  211. ]
  212. @pytest.mark.parametrize("mac_address", ["aa:bb:cc:dd:ee:ff"])
  213. @pytest.mark.parametrize("message_payload", [b"OPEN", b"CLOSE", b"STOP"])
  214. def test_execute_command_bluetooth_error(caplog, mac_address, message_payload):
  215. """
  216. paho.mqtt.python>=1.5.1 no longer implicitly suppresses exceptions in callbacks.
  217. verify pySwitchbot catches exceptions raised in bluetooth stack.
  218. https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L48
  219. https://github.com/Danielhiversen/pySwitchbot/blob/0.8.0/switchbot/__init__.py#L94
  220. """
  221. with unittest.mock.patch(
  222. "bluepy.btle.Peripheral",
  223. side_effect=bluepy.btle.BTLEDisconnectError(
  224. "Failed to connect to peripheral {}, addr type: random".format(mac_address)
  225. ),
  226. ), caplog.at_level(logging.ERROR):
  227. switchbot_mqtt._CurtainMotor(
  228. mac_address=mac_address, retry_count=10, password="secret"
  229. ).execute_command(mqtt_client="dummy", mqtt_message_payload=message_payload)
  230. assert caplog.record_tuples == [
  231. (
  232. "switchbot",
  233. logging.ERROR,
  234. "Switchbot communication failed. Stopping trying.",
  235. ),
  236. (
  237. "switchbot_mqtt",
  238. logging.ERROR,
  239. "failed to {} switchbot curtain {}".format(
  240. message_payload.decode().lower(), mac_address
  241. ),
  242. ),
  243. ]