ソースを参照

added command line option `--poweroff-delay-seconds`

Fabian Peter Hammerle 3 年 前
コミット
1df650d911

+ 3 - 0
CHANGELOG.md

@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Added
+- command line option `--poweroff-delay-seconds` (default: 4 seconds)
+
 ### Changed
 - docker build stage: revert user after applying `chown` workaround for inter-stage copy
 

+ 6 - 0
README.md

@@ -114,3 +114,9 @@ systemctl-mqtt --mqtt-username me --mqtt-password secret …
 # or
 systemctl-mqtt --mqtt-username me --mqtt-password-file /var/lib/secrets/mqtt/password …
 ```
+
+## Adapt Poweroff Delay
+
+```sh
+systemctl-mqtt --poweroff-delay-seconds 60 …
+```

+ 17 - 7
docker-compose.yml

@@ -1,19 +1,29 @@
 version: '2.2'
 
-volumes:
-  config:
+#volumes:
+#  config:
 
 services:
-  mqtt_client:
+  broker:
+    image: docker.io/eclipse-mosquitto:latest
+    user: mosquitto
+    read_only: yes
+    cap_drop: [all]
+    security_opt: [no-new-privileges]
+    cpus: 0.4
+    mem_limit: 32M
+  client:
     build: .
     image: fphammerle/systemctl-mqtt
     volumes:
-    - config:/etc/systemctl-mqtt:ro
+    #- config:/etc/systemctl-mqtt:ro
     - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:rw
     command: systemctl-mqtt
-      --mqtt-host mqtt-broker.local
-      --mqtt-username raspberrypi
-      --mqtt-password-file /etc/systemctl-mqtt/mqtt-password
+      --poweroff-delay-seconds 60
+      --mqtt-host broker
+      --mqtt-disable-tls
+    #  --mqtt-username raspberrypi
+    #  --mqtt-password-file /etc/systemctl-mqtt/mqtt-password
     hostname: raspberrypi
     userns_mode: host
     # prefering explicit user specification over "USER 0" in Dockerfile

+ 29 - 21
systemctl_mqtt/__init__.py

@@ -15,6 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
+import abc
 import argparse
 import datetime
 import functools
@@ -50,6 +51,7 @@ class _State:
         mqtt_topic_prefix: str,
         homeassistant_discovery_prefix: str,
         homeassistant_node_id: str,
+        poweroff_delay: datetime.timedelta,
     ) -> None:
         self._mqtt_topic_prefix = mqtt_topic_prefix
         self._homeassistant_discovery_prefix = homeassistant_discovery_prefix
@@ -59,6 +61,7 @@ class _State:
         )  # type: dbus.proxies.Interface
         self._shutdown_lock = None  # type: typing.Optional[dbus.types.UnixFd]
         self._shutdown_lock_mutex = threading.Lock()
+        self.poweroff_delay = poweroff_delay
 
     @property
     def mqtt_topic_prefix(self) -> str:
@@ -192,13 +195,10 @@ class _State:
         )
 
 
-class _MQTTAction:
-
-    # pylint: disable=too-few-public-methods
-
-    def __init__(self, name: str, action: typing.Callable) -> None:
-        self.name = name
-        self.action = action
+class _MQTTAction(metaclass=abc.ABCMeta):
+    @abc.abstractmethod
+    def trigger(self, state: _State) -> None:
+        pass  # pragma: no cover
 
     def mqtt_message_callback(
         self,
@@ -213,21 +213,23 @@ class _MQTTAction:
         if message.retain:
             _LOGGER.info("ignoring retained message")
             return
-        _LOGGER.debug("executing action %s (%r)", self.name, self.action)
-        self.action()
-        _LOGGER.debug("completed action %s (%r)", self.name, self.action)
+        _LOGGER.debug("executing action %s", self)
+        self.trigger(state=state)
+        _LOGGER.debug("completed action %s", self)
 
 
-_MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
-    "poweroff": _MQTTAction(
-        name="poweroff",
-        action=functools.partial(
-            # pylint: disable=protected-access
-            systemctl_mqtt._dbus.schedule_shutdown,
-            action="poweroff",
-        ),
-    ),
-}
+class _MQTTActionSchedulePoweroff(_MQTTAction):
+    def trigger(self, state: _State) -> None:
+        # pylint: disable=protected-access
+        systemctl_mqtt._dbus.schedule_shutdown(
+            action="poweroff", delay=state.poweroff_delay
+        )
+
+    def __str__(self) -> str:
+        return type(self).__name__
+
+
+_MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {"poweroff": _MQTTActionSchedulePoweroff()}
 
 
 def _mqtt_on_connect(
@@ -254,7 +256,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 %s", topic, action
         )
 
 
@@ -266,6 +268,7 @@ def _run(
     mqtt_topic_prefix: str,
     homeassistant_discovery_prefix: str,
     homeassistant_node_id: str,
+    poweroff_delay: datetime.timedelta,
     mqtt_disable_tls: bool = False,
 ) -> None:
     # pylint: disable=too-many-arguments
@@ -277,6 +280,7 @@ def _run(
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix=homeassistant_discovery_prefix,
             homeassistant_node_id=homeassistant_node_id,
+            poweroff_delay=poweroff_delay,
         )
     )
     mqtt_client.on_connect = _mqtt_on_connect
@@ -354,6 +358,9 @@ def _main() -> None:
         default=systemctl_mqtt._homeassistant.get_default_node_id(),
         help=" ",
     )
+    argparser.add_argument(
+        "--poweroff-delay-seconds", type=float, default=4.0, help=" ",
+    )
     args = argparser.parse_args()
     if args.mqtt_port:
         mqtt_port = args.mqtt_port
@@ -389,4 +396,5 @@ def _main() -> None:
         mqtt_topic_prefix=args.mqtt_topic_prefix,
         homeassistant_discovery_prefix=args.homeassistant_discovery_prefix,
         homeassistant_node_id=args.homeassistant_node_id,
+        poweroff_delay=datetime.timedelta(seconds=args.poweroff_delay_seconds),
     )

+ 2 - 4
systemctl_mqtt/_dbus.py

@@ -22,8 +22,6 @@ import dbus
 
 _LOGGER = logging.getLogger(__name__)
 
-_SHUTDOWN_DELAY = datetime.timedelta(seconds=4)
-
 
 def get_login_manager() -> dbus.proxies.Interface:
     # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html
@@ -61,10 +59,10 @@ def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
         _LOGGER.debug("no shutdown inhibitor locks found")
 
 
-def schedule_shutdown(action: str) -> None:
+def schedule_shutdown(action: str, delay: datetime.timedelta) -> 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
+    shutdown_datetime = datetime.datetime.now() + 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(

+ 27 - 0
tests/test_action.py

@@ -0,0 +1,27 @@
+import datetime
+import unittest.mock
+
+import pytest
+
+import systemctl_mqtt
+
+# pylint: disable=protected-access
+
+
+@pytest.mark.parametrize(
+    "delay", [datetime.timedelta(seconds=4), datetime.timedelta(hours=21)]
+)
+def test_poweroff_trigger(delay):
+    action = systemctl_mqtt._MQTTActionSchedulePoweroff()
+    with unittest.mock.patch(
+        "systemctl_mqtt._dbus.schedule_shutdown"
+    ) as schedule_shutdown_mock:
+        action.trigger(
+            state=systemctl_mqtt._State(
+                mqtt_topic_prefix="systemctl/hostname",
+                homeassistant_discovery_prefix="homeassistant",
+                homeassistant_node_id="node",
+                poweroff_delay=delay,
+            )
+        )
+    schedule_shutdown_mock.assert_called_once_with(action="poweroff", delay=delay)

+ 21 - 1
tests/test_cli.py

@@ -15,6 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
+import datetime
 import typing
 import unittest.mock
 
@@ -159,11 +160,12 @@ def test__main(
         mqtt_topic_prefix=expected_topic_prefix or "systemctl/hostname",
         homeassistant_discovery_prefix="homeassistant",
         homeassistant_node_id="hostname",
+        poweroff_delay=datetime.timedelta(seconds=4),
     )
 
 
 @pytest.mark.parametrize(
-    ("password_file_content", "expected_password",),
+    ("password_file_content", "expected_password"),
     [
         ("secret", "secret"),
         ("secret space", "secret space"),
@@ -205,6 +207,7 @@ def test__main_password_file(tmpdir, password_file_content, expected_password):
         mqtt_topic_prefix="systemctl/hostname",
         homeassistant_discovery_prefix="homeassistant",
         homeassistant_node_id="hostname",
+        poweroff_delay=datetime.timedelta(seconds=4),
     )
 
 
@@ -274,3 +277,20 @@ def test__main_homeassistant_node_id_invalid(args):
     ):
         with pytest.raises(ValueError):
             systemctl_mqtt._main()
+
+
+@pytest.mark.parametrize(
+    ("args", "poweroff_delay"),
+    [
+        ([], datetime.timedelta(seconds=4)),
+        (["--poweroff-delay-seconds", "42.21"], datetime.timedelta(seconds=42.21)),
+        (["--poweroff-delay-seconds", "3600"], datetime.timedelta(hours=1)),
+    ],
+)
+def test__main_poweroff_delay(args, poweroff_delay):
+    with unittest.mock.patch("systemctl_mqtt._run") as run_mock, unittest.mock.patch(
+        "sys.argv", ["", "--mqtt-host", "mqtt-broker.local"] + args
+    ):
+        systemctl_mqtt._main()
+    assert run_mock.call_count == 1
+    assert run_mock.call_args[1]["poweroff_delay"] == poweroff_delay

+ 17 - 9
tests/test_dbus.py

@@ -98,24 +98,23 @@ def test__log_shutdown_inhibitors_fail(caplog):
 
 
 @pytest.mark.parametrize("action", ["poweroff", "reboot"])
-def test__schedule_shutdown(action):
+@pytest.mark.parametrize("delay", [datetime.timedelta(0), datetime.timedelta(hours=1)])
+def test__schedule_shutdown(action, delay):
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(
         "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock,
     ):
-        systemctl_mqtt._dbus.schedule_shutdown(action=action)
+        systemctl_mqtt._dbus.schedule_shutdown(action=action, delay=delay)
     assert login_manager_mock.ScheduleShutdown.call_count == 1
     schedule_args, schedule_kwargs = login_manager_mock.ScheduleShutdown.call_args
     assert len(schedule_args) == 2
     assert schedule_args[0] == action
     assert isinstance(schedule_args[1], dbus.UInt64)
     shutdown_datetime = datetime.datetime.fromtimestamp(
-        schedule_args[1] / 10 ** 6, tz=_UTC,
-    )
-    delay = shutdown_datetime - datetime.datetime.now(tz=_UTC)
-    assert delay.total_seconds() == pytest.approx(
-        systemctl_mqtt._dbus._SHUTDOWN_DELAY.total_seconds(), abs=0.1,
+        schedule_args[1] / 10 ** 6, tz=_UTC
     )
+    actual_delay = shutdown_datetime - datetime.datetime.now(tz=_UTC)
+    assert actual_delay.total_seconds() == pytest.approx(delay.total_seconds(), abs=0.1)
     assert not schedule_kwargs
 
 
@@ -138,7 +137,9 @@ def test__schedule_shutdown_fail(caplog, action, exception_message, log_message)
     with unittest.mock.patch(
         "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock,
     ), caplog.at_level(logging.DEBUG):
-        systemctl_mqtt._dbus.schedule_shutdown(action=action)
+        systemctl_mqtt._dbus.schedule_shutdown(
+            action=action, delay=datetime.timedelta(seconds=21)
+        )
     assert login_manager_mock.ScheduleShutdown.call_count == 1
     assert len(caplog.records) == 3
     assert caplog.records[0].levelno == logging.INFO
@@ -159,7 +160,14 @@ def test_mqtt_topic_suffix_action_mapping(topic_suffix, expected_action_arg):
     with unittest.mock.patch(
         "systemctl_mqtt._dbus.get_login_manager", return_value=login_manager_mock,
     ):
-        mqtt_action.action()
+        mqtt_action.trigger(
+            state=systemctl_mqtt._State(
+                mqtt_topic_prefix="systemctl/hostname",
+                homeassistant_discovery_prefix="homeassistant",
+                homeassistant_node_id="node",
+                poweroff_delay=datetime.timedelta(),
+            )
+        )
     assert login_manager_mock.ScheduleShutdown.call_count == 1
     schedule_args, schedule_kwargs = login_manager_mock.ScheduleShutdown.call_args
     assert len(schedule_args) == 2

+ 23 - 20
tests/test_mqtt.py

@@ -15,6 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
+import datetime
 import logging
 import threading
 import time
@@ -66,6 +67,7 @@ def test__run(
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix=homeassistant_discovery_prefix,
             homeassistant_node_id=homeassistant_node_id,
+            poweroff_delay=datetime.timedelta(),
         )
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message == (
@@ -136,7 +138,7 @@ def test__run(
     assert caplog.records[5].message == "registered MQTT callback for topic {}".format(
         mqtt_topic_prefix + "/poweroff"
     ) + " triggering {}".format(
-        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"].action
+        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"]
     )
     # dbus loop started?
     glib_loop_mock.assert_called_once_with()
@@ -162,6 +164,7 @@ def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
             mqtt_topic_prefix="systemctl/hosts",
             homeassistant_discovery_prefix="homeassistant",
             homeassistant_node_id="host",
+            poweroff_delay=datetime.timedelta(),
         )
     assert caplog.records[0].levelno == logging.INFO
     assert caplog.records[0].message == (
@@ -188,6 +191,7 @@ def test__run_tls_default():
             mqtt_topic_prefix="systemctl/hosts",
             homeassistant_discovery_prefix="homeassistant",
             homeassistant_node_id="host",
+            poweroff_delay=datetime.timedelta(),
         )
     # enabled by default
     mqtt_client_class().tls_set.assert_called_once_with(ca_certs=None)
@@ -219,6 +223,7 @@ def test__run_authentication(
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix="discovery-prefix",
             homeassistant_node_id="node-id",
+            poweroff_delay=datetime.timedelta(),
         )
     assert mqtt_loop_forever_mock.call_count == 1
     (mqtt_client,) = mqtt_loop_forever_mock.call_args[0]
@@ -251,6 +256,7 @@ def _initialize_mqtt_client(
             mqtt_topic_prefix=mqtt_topic_prefix,
             homeassistant_discovery_prefix="discovery-prefix",
             homeassistant_node_id="node-id",
+            poweroff_delay=datetime.timedelta(),
         )
     while threading.active_count() > 1:
         time.sleep(0.01)
@@ -272,16 +278,16 @@ def test__client_handle_message(caplog, mqtt_host, mqtt_port, mqtt_topic_prefix)
     caplog.set_level(logging.DEBUG)
     poweroff_message = MQTTMessage(topic=mqtt_topic_prefix.encode() + b"/poweroff")
     with unittest.mock.patch.object(
-        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "action",
-    ) as poweroff_action_mock:
+        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "trigger"
+    ) as poweroff_trigger_mock:
         mqtt_client._handle_on_message(poweroff_message)
-    poweroff_action_mock.assert_called_once_with()
+    poweroff_trigger_mock.assert_called_once_with(state=mqtt_client._userdata)
     assert all(r.levelno == logging.DEBUG for r in caplog.records)
     assert caplog.records[0].message == "received topic={} payload=b''".format(
         poweroff_message.topic
     )
-    assert caplog.records[1].message.startswith("executing action poweroff")
-    assert caplog.records[2].message.startswith("completed action poweroff")
+    assert caplog.records[1].message == "executing action _MQTTActionSchedulePoweroff"
+    assert caplog.records[2].message == "completed action _MQTTActionSchedulePoweroff"
 
 
 @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
@@ -291,7 +297,7 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
     with unittest.mock.patch("paho.mqtt.client.Client"), unittest.mock.patch(
         "systemctl_mqtt._dbus.get_login_manager"
     ):
-        with pytest.raises(ValueError):
+        with pytest.raises(ValueError, match=r"^Missing MQTT username$"):
             systemctl_mqtt._run(
                 mqtt_host=mqtt_host,
                 mqtt_port=mqtt_port,
@@ -300,6 +306,7 @@ def test__run_authentication_missing_username(mqtt_host, mqtt_port, mqtt_passwor
                 mqtt_topic_prefix="prefix",
                 homeassistant_discovery_prefix="discovery-prefix",
                 homeassistant_node_id="node-id",
+                poweroff_delay=datetime.timedelta(),
             )
 
 
@@ -309,27 +316,23 @@ def test_mqtt_message_callback_poweroff(caplog, mqtt_topic: str, payload: bytes)
     message = MQTTMessage(topic=mqtt_topic.encode())
     message.payload = payload
     with unittest.mock.patch.object(
-        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "action",
-    ) as action_mock, caplog.at_level(logging.DEBUG):
+        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "trigger"
+    ) as trigger_mock, caplog.at_level(logging.DEBUG):
         systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING[
             "poweroff"
         ].mqtt_message_callback(
-            None, None, message  # type: ignore
+            None, "state_dummy", message  # type: ignore
         )
-    action_mock.assert_called_once_with()
+    trigger_mock.assert_called_once_with(state="state_dummy")
     assert len(caplog.records) == 3
     assert caplog.records[0].levelno == logging.DEBUG
     assert caplog.records[0].message == (
         "received topic={} payload={!r}".format(mqtt_topic, payload)
     )
     assert caplog.records[1].levelno == logging.DEBUG
-    assert caplog.records[1].message.startswith(
-        "executing action {} ({!r})".format("poweroff", action_mock)
-    )
+    assert caplog.records[1].message == "executing action _MQTTActionSchedulePoweroff"
     assert caplog.records[2].levelno == logging.DEBUG
-    assert caplog.records[2].message.startswith(
-        "completed action {} ({!r})".format("poweroff", action_mock)
-    )
+    assert caplog.records[2].message == "completed action _MQTTActionSchedulePoweroff"
 
 
 @pytest.mark.parametrize("mqtt_topic", ["system/command/poweroff"])
@@ -341,14 +344,14 @@ def test_mqtt_message_callback_poweroff_retained(
     message.payload = payload
     message.retain = True
     with unittest.mock.patch.object(
-        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "action",
-    ) as action_mock, caplog.at_level(logging.DEBUG):
+        systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING["poweroff"], "trigger"
+    ) as trigger_mock, caplog.at_level(logging.DEBUG):
         systemctl_mqtt._MQTT_TOPIC_SUFFIX_ACTION_MAPPING[
             "poweroff"
         ].mqtt_message_callback(
             None, None, message  # type: ignore
         )
-    action_mock.assert_not_called()
+    trigger_mock.assert_not_called()
     assert len(caplog.records) == 2
     assert caplog.records[0].levelno == logging.DEBUG
     assert caplog.records[0].message == (

+ 6 - 0
tests/test_state_dbus.py

@@ -15,6 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
+import datetime
 import json
 import logging
 import unittest.mock
@@ -34,6 +35,7 @@ def test_shutdown_lock():
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
             homeassistant_node_id=None,
+            poweroff_delay=datetime.timedelta(),
         )
         state._login_manager.Inhibit.return_value = lock_fd
         state.acquire_shutdown_lock()
@@ -55,6 +57,7 @@ def test_prepare_for_shutdown_handler(caplog, active):
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
             homeassistant_node_id=None,
+            poweroff_delay=datetime.timedelta(),
         )
     mqtt_client_mock = unittest.mock.MagicMock()
     state.register_prepare_for_shutdown_handler(mqtt_client=mqtt_client_mock)
@@ -98,6 +101,7 @@ def test_publish_preparing_for_shutdown(active):
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
             homeassistant_node_id=None,
+            poweroff_delay=datetime.timedelta(),
         )
     assert state._login_manager == login_manager_mock
     mqtt_client_mock = unittest.mock.MagicMock()
@@ -124,6 +128,7 @@ def test_publish_preparing_for_shutdown_get_fail(caplog):
             mqtt_topic_prefix="any",
             homeassistant_discovery_prefix=None,
             homeassistant_node_id=None,
+            poweroff_delay=datetime.timedelta(),
         )
     mqtt_client_mock = unittest.mock.MagicMock()
     state.publish_preparing_for_shutdown(mqtt_client=None)
@@ -147,6 +152,7 @@ def test_publish_preparing_for_shutdown_homeassistant_config(
         mqtt_topic_prefix=topic_prefix,
         homeassistant_discovery_prefix=discovery_prefix,
         homeassistant_node_id=node_id,
+        poweroff_delay=datetime.timedelta(),
     )
     mqtt_client = unittest.mock.MagicMock()
     with unittest.mock.patch(