__init__.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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 jeepney
  30. import jeepney.bus_messages
  31. import jeepney.io.blocking
  32. import paho.mqtt.client
  33. import systemctl_mqtt._dbus
  34. import systemctl_mqtt._homeassistant
  35. import systemctl_mqtt._mqtt
  36. _MQTT_DEFAULT_PORT = 1883
  37. _MQTT_DEFAULT_TLS_PORT = 8883
  38. _LOGGER = logging.getLogger(__name__)
  39. class _State:
  40. def __init__(
  41. self,
  42. *,
  43. mqtt_topic_prefix: str,
  44. homeassistant_discovery_prefix: str,
  45. homeassistant_discovery_object_id: str,
  46. poweroff_delay: datetime.timedelta,
  47. ) -> None:
  48. self._mqtt_topic_prefix = mqtt_topic_prefix
  49. self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
  50. self._homeassistant_discovery_object_id = homeassistant_discovery_object_id
  51. self._login_manager = systemctl_mqtt._dbus.get_login_manager_proxy()
  52. self._shutdown_lock: typing.Optional[jeepney.fds.FileDescriptor] = None
  53. self._shutdown_lock_mutex = threading.Lock()
  54. self.poweroff_delay = poweroff_delay
  55. @property
  56. def mqtt_topic_prefix(self) -> str:
  57. return self._mqtt_topic_prefix
  58. @property
  59. def shutdown_lock_acquired(self) -> bool:
  60. return self._shutdown_lock is not None
  61. def acquire_shutdown_lock(self) -> None:
  62. with self._shutdown_lock_mutex:
  63. assert self._shutdown_lock is None
  64. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  65. (self._shutdown_lock,) = self._login_manager.Inhibit(
  66. what="shutdown",
  67. who="systemctl-mqtt",
  68. why="Report shutdown via MQTT",
  69. mode="delay",
  70. )
  71. assert isinstance(
  72. self._shutdown_lock, jeepney.fds.FileDescriptor
  73. ), self._shutdown_lock
  74. _LOGGER.debug("acquired shutdown inhibitor lock")
  75. def release_shutdown_lock(self) -> None:
  76. with self._shutdown_lock_mutex:
  77. if self._shutdown_lock:
  78. self._shutdown_lock.close()
  79. _LOGGER.debug("released shutdown inhibitor lock")
  80. self._shutdown_lock = None
  81. @property
  82. def _preparing_for_shutdown_topic(self) -> str:
  83. return self.mqtt_topic_prefix + "/preparing-for-shutdown"
  84. def _publish_preparing_for_shutdown(
  85. self, *, mqtt_client: paho.mqtt.client.Client, active: bool, block: bool
  86. ) -> None:
  87. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1199
  88. topic = self._preparing_for_shutdown_topic
  89. # pylint: disable=protected-access
  90. payload = systemctl_mqtt._mqtt.encode_bool(active)
  91. _LOGGER.info("publishing %r on %s", payload, topic)
  92. msg_info = mqtt_client.publish(
  93. topic=topic, payload=payload, retain=True
  94. ) # type: paho.mqtt.client.MQTTMessageInfo
  95. if not block:
  96. return
  97. msg_info.wait_for_publish()
  98. if msg_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
  99. _LOGGER.error(
  100. "failed to publish on %s (return code %d)", topic, msg_info.rc
  101. )
  102. def preparing_for_shutdown_handler(
  103. self, active: bool, mqtt_client: paho.mqtt.client.Client
  104. ) -> None:
  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 publish_preparing_for_shutdown(
  114. self, mqtt_client: paho.mqtt.client.Client
  115. ) -> None:
  116. try:
  117. ((return_type, active),) = self._login_manager.Get("PreparingForShutdown")
  118. except jeepney.wrappers.DBusErrorResponse as exc:
  119. _LOGGER.error(
  120. "failed to read logind's PreparingForShutdown property: %s", exc
  121. )
  122. return
  123. assert return_type == "b", return_type
  124. assert isinstance(active, bool), active
  125. self._publish_preparing_for_shutdown(
  126. mqtt_client=mqtt_client,
  127. active=active,
  128. # https://github.com/eclipse/paho.mqtt.python/issues/439#issuecomment-565514393
  129. block=False,
  130. )
  131. def publish_homeassistant_device_config(
  132. self, mqtt_client: paho.mqtt.client.Client
  133. ) -> None:
  134. # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
  135. # https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
  136. discovery_topic = "/".join(
  137. (
  138. self._homeassistant_discovery_prefix,
  139. "device",
  140. self._homeassistant_discovery_object_id,
  141. "config",
  142. )
  143. )
  144. hostname = (
  145. # pylint: disable=protected-access; function in internal module
  146. systemctl_mqtt._utils.get_hostname()
  147. )
  148. package_metadata = importlib.metadata.metadata(__name__)
  149. unique_id_prefix = "systemctl-mqtt-" + hostname
  150. config = {
  151. "device": {"identifiers": [hostname], "name": hostname},
  152. "origin": {
  153. "name": package_metadata["Name"],
  154. "sw_version": package_metadata["Version"],
  155. "support_url": package_metadata["Home-page"],
  156. },
  157. "components": {
  158. "logind/preparing-for-shutdown": {
  159. "unique_id": unique_id_prefix + "-logind-preparing-for-shutdown",
  160. "object_id": f"{hostname}_logind_preparing_for_shutdown", # entity id
  161. "name": "preparing for shutdown", # home assistant prepends device name
  162. "platform": "binary_sensor",
  163. "state_topic": self._preparing_for_shutdown_topic,
  164. # pylint: disable=protected-access
  165. "payload_on": systemctl_mqtt._mqtt.encode_bool(True),
  166. "payload_off": systemctl_mqtt._mqtt.encode_bool(False),
  167. },
  168. },
  169. }
  170. for mqtt_topic_suffix in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.keys():
  171. # false positive warning by mypy:
  172. # > Unsupported target for indexed assignment
  173. config["components"]["logind/" + mqtt_topic_suffix] = { # type: ignore
  174. "unique_id": unique_id_prefix + "-logind-" + mqtt_topic_suffix,
  175. "object_id": hostname
  176. + "_logind_"
  177. + mqtt_topic_suffix.replace("-", "_"), # entity id
  178. "name": mqtt_topic_suffix.replace("-", " "),
  179. "platform": "button",
  180. "command_topic": self.mqtt_topic_prefix + "/" + mqtt_topic_suffix,
  181. }
  182. _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
  183. mqtt_client.publish(
  184. topic=discovery_topic, payload=json.dumps(config), retain=False
  185. )
  186. class _MQTTAction(metaclass=abc.ABCMeta):
  187. @abc.abstractmethod
  188. def trigger(self, state: _State) -> None:
  189. pass # pragma: no cover
  190. def mqtt_message_callback(
  191. self,
  192. mqtt_client: paho.mqtt.client.Client,
  193. state: _State,
  194. message: paho.mqtt.client.MQTTMessage,
  195. ) -> None:
  196. # pylint: disable=unused-argument; callback
  197. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L3416
  198. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  199. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  200. if message.retain:
  201. _LOGGER.info("ignoring retained message")
  202. return
  203. _LOGGER.debug("executing action %s", self)
  204. self.trigger(state=state)
  205. _LOGGER.debug("completed action %s", self)
  206. class _MQTTActionSchedulePoweroff(_MQTTAction):
  207. def trigger(self, state: _State) -> None:
  208. # pylint: disable=protected-access
  209. systemctl_mqtt._dbus.schedule_shutdown(
  210. action="poweroff", delay=state.poweroff_delay
  211. )
  212. def __str__(self) -> str:
  213. return type(self).__name__
  214. class _MQTTActionLockAllSessions(_MQTTAction):
  215. def trigger(self, state: _State) -> None:
  216. # pylint: disable=protected-access
  217. systemctl_mqtt._dbus.lock_all_sessions()
  218. def __str__(self) -> str:
  219. return type(self).__name__
  220. _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
  221. "poweroff": _MQTTActionSchedulePoweroff(),
  222. "lock-all-sessions": _MQTTActionLockAllSessions(),
  223. }
  224. def _mqtt_on_connect(
  225. mqtt_client: paho.mqtt.client.Client,
  226. state: _State,
  227. flags: typing.Dict,
  228. return_code: int,
  229. ) -> None:
  230. # pylint: disable=unused-argument; callback
  231. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
  232. assert return_code == 0, return_code # connection accepted
  233. mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
  234. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
  235. if not state.shutdown_lock_acquired:
  236. state.acquire_shutdown_lock()
  237. state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
  238. state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
  239. for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
  240. topic = state.mqtt_topic_prefix + "/" + topic_suffix
  241. _LOGGER.info("subscribing to %s", topic)
  242. mqtt_client.subscribe(topic)
  243. mqtt_client.message_callback_add(
  244. sub=topic, callback=action.mqtt_message_callback
  245. )
  246. _LOGGER.debug(
  247. "registered MQTT callback for topic %s triggering %s", topic, action
  248. )
  249. def _run( # pylint: disable=too-many-arguments
  250. *,
  251. mqtt_host: str,
  252. mqtt_port: int,
  253. mqtt_username: typing.Optional[str],
  254. mqtt_password: typing.Optional[str],
  255. mqtt_topic_prefix: str,
  256. homeassistant_discovery_prefix: str,
  257. homeassistant_discovery_object_id: str,
  258. poweroff_delay: datetime.timedelta,
  259. mqtt_disable_tls: bool = False,
  260. ) -> None:
  261. # pylint: disable=too-many-locals; will be split up when switching to async mqtt
  262. dbus_connection = jeepney.io.blocking.open_dbus_connection(bus="SYSTEM")
  263. bus_proxy = jeepney.io.blocking.Proxy(
  264. msggen=jeepney.bus_messages.message_bus, connection=dbus_connection
  265. )
  266. preparing_for_shutdown_match_rule = (
  267. # pylint: disable=protected-access
  268. systemctl_mqtt._dbus.get_login_manager_signal_match_rule("PrepareForShutdown")
  269. )
  270. assert bus_proxy.AddMatch(preparing_for_shutdown_match_rule) == ()
  271. state = _State(
  272. mqtt_topic_prefix=mqtt_topic_prefix,
  273. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  274. homeassistant_discovery_object_id=homeassistant_discovery_object_id,
  275. poweroff_delay=poweroff_delay,
  276. )
  277. # https://pypi.org/project/paho-mqtt/
  278. mqtt_client = paho.mqtt.client.Client(userdata=state)
  279. mqtt_client.on_connect = _mqtt_on_connect
  280. if not mqtt_disable_tls:
  281. mqtt_client.tls_set(ca_certs=None) # enable tls trusting default system certs
  282. _LOGGER.info(
  283. "connecting to MQTT broker %s:%d (TLS %s)",
  284. mqtt_host,
  285. mqtt_port,
  286. "disabled" if mqtt_disable_tls else "enabled",
  287. )
  288. if mqtt_username:
  289. mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)
  290. elif mqtt_password:
  291. raise ValueError("Missing MQTT username")
  292. mqtt_client.connect(host=mqtt_host, port=mqtt_port)
  293. # loop_start runs loop_forever in a new thread (daemon)
  294. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1814
  295. # loop_forever attempts to reconnect if disconnected
  296. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1744
  297. mqtt_client.loop_start()
  298. try:
  299. with dbus_connection.filter(preparing_for_shutdown_match_rule) as queue:
  300. while True:
  301. (preparing_for_sleep,) = dbus_connection.recv_until_filtered(queue).body
  302. state.preparing_for_shutdown_handler(
  303. active=preparing_for_sleep, mqtt_client=mqtt_client
  304. )
  305. finally:
  306. # blocks until loop_forever stops
  307. _LOGGER.debug("waiting for MQTT loop to stop")
  308. mqtt_client.loop_stop()
  309. _LOGGER.debug("MQTT loop stopped")
  310. def _main() -> None:
  311. logging.basicConfig(
  312. level=logging.DEBUG,
  313. format="%(asctime)s:%(levelname)s:%(message)s",
  314. datefmt="%Y-%m-%dT%H:%M:%S%z",
  315. )
  316. argparser = argparse.ArgumentParser(
  317. description="MQTT client triggering & reporting shutdown on systemd-based systems",
  318. )
  319. argparser.add_argument("--mqtt-host", type=str, required=True)
  320. argparser.add_argument(
  321. "--mqtt-port",
  322. type=int,
  323. help=f"default {_MQTT_DEFAULT_TLS_PORT} ({_MQTT_DEFAULT_PORT} with --mqtt-disable-tls)",
  324. )
  325. argparser.add_argument("--mqtt-username", type=str)
  326. argparser.add_argument("--mqtt-disable-tls", action="store_true")
  327. password_argument_group = argparser.add_mutually_exclusive_group()
  328. password_argument_group.add_argument("--mqtt-password", type=str)
  329. password_argument_group.add_argument(
  330. "--mqtt-password-file",
  331. type=pathlib.Path,
  332. metavar="PATH",
  333. dest="mqtt_password_path",
  334. help="stripping trailing newline",
  335. )
  336. argparser.add_argument(
  337. "--mqtt-topic-prefix",
  338. type=str,
  339. # pylint: disable=protected-access
  340. default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
  341. help="default: %(default)s",
  342. )
  343. # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
  344. argparser.add_argument(
  345. "--homeassistant-discovery-prefix",
  346. type=str,
  347. default="homeassistant",
  348. help="home assistant's prefix for discovery topics" + " (default: %(default)s)",
  349. )
  350. argparser.add_argument(
  351. "--homeassistant-discovery-object-id",
  352. type=str,
  353. # pylint: disable=protected-access
  354. default=systemctl_mqtt._homeassistant.get_default_discovery_object_id(),
  355. help="part of discovery topic (default: %(default)s)",
  356. )
  357. argparser.add_argument(
  358. "--poweroff-delay-seconds", type=float, default=4.0, help="default: %(default)s"
  359. )
  360. args = argparser.parse_args()
  361. if args.mqtt_port:
  362. mqtt_port = args.mqtt_port
  363. elif args.mqtt_disable_tls:
  364. mqtt_port = _MQTT_DEFAULT_PORT
  365. else:
  366. mqtt_port = _MQTT_DEFAULT_TLS_PORT
  367. if args.mqtt_password_path:
  368. # .read_text() replaces \r\n with \n
  369. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  370. if mqtt_password.endswith("\r\n"):
  371. mqtt_password = mqtt_password[:-2]
  372. elif mqtt_password.endswith("\n"):
  373. mqtt_password = mqtt_password[:-1]
  374. else:
  375. mqtt_password = args.mqtt_password
  376. # pylint: disable=protected-access
  377. if not systemctl_mqtt._homeassistant.validate_discovery_object_id(
  378. args.homeassistant_discovery_object_id
  379. ):
  380. raise ValueError(
  381. # pylint: disable=protected-access
  382. "invalid home assistant discovery object id"
  383. f" {args.homeassistant_discovery_object_id!r} (length >= 1"
  384. ", allowed characters:"
  385. f" {systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS})"
  386. "\nchange --homeassistant-discovery-object-id"
  387. )
  388. _run(
  389. mqtt_host=args.mqtt_host,
  390. mqtt_port=mqtt_port,
  391. mqtt_disable_tls=args.mqtt_disable_tls,
  392. mqtt_username=args.mqtt_username,
  393. mqtt_password=mqtt_password,
  394. mqtt_topic_prefix=args.mqtt_topic_prefix,
  395. homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
  396. homeassistant_discovery_object_id=args.homeassistant_discovery_object_id,
  397. poweroff_delay=datetime.timedelta(seconds=args.poweroff_delay_seconds),
  398. )