__init__.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  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 argparse
  18. import datetime
  19. import functools
  20. import json
  21. import logging
  22. import os
  23. import pathlib
  24. import socket
  25. import threading
  26. import typing
  27. import dbus
  28. import dbus.mainloop.glib
  29. # black keeps inserting a blank line above
  30. # https://pygobject.readthedocs.io/en/latest/getting_started.html#ubuntu-logo-ubuntu-debian-logo-debian
  31. import gi.repository.GLib # pylint-import-requirements: imports=PyGObject
  32. import paho.mqtt.client
  33. import systemctl_mqtt._dbus
  34. import systemctl_mqtt._homeassistant
  35. import systemctl_mqtt._mqtt
  36. _LOGGER = logging.getLogger(__name__)
  37. class _State:
  38. def __init__(
  39. self,
  40. mqtt_topic_prefix: str,
  41. homeassistant_discovery_prefix: str,
  42. homeassistant_node_id: str,
  43. ) -> None:
  44. self._mqtt_topic_prefix = mqtt_topic_prefix
  45. self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
  46. self._homeassistant_node_id = homeassistant_node_id
  47. self._login_manager = (
  48. systemctl_mqtt._dbus.get_login_manager()
  49. ) # type: dbus.proxies.Interface
  50. self._shutdown_lock = None # type: typing.Optional[dbus.types.UnixFd]
  51. self._shutdown_lock_mutex = threading.Lock()
  52. @property
  53. def mqtt_topic_prefix(self) -> str:
  54. return self._mqtt_topic_prefix
  55. @property
  56. def shutdown_lock_acquired(self) -> bool:
  57. return self._shutdown_lock is not None
  58. def acquire_shutdown_lock(self) -> None:
  59. with self._shutdown_lock_mutex:
  60. assert self._shutdown_lock is None
  61. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  62. self._shutdown_lock = self._login_manager.Inhibit(
  63. "shutdown", "systemctl-mqtt", "Report shutdown via MQTT", "delay",
  64. )
  65. _LOGGER.debug("acquired shutdown inhibitor lock")
  66. def release_shutdown_lock(self) -> None:
  67. with self._shutdown_lock_mutex:
  68. if self._shutdown_lock:
  69. # https://dbus.freedesktop.org/doc/dbus-python/dbus.types.html#dbus.types.UnixFd.take
  70. os.close(self._shutdown_lock.take())
  71. _LOGGER.debug("released shutdown inhibitor lock")
  72. self._shutdown_lock = None
  73. @property
  74. def _preparing_for_shutdown_topic(self) -> str:
  75. return self.mqtt_topic_prefix + "/preparing-for-shutdown"
  76. def _publish_preparing_for_shutdown(
  77. self, mqtt_client: paho.mqtt.client.Client, active: bool, block: bool,
  78. ) -> None:
  79. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1199
  80. topic = self._preparing_for_shutdown_topic
  81. # pylint: disable=protected-access
  82. payload = systemctl_mqtt._mqtt.encode_bool(active)
  83. _LOGGER.info("publishing %r on %s", payload, topic)
  84. msg_info = mqtt_client.publish(
  85. topic=topic, payload=payload, retain=True,
  86. ) # type: paho.mqtt.client.MQTTMessageInfo
  87. if not block:
  88. return
  89. msg_info.wait_for_publish()
  90. if msg_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
  91. _LOGGER.error(
  92. "failed to publish on %s (return code %d)", topic, msg_info.rc
  93. )
  94. def _prepare_for_shutdown_handler(
  95. self, active: dbus.Boolean, mqtt_client: paho.mqtt.client.Client
  96. ) -> None:
  97. assert isinstance(active, dbus.Boolean)
  98. active = bool(active)
  99. self._publish_preparing_for_shutdown(
  100. mqtt_client=mqtt_client, active=active, block=True,
  101. )
  102. if active:
  103. self.release_shutdown_lock()
  104. else:
  105. self.acquire_shutdown_lock()
  106. def register_prepare_for_shutdown_handler(
  107. self, mqtt_client: paho.mqtt.client.Client
  108. ) -> None:
  109. self._login_manager.connect_to_signal(
  110. signal_name="PrepareForShutdown",
  111. handler_function=functools.partial(
  112. self._prepare_for_shutdown_handler, mqtt_client=mqtt_client
  113. ),
  114. )
  115. def publish_preparing_for_shutdown(
  116. self, mqtt_client: paho.mqtt.client.Client,
  117. ) -> None:
  118. try:
  119. active = self._login_manager.Get(
  120. "org.freedesktop.login1.Manager",
  121. "PreparingForShutdown",
  122. dbus_interface="org.freedesktop.DBus.Properties",
  123. )
  124. except dbus.DBusException as exc:
  125. _LOGGER.error(
  126. "failed to read logind's PreparingForShutdown property: %s",
  127. exc.get_dbus_message(),
  128. )
  129. return
  130. assert isinstance(active, dbus.Boolean), active
  131. self._publish_preparing_for_shutdown(
  132. mqtt_client=mqtt_client,
  133. active=bool(active),
  134. # https://github.com/eclipse/paho.mqtt.python/issues/439#issuecomment-565514393
  135. block=False,
  136. )
  137. def publish_preparing_for_shutdown_homeassistant_config(
  138. self, mqtt_client: paho.mqtt.client.Client
  139. ) -> None:
  140. # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
  141. # https://www.home-assistant.io/docs/mqtt/discovery/
  142. discovery_topic = "/".join(
  143. (
  144. self._homeassistant_discovery_prefix,
  145. "binary_sensor",
  146. self._homeassistant_node_id,
  147. "preparing-for-shutdown",
  148. "config",
  149. )
  150. )
  151. unique_id = "/".join(
  152. (
  153. "systemctl-mqtt",
  154. self._homeassistant_node_id,
  155. "logind",
  156. "preparing-for-shutdown",
  157. )
  158. )
  159. # https://www.home-assistant.io/integrations/binary_sensor.mqtt/#configuration-variables
  160. config = {
  161. "unique_id": unique_id,
  162. "state_topic": self._preparing_for_shutdown_topic,
  163. # pylint: disable=protected-access
  164. "payload_on": systemctl_mqtt._mqtt.encode_bool(True),
  165. "payload_off": systemctl_mqtt._mqtt.encode_bool(False),
  166. # friendly_name & template for default entity_id
  167. "name": "{} preparing for shutdown".format(self._homeassistant_node_id),
  168. }
  169. _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
  170. mqtt_client.publish(
  171. topic=discovery_topic, payload=json.dumps(config), retain=True,
  172. )
  173. class _MQTTAction:
  174. # pylint: disable=too-few-public-methods
  175. def __init__(self, name: str, action: typing.Callable) -> None:
  176. self.name = name
  177. self.action = action
  178. def mqtt_message_callback(
  179. self,
  180. mqtt_client: paho.mqtt.client.Client,
  181. state: _State,
  182. message: paho.mqtt.client.MQTTMessage,
  183. ) -> None:
  184. # pylint: disable=unused-argument; callback
  185. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L3416
  186. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  187. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  188. if message.retain:
  189. _LOGGER.info("ignoring retained message")
  190. return
  191. _LOGGER.debug("executing action %s (%r)", self.name, self.action)
  192. self.action()
  193. _LOGGER.debug("completed action %s (%r)", self.name, self.action)
  194. _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
  195. "poweroff": _MQTTAction(
  196. name="poweroff",
  197. action=functools.partial(
  198. # pylint: disable=protected-access
  199. systemctl_mqtt._dbus.schedule_shutdown,
  200. action="poweroff",
  201. ),
  202. ),
  203. }
  204. def _mqtt_on_connect(
  205. mqtt_client: paho.mqtt.client.Client,
  206. state: _State,
  207. flags: typing.Dict,
  208. return_code: int,
  209. ) -> None:
  210. # pylint: disable=unused-argument; callback
  211. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
  212. assert return_code == 0, return_code # connection accepted
  213. mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
  214. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
  215. if not state.shutdown_lock_acquired:
  216. state.acquire_shutdown_lock()
  217. state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
  218. state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
  219. state.publish_preparing_for_shutdown_homeassistant_config(mqtt_client=mqtt_client)
  220. for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
  221. topic = state.mqtt_topic_prefix + "/" + topic_suffix
  222. _LOGGER.info("subscribing to %s", topic)
  223. mqtt_client.subscribe(topic)
  224. mqtt_client.message_callback_add(
  225. sub=topic, callback=action.mqtt_message_callback
  226. )
  227. _LOGGER.debug(
  228. "registered MQTT callback for topic %s triggering %r", topic, action.action,
  229. )
  230. def _run(
  231. mqtt_host: str,
  232. mqtt_port: int,
  233. mqtt_username: typing.Optional[str],
  234. mqtt_password: typing.Optional[str],
  235. mqtt_topic_prefix: str,
  236. homeassistant_discovery_prefix: str,
  237. homeassistant_node_id: str,
  238. ) -> None:
  239. # pylint: disable=too-many-arguments
  240. # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html#setting-up-an-event-loop
  241. dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
  242. # https://pypi.org/project/paho-mqtt/
  243. mqtt_client = paho.mqtt.client.Client(
  244. userdata=_State(
  245. mqtt_topic_prefix=mqtt_topic_prefix,
  246. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  247. homeassistant_node_id=homeassistant_node_id,
  248. )
  249. )
  250. mqtt_client.on_connect = _mqtt_on_connect
  251. mqtt_client.tls_set(ca_certs=None) # enable tls trusting default system certs
  252. _LOGGER.info(
  253. "connecting to MQTT broker %s:%d", mqtt_host, mqtt_port,
  254. )
  255. if mqtt_username:
  256. mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)
  257. elif mqtt_password:
  258. raise ValueError("Missing MQTT username")
  259. mqtt_client.connect(host=mqtt_host, port=mqtt_port)
  260. # loop_start runs loop_forever in a new thread (daemon)
  261. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1814
  262. # loop_forever attempts to reconnect if disconnected
  263. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1744
  264. mqtt_client.loop_start()
  265. try:
  266. gi.repository.GLib.MainLoop().run()
  267. finally:
  268. # blocks until loop_forever stops
  269. _LOGGER.debug("waiting for MQTT loop to stop")
  270. mqtt_client.loop_stop()
  271. _LOGGER.debug("MQTT loop stopped")
  272. def _main() -> None:
  273. logging.basicConfig(
  274. level=logging.DEBUG,
  275. format="%(asctime)s:%(levelname)s:%(message)s",
  276. datefmt="%Y-%m-%dT%H:%M:%S%z",
  277. )
  278. argparser = argparse.ArgumentParser(
  279. description="MQTT client triggering & reporting shutdown on systemd-based systems",
  280. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  281. )
  282. argparser.add_argument("--mqtt-host", type=str, required=True)
  283. argparser.add_argument("--mqtt-port", type=int, default=8883)
  284. argparser.add_argument("--mqtt-username", type=str)
  285. password_argument_group = argparser.add_mutually_exclusive_group()
  286. password_argument_group.add_argument("--mqtt-password", type=str)
  287. password_argument_group.add_argument(
  288. "--mqtt-password-file",
  289. type=pathlib.Path,
  290. metavar="PATH",
  291. dest="mqtt_password_path",
  292. help="stripping trailing newline",
  293. )
  294. argparser.add_argument(
  295. "--mqtt-topic-prefix",
  296. type=str,
  297. # pylint: disable=protected-access
  298. default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
  299. help=" ", # show default
  300. )
  301. # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
  302. argparser.add_argument(
  303. "--homeassistant-discovery-prefix", type=str, default="homeassistant", help=" ",
  304. )
  305. argparser.add_argument(
  306. "--homeassistant-node-id",
  307. type=str,
  308. # pylint: disable=protected-access
  309. default=systemctl_mqtt._homeassistant.get_default_node_id(),
  310. help=" ",
  311. )
  312. args = argparser.parse_args()
  313. if args.mqtt_password_path:
  314. # .read_text() replaces \r\n with \n
  315. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  316. if mqtt_password.endswith("\r\n"):
  317. mqtt_password = mqtt_password[:-2]
  318. elif mqtt_password.endswith("\n"):
  319. mqtt_password = mqtt_password[:-1]
  320. else:
  321. mqtt_password = args.mqtt_password
  322. # pylint: disable=protected-access
  323. if not systemctl_mqtt._homeassistant.validate_node_id(args.homeassistant_node_id):
  324. raise ValueError(
  325. "invalid home assistant node id {!r} (length >= 1, allowed characters: {})".format(
  326. args.homeassistant_node_id,
  327. # pylint: disable=protected-access
  328. systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS,
  329. )
  330. + "\nchange --homeassistant-node-id"
  331. )
  332. _run(
  333. mqtt_host=args.mqtt_host,
  334. mqtt_port=args.mqtt_port,
  335. mqtt_username=args.mqtt_username,
  336. mqtt_password=mqtt_password,
  337. mqtt_topic_prefix=args.mqtt_topic_prefix,
  338. homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
  339. homeassistant_node_id=args.homeassistant_node_id,
  340. )