__init__.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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 os
  25. import pathlib
  26. import queue
  27. import re
  28. import shlex
  29. import typing
  30. import bluepy.btle
  31. import paho.mqtt.client
  32. import switchbot
  33. _LOGGER = logging.getLogger(__name__)
  34. _MAC_ADDRESS_REGEX = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
  35. class _MQTTTopicPlaceholder(enum.Enum):
  36. MAC_ADDRESS = "MAC_ADDRESS"
  37. _MQTTTopicLevel = typing.Union[str, _MQTTTopicPlaceholder]
  38. # "homeassistant" for historic reason, may be parametrized in future
  39. _MQTT_TOPIC_LEVELS_PREFIX: typing.List[_MQTTTopicLevel] = ["homeassistant"]
  40. def _join_mqtt_topic_levels(
  41. topic_levels: typing.List[_MQTTTopicLevel], mac_address: str
  42. ) -> str:
  43. return "/".join(
  44. mac_address if l == _MQTTTopicPlaceholder.MAC_ADDRESS else typing.cast(str, l)
  45. for l in topic_levels
  46. )
  47. def _mac_address_valid(mac_address: str) -> bool:
  48. return _MAC_ADDRESS_REGEX.match(mac_address.lower()) is not None
  49. class _QueueLogHandler(logging.Handler):
  50. """
  51. logging.handlers.QueueHandler drops exc_info
  52. """
  53. # TypeError: 'type' object is not subscriptable
  54. def __init__(self, log_queue: "queue.Queue[logging.LogRecord]") -> None:
  55. self.log_queue = log_queue
  56. super().__init__()
  57. def emit(self, record: logging.LogRecord) -> None:
  58. self.log_queue.put(record)
  59. class _MQTTCallbackUserdata:
  60. # pylint: disable=too-few-public-methods; @dataclasses.dataclass when python_requires>=3.7
  61. def __init__(
  62. self,
  63. *,
  64. retry_count: int,
  65. device_passwords: typing.Dict[str, str],
  66. fetch_device_info: bool,
  67. ) -> None:
  68. self.retry_count = retry_count
  69. self.device_passwords = device_passwords
  70. self.fetch_device_info = fetch_device_info
  71. def __eq__(self, other: object) -> bool:
  72. return isinstance(other, type(self)) and vars(self) == vars(other)
  73. class _MQTTControlledActor(abc.ABC):
  74. MQTT_COMMAND_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  75. MQTT_STATE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  76. _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS: typing.List[_MQTTTopicLevel] = NotImplemented
  77. @classmethod
  78. def get_mqtt_battery_percentage_topic(cls, mac_address: str) -> str:
  79. return _join_mqtt_topic_levels(
  80. topic_levels=cls._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
  81. mac_address=mac_address,
  82. )
  83. @abc.abstractmethod
  84. def __init__(
  85. self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
  86. ) -> None:
  87. # alternative: pySwitchbot >=0.10.0 provides SwitchbotDevice.get_mac()
  88. self._mac_address = mac_address
  89. @abc.abstractmethod
  90. def _get_device(self) -> switchbot.SwitchbotDevice:
  91. raise NotImplementedError()
  92. def _update_device_info(self) -> None:
  93. log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=0)
  94. logging.getLogger("switchbot").addHandler(_QueueLogHandler(log_queue))
  95. try:
  96. self._get_device().update()
  97. # pySwitchbot>=v0.10.1 catches bluepy.btle.BTLEManagementError :(
  98. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.1/switchbot/__init__.py#L141
  99. while not log_queue.empty():
  100. log_record = log_queue.get()
  101. if log_record.exc_info:
  102. exc: typing.Optional[BaseException] = log_record.exc_info[1]
  103. if (
  104. isinstance(exc, bluepy.btle.BTLEManagementError)
  105. and exc.emsg == "Permission Denied"
  106. ):
  107. raise exc
  108. except bluepy.btle.BTLEManagementError as exc:
  109. if (
  110. exc.emsg == "Permission Denied"
  111. and exc.message == "Failed to execute management command 'le on'"
  112. ):
  113. raise PermissionError(
  114. "bluepy-helper failed to enable low energy mode"
  115. " due to insufficient permissions."
  116. "\nSee https://github.com/IanHarvey/bluepy/issues/313#issuecomment-428324639"
  117. ", https://github.com/fphammerle/switchbot-mqtt/pull/31#issuecomment-846383603"
  118. ", and https://github.com/IanHarvey/bluepy/blob/v/1.3.0/bluepy"
  119. "/bluepy-helper.c#L1260."
  120. "\nInsecure workaround:"
  121. "\n1. sudo apt-get install --no-install-recommends libcap2-bin"
  122. f"\n2. sudo setcap cap_net_admin+ep {shlex.quote(bluepy.btle.helperExe)}"
  123. "\n3. restart switchbot-mqtt"
  124. "\nIn docker-based setups, you could use"
  125. " `sudo docker run --cap-drop ALL --cap-add NET_ADMIN --user 0 …`"
  126. " (seriously insecure)."
  127. ) from exc
  128. raise
  129. def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
  130. # > battery: Percentage of battery that is left.
  131. # https://www.home-assistant.io/integrations/sensor/#device-class
  132. self._mqtt_publish(
  133. topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS,
  134. payload=str(self._get_device().get_battery_percent()).encode(),
  135. mqtt_client=mqtt_client,
  136. )
  137. def _update_and_report_device_info(
  138. self, mqtt_client: paho.mqtt.client.Client
  139. ) -> None:
  140. self._update_device_info()
  141. self._report_battery_level(mqtt_client=mqtt_client)
  142. @abc.abstractmethod
  143. def execute_command(
  144. self,
  145. mqtt_message_payload: bytes,
  146. mqtt_client: paho.mqtt.client.Client,
  147. update_device_info: bool,
  148. ) -> None:
  149. raise NotImplementedError()
  150. @classmethod
  151. def _mqtt_command_callback(
  152. cls,
  153. mqtt_client: paho.mqtt.client.Client,
  154. userdata: _MQTTCallbackUserdata,
  155. message: paho.mqtt.client.MQTTMessage,
  156. ) -> None:
  157. # pylint: disable=unused-argument; callback
  158. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  159. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  160. if message.retain:
  161. _LOGGER.info("ignoring retained message")
  162. return
  163. topic_split = message.topic.split("/")
  164. if len(topic_split) != len(cls.MQTT_COMMAND_TOPIC_LEVELS):
  165. _LOGGER.warning("unexpected topic %s", message.topic)
  166. return
  167. mac_address = None
  168. for given_part, expected_part in zip(
  169. topic_split, cls.MQTT_COMMAND_TOPIC_LEVELS
  170. ):
  171. if expected_part == _MQTTTopicPlaceholder.MAC_ADDRESS:
  172. mac_address = given_part
  173. elif expected_part != given_part:
  174. _LOGGER.warning("unexpected topic %s", message.topic)
  175. return
  176. assert mac_address
  177. if not _mac_address_valid(mac_address):
  178. _LOGGER.warning("invalid mac address %s", mac_address)
  179. return
  180. actor = cls(
  181. mac_address=mac_address,
  182. retry_count=userdata.retry_count,
  183. password=userdata.device_passwords.get(mac_address, None),
  184. )
  185. actor.execute_command(
  186. mqtt_message_payload=message.payload,
  187. mqtt_client=mqtt_client,
  188. # consider calling update+report method directly when adding support for battery levels
  189. update_device_info=userdata.fetch_device_info,
  190. )
  191. @classmethod
  192. def mqtt_subscribe(cls, mqtt_client: paho.mqtt.client.Client) -> None:
  193. command_topic = "/".join(
  194. "+" if isinstance(l, _MQTTTopicPlaceholder) else l
  195. for l in cls.MQTT_COMMAND_TOPIC_LEVELS
  196. )
  197. _LOGGER.info("subscribing to MQTT topic %r", command_topic)
  198. mqtt_client.subscribe(command_topic)
  199. mqtt_client.message_callback_add(
  200. sub=command_topic,
  201. callback=cls._mqtt_command_callback,
  202. )
  203. def _mqtt_publish(
  204. self,
  205. *,
  206. topic_levels: typing.List[_MQTTTopicLevel],
  207. payload: bytes,
  208. mqtt_client: paho.mqtt.client.Client,
  209. ) -> None:
  210. topic = _join_mqtt_topic_levels(
  211. topic_levels=topic_levels, mac_address=self._mac_address
  212. )
  213. # https://pypi.org/project/paho-mqtt/#publishing
  214. _LOGGER.debug("publishing topic=%s payload=%r", topic, payload)
  215. message_info: paho.mqtt.client.MQTTMessageInfo = mqtt_client.publish(
  216. topic=topic, payload=payload, retain=True
  217. )
  218. # wait before checking status?
  219. if message_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
  220. _LOGGER.error(
  221. "Failed to publish MQTT message on topic %s (rc=%d)",
  222. topic,
  223. message_info.rc,
  224. )
  225. def report_state(self, state: bytes, mqtt_client: paho.mqtt.client.Client) -> None:
  226. self._mqtt_publish(
  227. topic_levels=self.MQTT_STATE_TOPIC_LEVELS,
  228. payload=state,
  229. mqtt_client=mqtt_client,
  230. )
  231. class _ButtonAutomator(_MQTTControlledActor):
  232. # https://www.home-assistant.io/integrations/switch.mqtt/
  233. MQTT_COMMAND_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  234. "switch",
  235. "switchbot",
  236. _MQTTTopicPlaceholder.MAC_ADDRESS,
  237. "set",
  238. ]
  239. MQTT_STATE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  240. "switch",
  241. "switchbot",
  242. _MQTTTopicPlaceholder.MAC_ADDRESS,
  243. "state",
  244. ]
  245. _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  246. "switch",
  247. "switchbot",
  248. _MQTTTopicPlaceholder.MAC_ADDRESS,
  249. "battery-percentage",
  250. ]
  251. # for downward compatibility (will be removed in v3):
  252. _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS_LEGACY = _MQTT_TOPIC_LEVELS_PREFIX + [
  253. "cover",
  254. "switchbot",
  255. _MQTTTopicPlaceholder.MAC_ADDRESS,
  256. "battery-percentage",
  257. ]
  258. def __init__(
  259. self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
  260. ) -> None:
  261. self.__device = switchbot.Switchbot(
  262. mac=mac_address, password=password, retry_count=retry_count
  263. )
  264. super().__init__(
  265. mac_address=mac_address, retry_count=retry_count, password=password
  266. )
  267. def _get_device(self) -> switchbot.SwitchbotDevice:
  268. return self.__device
  269. def _report_battery_level(self, mqtt_client: paho.mqtt.client.Client) -> None:
  270. super()._report_battery_level(mqtt_client=mqtt_client)
  271. # kept for downward compatibility (will be removed in v3)
  272. self._mqtt_publish(
  273. topic_levels=self._MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS_LEGACY,
  274. payload=str(self._get_device().get_battery_percent()).encode(),
  275. mqtt_client=mqtt_client,
  276. )
  277. def execute_command(
  278. self,
  279. mqtt_message_payload: bytes,
  280. mqtt_client: paho.mqtt.client.Client,
  281. update_device_info: bool,
  282. ) -> None:
  283. # https://www.home-assistant.io/integrations/switch.mqtt/#payload_on
  284. if mqtt_message_payload.lower() == b"on":
  285. if not self.__device.turn_on():
  286. _LOGGER.error("failed to turn on switchbot %s", self._mac_address)
  287. else:
  288. _LOGGER.info("switchbot %s turned on", self._mac_address)
  289. # https://www.home-assistant.io/integrations/switch.mqtt/#state_on
  290. self.report_state(mqtt_client=mqtt_client, state=b"ON")
  291. if update_device_info:
  292. self._update_and_report_device_info(mqtt_client)
  293. # https://www.home-assistant.io/integrations/switch.mqtt/#payload_off
  294. elif mqtt_message_payload.lower() == b"off":
  295. if not self.__device.turn_off():
  296. _LOGGER.error("failed to turn off switchbot %s", self._mac_address)
  297. else:
  298. _LOGGER.info("switchbot %s turned off", self._mac_address)
  299. self.report_state(mqtt_client=mqtt_client, state=b"OFF")
  300. if update_device_info:
  301. self._update_and_report_device_info(mqtt_client)
  302. else:
  303. _LOGGER.warning(
  304. "unexpected payload %r (expected 'ON' or 'OFF')", mqtt_message_payload
  305. )
  306. class _CurtainMotor(_MQTTControlledActor):
  307. # https://www.home-assistant.io/integrations/cover.mqtt/
  308. MQTT_COMMAND_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  309. "cover",
  310. "switchbot-curtain",
  311. _MQTTTopicPlaceholder.MAC_ADDRESS,
  312. "set",
  313. ]
  314. MQTT_STATE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  315. "cover",
  316. "switchbot-curtain",
  317. _MQTTTopicPlaceholder.MAC_ADDRESS,
  318. "state",
  319. ]
  320. _MQTT_BATTERY_PERCENTAGE_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  321. "cover",
  322. "switchbot-curtain",
  323. _MQTTTopicPlaceholder.MAC_ADDRESS,
  324. "battery-percentage",
  325. ]
  326. _MQTT_POSITION_TOPIC_LEVELS = _MQTT_TOPIC_LEVELS_PREFIX + [
  327. "cover",
  328. "switchbot-curtain",
  329. _MQTTTopicPlaceholder.MAC_ADDRESS,
  330. "position",
  331. ]
  332. @classmethod
  333. def get_mqtt_position_topic(cls, mac_address: str) -> str:
  334. return _join_mqtt_topic_levels(
  335. topic_levels=cls._MQTT_POSITION_TOPIC_LEVELS, mac_address=mac_address
  336. )
  337. def __init__(
  338. self, *, mac_address: str, retry_count: int, password: typing.Optional[str]
  339. ) -> None:
  340. # > The position of the curtain is saved in self._pos with 0 = open and 100 = closed.
  341. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L150
  342. self.__device = switchbot.SwitchbotCurtain(
  343. mac=mac_address,
  344. password=password,
  345. retry_count=retry_count,
  346. reverse_mode=True,
  347. )
  348. super().__init__(
  349. mac_address=mac_address, retry_count=retry_count, password=password
  350. )
  351. def _get_device(self) -> switchbot.SwitchbotDevice:
  352. return self.__device
  353. def _report_position(self, mqtt_client: paho.mqtt.client.Client) -> None:
  354. # > position_closed integer (Optional, default: 0)
  355. # > position_open integer (Optional, default: 100)
  356. # https://www.home-assistant.io/integrations/cover.mqtt/#position_closed
  357. # SwitchbotCurtain.get_position() returns a cached value within [0, 100].
  358. # SwitchbotCurtain.open() and .close() update the position optimistically,
  359. # SwitchbotCurtain.update() fetches the real position via bluetooth.
  360. # https://github.com/Danielhiversen/pySwitchbot/blob/0.10.0/switchbot/__init__.py#L202
  361. self._mqtt_publish(
  362. topic_levels=self._MQTT_POSITION_TOPIC_LEVELS,
  363. payload=str(int(self.__device.get_position())).encode(),
  364. mqtt_client=mqtt_client,
  365. )
  366. def _update_and_report_device_info( # pylint: disable=arguments-differ; report_position is optional
  367. self, mqtt_client: paho.mqtt.client.Client, *, report_position: bool = True
  368. ) -> None:
  369. super()._update_and_report_device_info(mqtt_client)
  370. if report_position:
  371. self._report_position(mqtt_client=mqtt_client)
  372. def execute_command(
  373. self,
  374. mqtt_message_payload: bytes,
  375. mqtt_client: paho.mqtt.client.Client,
  376. update_device_info: bool,
  377. ) -> None:
  378. # https://www.home-assistant.io/integrations/cover.mqtt/#payload_open
  379. report_device_info, report_position = False, False
  380. if mqtt_message_payload.lower() == b"open":
  381. if not self.__device.open():
  382. _LOGGER.error("failed to open switchbot curtain %s", self._mac_address)
  383. else:
  384. _LOGGER.info("switchbot curtain %s opening", self._mac_address)
  385. # > state_opening string (Optional, default: opening)
  386. # https://www.home-assistant.io/integrations/cover.mqtt/#state_opening
  387. self.report_state(mqtt_client=mqtt_client, state=b"opening")
  388. report_device_info = update_device_info
  389. elif mqtt_message_payload.lower() == b"close":
  390. if not self.__device.close():
  391. _LOGGER.error("failed to close switchbot curtain %s", self._mac_address)
  392. else:
  393. _LOGGER.info("switchbot curtain %s closing", self._mac_address)
  394. # https://www.home-assistant.io/integrations/cover.mqtt/#state_closing
  395. self.report_state(mqtt_client=mqtt_client, state=b"closing")
  396. report_device_info = update_device_info
  397. elif mqtt_message_payload.lower() == b"stop":
  398. if not self.__device.stop():
  399. _LOGGER.error("failed to stop switchbot curtain %s", self._mac_address)
  400. else:
  401. _LOGGER.info("switchbot curtain %s stopped", self._mac_address)
  402. # no "stopped" state mentioned at
  403. # https://www.home-assistant.io/integrations/cover.mqtt/#configuration-variables
  404. # https://community.home-assistant.io/t/mqtt-how-to-remove-retained-messages/79029/2
  405. self.report_state(mqtt_client=mqtt_client, state=b"")
  406. report_device_info = update_device_info
  407. report_position = True
  408. else:
  409. _LOGGER.warning(
  410. "unexpected payload %r (expected 'OPEN', 'CLOSE', or 'STOP')",
  411. mqtt_message_payload,
  412. )
  413. if report_device_info:
  414. self._update_and_report_device_info(
  415. mqtt_client=mqtt_client, report_position=report_position
  416. )
  417. def _mqtt_on_connect(
  418. mqtt_client: paho.mqtt.client.Client,
  419. userdata: _MQTTCallbackUserdata,
  420. flags: typing.Dict,
  421. return_code: int,
  422. ) -> None:
  423. # pylint: disable=unused-argument; callback
  424. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
  425. assert return_code == 0, return_code # connection accepted
  426. mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
  427. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
  428. _ButtonAutomator.mqtt_subscribe(mqtt_client=mqtt_client)
  429. _CurtainMotor.mqtt_subscribe(mqtt_client=mqtt_client)
  430. def _run(
  431. *,
  432. mqtt_host: str,
  433. mqtt_port: int,
  434. mqtt_username: typing.Optional[str],
  435. mqtt_password: typing.Optional[str],
  436. retry_count: int,
  437. device_passwords: typing.Dict[str, str],
  438. fetch_device_info: bool,
  439. ) -> None:
  440. # https://pypi.org/project/paho-mqtt/
  441. mqtt_client = paho.mqtt.client.Client(
  442. userdata=_MQTTCallbackUserdata(
  443. retry_count=retry_count,
  444. device_passwords=device_passwords,
  445. fetch_device_info=fetch_device_info,
  446. )
  447. )
  448. mqtt_client.on_connect = _mqtt_on_connect
  449. _LOGGER.info("connecting to MQTT broker %s:%d", mqtt_host, mqtt_port)
  450. if mqtt_username:
  451. mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)
  452. elif mqtt_password:
  453. raise ValueError("Missing MQTT username")
  454. mqtt_client.connect(host=mqtt_host, port=mqtt_port)
  455. # https://github.com/eclipse/paho.mqtt.python/blob/master/src/paho/mqtt/client.py#L1740
  456. mqtt_client.loop_forever()
  457. def _main() -> None:
  458. argparser = argparse.ArgumentParser(
  459. description="MQTT client controlling SwitchBot button automators, "
  460. "compatible with home-assistant.io's MQTT Switch platform"
  461. )
  462. argparser.add_argument("--mqtt-host", type=str, required=True)
  463. argparser.add_argument("--mqtt-port", type=int, default=1883)
  464. argparser.add_argument("--mqtt-username", type=str)
  465. password_argument_group = argparser.add_mutually_exclusive_group()
  466. password_argument_group.add_argument("--mqtt-password", type=str)
  467. password_argument_group.add_argument(
  468. "--mqtt-password-file",
  469. type=pathlib.Path,
  470. metavar="PATH",
  471. dest="mqtt_password_path",
  472. help="stripping trailing newline",
  473. )
  474. argparser.add_argument(
  475. "--device-password-file",
  476. type=pathlib.Path,
  477. metavar="PATH",
  478. dest="device_password_path",
  479. help="path to json file mapping mac addresses of switchbot devices to passwords, e.g. "
  480. + json.dumps({"11:22:33:44:55:66": "password", "aa:bb:cc:dd:ee:ff": "secret"}),
  481. )
  482. argparser.add_argument(
  483. "--retries",
  484. dest="retry_count",
  485. type=int,
  486. default=switchbot.DEFAULT_RETRY_COUNT,
  487. help="Maximum number of attempts to send a command to a SwitchBot device"
  488. " (default: %(default)d)",
  489. )
  490. argparser.add_argument(
  491. "--fetch-device-info",
  492. action="store_true",
  493. help="Report devices' battery level on topic"
  494. f" {_ButtonAutomator.get_mqtt_battery_percentage_topic(mac_address='MAC_ADDRESS')}"
  495. " or, respectively,"
  496. f" {_CurtainMotor.get_mqtt_battery_percentage_topic(mac_address='MAC_ADDRESS')}"
  497. " after every command. Additionally report curtain motors' position on"
  498. f" topic {_CurtainMotor.get_mqtt_position_topic(mac_address='MAC_ADDRESS')}"
  499. " after executing stop commands."
  500. " This option can also be enabled by assigning a non-empty value to the"
  501. " environment variable FETCH_DEVICE_INFO.",
  502. )
  503. argparser.add_argument("--debug", action="store_true")
  504. args = argparser.parse_args()
  505. # https://github.com/fphammerle/python-cc1101/blob/26d8122661fc4587ecc7c73df55b92d05cf98fe8/cc1101/_cli.py#L51
  506. logging.basicConfig(
  507. level=logging.DEBUG if args.debug else logging.INFO,
  508. format="%(asctime)s:%(levelname)s:%(name)s:%(funcName)s:%(message)s"
  509. if args.debug
  510. else "%(message)s",
  511. datefmt="%Y-%m-%dT%H:%M:%S%z",
  512. )
  513. _LOGGER.debug("args=%r", args)
  514. if args.mqtt_password_path:
  515. # .read_text() replaces \r\n with \n
  516. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  517. if mqtt_password.endswith("\r\n"):
  518. mqtt_password = mqtt_password[:-2]
  519. elif mqtt_password.endswith("\n"):
  520. mqtt_password = mqtt_password[:-1]
  521. else:
  522. mqtt_password = args.mqtt_password
  523. if args.device_password_path:
  524. device_passwords = json.loads(args.device_password_path.read_text())
  525. else:
  526. device_passwords = {}
  527. _run(
  528. mqtt_host=args.mqtt_host,
  529. mqtt_port=args.mqtt_port,
  530. mqtt_username=args.mqtt_username,
  531. mqtt_password=mqtt_password,
  532. retry_count=args.retry_count,
  533. device_passwords=device_passwords,
  534. fetch_device_info=args.fetch_device_info
  535. # > In formal language theory, the empty string, [...], is the unique string of length zero.
  536. # https://en.wikipedia.org/wiki/Empty_string
  537. or bool(os.environ.get("FETCH_DEVICE_INFO")),
  538. )