__init__.py 17 KB

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