__init__.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  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 ssl
  29. import threading
  30. import typing
  31. import aiomqtt
  32. import jeepney
  33. import jeepney.bus_messages
  34. import jeepney.io.asyncio
  35. import systemctl_mqtt._dbus.login_manager
  36. import systemctl_mqtt._homeassistant
  37. import systemctl_mqtt._mqtt
  38. _MQTT_DEFAULT_PORT = 1883
  39. _MQTT_DEFAULT_TLS_PORT = 8883
  40. # > payload_not_available string (Optional, default: offline)
  41. # https://web.archive.org/web/20250101075341/https://www.home-assistant.io/integrations/sensor.mqtt/#payload_not_available
  42. _MQTT_PAYLOAD_NOT_AVAILABLE = "offline"
  43. _MQTT_PAYLOAD_AVAILABLE = "online"
  44. _ARGUMENT_LOG_LEVEL_MAPPING = {
  45. a: getattr(logging, a.upper())
  46. for a in ("debug", "info", "warning", "error", "critical")
  47. }
  48. _LOGGER = logging.getLogger(__name__)
  49. class _State:
  50. def __init__(
  51. self,
  52. *,
  53. mqtt_topic_prefix: str,
  54. homeassistant_discovery_prefix: str,
  55. homeassistant_discovery_object_id: str,
  56. poweroff_delay: datetime.timedelta,
  57. ) -> None:
  58. self._mqtt_topic_prefix = mqtt_topic_prefix
  59. self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
  60. self._homeassistant_discovery_object_id = homeassistant_discovery_object_id
  61. self._login_manager = (
  62. systemctl_mqtt._dbus.login_manager.get_login_manager_proxy()
  63. )
  64. self._shutdown_lock: typing.Optional[jeepney.fds.FileDescriptor] = None
  65. self._shutdown_lock_mutex = threading.Lock()
  66. self.poweroff_delay = poweroff_delay
  67. @property
  68. def mqtt_topic_prefix(self) -> str:
  69. return self._mqtt_topic_prefix
  70. @property
  71. def mqtt_availability_topic(self) -> str:
  72. # > mqtt.ATTR_TOPIC: "homeassistant/status",
  73. # https://github.com/home-assistant/core/blob/2024.12.5/tests/components/mqtt/conftest.py#L23
  74. # > _MQTT_AVAILABILITY_TOPIC = "switchbot-mqtt/status"
  75. # https://github.com/fphammerle/switchbot-mqtt/blob/v3.3.1/switchbot_mqtt/__init__.py#L30
  76. return self._mqtt_topic_prefix + "/status"
  77. @property
  78. def shutdown_lock_acquired(self) -> bool:
  79. return self._shutdown_lock is not None
  80. def acquire_shutdown_lock(self) -> None:
  81. with self._shutdown_lock_mutex:
  82. assert self._shutdown_lock is None
  83. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  84. (self._shutdown_lock,) = self._login_manager.Inhibit(
  85. what="shutdown",
  86. who="systemctl-mqtt",
  87. why="Report shutdown via MQTT",
  88. mode="delay",
  89. )
  90. assert isinstance(
  91. self._shutdown_lock, jeepney.fds.FileDescriptor
  92. ), self._shutdown_lock
  93. _LOGGER.debug("acquired shutdown inhibitor lock")
  94. def release_shutdown_lock(self) -> None:
  95. with self._shutdown_lock_mutex:
  96. if self._shutdown_lock:
  97. self._shutdown_lock.close()
  98. _LOGGER.debug("released shutdown inhibitor lock")
  99. self._shutdown_lock = None
  100. @property
  101. def _preparing_for_shutdown_topic(self) -> str:
  102. return self.mqtt_topic_prefix + "/preparing-for-shutdown"
  103. async def _publish_preparing_for_shutdown(
  104. self, *, mqtt_client: aiomqtt.Client, active: bool
  105. ) -> None:
  106. topic = self._preparing_for_shutdown_topic
  107. # pylint: disable=protected-access
  108. payload = systemctl_mqtt._mqtt.encode_bool(active)
  109. _LOGGER.info("publishing %r on %s", payload, topic)
  110. await mqtt_client.publish(topic=topic, payload=payload, retain=False)
  111. async def preparing_for_shutdown_handler(
  112. self, active: bool, mqtt_client: aiomqtt.Client
  113. ) -> None:
  114. active = bool(active)
  115. await self._publish_preparing_for_shutdown(
  116. mqtt_client=mqtt_client, active=active
  117. )
  118. if active:
  119. self.release_shutdown_lock()
  120. else:
  121. self.acquire_shutdown_lock()
  122. async def publish_preparing_for_shutdown(self, mqtt_client: aiomqtt.Client) -> None:
  123. try:
  124. ((return_type, active),) = self._login_manager.Get("PreparingForShutdown")
  125. except jeepney.wrappers.DBusErrorResponse as exc:
  126. _LOGGER.error(
  127. "failed to read logind's PreparingForShutdown property: %s", exc
  128. )
  129. return
  130. assert return_type == "b", return_type
  131. assert isinstance(active, bool), active
  132. await self._publish_preparing_for_shutdown(
  133. mqtt_client=mqtt_client, active=active
  134. )
  135. async def publish_homeassistant_device_config(
  136. self, mqtt_client: aiomqtt.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. "availability": {"topic": self.mqtt_availability_topic},
  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. await 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 __str__(self) -> str:
  196. return type(self).__name__
  197. class _MQTTActionSchedulePoweroff(_MQTTAction):
  198. # pylint: disable=too-few-public-methods
  199. def trigger(self, state: _State) -> None:
  200. # pylint: disable=protected-access
  201. systemctl_mqtt._dbus.login_manager.schedule_shutdown(
  202. action="poweroff", delay=state.poweroff_delay
  203. )
  204. class _MQTTActionLockAllSessions(_MQTTAction):
  205. # pylint: disable=too-few-public-methods
  206. def trigger(self, state: _State) -> None:
  207. # pylint: disable=protected-access
  208. systemctl_mqtt._dbus.login_manager.lock_all_sessions()
  209. class _MQTTActionSuspend(_MQTTAction):
  210. # pylint: disable=too-few-public-methods
  211. def trigger(self, state: _State) -> None:
  212. # pylint: disable=protected-access
  213. systemctl_mqtt._dbus.login_manager.suspend()
  214. _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
  215. "poweroff": _MQTTActionSchedulePoweroff(),
  216. "lock-all-sessions": _MQTTActionLockAllSessions(),
  217. "suspend": _MQTTActionSuspend(),
  218. }
  219. async def _mqtt_message_loop(*, state: _State, mqtt_client: aiomqtt.Client) -> None:
  220. action_by_topic: typing.Dict[str, _MQTTAction] = {}
  221. for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
  222. topic = state.mqtt_topic_prefix + "/" + topic_suffix
  223. _LOGGER.info("subscribing to %s", topic)
  224. await mqtt_client.subscribe(topic)
  225. action_by_topic[topic] = action
  226. async for message in mqtt_client.messages:
  227. if message.retain:
  228. _LOGGER.info("ignoring retained message on topic %r", message.topic.value)
  229. else:
  230. _LOGGER.debug(
  231. "received message on topic %r: %r", message.topic.value, message.payload
  232. )
  233. action_by_topic[message.topic.value].trigger(state=state)
  234. async def _dbus_signal_loop(*, state: _State, mqtt_client: aiomqtt.Client) -> None:
  235. async with jeepney.io.asyncio.open_dbus_router(bus="SYSTEM") as router:
  236. # router: jeepney.io.asyncio.DBusRouter
  237. bus_proxy = jeepney.io.asyncio.Proxy(
  238. msggen=jeepney.bus_messages.message_bus, router=router
  239. )
  240. preparing_for_shutdown_match_rule = (
  241. # pylint: disable=protected-access
  242. systemctl_mqtt._dbus.login_manager.get_login_manager_signal_match_rule(
  243. "PrepareForShutdown"
  244. )
  245. )
  246. assert await bus_proxy.AddMatch(preparing_for_shutdown_match_rule) == ()
  247. with router.filter(preparing_for_shutdown_match_rule) as queue:
  248. while True:
  249. message: jeepney.low_level.Message = await queue.get()
  250. (preparing_for_shutdown,) = message.body
  251. await state.preparing_for_shutdown_handler(
  252. active=preparing_for_shutdown, mqtt_client=mqtt_client
  253. )
  254. queue.task_done()
  255. async def _run( # pylint: disable=too-many-arguments
  256. *,
  257. mqtt_host: str,
  258. mqtt_port: int,
  259. mqtt_username: typing.Optional[str],
  260. mqtt_password: typing.Optional[str],
  261. mqtt_topic_prefix: str,
  262. homeassistant_discovery_prefix: str,
  263. homeassistant_discovery_object_id: str,
  264. poweroff_delay: datetime.timedelta,
  265. mqtt_disable_tls: bool = False,
  266. ) -> None:
  267. state = _State(
  268. mqtt_topic_prefix=mqtt_topic_prefix,
  269. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  270. homeassistant_discovery_object_id=homeassistant_discovery_object_id,
  271. poweroff_delay=poweroff_delay,
  272. )
  273. _LOGGER.info(
  274. "connecting to MQTT broker %s:%d (TLS %s)",
  275. mqtt_host,
  276. mqtt_port,
  277. "disabled" if mqtt_disable_tls else "enabled",
  278. )
  279. if mqtt_password and not mqtt_username:
  280. raise ValueError("Missing MQTT username")
  281. async with aiomqtt.Client( # raises aiomqtt.MqttError
  282. hostname=mqtt_host,
  283. port=mqtt_port,
  284. # > The settings [...] usually represent a higher security level than
  285. # > when calling the SSLContext constructor directly.
  286. # https://web.archive.org/web/20230714183106/https://docs.python.org/3/library/ssl.html
  287. tls_context=None if mqtt_disable_tls else ssl.create_default_context(),
  288. username=None if mqtt_username is None else mqtt_username,
  289. password=None if mqtt_password is None else mqtt_password,
  290. will=aiomqtt.Will( # e.g. on SIGTERM & SIGKILL
  291. topic=state.mqtt_availability_topic,
  292. payload=_MQTT_PAYLOAD_NOT_AVAILABLE,
  293. retain=True,
  294. ),
  295. ) as mqtt_client:
  296. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_host, mqtt_port)
  297. if not state.shutdown_lock_acquired:
  298. state.acquire_shutdown_lock()
  299. await state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
  300. await state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
  301. try:
  302. await mqtt_client.publish(
  303. topic=state.mqtt_availability_topic,
  304. payload=_MQTT_PAYLOAD_AVAILABLE,
  305. retain=True,
  306. )
  307. # asynpio.TaskGroup added in python3.11
  308. await asyncio.gather(
  309. _mqtt_message_loop(state=state, mqtt_client=mqtt_client),
  310. _dbus_signal_loop(state=state, mqtt_client=mqtt_client),
  311. return_exceptions=False,
  312. )
  313. finally: # e.g. on SIGINT
  314. # https://web.archive.org/web/20250101080719/https://github.com/empicano/aiomqtt/issues/28
  315. await mqtt_client.publish(
  316. topic=state.mqtt_availability_topic,
  317. payload=_MQTT_PAYLOAD_NOT_AVAILABLE,
  318. retain=True,
  319. )
  320. def _main() -> None:
  321. logging.basicConfig(
  322. level=logging.INFO,
  323. format="%(asctime)s:%(levelname)s:%(message)s",
  324. datefmt="%Y-%m-%dT%H:%M:%S%z",
  325. )
  326. argparser = argparse.ArgumentParser(
  327. description="MQTT client triggering & reporting shutdown on systemd-based systems",
  328. )
  329. argparser.add_argument(
  330. "--log-level",
  331. choices=_ARGUMENT_LOG_LEVEL_MAPPING.keys(),
  332. default="info",
  333. help="log level (default: %(default)s)",
  334. )
  335. argparser.add_argument("--mqtt-host", type=str, required=True)
  336. argparser.add_argument(
  337. "--mqtt-port",
  338. type=int,
  339. help=f"default {_MQTT_DEFAULT_TLS_PORT} ({_MQTT_DEFAULT_PORT} with --mqtt-disable-tls)",
  340. )
  341. argparser.add_argument("--mqtt-username", type=str)
  342. argparser.add_argument("--mqtt-disable-tls", action="store_true")
  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="default: %(default)s",
  358. )
  359. # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
  360. argparser.add_argument(
  361. "--homeassistant-discovery-prefix",
  362. type=str,
  363. default="homeassistant",
  364. help="home assistant's prefix for discovery topics" + " (default: %(default)s)",
  365. )
  366. argparser.add_argument(
  367. "--homeassistant-discovery-object-id",
  368. type=str,
  369. # pylint: disable=protected-access
  370. default=systemctl_mqtt._homeassistant.get_default_discovery_object_id(),
  371. help="part of discovery topic (default: %(default)s)",
  372. )
  373. argparser.add_argument(
  374. "--poweroff-delay-seconds", type=float, default=4.0, help="default: %(default)s"
  375. )
  376. args = argparser.parse_args()
  377. logging.root.setLevel(_ARGUMENT_LOG_LEVEL_MAPPING[args.log_level])
  378. if args.mqtt_port:
  379. mqtt_port = args.mqtt_port
  380. elif args.mqtt_disable_tls:
  381. mqtt_port = _MQTT_DEFAULT_PORT
  382. else:
  383. mqtt_port = _MQTT_DEFAULT_TLS_PORT
  384. if args.mqtt_password_path:
  385. # .read_text() replaces \r\n with \n
  386. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  387. if mqtt_password.endswith("\r\n"):
  388. mqtt_password = mqtt_password[:-2]
  389. elif mqtt_password.endswith("\n"):
  390. mqtt_password = mqtt_password[:-1]
  391. else:
  392. mqtt_password = args.mqtt_password
  393. # pylint: disable=protected-access
  394. if not systemctl_mqtt._homeassistant.validate_discovery_object_id(
  395. args.homeassistant_discovery_object_id
  396. ):
  397. raise ValueError(
  398. # pylint: disable=protected-access
  399. "invalid home assistant discovery object id"
  400. f" {args.homeassistant_discovery_object_id!r} (length >= 1"
  401. ", allowed characters:"
  402. f" {systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS})"
  403. "\nchange --homeassistant-discovery-object-id"
  404. )
  405. asyncio.run(
  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. )
  417. )