__init__.py 17 KB

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