1
0

__init__.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  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. print("nathan's version 3")
  18. import abc
  19. import argparse
  20. import asyncio
  21. import datetime
  22. import functools
  23. import importlib.metadata
  24. import json
  25. import logging
  26. import os
  27. import pathlib
  28. import socket
  29. import ssl
  30. import threading
  31. import typing
  32. import aiomqtt
  33. import jeepney
  34. import jeepney.bus_messages
  35. import jeepney.io.asyncio
  36. import systemctl_mqtt._dbus.login_manager
  37. import systemctl_mqtt._dbus.service_manager
  38. import systemctl_mqtt._homeassistant
  39. import systemctl_mqtt._mqtt
  40. _MQTT_DEFAULT_PORT = 1883
  41. _MQTT_DEFAULT_TLS_PORT = 8883
  42. # > payload_not_available string (Optional, default: offline)
  43. # https://web.archive.org/web/20250101075341/https://www.home-assistant.io/integrations/sensor.mqtt/#payload_not_available
  44. _MQTT_PAYLOAD_NOT_AVAILABLE = "offline"
  45. _MQTT_PAYLOAD_AVAILABLE = "online"
  46. _ARGUMENT_LOG_LEVEL_MAPPING = {
  47. a: getattr(logging, a.upper())
  48. for a in ("debug", "info", "warning", "error", "critical")
  49. }
  50. _LOGGER = logging.getLogger(__name__)
  51. class _State:
  52. # pylint: disable=too-many-instance-attributes
  53. def __init__( # pylint: disable=too-many-arguments
  54. self,
  55. *,
  56. mqtt_topic_prefix: str,
  57. homeassistant_discovery_prefix: str,
  58. homeassistant_discovery_object_id: str,
  59. poweroff_delay: datetime.timedelta,
  60. monitored_system_unit_names: typing.List[str],
  61. controlled_system_unit_names: typing.List[str],
  62. ) -> None:
  63. self._mqtt_topic_prefix = mqtt_topic_prefix
  64. self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
  65. self._homeassistant_discovery_object_id = homeassistant_discovery_object_id
  66. self._login_manager = (
  67. systemctl_mqtt._dbus.login_manager.get_login_manager_proxy()
  68. )
  69. self._shutdown_lock: typing.Optional[jeepney.fds.FileDescriptor] = None
  70. self._shutdown_lock_mutex = threading.Lock()
  71. self.poweroff_delay = poweroff_delay
  72. self._monitored_system_unit_names = monitored_system_unit_names
  73. self._controlled_system_unit_names = controlled_system_unit_names
  74. @property
  75. def mqtt_topic_prefix(self) -> str:
  76. return self._mqtt_topic_prefix
  77. @property
  78. def mqtt_availability_topic(self) -> str:
  79. # > mqtt.ATTR_TOPIC: "homeassistant/status",
  80. # https://github.com/home-assistant/core/blob/2024.12.5/tests/components/mqtt/conftest.py#L23
  81. # > _MQTT_AVAILABILITY_TOPIC = "switchbot-mqtt/status"
  82. # https://github.com/fphammerle/switchbot-mqtt/blob/v3.3.1/switchbot_mqtt/__init__.py#L30
  83. return self._mqtt_topic_prefix + "/status"
  84. def get_system_unit_active_state_mqtt_topic(self, *, unit_name: str) -> str:
  85. return self._mqtt_topic_prefix + "/unit/system/" + unit_name + "/active-state"
  86. def get_system_unit_restart_mqtt_topic(self, *, unit_name: str) -> str:
  87. return self._mqtt_topic_prefix + "/unit/system/" + unit_name + "/restart"
  88. @property
  89. def monitored_system_unit_names(self) -> typing.List[str]:
  90. return self._monitored_system_unit_names
  91. @property
  92. def controlled_system_unit_names(self) -> typing.List[str]:
  93. return self._controlled_system_unit_names
  94. @property
  95. def shutdown_lock_acquired(self) -> bool:
  96. return self._shutdown_lock is not None
  97. def acquire_shutdown_lock(self) -> None:
  98. with self._shutdown_lock_mutex:
  99. assert self._shutdown_lock is None
  100. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  101. (self._shutdown_lock,) = self._login_manager.Inhibit(
  102. what="shutdown",
  103. who="systemctl-mqtt",
  104. why="Report shutdown via MQTT",
  105. mode="delay",
  106. )
  107. assert isinstance(
  108. self._shutdown_lock, jeepney.fds.FileDescriptor
  109. ), self._shutdown_lock
  110. _LOGGER.debug("acquired shutdown inhibitor lock")
  111. def release_shutdown_lock(self) -> None:
  112. with self._shutdown_lock_mutex:
  113. if self._shutdown_lock:
  114. self._shutdown_lock.close()
  115. _LOGGER.debug("released shutdown inhibitor lock")
  116. self._shutdown_lock = None
  117. @property
  118. def _preparing_for_shutdown_topic(self) -> str:
  119. return self.mqtt_topic_prefix + "/preparing-for-shutdown"
  120. async def _publish_preparing_for_shutdown(
  121. self, *, mqtt_client: aiomqtt.Client, active: bool
  122. ) -> None:
  123. topic = self._preparing_for_shutdown_topic
  124. # pylint: disable=protected-access
  125. payload = systemctl_mqtt._mqtt.encode_bool(active)
  126. _LOGGER.info("publishing %r on %s", payload, topic)
  127. await mqtt_client.publish(topic=topic, payload=payload, retain=False)
  128. async def preparing_for_shutdown_handler(
  129. self, active: bool, mqtt_client: aiomqtt.Client
  130. ) -> None:
  131. active = bool(active)
  132. await self._publish_preparing_for_shutdown(
  133. mqtt_client=mqtt_client, active=active
  134. )
  135. if active:
  136. self.release_shutdown_lock()
  137. else:
  138. self.acquire_shutdown_lock()
  139. async def publish_preparing_for_shutdown(self, mqtt_client: aiomqtt.Client) -> None:
  140. try:
  141. ((return_type, active),) = self._login_manager.Get("PreparingForShutdown")
  142. except jeepney.wrappers.DBusErrorResponse as exc:
  143. _LOGGER.error(
  144. "failed to read logind's PreparingForShutdown property: %s", exc
  145. )
  146. return
  147. assert return_type == "b", return_type
  148. assert isinstance(active, bool), active
  149. await self._publish_preparing_for_shutdown(
  150. mqtt_client=mqtt_client, active=active
  151. )
  152. async def publish_homeassistant_device_config(
  153. self, mqtt_client: aiomqtt.Client
  154. ) -> None:
  155. # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
  156. # https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
  157. discovery_topic = "/".join(
  158. (
  159. self._homeassistant_discovery_prefix,
  160. "device",
  161. self._homeassistant_discovery_object_id,
  162. "config",
  163. )
  164. )
  165. hostname = (
  166. # pylint: disable=protected-access; function in internal module
  167. systemctl_mqtt._utils.get_hostname()
  168. )
  169. package_metadata = importlib.metadata.metadata(__name__)
  170. unique_id_prefix = "systemctl-mqtt-" + hostname
  171. config = {
  172. "device": {"identifiers": [hostname], "name": hostname},
  173. "origin": {
  174. "name": package_metadata["Name"],
  175. "sw_version": package_metadata["Version"],
  176. "support_url": package_metadata["Home-page"],
  177. },
  178. "availability": {"topic": self.mqtt_availability_topic},
  179. "components": {
  180. "logind/preparing-for-shutdown": {
  181. "unique_id": unique_id_prefix + "-logind-preparing-for-shutdown",
  182. "object_id": f"{hostname}_logind_preparing_for_shutdown", # entity id
  183. "name": "preparing for shutdown", # home assistant prepends device name
  184. "platform": "binary_sensor",
  185. "state_topic": self._preparing_for_shutdown_topic,
  186. # pylint: disable=protected-access
  187. "payload_on": systemctl_mqtt._mqtt.encode_bool(True),
  188. "payload_off": systemctl_mqtt._mqtt.encode_bool(False),
  189. },
  190. },
  191. }
  192. for mqtt_topic_suffix in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.keys():
  193. # false positive warning by mypy:
  194. # > Unsupported target for indexed assignment
  195. config["components"]["logind/" + mqtt_topic_suffix] = { # type: ignore
  196. "unique_id": unique_id_prefix + "-logind-" + mqtt_topic_suffix,
  197. "object_id": hostname
  198. + "_logind_"
  199. + mqtt_topic_suffix.replace("-", "_"), # entity id
  200. "name": mqtt_topic_suffix.replace("-", " "),
  201. "platform": "button",
  202. "command_topic": self.mqtt_topic_prefix + "/" + mqtt_topic_suffix,
  203. }
  204. for unit_name in self._monitored_system_unit_names:
  205. config["components"]["unit/system/" + unit_name + "/active-state"] = { # type: ignore
  206. "unique_id": f"{unique_id_prefix}-unit-system-{unit_name}-active-state",
  207. "object_id": f"{hostname}_unit_system_{unit_name}_active_state",
  208. "name": f"{unit_name} active state",
  209. "platform": "sensor",
  210. "state_topic": self.get_system_unit_active_state_mqtt_topic(
  211. unit_name=unit_name
  212. ),
  213. }
  214. for unit_name in self._controlled_system_unit_names:
  215. config["components"]["unit/system/" + unit_name + "/restart"] = { # type: ignore
  216. "unique_id": f"{unique_id_prefix}-unit-system-{unit_name}-restart",
  217. "object_id": f"{hostname}_unit_system_{unit_name}_restart",
  218. "name": f"{unit_name} restart",
  219. "platform": "button",
  220. "command_topic": self.get_system_unit_restart_mqtt_topic(
  221. unit_name=unit_name
  222. ),
  223. }
  224. _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
  225. await mqtt_client.publish(
  226. topic=discovery_topic, payload=json.dumps(config), retain=False
  227. )
  228. class _MQTTAction(metaclass=abc.ABCMeta):
  229. @abc.abstractmethod
  230. def trigger(self, state: _State) -> None:
  231. pass # pragma: no cover
  232. def __str__(self) -> str:
  233. return type(self).__name__
  234. class _MQTTActionSchedulePoweroff(_MQTTAction):
  235. # pylint: disable=too-few-public-methods
  236. def trigger(self, state: _State) -> None:
  237. # pylint: disable=protected-access
  238. systemctl_mqtt._dbus.login_manager.schedule_shutdown(
  239. action="poweroff", delay=state.poweroff_delay
  240. )
  241. class _MQTTActionRestartUnit(_MQTTAction):
  242. # pylint: disable=protected-access,too-few-public-methods
  243. def __init__(self, unit_name: str):
  244. self._unit_name = unit_name
  245. def trigger(self, state: _State) -> None:
  246. systemctl_mqtt._dbus.service_manager.restart_unit(unit_name=self._unit_name)
  247. class _MQTTActionLockAllSessions(_MQTTAction):
  248. # pylint: disable=too-few-public-methods
  249. def trigger(self, state: _State) -> None:
  250. # pylint: disable=protected-access
  251. systemctl_mqtt._dbus.login_manager.lock_all_sessions()
  252. class _MQTTActionSuspend(_MQTTAction):
  253. # pylint: disable=too-few-public-methods
  254. def trigger(self, state: _State) -> None:
  255. # pylint: disable=protected-access
  256. systemctl_mqtt._dbus.login_manager.suspend()
  257. _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
  258. "poweroff": _MQTTActionSchedulePoweroff(),
  259. "lock-all-sessions": _MQTTActionLockAllSessions(),
  260. "suspend": _MQTTActionSuspend(),
  261. }
  262. async def _mqtt_message_loop(*, state: _State, mqtt_client: aiomqtt.Client) -> None:
  263. action_by_topic: typing.Dict[str, _MQTTAction] = {}
  264. for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
  265. topic = state.mqtt_topic_prefix + "/" + topic_suffix
  266. _LOGGER.info("subscribing to %s", topic)
  267. await mqtt_client.subscribe(topic)
  268. action_by_topic[topic] = action
  269. for unit_name in state.controlled_system_unit_names:
  270. topic = state.mqtt_topic_prefix + "/unit/system/" + unit_name + "/restart"
  271. _LOGGER.info("subscribing to %s", topic)
  272. await mqtt_client.subscribe(topic)
  273. action = _MQTTActionRestartUnit(unit_name=unit_name)
  274. action_by_topic[topic] = action
  275. async for message in mqtt_client.messages:
  276. if message.retain:
  277. _LOGGER.info("ignoring retained message on topic %r", message.topic.value)
  278. else:
  279. _LOGGER.debug(
  280. "received message on topic %r: %r", message.topic.value, message.payload
  281. )
  282. action_by_topic[message.topic.value].trigger(state=state)
  283. async def _dbus_signal_loop_preparing_for_shutdown(
  284. *,
  285. state: _State,
  286. mqtt_client: aiomqtt.Client,
  287. dbus_router: jeepney.io.asyncio.DBusRouter,
  288. bus_proxy: jeepney.io.asyncio.Proxy,
  289. ) -> None:
  290. preparing_for_shutdown_match_rule = (
  291. # pylint: disable=protected-access
  292. systemctl_mqtt._dbus.login_manager.get_login_manager_signal_match_rule(
  293. "PrepareForShutdown"
  294. )
  295. )
  296. assert await bus_proxy.AddMatch(preparing_for_shutdown_match_rule) == ()
  297. with dbus_router.filter(preparing_for_shutdown_match_rule) as queue:
  298. while True:
  299. message: jeepney.low_level.Message = await queue.get()
  300. (preparing_for_shutdown,) = message.body
  301. await state.preparing_for_shutdown_handler(
  302. active=preparing_for_shutdown, mqtt_client=mqtt_client
  303. )
  304. queue.task_done()
  305. async def _get_unit_path(
  306. *, service_manager: jeepney.io.asyncio.Proxy, unit_name: str
  307. ) -> str:
  308. (path,) = await service_manager.GetUnit(name=unit_name)
  309. return path
  310. async def _dbus_signal_loop_unit( # pylint: disable=too-many-arguments
  311. *,
  312. state: _State,
  313. mqtt_client: aiomqtt.Client,
  314. dbus_router: jeepney.io.asyncio.DBusRouter,
  315. bus_proxy: jeepney.io.asyncio.Proxy,
  316. unit_name: str,
  317. unit_path: str,
  318. ) -> None:
  319. unit_proxy = jeepney.io.asyncio.Proxy(
  320. # pylint: disable=protected-access
  321. msggen=systemctl_mqtt._dbus.service_manager.Unit(object_path=unit_path),
  322. router=dbus_router,
  323. )
  324. unit_properties_changed_match_rule = jeepney.MatchRule(
  325. type="signal",
  326. interface="org.freedesktop.DBus.Properties",
  327. member="PropertiesChanged",
  328. path=unit_path,
  329. )
  330. assert (await bus_proxy.AddMatch(unit_properties_changed_match_rule)) == ()
  331. # > Table 1. Unit ACTIVE states …
  332. # > active Started, bound, plugged in, …
  333. # > inactive Stopped, unbound, unplugged, …
  334. # > failed … process returned error code on exit, crashed, an operation
  335. # . timed out, or after too many restarts).
  336. # > activating Changing from inactive to active.
  337. # > deactivating Changing from active to inactive.
  338. # > maintenance Unit is inactive and … maintenance … in progress.
  339. # > reloading Unit is active and it is reloading its configuration.
  340. # > refreshing Unit is active and a new mount is being activated in its
  341. # . namespace.
  342. # https://web.archive.org/web/20250101121304/https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html
  343. active_state_topic = state.get_system_unit_active_state_mqtt_topic(
  344. unit_name=unit_name
  345. )
  346. ((_, last_active_state),) = await unit_proxy.Get(property_name="ActiveState")
  347. await mqtt_client.publish(topic=active_state_topic, payload=last_active_state, retain=True)
  348. with dbus_router.filter(unit_properties_changed_match_rule) as queue:
  349. while True:
  350. await queue.get()
  351. ((_, current_active_state),) = await unit_proxy.Get(
  352. property_name="ActiveState"
  353. )
  354. if current_active_state != last_active_state:
  355. await mqtt_client.publish(
  356. topic=active_state_topic, payload=current_active_state, retain=True
  357. )
  358. last_active_state = current_active_state
  359. queue.task_done()
  360. async def _dbus_signal_loop(*, state: _State, mqtt_client: aiomqtt.Client) -> None:
  361. async with jeepney.io.asyncio.open_dbus_router(bus="SYSTEM") as router:
  362. # router: jeepney.io.asyncio.DBusRouter
  363. bus_proxy = jeepney.io.asyncio.Proxy(
  364. msggen=jeepney.bus_messages.message_bus, router=router
  365. )
  366. system_service_manager = jeepney.io.asyncio.Proxy(
  367. # pylint: disable=protected-access
  368. msggen=systemctl_mqtt._dbus.service_manager.ServiceManager(),
  369. router=router,
  370. )
  371. await asyncio.gather(
  372. *[
  373. _dbus_signal_loop_preparing_for_shutdown(
  374. state=state,
  375. mqtt_client=mqtt_client,
  376. dbus_router=router,
  377. bus_proxy=bus_proxy,
  378. )
  379. ]
  380. + [
  381. _dbus_signal_loop_unit(
  382. state=state,
  383. mqtt_client=mqtt_client,
  384. dbus_router=router,
  385. bus_proxy=bus_proxy,
  386. unit_name=unit_name,
  387. unit_path=await _get_unit_path(
  388. service_manager=system_service_manager, unit_name=unit_name
  389. ),
  390. )
  391. for unit_name in state.monitored_system_unit_names
  392. ],
  393. return_exceptions=False,
  394. )
  395. async def _run( # pylint: disable=too-many-arguments
  396. *,
  397. mqtt_host: str,
  398. mqtt_port: int,
  399. mqtt_username: typing.Optional[str],
  400. mqtt_password: typing.Optional[str],
  401. mqtt_topic_prefix: str,
  402. homeassistant_discovery_prefix: str,
  403. homeassistant_discovery_object_id: str,
  404. poweroff_delay: datetime.timedelta,
  405. monitored_system_unit_names: typing.List[str],
  406. controlled_system_unit_names: typing.List[str],
  407. mqtt_disable_tls: bool = False,
  408. ) -> None:
  409. state = _State(
  410. mqtt_topic_prefix=mqtt_topic_prefix,
  411. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  412. homeassistant_discovery_object_id=homeassistant_discovery_object_id,
  413. poweroff_delay=poweroff_delay,
  414. monitored_system_unit_names=monitored_system_unit_names,
  415. controlled_system_unit_names=controlled_system_unit_names,
  416. )
  417. _LOGGER.info(
  418. "connecting to MQTT broker %s:%d (TLS %s)",
  419. mqtt_host,
  420. mqtt_port,
  421. "disabled" if mqtt_disable_tls else "enabled",
  422. )
  423. if mqtt_password and not mqtt_username:
  424. raise ValueError("Missing MQTT username")
  425. async with aiomqtt.Client( # raises aiomqtt.MqttError
  426. hostname=mqtt_host,
  427. port=mqtt_port,
  428. # > The settings [...] usually represent a higher security level than
  429. # > when calling the SSLContext constructor directly.
  430. # https://web.archive.org/web/20230714183106/https://docs.python.org/3/library/ssl.html
  431. tls_context=None if mqtt_disable_tls else ssl.create_default_context(),
  432. username=None if mqtt_username is None else mqtt_username,
  433. password=None if mqtt_password is None else mqtt_password,
  434. will=aiomqtt.Will( # e.g. on SIGTERM & SIGKILL
  435. topic=state.mqtt_availability_topic,
  436. payload=_MQTT_PAYLOAD_NOT_AVAILABLE,
  437. retain=True,
  438. ),
  439. ) as mqtt_client:
  440. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_host, mqtt_port)
  441. if not state.shutdown_lock_acquired:
  442. state.acquire_shutdown_lock()
  443. await state.publish_homeassistant_device_config(mqtt_client=mqtt_client)
  444. await state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
  445. try:
  446. await mqtt_client.publish(
  447. topic=state.mqtt_availability_topic,
  448. payload=_MQTT_PAYLOAD_AVAILABLE,
  449. retain=True,
  450. )
  451. # asynpio.TaskGroup added in python3.11
  452. await asyncio.gather(
  453. _mqtt_message_loop(state=state, mqtt_client=mqtt_client),
  454. _dbus_signal_loop(state=state, mqtt_client=mqtt_client),
  455. return_exceptions=False,
  456. )
  457. finally: # e.g. on SIGINT
  458. # https://web.archive.org/web/20250101080719/https://github.com/empicano/aiomqtt/issues/28
  459. await mqtt_client.publish(
  460. topic=state.mqtt_availability_topic,
  461. payload=_MQTT_PAYLOAD_NOT_AVAILABLE,
  462. retain=True,
  463. )
  464. for monitored_system_unit in state.monitored_system_unit_names:
  465. await mqtt_client.publish(
  466. topic=state.get_system_unit_active_state_mqtt_topic(unit_name=monitored_system_unit),
  467. payload="unknown",
  468. retain=True,
  469. )
  470. def _main() -> None:
  471. logging.basicConfig(
  472. level=logging.INFO,
  473. format="%(asctime)s:%(levelname)s:%(message)s",
  474. datefmt="%Y-%m-%dT%H:%M:%S%z",
  475. )
  476. argparser = argparse.ArgumentParser(
  477. description="MQTT client triggering & reporting shutdown on systemd-based systems",
  478. )
  479. argparser.add_argument(
  480. "--log-level",
  481. choices=_ARGUMENT_LOG_LEVEL_MAPPING.keys(),
  482. default="info",
  483. help="log level (default: %(default)s)",
  484. )
  485. argparser.add_argument("--mqtt-host", type=str, required=True)
  486. argparser.add_argument(
  487. "--mqtt-port",
  488. type=int,
  489. help=f"default {_MQTT_DEFAULT_TLS_PORT} ({_MQTT_DEFAULT_PORT} with --mqtt-disable-tls)",
  490. )
  491. argparser.add_argument("--mqtt-username", type=str)
  492. argparser.add_argument("--mqtt-disable-tls", action="store_true")
  493. password_argument_group = argparser.add_mutually_exclusive_group()
  494. password_argument_group.add_argument("--mqtt-password", type=str)
  495. password_argument_group.add_argument(
  496. "--mqtt-password-file",
  497. type=pathlib.Path,
  498. metavar="PATH",
  499. dest="mqtt_password_path",
  500. help="stripping trailing newline",
  501. )
  502. argparser.add_argument(
  503. "--mqtt-topic-prefix",
  504. type=str,
  505. # pylint: disable=protected-access
  506. default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
  507. help="default: %(default)s",
  508. )
  509. # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
  510. argparser.add_argument(
  511. "--homeassistant-discovery-prefix",
  512. type=str,
  513. default="homeassistant",
  514. help="home assistant's prefix for discovery topics" + " (default: %(default)s)",
  515. )
  516. argparser.add_argument(
  517. "--homeassistant-discovery-object-id",
  518. type=str,
  519. # pylint: disable=protected-access
  520. default=systemctl_mqtt._homeassistant.get_default_discovery_object_id(),
  521. help="part of discovery topic (default: %(default)s)",
  522. )
  523. argparser.add_argument(
  524. "--poweroff-delay-seconds", type=float, default=4.0, help="default: %(default)s"
  525. )
  526. argparser.add_argument(
  527. "--monitor-system-unit",
  528. type=str,
  529. metavar="UNIT_NAME",
  530. dest="monitored_system_unit_names",
  531. action="append",
  532. help="e.g. --monitor-system-unit ssh.service --monitor-system-unit custom.service",
  533. )
  534. argparser.add_argument(
  535. "--control-system-unit",
  536. type=str,
  537. metavar="UNIT_NAME",
  538. dest="controlled_system_unit_names",
  539. action="append",
  540. help="e.g. --control-system-unit ansible-pull.service --control-system-unit custom.service",
  541. )
  542. args = argparser.parse_args()
  543. logging.root.setLevel(_ARGUMENT_LOG_LEVEL_MAPPING[args.log_level])
  544. if args.mqtt_port:
  545. mqtt_port = args.mqtt_port
  546. elif args.mqtt_disable_tls:
  547. mqtt_port = _MQTT_DEFAULT_PORT
  548. else:
  549. mqtt_port = _MQTT_DEFAULT_TLS_PORT
  550. if args.mqtt_password_path:
  551. # .read_text() replaces \r\n with \n
  552. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  553. if mqtt_password.endswith("\r\n"):
  554. mqtt_password = mqtt_password[:-2]
  555. elif mqtt_password.endswith("\n"):
  556. mqtt_password = mqtt_password[:-1]
  557. else:
  558. mqtt_password = args.mqtt_password
  559. # pylint: disable=protected-access
  560. if not systemctl_mqtt._homeassistant.validate_discovery_object_id(
  561. args.homeassistant_discovery_object_id
  562. ):
  563. raise ValueError(
  564. # pylint: disable=protected-access
  565. "invalid home assistant discovery object id"
  566. f" {args.homeassistant_discovery_object_id!r} (length >= 1"
  567. ", allowed characters:"
  568. f" {systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS})"
  569. "\nchange --homeassistant-discovery-object-id"
  570. )
  571. asyncio.run(
  572. _run(
  573. mqtt_host=args.mqtt_host,
  574. mqtt_port=mqtt_port,
  575. mqtt_disable_tls=args.mqtt_disable_tls,
  576. mqtt_username=args.mqtt_username,
  577. mqtt_password=mqtt_password,
  578. mqtt_topic_prefix=args.mqtt_topic_prefix,
  579. homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
  580. homeassistant_discovery_object_id=args.homeassistant_discovery_object_id,
  581. poweroff_delay=datetime.timedelta(seconds=args.poweroff_delay_seconds),
  582. monitored_system_unit_names=args.monitored_system_unit_names or [],
  583. controlled_system_unit_names=args.controlled_system_unit_names or [],
  584. )
  585. )