_base.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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 abc
  19. import logging
  20. import queue
  21. import shlex
  22. import typing
  23. import bluepy.btle
  24. import paho.mqtt.client
  25. import switchbot
  26. from switchbot_mqtt._utils import (
  27. _join_mqtt_topic_levels,
  28. _mac_address_valid,
  29. _MQTTTopicLevel,
  30. _MQTTTopicPlaceholder,
  31. _parse_mqtt_topic,
  32. _QueueLogHandler,
  33. )
  34. _LOGGER = logging.getLogger(__name__)
  35. class _MQTTCallbackUserdata:
  36. # pylint: disable=too-few-public-methods; @dataclasses.dataclass when python_requires>=3.7
  37. def __init__(
  38. self,
  39. *,
  40. retry_count: int,
  41. device_passwords: typing.Dict[str, str],
  42. fetch_device_info: bool,
  43. ) -> None:
  44. self.retry_count = retry_count
  45. self.device_passwords = device_passwords
  46. self.fetch_device_info = fetch_device_info
  47. def __eq__(self, other: object) -> bool:
  48. return isinstance(other, type(self)) and vars(self) == vars(other)
  49. class _MQTTControlledActor(abc.ABC):
  50. MQTT_COMMAND_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  51. _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  52. MQTT_STATE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  53. _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  54. @classmethod
  55. def get_mqtt_update_device_info_topic(cls, mac_address: str) -> str:
  56. return _join_mqtt_topic_levels(
  57. topic_levels=cls._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  58. mac_address=mac_address,
  59. )
  60. @classmethod
  61. def get_mqtt_battery_percentage_topic(cls, mac_address: str) -> str:
  62. return _join_mqtt_topic_levels(
  63. topic_levels=cls._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
  64. mac_address=mac_address,
  65. )
  66. @abc.abstractmethod
  67. def __init__(
  68. self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
  69. ) -> None:
  70. # alternative: pySwitchbot >=0.10.0 provides SwitchbotDevice.get_mac()
  71. self._mac_address = mac_address
  72. @abc.abstractmethod
  73. def _get_device(self) -> switchbot.SwitchbotDevice:
  74. raise NotImplementedError()
  75. def _update_device_info(self) -> None:
  76. log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=0)
  77. logging.getLogger("switchbot").addHandler(_QueueLogHandler(log_queue))
  78. try:
  79. self._get_device().update()
  80. # pySwitchbot>=v0.10.1 catches bluepy.btle.BTLEManagementError :(
  81. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.1/switchbot/__init__.py#L141
  82. while not log_queue.empty():
  83. log_record = log_queue.get()
  84. if log_record.exc_info:
  85. exc: typing.Optional[BaseException] = log_record.exc_info[1]
  86. if (
  87. isinstance(exc, bluepy.btle.BTLEManagementError)
  88. and exc.emsg == "Permission Denied"
  89. ):
  90. raise exc
  91. except bluepy.btle.BTLEManagementError as exc:
  92. if (
  93. exc.emsg == "Permission Denied"
  94. and exc.message == "Failed to execute management command 'le on'"
  95. ):
  96. raise PermissionError(
  97. "bluepy-helper failed to enable low energy mode"
  98. " due to insufficient permissions."
  99. "\nSee https://github.com/IanHarvey/bluepy/issues/313#issuecomment-428324639"
  100. ", https://github.com/fphammerle/switchbot-mqtt/pull/31#issuecomment-846383603"
  101. ", and https://github.com/IanHarvey/bluepy/blob/v/1.3.0/bluepy"
  102. "/bluepy-helper.c#L1260."
  103. "\nInsecure workaround:"
  104. "\n1. sudo apt-get install --no-install-recommends libcap2-bin"
  105. f"\n2. sudo setcap cap_net_admin+ep {shlex.quote(bluepy.btle.helperExe)}"
  106. "\n3. restart switchbot-mqtt"
  107. "\nIn docker-based setups, you could use"
  108. " `sudo docker run --cap-drop ALL --cap-add NET_ADMIN --user 0 …`"
  109. " (seriously insecure)."
  110. ) from exc
  111. raise
  112. def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
  113. # > battery: Percentage of battery that is left.
  114. # https://www.home-assistant.io/integrations/sensor/#device-class
  115. self._mqtt_publish(
  116. topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
  117. payload=str(self._get_device().get_battery_percent()).encode(),
  118. mqtt_client=mqtt_client,
  119. )
  120. def _update_and_report_device_info(
  121. self, mqtt_client: paho.mqtt.client.Client
  122. ) -> None:
  123. self._update_device_info()
  124. self._report_battery_level(mqtt_client=mqtt_client)
  125. @classmethod
  126. def _init_from_topic(
  127. cls,
  128. userdata: _MQTTCallbackUserdata,
  129. topic: str,
  130. expected_topic_levels: typing.List[_MQTTTopicLevel],
  131. ) -> typing.Optional["_MQTTControlledActor"]:
  132. try:
  133. mac_address = _parse_mqtt_topic(
  134. topic=topic, expected_levels=expected_topic_levels
  135. )[_MQTTTopicPlaceholder.MAC_ADDRESS]
  136. except ValueError as exc:
  137. _LOGGER.warning(str(exc), exc_info=False)
  138. return None
  139. if not _mac_address_valid(mac_address):
  140. _LOGGER.warning("invalid mac address %s", mac_address)
  141. return None
  142. return cls(
  143. mac_address=mac_address,
  144. retry_count=userdata.retry_count,
  145. password=userdata.device_passwords.get(mac_address, None),
  146. )
  147. @classmethod
  148. def _mqtt_update_device_info_callback(
  149. cls,
  150. mqtt_client: paho.mqtt.client.Client,
  151. userdata: _MQTTCallbackUserdata,
  152. message: paho.mqtt.client.MQTTMessage,
  153. ) -> None:
  154. # pylint: disable=unused-argument; callback
  155. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  156. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  157. if message.retain:
  158. _LOGGER.info("ignoring retained message")
  159. return
  160. actor = cls._init_from_topic(
  161. userdata=userdata,
  162. topic=message.topic,
  163. expected_topic_levels=cls._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  164. )
  165. if actor:
  166. # pylint: disable=protected-access; own instance
  167. actor._update_and_report_device_info(mqtt_client)
  168. @abc.abstractmethod
  169. def execute_command(
  170. self,
  171. mqtt_message_payload: bytes,
  172. mqtt_client: paho.mqtt.client.Client,
  173. update_device_info: bool,
  174. ) -> None:
  175. raise NotImplementedError()
  176. @classmethod
  177. def _mqtt_command_callback(
  178. cls,
  179. mqtt_client: paho.mqtt.client.Client,
  180. userdata: _MQTTCallbackUserdata,
  181. message: paho.mqtt.client.MQTTMessage,
  182. ) -> None:
  183. # pylint: disable=unused-argument; callback
  184. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  185. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  186. if message.retain:
  187. _LOGGER.info("ignoring retained message")
  188. return
  189. actor = cls._init_from_topic(
  190. userdata=userdata,
  191. topic=message.topic,
  192. expected_topic_levels=cls.MQTT_COMMAND_TOPIC_LEVELS,
  193. )
  194. if actor:
  195. actor.execute_command(
  196. mqtt_message_payload=message.payload,
  197. mqtt_client=mqtt_client,
  198. update_device_info=userdata.fetch_device_info,
  199. )
  200. @classmethod
  201. def mqtt_subscribe(
  202. cls,
  203. mqtt_client: paho.mqtt.client.Client,
  204. *,
  205. enable_device_info_update_topic: bool,
  206. ) -> None:
  207. topics = [(cls.MQTT_COMMAND_TOPIC_LEVELS, cls._mqtt_command_callback)]
  208. if enable_device_info_update_topic:
  209. topics.append(
  210. (
  211. cls._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  212. cls._mqtt_update_device_info_callback,
  213. )
  214. )
  215. for topic_levels, callback in topics:
  216. topic = _join_mqtt_topic_levels(topic_levels, mac_address="+")
  217. _LOGGER.info("subscribing to MQTT topic %r", topic)
  218. mqtt_client.subscribe(topic)
  219. mqtt_client.message_callback_add(sub=topic, callback=callback)
  220. def _mqtt_publish(
  221. self,
  222. *,
  223. topic_levels: typing.List[_MQTTTopicLevel],
  224. payload: bytes,
  225. mqtt_client: paho.mqtt.client.Client,
  226. ) -> None:
  227. topic = _join_mqtt_topic_levels(
  228. topic_levels=topic_levels, mac_address=self._mac_address
  229. )
  230. # https://pypi.org/project/paho-mqtt/#publishing
  231. _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
  232. message_info: paho.mqtt.client.MQTTMessageInfo = mqtt_client.publish(
  233. topic=topic, payload=payload, retain=True
  234. )
  235. # wait before checking status?
  236. if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
  237. _LOGGER.error(
  238. "Failed to publish MQTT message on topic %s (rc=%d)",
  239. topic,
  240. message_info.rc,
  241. )
  242. def report_state(self, state: bytes, mqtt_client: paho.mqtt.client.Client) -> None:
  243. self._mqtt_publish(
  244. topic_levels=self.MQTT_STATE_TOPIC_LEVELS,
  245. payload=state,
  246. mqtt_client=mqtt_client,
  247. )