__init__.py 17 KB

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