|
@@ -18,13 +18,21 @@
|
|
|
import argparse
|
|
|
import datetime
|
|
|
import functools
|
|
|
+import json
|
|
|
import logging
|
|
|
+import os
|
|
|
import pathlib
|
|
|
import socket
|
|
|
+import threading
|
|
|
import typing
|
|
|
|
|
|
import dbus
|
|
|
+import dbus.mainloop.glib
|
|
|
+import dbus.types
|
|
|
|
|
|
+# black keeps inserting a blank line above
|
|
|
+# https://pygobject.readthedocs.io/en/latest/getting_started.html#ubuntu-logo-ubuntu-debian-logo-debian
|
|
|
+import gi.repository.GLib # pylint-import-requirements: imports=PyGObject
|
|
|
import paho.mqtt.client
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -42,6 +50,32 @@ def _get_login_manager() -> dbus.proxies.Interface:
|
|
|
return dbus.Interface(object=proxy, dbus_interface="org.freedesktop.login1.Manager")
|
|
|
|
|
|
|
|
|
+def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
|
|
|
+ if _LOGGER.getEffectiveLevel() > logging.DEBUG:
|
|
|
+ return
|
|
|
+ found_inhibitor = False
|
|
|
+ try:
|
|
|
+ # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
|
|
|
+ for what, who, why, mode, uid, pid in login_manager.ListInhibitors():
|
|
|
+ if "shutdown" in what:
|
|
|
+ found_inhibitor = True
|
|
|
+ _LOGGER.debug(
|
|
|
+ "detected shutdown inhibitor %s (pid=%u, uid=%u, mode=%s): %s",
|
|
|
+ who,
|
|
|
+ pid,
|
|
|
+ uid,
|
|
|
+ mode,
|
|
|
+ why,
|
|
|
+ )
|
|
|
+ except dbus.DBusException as exc:
|
|
|
+ _LOGGER.warning(
|
|
|
+ "failed to fetch shutdown inhibitors: %s", exc.get_dbus_message()
|
|
|
+ )
|
|
|
+ return
|
|
|
+ if not found_inhibitor:
|
|
|
+ _LOGGER.debug("no shutdown inhibitor locks found")
|
|
|
+
|
|
|
+
|
|
|
def _schedule_shutdown(action: str) -> None:
|
|
|
# https://github.com/systemd/systemd/blob/v237/src/systemctl/systemctl.c#L8553
|
|
|
assert action in ["poweroff", "reboot"], action
|
|
@@ -51,7 +85,9 @@ def _schedule_shutdown(action: str) -> None:
|
|
|
_LOGGER.info(
|
|
|
"scheduling %s for %s", action, shutdown_datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
)
|
|
|
- shutdown_epoch_usec = int(shutdown_datetime.timestamp() * 10 ** 6)
|
|
|
+ # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html?highlight=signature#basic-types
|
|
|
+ shutdown_epoch_usec = dbus.UInt64(shutdown_datetime.timestamp() * 10 ** 6)
|
|
|
+ login_manager = _get_login_manager()
|
|
|
try:
|
|
|
# $ gdbus introspect --system --dest org.freedesktop.login1 \
|
|
|
# --object-path /org/freedesktop/login1 | grep -A 1 ScheduleShutdown
|
|
@@ -65,7 +101,7 @@ def _schedule_shutdown(action: str) -> None:
|
|
|
# /org/freedesktop/login1 \
|
|
|
# org.freedesktop.login1.Manager.ScheduleShutdown \
|
|
|
# string:poweroff "uint64:$(date --date=10min +%s)000000"
|
|
|
- _get_login_manager().ScheduleShutdown(action, shutdown_epoch_usec)
|
|
|
+ login_manager.ScheduleShutdown(action, shutdown_epoch_usec)
|
|
|
except dbus.DBusException as exc:
|
|
|
exc_msg = exc.get_dbus_message()
|
|
|
if "authentication required" in exc_msg.lower():
|
|
@@ -75,19 +111,74 @@ def _schedule_shutdown(action: str) -> None:
|
|
|
)
|
|
|
else:
|
|
|
_LOGGER.error("failed to schedule %s: %s", action, exc_msg)
|
|
|
+ _log_shutdown_inhibitors(login_manager)
|
|
|
|
|
|
|
|
|
-class _Settings:
|
|
|
-
|
|
|
- # pylint: disable=too-few-public-methods
|
|
|
-
|
|
|
+class _State:
|
|
|
def __init__(self, mqtt_topic_prefix: str) -> None:
|
|
|
self._mqtt_topic_prefix = mqtt_topic_prefix
|
|
|
+ self._login_manager = _get_login_manager() # type: dbus.proxies.Interface
|
|
|
+ self._shutdown_lock = None # type: typing.Optional[dbus.types.UnixFd]
|
|
|
+ self._shutdown_lock_mutex = threading.Lock()
|
|
|
|
|
|
@property
|
|
|
def mqtt_topic_prefix(self) -> str:
|
|
|
return self._mqtt_topic_prefix
|
|
|
|
|
|
+ def acquire_shutdown_lock(self) -> None:
|
|
|
+ with self._shutdown_lock_mutex:
|
|
|
+ assert self._shutdown_lock is None
|
|
|
+ # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
|
|
|
+ self._shutdown_lock = self._login_manager.Inhibit(
|
|
|
+ "shutdown", "systemctl-mqtt", "Report shutdown via MQTT", "delay",
|
|
|
+ )
|
|
|
+ _LOGGER.debug("acquired shutdown inhibitor lock")
|
|
|
+
|
|
|
+ def release_shutdown_lock(self) -> None:
|
|
|
+ with self._shutdown_lock_mutex:
|
|
|
+ if self._shutdown_lock:
|
|
|
+ # https://dbus.freedesktop.org/doc/dbus-python/dbus.types.html#dbus.types.UnixFd.take
|
|
|
+ os.close(self._shutdown_lock.take())
|
|
|
+ _LOGGER.debug("released shutdown inhibitor lock")
|
|
|
+ self._shutdown_lock = None
|
|
|
+
|
|
|
+ def _publish_preparing_for_shutdown(
|
|
|
+ self, mqtt_client: paho.mqtt.client.Client, active: bool
|
|
|
+ ) -> None:
|
|
|
+ # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1199
|
|
|
+ topic = self.mqtt_topic_prefix + "/preparing-for-shutdown"
|
|
|
+ payload = json.dumps(active)
|
|
|
+ _LOGGER.info("publishing %r on %s", payload, topic)
|
|
|
+ msg_info = mqtt_client.publish(
|
|
|
+ topic=topic, payload=payload,
|
|
|
+ ) # type: paho.mqtt.client.MQTTMessageInfo
|
|
|
+ msg_info.wait_for_publish()
|
|
|
+ if msg_info.rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
|
|
|
+ _LOGGER.error(
|
|
|
+ "failed to publish on %s (return code %d)", topic, msg_info.rc
|
|
|
+ )
|
|
|
+
|
|
|
+ def _prepare_for_shutdown_handler(
|
|
|
+ self, active: dbus.Boolean, mqtt_client: paho.mqtt.client.Client
|
|
|
+ ) -> None:
|
|
|
+ assert isinstance(active, dbus.Boolean)
|
|
|
+ active = bool(active)
|
|
|
+ self._publish_preparing_for_shutdown(mqtt_client=mqtt_client, active=active)
|
|
|
+ if active:
|
|
|
+ self.release_shutdown_lock()
|
|
|
+ else:
|
|
|
+ self.acquire_shutdown_lock()
|
|
|
+
|
|
|
+ def register_prepare_for_shutdown_handler(
|
|
|
+ self, mqtt_client: paho.mqtt.client.Client
|
|
|
+ ) -> None:
|
|
|
+ self._login_manager.connect_to_signal(
|
|
|
+ signal_name="PrepareForShutdown",
|
|
|
+ handler_function=functools.partial(
|
|
|
+ self._prepare_for_shutdown_handler, mqtt_client=mqtt_client
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
|
|
|
class _MQTTAction:
|
|
|
|
|
@@ -100,7 +191,7 @@ class _MQTTAction:
|
|
|
def mqtt_message_callback(
|
|
|
self,
|
|
|
mqtt_client: paho.mqtt.client.Client,
|
|
|
- settings: _Settings,
|
|
|
+ state: _State,
|
|
|
message: paho.mqtt.client.MQTTMessage,
|
|
|
) -> None:
|
|
|
# pylint: disable=unused-argument; callback
|
|
@@ -124,7 +215,7 @@ _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
|
|
|
|
|
|
def _mqtt_on_connect(
|
|
|
mqtt_client: paho.mqtt.client.Client,
|
|
|
- settings: _Settings,
|
|
|
+ state: _State,
|
|
|
flags: typing.Dict,
|
|
|
return_code: int,
|
|
|
) -> None:
|
|
@@ -133,8 +224,10 @@ def _mqtt_on_connect(
|
|
|
assert return_code == 0, return_code # connection accepted
|
|
|
mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
|
|
|
_LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
|
|
|
+ state.acquire_shutdown_lock()
|
|
|
+ state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
|
|
|
for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
|
|
|
- topic = settings.mqtt_topic_prefix + "/" + topic_suffix
|
|
|
+ topic = state.mqtt_topic_prefix + "/" + topic_suffix
|
|
|
_LOGGER.info("subscribing to %s", topic)
|
|
|
mqtt_client.subscribe(topic)
|
|
|
mqtt_client.message_callback_add(
|
|
@@ -152,9 +245,11 @@ def _run(
|
|
|
mqtt_password: typing.Optional[str],
|
|
|
mqtt_topic_prefix: str,
|
|
|
) -> None:
|
|
|
+ # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html#setting-up-an-event-loop
|
|
|
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
|
|
# https://pypi.org/project/paho-mqtt/
|
|
|
mqtt_client = paho.mqtt.client.Client(
|
|
|
- userdata=_Settings(mqtt_topic_prefix=mqtt_topic_prefix)
|
|
|
+ userdata=_State(mqtt_topic_prefix=mqtt_topic_prefix)
|
|
|
)
|
|
|
mqtt_client.on_connect = _mqtt_on_connect
|
|
|
mqtt_client.tls_set(ca_certs=None) # enable tls trusting default system certs
|
|
@@ -166,7 +261,18 @@ def _run(
|
|
|
elif mqtt_password:
|
|
|
raise ValueError("Missing MQTT username")
|
|
|
mqtt_client.connect(host=mqtt_host, port=mqtt_port)
|
|
|
- mqtt_client.loop_forever()
|
|
|
+ # loop_start runs loop_forever in a new thread (daemon)
|
|
|
+ # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1814
|
|
|
+ # loop_forever attempts to reconnect if disconnected
|
|
|
+ # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1744
|
|
|
+ mqtt_client.loop_start()
|
|
|
+ try:
|
|
|
+ gi.repository.GLib.MainLoop().run()
|
|
|
+ finally:
|
|
|
+ # blocks until loop_forever stops
|
|
|
+ _LOGGER.debug("waiting for MQTT loop to stop")
|
|
|
+ mqtt_client.loop_stop()
|
|
|
+ _LOGGER.debug("MQTT loop stopped")
|
|
|
|
|
|
|
|
|
def _get_hostname() -> str:
|