__init__.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. # systemctl-mqtt - MQTT client triggering & reporting shutdown on systemd-based systems
  2. #
  3. # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. import abc
  18. import argparse
  19. import datetime
  20. import functools
  21. import importlib.metadata
  22. import json
  23. import logging
  24. import os
  25. import pathlib
  26. import socket
  27. import threading
  28. import typing
  29. import dbus
  30. import dbus.mainloop.glib
  31. # black keeps inserting a blank line above
  32. # https://pygobject.readthedocs.io/en/latest/getting_started.html#ubuntu-logo-ubuntu-debian-logo-debian
  33. import gi.repository.GLib # pylint-import-requirements: imports=PyGObject
  34. import paho.mqtt.client
  35. import systemctl_mqtt._dbus
  36. import systemctl_mqtt._homeassistant
  37. import systemctl_mqtt._mqtt
  38. _MQTT_DEFAULT_PORT = 1883
  39. _MQTT_DEFAULT_TLS_PORT = 8883
  40. _LOGGER = logging.getLogger(__name__)
  41. class _State:
  42. def __init__(
  43. self,
  44. *,
  45. mqtt_topic_prefix: str,
  46. homeassistant_discovery_prefix: str,
  47. homeassistant_discovery_object_id: str,
  48. poweroff_delay: datetime.timedelta,
  49. ) -> None:
  50. self._mqtt_topic_prefix = mqtt_topic_prefix
  51. self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
  52. self._homeassistant_discovery_object_id = homeassistant_discovery_object_id
  53. self._login_manager: dbus.proxies.Interface = (
  54. systemctl_mqtt._dbus.get_login_manager()
  55. )
  56. self._shutdown_lock: typing.Optional[dbus.types.UnixFd] = None
  57. self._shutdown_lock_mutex = threading.Lock()
  58. self.poweroff_delay = poweroff_delay
  59. @property
  60. def mqtt_topic_prefix(self) -> str:
  61. return self._mqtt_topic_prefix
  62. @property
  63. def shutdown_lock_acquired(self) -> bool:
  64. return self._shutdown_lock is not None
  65. def acquire_shutdown_lock(self) -> None:
  66. with self._shutdown_lock_mutex:
  67. assert self._shutdown_lock is None
  68. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  69. self._shutdown_lock = self._login_manager.Inhibit(
  70. "shutdown", "systemctl-mqtt", "Report shutdown via MQTT", "delay"
  71. )
  72. _LOGGER.debug("acquired shutdown inhibitor lock")
  73. def release_shutdown_lock(self) -> None:
  74. with self._shutdown_lock_mutex:
  75. if self._shutdown_lock:
  76. # https://dbus.freedesktop.org/doc/dbus-python/dbus.types.html#dbus.types.UnixFd.take
  77. os.close(self._shutdown_lock.take())
  78. _LOGGER.debug("released shutdown inhibitor lock")
  79. self._shutdown_lock = None
  80. @property
  81. def _preparing_for_shutdown_topic(self) -> str:
  82. return self.mqtt_topic_prefix + "/preparing-for-shutdown"
  83. def _publish_preparing_for_shutdown(
  84. self, *, mqtt_client: paho.mqtt.client.Client, active: bool, block: bool
  85. ) -> None:
  86. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1199
  87. topic = self._preparing_for_shutdown_topic
  88. # pylint: disable=protected-access
  89. payload = systemctl_mqtt._mqtt.encode_bool(active)
  90. _LOGGER.info("publishing %r on %s", payload, topic)
  91. msg_info = mqtt_client.publish(
  92. topic=topic, payload=payload, retain=True
  93. ) # type: paho.mqtt.client.MQTTMessageInfo
  94. if not block:
  95. return
  96. msg_info.wait_for_publish()
  97. if msg_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
  98. _LOGGER.error(
  99. "failed to publish on %s (return code %d)", topic, msg_info.rc
  100. )
  101. def _prepare_for_shutdown_handler(
  102. self, active: dbus.Boolean, mqtt_client: paho.mqtt.client.Client
  103. ) -> None:
  104. assert isinstance(active, dbus.Boolean)
  105. active = bool(active)
  106. self._publish_preparing_for_shutdown(
  107. mqtt_client=mqtt_client, active=active, block=True
  108. )
  109. if active:
  110. self.release_shutdown_lock()
  111. else:
  112. self.acquire_shutdown_lock()
  113. def register_prepare_for_shutdown_handler(
  114. self, mqtt_client: paho.mqtt.client.Client
  115. ) -> None:
  116. self._login_manager.connect_to_signal(
  117. signal_name="PrepareForShutdown",
  118. handler_function=functools.partial(
  119. self._prepare_for_shutdown_handler, mqtt_client=mqtt_client
  120. ),
  121. )
  122. def publish_preparing_for_shutdown(
  123. self, mqtt_client: paho.mqtt.client.Client
  124. ) -> None:
  125. try:
  126. active = self._login_manager.Get(
  127. "org.freedesktop.login1.Manager",
  128. "PreparingForShutdown",
  129. dbus_interface="org.freedesktop.DBus.Properties",
  130. )
  131. except dbus.DBusException as exc:
  132. _LOGGER.error(
  133. "failed to read logind's PreparingForShutdown property: %s",
  134. exc.get_dbus_message(),
  135. )
  136. return
  137. assert isinstance(active, dbus.Boolean), active
  138. self._publish_preparing_for_shutdown(
  139. mqtt_client=mqtt_client,
  140. active=bool(active),
  141. # https://github.com/eclipse/paho.mqtt.python/issues/439#issuecomment-565514393
  142. block=False,
  143. )
  144. def publish_homeassistant_device_config(
  145. self, mqtt_client: paho.mqtt.client.Client
  146. ) -> None:
  147. # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
  148. # https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
  149. discovery_topic = "/".join(
  150. (
  151. self._homeassistant_discovery_prefix,
  152. "device",
  153. self._homeassistant_discovery_object_id,
  154. "config",
  155. )
  156. )
  157. hostname = (
  158. # pylint: disable=protected-access; function in internal module
  159. systemctl_mqtt._utils.get_hostname()
  160. )
  161. package_metadata = importlib.metadata.metadata(__name__)
  162. unique_id_prefix = "systemctl-mqtt-" + hostname
  163. config = {
  164. "device": {"identifiers": [hostname], "name": hostname},
  165. "origin": {
  166. "name": package_metadata["Name"],
  167. "sw_version": package_metadata["Version"],
  168. "support_url": package_metadata["Home-page"],
  169. },
  170. "components": {
  171. "logind/preparing-for-shutdown": {
  172. "unique_id": unique_id_prefix + "-logind-preparing-for-shutdown",
  173. "object_id": f"{hostname}_logind_preparing_for_shutdown", # entity id
  174. "name": "preparing for shutdown", # home assistant prepends device name
  175. "platform": "binary_sensor",
  176. "state_topic": self._preparing_for_shutdown_topic,
  177. # pylint: disable=protected-access
  178. "payload_on": systemctl_mqtt._mqtt.encode_bool(True),
  179. "payload_off": systemctl_mqtt._mqtt.encode_bool(False),
  180. },
  181. },
  182. }
  183. for mqtt_topic_suffix in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.keys():
  184. # false positive warning by mypy:
  185. # > Unsupported target for indexed assignment
  186. config["components"]["logind/" + mqtt_topic_suffix] = { # type: ignore
  187. "unique_id": unique_id_prefix + "-logind-" + mqtt_topic_suffix,
  188. "object_id": hostname
  189. + "_logind_"
  190. + mqtt_topic_suffix.replace("-", "_"), # entity id
  191. "name": mqtt_topic_suffix.replace("-", " "),
  192. "platform": "button",
  193. "command_topic": self.mqtt_topic_prefix + "/" + mqtt_topic_suffix,
  194. }
  195. _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
  196. mqtt_client.publish(
  197. topic=discovery_topic, payload=json.dumps(config), retain=False
  198. )
  199. class _MQTTAction(metaclass=abc.ABCMeta):
  200. @abc.abstractmethod
  201. def trigger(self, state: _State) -> None:
  202. pass # pragma: no cover
  203. def mqtt_message_callback(
  204. self,
  205. mqtt_client: paho.mqtt.client.Client,
  206. state: _State,
  207. message: paho.mqtt.client.MQTTMessage,
  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#L3416
  211. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  212. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  213. if message.retain:
  214. _LOGGER.info("ignoring retained message")
  215. return
  216. _LOGGER.debug("executing action %s", self)
  217. self.trigger(state=state)
  218. _LOGGER.debug("completed action %s", self)
  219. class _MQTTActionSchedulePoweroff(_MQTTAction):
  220. def trigger(self, state: _State) -> None:
  221. # pylint: disable=protected-access
  222. systemctl_mqtt._dbus.schedule_shutdown(
  223. action="poweroff", delay=state.poweroff_delay
  224. )
  225. def __str__(self) -> str:
  226. return type(self).__name__
  227. class _MQTTActionLockAllSessions(_MQTTAction):
  228. def trigger(self, state: _State) -> None:
  229. # pylint: disable=protected-access
  230. systemctl_mqtt._dbus.lock_all_sessions()
  231. def __str__(self) -> str:
  232. return type(self).__name__
  233. _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
  234. "poweroff": _MQTTActionSchedulePoweroff(),
  235. "lock-all-sessions": _MQTTActionLockAllSessions(),
  236. }
  237. def _mqtt_on_connect(
  238. mqtt_client: paho.mqtt.client.Client,
  239. state: _State,
  240. flags: typing.Dict,
  241. return_code: int,
  242. ) -> None:
  243. # pylint: disable=unused-argument; callback
  244. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
  245. assert return_code == 0, return_code # connection accepted
  246. mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
  247. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
  248. if not state.shutdown_lock_acquired:
  249. state.acquire_shutdown_lock()
  250. state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
  251. state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
  252. state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
  253. for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
  254. topic = state.mqtt_topic_prefix + "/" + topic_suffix
  255. _LOGGER.info("subscribing to %s", topic)
  256. mqtt_client.subscribe(topic)
  257. mqtt_client.message_callback_add(
  258. sub=topic, callback=action.mqtt_message_callback
  259. )
  260. _LOGGER.debug(
  261. "registered MQTT callback for topic %s triggering %s", topic, action
  262. )
  263. def _run(
  264. *,
  265. mqtt_host: str,
  266. mqtt_port: int,
  267. mqtt_username: typing.Optional[str],
  268. mqtt_password: typing.Optional[str],
  269. mqtt_topic_prefix: str,
  270. homeassistant_discovery_prefix: str,
  271. homeassistant_discovery_object_id: str,
  272. poweroff_delay: datetime.timedelta,
  273. mqtt_disable_tls: bool = False,
  274. ) -> None:
  275. # pylint: disable=too-many-arguments
  276. # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html#setting-up-an-event-loop
  277. dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
  278. # https://pypi.org/project/paho-mqtt/
  279. mqtt_client = paho.mqtt.client.Client(
  280. userdata=_State(
  281. mqtt_topic_prefix=mqtt_topic_prefix,
  282. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  283. homeassistant_discovery_object_id=homeassistant_discovery_object_id,
  284. poweroff_delay=poweroff_delay,
  285. )
  286. )
  287. mqtt_client.on_connect = _mqtt_on_connect
  288. if not mqtt_disable_tls:
  289. mqtt_client.tls_set(ca_certs=None) # enable tls trusting default system certs
  290. _LOGGER.info(
  291. "connecting to MQTT broker %s:%d (TLS %s)",
  292. mqtt_host,
  293. mqtt_port,
  294. "disabled" if mqtt_disable_tls else "enabled",
  295. )
  296. if mqtt_username:
  297. mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)
  298. elif mqtt_password:
  299. raise ValueError("Missing MQTT username")
  300. mqtt_client.connect(host=mqtt_host, port=mqtt_port)
  301. # loop_start runs loop_forever in a new thread (daemon)
  302. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1814
  303. # loop_forever attempts to reconnect if disconnected
  304. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1744
  305. mqtt_client.loop_start()
  306. try:
  307. gi.repository.GLib.MainLoop().run()
  308. finally:
  309. # blocks until loop_forever stops
  310. _LOGGER.debug("waiting for MQTT loop to stop")
  311. mqtt_client.loop_stop()
  312. _LOGGER.debug("MQTT loop stopped")
  313. def _main() -> None:
  314. logging.basicConfig(
  315. level=logging.DEBUG,
  316. format="%(asctime)s:%(levelname)s:%(message)s",
  317. datefmt="%Y-%m-%dT%H:%M:%S%z",
  318. )
  319. argparser = argparse.ArgumentParser(
  320. description="MQTT client triggering & reporting shutdown on systemd-based systems",
  321. )
  322. argparser.add_argument("--mqtt-host", type=str, required=True)
  323. argparser.add_argument(
  324. "--mqtt-port",
  325. type=int,
  326. help=f"default {_MQTT_DEFAULT_TLS_PORT} ({_MQTT_DEFAULT_PORT} with --mqtt-disable-tls)",
  327. )
  328. argparser.add_argument("--mqtt-username", type=str)
  329. argparser.add_argument("--mqtt-disable-tls", action="store_true")
  330. password_argument_group = argparser.add_mutually_exclusive_group()
  331. password_argument_group.add_argument("--mqtt-password", type=str)
  332. password_argument_group.add_argument(
  333. "--mqtt-password-file",
  334. type=pathlib.Path,
  335. metavar="PATH",
  336. dest="mqtt_password_path",
  337. help="stripping trailing newline",
  338. )
  339. argparser.add_argument(
  340. "--mqtt-topic-prefix",
  341. type=str,
  342. # pylint: disable=protected-access
  343. default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
  344. help="default: %(default)s",
  345. )
  346. # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
  347. argparser.add_argument(
  348. "--homeassistant-discovery-prefix",
  349. type=str,
  350. default="homeassistant",
  351. help="home assistant's prefix for discovery topics" + " (default: %(default)s)",
  352. )
  353. argparser.add_argument(
  354. "--homeassistant-discovery-object-id",
  355. type=str,
  356. # pylint: disable=protected-access
  357. default=systemctl_mqtt._homeassistant.get_default_discovery_object_id(),
  358. help="part of discovery topic (default: %(default)s)",
  359. )
  360. argparser.add_argument(
  361. "--poweroff-delay-seconds", type=float, default=4.0, help="default: %(default)s"
  362. )
  363. args = argparser.parse_args()
  364. if args.mqtt_port:
  365. mqtt_port = args.mqtt_port
  366. elif args.mqtt_disable_tls:
  367. mqtt_port = _MQTT_DEFAULT_PORT
  368. else:
  369. mqtt_port = _MQTT_DEFAULT_TLS_PORT
  370. if args.mqtt_password_path:
  371. # .read_text() replaces \r\n with \n
  372. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  373. if mqtt_password.endswith("\r\n"):
  374. mqtt_password = mqtt_password[:-2]
  375. elif mqtt_password.endswith("\n"):
  376. mqtt_password = mqtt_password[:-1]
  377. else:
  378. mqtt_password = args.mqtt_password
  379. # pylint: disable=protected-access
  380. if not systemctl_mqtt._homeassistant.validate_discovery_object_id(
  381. args.homeassistant_discovery_object_id
  382. ):
  383. raise ValueError(
  384. # pylint: disable=protected-access
  385. "invalid home assistant discovery object id"
  386. f" {args.homeassistant_discovery_object_id!r} (length >= 1"
  387. ", allowed characters:"
  388. f" {systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS})"
  389. "\nchange --homeassistant-discovery-object-id"
  390. )
  391. _run(
  392. mqtt_host=args.mqtt_host,
  393. mqtt_port=mqtt_port,
  394. mqtt_disable_tls=args.mqtt_disable_tls,
  395. mqtt_username=args.mqtt_username,
  396. mqtt_password=mqtt_password,
  397. mqtt_topic_prefix=args.mqtt_topic_prefix,
  398. homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
  399. homeassistant_discovery_object_id=args.homeassistant_discovery_object_id,
  400. poweroff_delay=datetime.timedelta(seconds=args.poweroff_delay_seconds),
  401. )