base.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  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. # > Even with __all__ set appropriately, internal interfaces (packages,
  19. # > modules, classes, functions, attributes or other names) should still be
  20. # > prefixed with a single leading underscore. An interface is also considered
  21. # > internal if any containing namespace (package, module or class) is
  22. # > considered internal.
  23. # https://peps.python.org/pep-0008/#public-and-internal-interfaces
  24. from __future__ import annotations # PEP563 (default in python>=3.10)
  25. import abc
  26. import logging
  27. import queue
  28. import shlex
  29. import typing
  30. import aiomqtt
  31. import bluepy.btle
  32. import switchbot
  33. from switchbot_mqtt._utils import (
  34. _join_mqtt_topic_levels,
  35. _mac_address_valid,
  36. _MQTTTopicLevel,
  37. _MQTTTopicPlaceholder,
  38. _parse_mqtt_topic,
  39. _QueueLogHandler,
  40. )
  41. _LOGGER = logging.getLogger(__name__)
  42. class _MQTTControlledActor(abc.ABC):
  43. MQTT_COMMAND_TOPIC_LEVELS: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented
  44. _MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS: typing.Tuple[
  45. _MQTTTopicLevel, ...
  46. ] = NotImplemented
  47. MQTT_STATE_TOPIC_LEVELS: typing.Tuple[_MQTTTopicLevel, ...] = NotImplemented
  48. _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS: typing.Tuple[
  49. _MQTTTopicLevel, ...
  50. ] = NotImplemented
  51. @classmethod
  52. def get_mqtt_update_device_info_topic(cls, *, prefix: str, mac_address: str) -> str:
  53. return _join_mqtt_topic_levels(
  54. topic_prefix=prefix,
  55. topic_levels=cls._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  56. mac_address=mac_address,
  57. )
  58. @classmethod
  59. def get_mqtt_battery_percentage_topic(cls, *, prefix: str, mac_address: str) -> str:
  60. return _join_mqtt_topic_levels(
  61. topic_prefix=prefix,
  62. topic_levels=cls._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
  63. mac_address=mac_address,
  64. )
  65. @abc.abstractmethod
  66. def __init__(
  67. self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
  68. ) -> None:
  69. # alternative: pySwitchbot >=0.10.0 provides SwitchbotDevice.get_mac()
  70. self._mac_address = mac_address
  71. @abc.abstractmethod
  72. def _get_device(self) -> switchbot.SwitchbotDevice:
  73. raise NotImplementedError()
  74. def _update_device_info(self) -> None:
  75. log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=0)
  76. logging.getLogger("switchbot").addHandler(_QueueLogHandler(log_queue))
  77. try:
  78. self._get_device().update()
  79. # pySwitchbot>=v0.10.1 catches bluepy.btle.BTLEManagementError :(
  80. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.1/switchbot/__init__.py#L141
  81. # pySwitchbot<0.11.0 WARNING, >=0.11.0 ERROR
  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. async def _report_battery_level(
  113. self, mqtt_client: aiomqtt.Client, mqtt_topic_prefix: str
  114. ) -> None:
  115. # > battery: Percentage of battery that is left.
  116. # https://www.home-assistant.io/integrations/sensor/#device-class
  117. await self._mqtt_publish(
  118. topic_prefix=mqtt_topic_prefix,
  119. topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
  120. payload=str(self._get_device().get_battery_percent()).encode(),
  121. mqtt_client=mqtt_client,
  122. )
  123. async def _update_and_report_device_info(
  124. self, mqtt_client: aiomqtt.Client, mqtt_topic_prefix: str
  125. ) -> None:
  126. self._update_device_info()
  127. await self._report_battery_level(
  128. mqtt_client=mqtt_client, mqtt_topic_prefix=mqtt_topic_prefix
  129. )
  130. @classmethod
  131. def _init_from_topic(
  132. cls,
  133. *,
  134. topic: aiomqtt.Topic,
  135. mqtt_topic_prefix: str,
  136. expected_topic_levels: typing.Collection[_MQTTTopicLevel],
  137. retry_count: int,
  138. device_passwords: typing.Dict[str, str],
  139. ) -> typing.Optional[_MQTTControlledActor]:
  140. try:
  141. mac_address = _parse_mqtt_topic(
  142. topic=topic.value,
  143. expected_prefix=mqtt_topic_prefix,
  144. expected_levels=expected_topic_levels,
  145. )[_MQTTTopicPlaceholder.MAC_ADDRESS]
  146. except ValueError as exc:
  147. _LOGGER.warning(str(exc), exc_info=False)
  148. return None
  149. if not _mac_address_valid(mac_address):
  150. _LOGGER.warning("invalid mac address %s", mac_address)
  151. return None
  152. return cls(
  153. mac_address=mac_address,
  154. retry_count=retry_count,
  155. password=device_passwords.get(mac_address, None),
  156. )
  157. @classmethod
  158. async def _mqtt_update_device_info_callback(
  159. # pylint: disable=duplicate-code; other callbacks with same params
  160. cls,
  161. *,
  162. mqtt_client: aiomqtt.Client,
  163. message: aiomqtt.Message,
  164. mqtt_topic_prefix: str,
  165. retry_count: int,
  166. device_passwords: typing.Dict[str, str],
  167. fetch_device_info: bool,
  168. ) -> None:
  169. # pylint: disable=unused-argument; callback
  170. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  171. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  172. if message.retain:
  173. _LOGGER.info("ignoring retained message")
  174. return
  175. actor = cls._init_from_topic(
  176. topic=message.topic,
  177. mqtt_topic_prefix=mqtt_topic_prefix,
  178. expected_topic_levels=cls._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS,
  179. retry_count=retry_count,
  180. device_passwords=device_passwords,
  181. )
  182. if actor:
  183. # pylint: disable=protected-access; own instance
  184. await actor._update_and_report_device_info(
  185. mqtt_client=mqtt_client, mqtt_topic_prefix=mqtt_topic_prefix
  186. )
  187. @abc.abstractmethod
  188. async def execute_command( # pylint: disable=duplicate-code; implementations
  189. self,
  190. *,
  191. mqtt_message_payload: bytes,
  192. mqtt_client: aiomqtt.Client,
  193. update_device_info: bool,
  194. mqtt_topic_prefix: str,
  195. ) -> None:
  196. raise NotImplementedError()
  197. @classmethod
  198. async def _mqtt_command_callback(
  199. # pylint: disable=duplicate-code; other callbacks with same params
  200. cls,
  201. *,
  202. mqtt_client: aiomqtt.Client,
  203. message: aiomqtt.Message,
  204. mqtt_topic_prefix: str,
  205. retry_count: int,
  206. device_passwords: typing.Dict[str, str],
  207. fetch_device_info: bool,
  208. ) -> None:
  209. # pylint: disable=unused-argument; callback
  210. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  211. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  212. if message.retain:
  213. _LOGGER.info("ignoring retained message")
  214. return
  215. actor = cls._init_from_topic(
  216. topic=message.topic,
  217. mqtt_topic_prefix=mqtt_topic_prefix,
  218. expected_topic_levels=cls.MQTT_COMMAND_TOPIC_LEVELS,
  219. retry_count=retry_count,
  220. device_passwords=device_passwords,
  221. )
  222. if actor:
  223. assert isinstance(message.payload, bytes), message.payload
  224. await actor.execute_command(
  225. mqtt_message_payload=message.payload,
  226. mqtt_client=mqtt_client,
  227. update_device_info=fetch_device_info,
  228. mqtt_topic_prefix=mqtt_topic_prefix,
  229. )
  230. @classmethod
  231. def _get_mqtt_message_callbacks(
  232. cls,
  233. *,
  234. enable_device_info_update_topic: bool,
  235. ) -> typing.Dict[typing.Tuple[_MQTTTopicLevel, ...], typing.Callable]:
  236. # returning dict because `paho.mqtt.client.Client.message_callback_add` overwrites
  237. # callbacks with same topic pattern
  238. # https://github.com/eclipse/paho.mqtt.python/blob/v1.6.1/src/paho/mqtt/client.py#L2304
  239. # https://github.com/eclipse/paho.mqtt.python/blob/v1.6.1/src/paho/mqtt/matcher.py#L19
  240. callbacks = {cls.MQTT_COMMAND_TOPIC_LEVELS: cls._mqtt_command_callback}
  241. if enable_device_info_update_topic:
  242. callbacks[
  243. cls._MQTT_UPDATE_DEVICE_INFO_TOPIC_LEVELS
  244. ] = cls._mqtt_update_device_info_callback
  245. return callbacks
  246. @classmethod
  247. async def mqtt_subscribe(
  248. cls,
  249. *,
  250. mqtt_client: aiomqtt.Client,
  251. mqtt_topic_prefix: str,
  252. fetch_device_info: bool,
  253. ) -> typing.AsyncIterator[typing.Tuple[str, typing.Callable]]:
  254. for topic_levels, callback in cls._get_mqtt_message_callbacks(
  255. enable_device_info_update_topic=fetch_device_info
  256. ).items():
  257. topic = _join_mqtt_topic_levels(
  258. topic_prefix=mqtt_topic_prefix,
  259. topic_levels=topic_levels,
  260. mac_address="+",
  261. )
  262. _LOGGER.info("subscribing to MQTT topic %r", topic)
  263. await mqtt_client.subscribe(topic)
  264. yield (topic, callback)
  265. async def _mqtt_publish(
  266. self,
  267. *,
  268. topic_prefix: str,
  269. topic_levels: typing.Iterable[_MQTTTopicLevel],
  270. payload: bytes,
  271. mqtt_client: aiomqtt.Client,
  272. ) -> None:
  273. topic = _join_mqtt_topic_levels(
  274. topic_prefix=topic_prefix,
  275. topic_levels=topic_levels,
  276. mac_address=self._mac_address,
  277. )
  278. # https://pypi.org/project/paho-mqtt/#publishing
  279. _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
  280. try:
  281. await mqtt_client.publish(topic=topic, payload=payload, retain=True)
  282. except aiomqtt.MqttCodeError as exc:
  283. _LOGGER.error(
  284. "Failed to publish MQTT message on topic %s: aiomqtt.MqttCodeError %s",
  285. topic,
  286. exc,
  287. )
  288. async def report_state(
  289. self,
  290. state: bytes,
  291. mqtt_client: aiomqtt.Client,
  292. mqtt_topic_prefix: str,
  293. ) -> None:
  294. await self._mqtt_publish(
  295. topic_prefix=mqtt_topic_prefix,
  296. topic_levels=self.MQTT_STATE_TOPIC_LEVELS,
  297. payload=state,
  298. mqtt_client=mqtt_client,
  299. )