|
@@ -28,96 +28,32 @@ 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__)
|
|
|
-
|
|
|
-_SHUTDOWN_DELAY = datetime.timedelta(seconds=4)
|
|
|
-
|
|
|
+import systemctl_mqtt._dbus
|
|
|
+import systemctl_mqtt._homeassistant
|
|
|
+import systemctl_mqtt._mqtt
|
|
|
|
|
|
-def _get_login_manager() -> dbus.proxies.Interface:
|
|
|
- # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html
|
|
|
- bus = dbus.SystemBus()
|
|
|
- proxy = bus.get_object(
|
|
|
- bus_name="org.freedesktop.login1", object_path="/org/freedesktop/login1"
|
|
|
- ) # type: dbus.proxies.ProxyObject
|
|
|
- # https://freedesktop.org/wiki/Software/systemd/logind/
|
|
|
- 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
|
|
|
- shutdown_datetime = datetime.datetime.now() + _SHUTDOWN_DELAY
|
|
|
- # datetime.datetime.isoformat(timespec=) not available in python3.5
|
|
|
- # https://github.com/python/cpython/blob/v3.5.9/Lib/datetime.py#L1552
|
|
|
- _LOGGER.info(
|
|
|
- "scheduling %s for %s", action, shutdown_datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
- )
|
|
|
- # 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
|
|
|
- # ScheduleShutdown(in s arg_0,
|
|
|
- # in t arg_1);
|
|
|
- # $ gdbus call --system --dest org.freedesktop.login1 \
|
|
|
- # --object-path /org/freedesktop/login1 \
|
|
|
- # --method org.freedesktop.login1.Manager.ScheduleShutdown \
|
|
|
- # poweroff "$(date --date=10min +%s)000000"
|
|
|
- # $ dbus-send --type=method_call --print-reply --system --dest=org.freedesktop.login1 \
|
|
|
- # /org/freedesktop/login1 \
|
|
|
- # org.freedesktop.login1.Manager.ScheduleShutdown \
|
|
|
- # string:poweroff "uint64:$(date --date=10min +%s)000000"
|
|
|
- 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():
|
|
|
- _LOGGER.error(
|
|
|
- "failed to schedule %s: unauthorized; missing polkit authorization rules?",
|
|
|
- action,
|
|
|
- )
|
|
|
- else:
|
|
|
- _LOGGER.error("failed to schedule %s: %s", action, exc_msg)
|
|
|
- _log_shutdown_inhibitors(login_manager)
|
|
|
+_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
class _State:
|
|
|
- def __init__(self, mqtt_topic_prefix: str) -> None:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ mqtt_topic_prefix: str,
|
|
|
+ homeassistant_discovery_prefix: str,
|
|
|
+ homeassistant_node_id: str,
|
|
|
+ ) -> None:
|
|
|
self._mqtt_topic_prefix = mqtt_topic_prefix
|
|
|
- self._login_manager = _get_login_manager() # type: dbus.proxies.Interface
|
|
|
+ self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
|
|
|
+ self._homeassistant_node_id = homeassistant_node_id
|
|
|
+ self._login_manager = (
|
|
|
+ systemctl_mqtt._dbus.get_login_manager()
|
|
|
+ ) # type: dbus.proxies.Interface
|
|
|
self._shutdown_lock = None # type: typing.Optional[dbus.types.UnixFd]
|
|
|
self._shutdown_lock_mutex = threading.Lock()
|
|
|
|
|
@@ -125,6 +61,10 @@ class _State:
|
|
|
def mqtt_topic_prefix(self) -> str:
|
|
|
return self._mqtt_topic_prefix
|
|
|
|
|
|
+ @property
|
|
|
+ def shutdown_lock_acquired(self) -> bool:
|
|
|
+ return self._shutdown_lock is not None
|
|
|
+
|
|
|
def acquire_shutdown_lock(self) -> None:
|
|
|
with self._shutdown_lock_mutex:
|
|
|
assert self._shutdown_lock is None
|
|
@@ -142,12 +82,17 @@ class _State:
|
|
|
_LOGGER.debug("released shutdown inhibitor lock")
|
|
|
self._shutdown_lock = None
|
|
|
|
|
|
+ @property
|
|
|
+ def _preparing_for_shutdown_topic(self) -> str:
|
|
|
+ return self.mqtt_topic_prefix + "/preparing-for-shutdown"
|
|
|
+
|
|
|
def _publish_preparing_for_shutdown(
|
|
|
self, mqtt_client: paho.mqtt.client.Client, active: bool, block: 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)
|
|
|
+ topic = self._preparing_for_shutdown_topic
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ payload = systemctl_mqtt._mqtt.encode_bool(active)
|
|
|
_LOGGER.info("publishing %r on %s", payload, topic)
|
|
|
msg_info = mqtt_client.publish(
|
|
|
topic=topic, payload=payload, retain=True,
|
|
@@ -206,6 +151,43 @@ class _State:
|
|
|
block=False,
|
|
|
)
|
|
|
|
|
|
+ def publish_preparing_for_shutdown_homeassistant_config(
|
|
|
+ self, mqtt_client: paho.mqtt.client.Client
|
|
|
+ ) -> None:
|
|
|
+ # <discovery_prefix>/<component>/[<node_id>/]<object_id>/config
|
|
|
+ # https://www.home-assistant.io/docs/mqtt/discovery/
|
|
|
+ discovery_topic = "/".join(
|
|
|
+ (
|
|
|
+ self._homeassistant_discovery_prefix,
|
|
|
+ "binary_sensor",
|
|
|
+ self._homeassistant_node_id,
|
|
|
+ "preparing-for-shutdown",
|
|
|
+ "config",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ unique_id = "/".join(
|
|
|
+ (
|
|
|
+ "systemctl-mqtt",
|
|
|
+ self._homeassistant_node_id,
|
|
|
+ "logind",
|
|
|
+ "preparing-for-shutdown",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ # https://www.home-assistant.io/integrations/binary_sensor.mqtt/#configuration-variables
|
|
|
+ config = {
|
|
|
+ "unique_id": unique_id,
|
|
|
+ "state_topic": self._preparing_for_shutdown_topic,
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ "payload_on": systemctl_mqtt._mqtt.encode_bool(True),
|
|
|
+ "payload_off": systemctl_mqtt._mqtt.encode_bool(False),
|
|
|
+ # friendly_name & template for default entity_id
|
|
|
+ "name": "{} preparing for shutdown".format(self._homeassistant_node_id),
|
|
|
+ }
|
|
|
+ _LOGGER.debug("publishing home assistant config on %s", discovery_topic)
|
|
|
+ mqtt_client.publish(
|
|
|
+ topic=discovery_topic, payload=json.dumps(config), retain=True,
|
|
|
+ )
|
|
|
+
|
|
|
|
|
|
class _MQTTAction:
|
|
|
|
|
@@ -235,7 +217,12 @@ class _MQTTAction:
|
|
|
|
|
|
_MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
|
|
|
"poweroff": _MQTTAction(
|
|
|
- name="poweroff", action=functools.partial(_schedule_shutdown, action="poweroff")
|
|
|
+ name="poweroff",
|
|
|
+ action=functools.partial(
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ systemctl_mqtt._dbus.schedule_shutdown,
|
|
|
+ action="poweroff",
|
|
|
+ ),
|
|
|
),
|
|
|
}
|
|
|
|
|
@@ -251,9 +238,11 @@ 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()
|
|
|
+ if not state.shutdown_lock_acquired:
|
|
|
+ state.acquire_shutdown_lock()
|
|
|
state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client)
|
|
|
state.publish_preparing_for_shutdown(mqtt_client=mqtt_client)
|
|
|
+ state.publish_preparing_for_shutdown_homeassistant_config(mqtt_client=mqtt_client)
|
|
|
for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
|
|
|
topic = state.mqtt_topic_prefix + "/" + topic_suffix
|
|
|
_LOGGER.info("subscribing to %s", topic)
|
|
@@ -262,7 +251,7 @@ def _mqtt_on_connect(
|
|
|
sub=topic, callback=action.mqtt_message_callback
|
|
|
)
|
|
|
_LOGGER.debug(
|
|
|
- "registered MQTT callback for topic %s triggering %r", topic, action.action
|
|
|
+ "registered MQTT callback for topic %s triggering %r", topic, action.action,
|
|
|
)
|
|
|
|
|
|
|
|
@@ -272,12 +261,19 @@ def _run(
|
|
|
mqtt_username: typing.Optional[str],
|
|
|
mqtt_password: typing.Optional[str],
|
|
|
mqtt_topic_prefix: str,
|
|
|
+ homeassistant_discovery_prefix: str,
|
|
|
+ homeassistant_node_id: str,
|
|
|
) -> None:
|
|
|
+ # pylint: disable=too-many-arguments
|
|
|
# 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=_State(mqtt_topic_prefix=mqtt_topic_prefix)
|
|
|
+ userdata=_State(
|
|
|
+ mqtt_topic_prefix=mqtt_topic_prefix,
|
|
|
+ homeassistant_discovery_prefix=homeassistant_discovery_prefix,
|
|
|
+ homeassistant_node_id=homeassistant_node_id,
|
|
|
+ )
|
|
|
)
|
|
|
mqtt_client.on_connect = _mqtt_on_connect
|
|
|
mqtt_client.tls_set(ca_certs=None) # enable tls trusting default system certs
|
|
@@ -303,10 +299,6 @@ def _run(
|
|
|
_LOGGER.debug("MQTT loop stopped")
|
|
|
|
|
|
|
|
|
-def _get_hostname() -> str:
|
|
|
- return socket.gethostname()
|
|
|
-
|
|
|
-
|
|
|
def _main() -> None:
|
|
|
logging.basicConfig(
|
|
|
level=logging.DEBUG,
|
|
@@ -329,13 +321,24 @@ def _main() -> None:
|
|
|
dest="mqtt_password_path",
|
|
|
help="stripping trailing newline",
|
|
|
)
|
|
|
- # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
|
|
|
argparser.add_argument(
|
|
|
"--mqtt-topic-prefix",
|
|
|
type=str,
|
|
|
- default="systemctl/" + _get_hostname(),
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ default="systemctl/" + systemctl_mqtt._utils.get_hostname(),
|
|
|
help=" ", # show default
|
|
|
)
|
|
|
+ # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
|
|
|
+ argparser.add_argument(
|
|
|
+ "--homeassistant-discovery-prefix", type=str, default="homeassistant", help=" ",
|
|
|
+ )
|
|
|
+ argparser.add_argument(
|
|
|
+ "--homeassistant-node-id",
|
|
|
+ type=str,
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ default=systemctl_mqtt._homeassistant.get_default_node_id(),
|
|
|
+ help=" ",
|
|
|
+ )
|
|
|
args = argparser.parse_args()
|
|
|
if args.mqtt_password_path:
|
|
|
# .read_text() replaces \r\n with \n
|
|
@@ -346,10 +349,22 @@ def _main() -> None:
|
|
|
mqtt_password = mqtt_password[:-1]
|
|
|
else:
|
|
|
mqtt_password = args.mqtt_password
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ if not systemctl_mqtt._homeassistant.validate_node_id(args.homeassistant_node_id):
|
|
|
+ raise ValueError(
|
|
|
+ "invalid home assistant node id {!r} (length >= 1, allowed characters: {})".format(
|
|
|
+ args.homeassistant_node_id,
|
|
|
+ # pylint: disable=protected-access
|
|
|
+ systemctl_mqtt._homeassistant.NODE_ID_ALLOWED_CHARS,
|
|
|
+ )
|
|
|
+ + "\nchange --homeassistant-node-id"
|
|
|
+ )
|
|
|
_run(
|
|
|
mqtt_host=args.mqtt_host,
|
|
|
mqtt_port=args.mqtt_port,
|
|
|
mqtt_username=args.mqtt_username,
|
|
|
mqtt_password=mqtt_password,
|
|
|
mqtt_topic_prefix=args.mqtt_topic_prefix,
|
|
|
+ homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
|
|
|
+ homeassistant_node_id=args.homeassistant_node_id,
|
|
|
)
|