__init__.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 argparse
  20. import collections
  21. import enum
  22. import json
  23. import logging
  24. import pathlib
  25. import re
  26. import shlex
  27. import typing
  28. import bluepy.btle
  29. import paho.mqtt.client
  30. import switchbot
  31. _LOGGER = logging.getLogger(__name__)
  32. _MAC_ADDRESS_REGEX = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
  33. class _MQTTTopicPlaceholder(enum.Enum):
  34. MAC_ADDRESS = "MAC_ADDRESS"
  35. _MQTTTopicLevel = typing.Union[str, _MQTTTopicPlaceholder]
  36. # "homeassistant" for historic reason, may be parametrized in future
  37. _MQTT_TOPIC_LEVELS_PREFIX = ["homeassistant"] # type: typing.List[_MQTTTopicLevel]
  38. def _mac_address_valid(mac_address: str) -> bool:
  39. return _MAC_ADDRESS_REGEX.match(mac_address.lower()) is not None
  40. class _MQTTCallbackUserdata:
  41. # pylint: disable=too-few-public-methods; @dataclasses.dataclass when python_requires>=3.7
  42. def __init__(
  43. self,
  44. retry_count: int,
  45. device_passwords: typing.Dict[str, str],
  46. fetch_device_info: bool,
  47. ) -> None:
  48. self.retry_count = retry_count
  49. self.device_passwords = device_passwords
  50. self.fetch_device_info = fetch_device_info
  51. def __eq__(self, other: object) -> bool:
  52. return isinstance(other, type(self)) and vars(self) == vars(other)
  53. class _MQTTControlledActor(abc.ABC):
  54. MQTT_COMMAND_TOPIC_LEVELS = NotImplemented # type: typing.List[_MQTTTopicLevel]
  55. MQTT_STATE_TOPIC_LEVELS = NotImplemented # type: typing.List[_MQTTTopicLevel]
  56. @abc.abstractmethod
  57. def __init__(
  58. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  59. ) -> None:
  60. # alternative: pySwitchbot >=0.10.0 provides SwitchbotDevice.get_mac()
  61. self._mac_address = mac_address
  62. @abc.abstractmethod
  63. def execute_command(
  64. self,
  65. mqtt_message_payload: bytes,
  66. mqtt_client: paho.mqtt.client.Client,
  67. update_device_info: bool,
  68. ) -> None:
  69. raise NotImplementedError()
  70. @classmethod
  71. def _mqtt_command_callback(
  72. cls,
  73. mqtt_client: paho.mqtt.client.Client,
  74. userdata: _MQTTCallbackUserdata,
  75. message: paho.mqtt.client.MQTTMessage,
  76. ) -> None:
  77. # pylint: disable=unused-argument; callback
  78. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  79. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  80. if message.retain:
  81. _LOGGER.info("ignoring retained message")
  82. return
  83. topic_split = message.topic.split("/")
  84. if len(topic_split) != len(cls.MQTT_COMMAND_TOPIC_LEVELS):
  85. _LOGGER.warning("unexpected topic %s", message.topic)
  86. return
  87. mac_address = None
  88. for given_part, expected_part in zip(
  89. topic_split, cls.MQTT_COMMAND_TOPIC_LEVELS
  90. ):
  91. if expected_part == _MQTTTopicPlaceholder.MAC_ADDRESS:
  92. mac_address = given_part
  93. elif expected_part != given_part:
  94. _LOGGER.warning("unexpected topic %s", message.topic)
  95. return
  96. assert mac_address
  97. if not _mac_address_valid(mac_address):
  98. _LOGGER.warning("invalid mac address %s", mac_address)
  99. return
  100. actor = cls(
  101. mac_address=mac_address,
  102. retry_count=userdata.retry_count,
  103. password=userdata.device_passwords.get(mac_address, None),
  104. )
  105. actor.execute_command(
  106. mqtt_message_payload=message.payload,
  107. mqtt_client=mqtt_client,
  108. # consider calling update+report method directly when adding support for battery levels
  109. update_device_info=userdata.fetch_device_info,
  110. )
  111. @classmethod
  112. def mqtt_subscribe(cls, mqtt_client: paho.mqtt.client.Client) -> None:
  113. command_topic = "/".join(
  114. "+" if isinstance(l, _MQTTTopicPlaceholder) else l
  115. for l in cls.MQTT_COMMAND_TOPIC_LEVELS
  116. )
  117. _LOGGER.info("subscribing to MQTT topic %r", command_topic)
  118. mqtt_client.subscribe(command_topic)
  119. mqtt_client.message_callback_add(
  120. sub=command_topic,
  121. callback=cls._mqtt_command_callback,
  122. )
  123. def _mqtt_publish(
  124. self,
  125. topic_levels: typing.List[_MQTTTopicLevel],
  126. payload: bytes,
  127. mqtt_client: paho.mqtt.client.Client,
  128. ) -> None:
  129. topic = "/".join(
  130. self._mac_address
  131. if l == _MQTTTopicPlaceholder.MAC_ADDRESS
  132. else typing.cast(str, l)
  133. for l in topic_levels
  134. )
  135. # https://pypi.org/project/paho-mqtt/#publishing
  136. _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
  137. message_info = mqtt_client.publish(
  138. topic=topic, payload=payload, retain=True
  139. ) # type: paho.mqtt.client.MQTTMessageInfo
  140. # wait before checking status?
  141. if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
  142. _LOGGER.error(
  143. "Failed to publish MQTT message on topic %s (rc=%d)",
  144. topic,
  145. message_info.rc,
  146. )
  147. def report_state(self, state: bytes, mqtt_client: paho.mqtt.client.Client) -> None:
  148. self._mqtt_publish(
  149. topic_levels=self.MQTT_STATE_TOPIC_LEVELS,
  150. payload=state,
  151. mqtt_client=mqtt_client,
  152. )
  153. class _ButtonAutomator(_MQTTControlledActor):
  154. # https://www.home-assistant.io/integrations/switch.mqtt/
  155. MQTT_COMMAND_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  156. "switch",
  157. "switchbot",
  158. _MQTTTopicPlaceholder.MAC_ADDRESS,
  159. "set",
  160. ]
  161. MQTT_STATE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  162. "switch",
  163. "switchbot",
  164. _MQTTTopicPlaceholder.MAC_ADDRESS,
  165. "state",
  166. ]
  167. def __init__(
  168. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  169. ) -> None:
  170. self._device = switchbot.Switchbot(
  171. mac=mac_address, password=password, retry_count=retry_count
  172. )
  173. super().__init__(
  174. mac_address=mac_address, retry_count=retry_count, password=password
  175. )
  176. def execute_command(
  177. self,
  178. mqtt_message_payload: bytes,
  179. mqtt_client: paho.mqtt.client.Client,
  180. update_device_info: bool,
  181. ) -> None:
  182. # https://www.home-assistant.io/integrations/switch.mqtt/#payload_on
  183. if mqtt_message_payload.lower() == b"on":
  184. if not self._device.turn_on():
  185. _LOGGER.error("failed to turn on switchbot %s", self._mac_address)
  186. else:
  187. _LOGGER.info("switchbot %s turned on", self._mac_address)
  188. # https://www.home-assistant.io/integrations/switch.mqtt/#state_on
  189. self.report_state(mqtt_client=mqtt_client, state=b"ON")
  190. # https://www.home-assistant.io/integrations/switch.mqtt/#payload_off
  191. elif mqtt_message_payload.lower() == b"off":
  192. if not self._device.turn_off():
  193. _LOGGER.error("failed to turn off switchbot %s", self._mac_address)
  194. else:
  195. _LOGGER.info("switchbot %s turned off", self._mac_address)
  196. self.report_state(mqtt_client=mqtt_client, state=b"OFF")
  197. else:
  198. _LOGGER.warning(
  199. "unexpected payload %r (expected 'ON' or 'OFF')", mqtt_message_payload
  200. )
  201. class _CurtainMotor(_MQTTControlledActor):
  202. # https://www.home-assistant.io/integrations/cover.mqtt/
  203. MQTT_COMMAND_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  204. "cover",
  205. "switchbot-curtain",
  206. _MQTTTopicPlaceholder.MAC_ADDRESS,
  207. "set",
  208. ]
  209. MQTT_STATE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  210. "cover",
  211. "switchbot-curtain",
  212. _MQTTTopicPlaceholder.MAC_ADDRESS,
  213. "state",
  214. ]
  215. _MQTT_POSITION_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  216. "cover",
  217. "switchbot-curtain",
  218. _MQTTTopicPlaceholder.MAC_ADDRESS,
  219. "position",
  220. ]
  221. def __init__(
  222. self, mac_address: str, retry_count: int, password: typing.Optional[str]
  223. ) -> None:
  224. # > The position of the curtain is saved in self._pos with 0 = open and 100 = closed.
  225. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L150
  226. self._device = switchbot.SwitchbotCurtain(
  227. mac=mac_address,
  228. password=password,
  229. retry_count=retry_count,
  230. reverse_mode=True,
  231. )
  232. super().__init__(
  233. mac_address=mac_address, retry_count=retry_count, password=password
  234. )
  235. def _report_position(self, mqtt_client: paho.mqtt.client.Client) -> None:
  236. # > position_closed integer (Optional, default: 0)
  237. # > position_open integer (Optional, default: 100)
  238. # https://www.home-assistant.io/integrations/cover.mqtt/#position_closed
  239. # SwitchbotCurtain.get_position() returns a cached value within [0, 100].
  240. # SwitchbotCurtain.open() and .close() update the position optimistically,
  241. # SwitchbotCurtain.update() fetches the real position via bluetooth.
  242. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L202
  243. self._mqtt_publish(
  244. topic_levels=self._MQTT_POSITION_TOPIC_LEVELS,
  245. payload=str(int(self._device.get_position())).encode(),
  246. mqtt_client=mqtt_client,
  247. )
  248. def _update_position(self, mqtt_client: paho.mqtt.client.Client) -> None:
  249. try:
  250. self._device.update()
  251. except bluepy.btle.BTLEManagementError as exc:
  252. if (
  253. exc.emsg == "Permission Denied"
  254. and exc.message == "Failed to execute management command 'le on'"
  255. ):
  256. raise PermissionError(
  257. "bluepy-helper failed to enable low energy mode"
  258. + " due to insufficient permissions."
  259. + "\nSee {}, {}, and {}.".format(
  260. "https://github.com/IanHarvey/bluepy/issues/313#issuecomment-428324639",
  261. "https://github.com/fphammerle/switchbot-mqtt/pull/31"
  262. + "#issuecomment-846383603",
  263. "https://github.com/IanHarvey/bluepy/blob/v/1.3.0/bluepy/bluepy-helper.c"
  264. + "#L1260",
  265. )
  266. + "\nInsecure workaround:"
  267. + "\n1. sudo apt-get install --no-install-recommends libcap2-bin"
  268. + "\n2. sudo setcap cap_net_admin+ep {}".format(
  269. shlex.quote(bluepy.btle.helperExe)
  270. )
  271. + "\n3. restart switchbot-mqtt"
  272. ) from exc
  273. raise
  274. self._report_position(mqtt_client=mqtt_client)
  275. def execute_command(
  276. self,
  277. mqtt_message_payload: bytes,
  278. mqtt_client: paho.mqtt.client.Client,
  279. update_device_info: bool,
  280. ) -> None:
  281. # https://www.home-assistant.io/integrations/cover.mqtt/#payload_open
  282. if mqtt_message_payload.lower() == b"open":
  283. if not self._device.open():
  284. _LOGGER.error("failed to open switchbot curtain %s", self._mac_address)
  285. else:
  286. _LOGGER.info("switchbot curtain %s opening", self._mac_address)
  287. # > state_opening string (Optional, default: opening)
  288. # https://www.home-assistant.io/integrations/cover.mqtt/#state_opening
  289. self.report_state(mqtt_client=mqtt_client, state=b"opening")
  290. elif mqtt_message_payload.lower() == b"close":
  291. if not self._device.close():
  292. _LOGGER.error("failed to close switchbot curtain %s", self._mac_address)
  293. else:
  294. _LOGGER.info("switchbot curtain %s closing", self._mac_address)
  295. # https://www.home-assistant.io/integrations/cover.mqtt/#state_closing
  296. self.report_state(mqtt_client=mqtt_client, state=b"closing")
  297. elif mqtt_message_payload.lower() == b"stop":
  298. if not self._device.stop():
  299. _LOGGER.error("failed to stop switchbot curtain %s", self._mac_address)
  300. else:
  301. _LOGGER.info("switchbot curtain %s stopped", self._mac_address)
  302. # no "stopped" state mentioned at
  303. # https://www.home-assistant.io/integrations/cover.mqtt/#configuration-variables
  304. # https://community.home-assistant.io/t/mqtt-how-to-remove-retained-messages/79029/2
  305. self.report_state(mqtt_client=mqtt_client, state=b"")
  306. if update_device_info:
  307. self._update_position(mqtt_client=mqtt_client)
  308. else:
  309. _LOGGER.warning(
  310. "unexpected payload %r (expected 'OPEN', 'CLOSE', or 'STOP')",
  311. mqtt_message_payload,
  312. )
  313. def _mqtt_on_connect(
  314. mqtt_client: paho.mqtt.client.Client,
  315. userdata: _MQTTCallbackUserdata,
  316. flags: typing.Dict,
  317. return_code: int,
  318. ) -> None:
  319. # pylint: disable=unused-argument; callback
  320. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
  321. assert return_code == 0, return_code # connection accepted
  322. mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
  323. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
  324. _ButtonAutomator.mqtt_subscribe(mqtt_client=mqtt_client)
  325. _CurtainMotor.mqtt_subscribe(mqtt_client=mqtt_client)
  326. def _run(
  327. mqtt_host: str,
  328. mqtt_port: int,
  329. mqtt_username: typing.Optional[str],
  330. mqtt_password: typing.Optional[str],
  331. retry_count: int,
  332. device_passwords: typing.Dict[str, str],
  333. fetch_device_info: bool,
  334. ) -> None:
  335. # https://pypi.org/project/paho-mqtt/
  336. mqtt_client = paho.mqtt.client.Client(
  337. userdata=_MQTTCallbackUserdata(
  338. retry_count=retry_count,
  339. device_passwords=device_passwords,
  340. fetch_device_info=fetch_device_info,
  341. )
  342. )
  343. mqtt_client.on_connect = _mqtt_on_connect
  344. _LOGGER.info("connecting to MQTT broker %s:%d", mqtt_host, mqtt_port)
  345. if mqtt_username:
  346. mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)
  347. elif mqtt_password:
  348. raise ValueError("Missing MQTT username")
  349. mqtt_client.connect(host=mqtt_host, port=mqtt_port)
  350. # https://github.com/eclipse/paho.mqtt.python/blob/master/src/paho/mqtt/client.py#L1740
  351. mqtt_client.loop_forever()
  352. def _main() -> None:
  353. logging.basicConfig(
  354. level=logging.DEBUG,
  355. format="%(asctime)s:%(levelname)s:%(name)s:%(message)s",
  356. datefmt="%Y-%m-%dT%H:%M:%S%z",
  357. )
  358. argparser = argparse.ArgumentParser(
  359. description="MQTT client controlling SwitchBot button automators, "
  360. "compatible with home-assistant.io's MQTT Switch platform"
  361. )
  362. argparser.add_argument("--mqtt-host", type=str, required=True)
  363. argparser.add_argument("--mqtt-port", type=int, default=1883)
  364. argparser.add_argument("--mqtt-username", type=str)
  365. password_argument_group = argparser.add_mutually_exclusive_group()
  366. password_argument_group.add_argument("--mqtt-password", type=str)
  367. password_argument_group.add_argument(
  368. "--mqtt-password-file",
  369. type=pathlib.Path,
  370. metavar="PATH",
  371. dest="mqtt_password_path",
  372. help="stripping trailing newline",
  373. )
  374. argparser.add_argument(
  375. "--device-password-file",
  376. type=pathlib.Path,
  377. metavar="PATH",
  378. dest="device_password_path",
  379. help="path to json file mapping mac addresses of switchbot devices to passwords, e.g. "
  380. + json.dumps({"11:22:33:44:55:66": "password", "aa:bb:cc:dd:ee:ff": "secret"}),
  381. )
  382. argparser.add_argument(
  383. "--retries",
  384. dest="retry_count",
  385. type=int,
  386. default=switchbot.DEFAULT_RETRY_COUNT,
  387. help="Maximum number of attempts to send a command to a SwitchBot device"
  388. " (default: %(default)d)",
  389. )
  390. argparser.add_argument(
  391. "--fetch-device-info", # generic name to cover future addition of battery level etc.
  392. action="store_true",
  393. help="Report curtain motors' position on topic"
  394. " homeassistant/cover/switchbot-curtain/MAC_ADDRESS/position after sending stop command.",
  395. )
  396. args = argparser.parse_args()
  397. if args.mqtt_password_path:
  398. # .read_text() replaces \r\n with \n
  399. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  400. if mqtt_password.endswith("\r\n"):
  401. mqtt_password = mqtt_password[:-2]
  402. elif mqtt_password.endswith("\n"):
  403. mqtt_password = mqtt_password[:-1]
  404. else:
  405. mqtt_password = args.mqtt_password
  406. if args.device_password_path:
  407. device_passwords = json.loads(args.device_password_path.read_text())
  408. else:
  409. device_passwords = {}
  410. _run(
  411. mqtt_host=args.mqtt_host,
  412. mqtt_port=args.mqtt_port,
  413. mqtt_username=args.mqtt_username,
  414. mqtt_password=mqtt_password,
  415. retry_count=args.retry_count,
  416. device_passwords=device_passwords,
  417. fetch_device_info=args.fetch_device_info,
  418. )