diff --git a/application/power-management/default.nix b/application/power-management/default.nix index c74c5bde..4b9c3bb4 100644 --- a/application/power-management/default.nix +++ b/application/power-management/default.nix @@ -60,7 +60,7 @@ in { systemd.services.power-button-shutdown = { enable = true; - description = "Detect Power Button key presses, by Power Button device only and not remote controls, then shutdown computer"; + description = "Handle Power Button device key presses."; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${power-button-shutdown}/bin/power-button-shutdown"; diff --git a/application/power-management/power-button-shutdown.py b/application/power-management/power-button-shutdown.py index 60311cb5..49e292cd 100644 --- a/application/power-management/power-button-shutdown.py +++ b/application/power-management/power-button-shutdown.py @@ -2,22 +2,59 @@ import evdev import logging -import os +import subprocess import sys +import threading logging.basicConfig(level=logging.INFO) -devices = [evdev.InputDevice(path) for path in evdev.list_devices()] -device_names = ', '.join([d.name for d in devices]) -logging.info(f'Found devices: {device_names}') +def handle_device(device): + """Handle power key presses for the given input device by invoking poweroff.""" -power_off_device = next((d for d in devices if d.name == 'Power Button'), None) -if power_off_device is None: - logging.error(f'Power Button device not found') - sys.exit(1) + logging.info(f'Listening to Power Button on device {device.path}') + try: + for event in device.read_loop(): + if event.type == evdev.ecodes.EV_KEY and event.code == evdev.ecodes.KEY_POWER and event.value: + logging.info(f'KEY_POWER detected on {device.path}, shutting down') + subprocess.run(['systemctl', 'start', 'poweroff.target'], check=True) + except Exception as e: + logging.error(f'Error handling {device.path}: {e}') + +def get_power_button_devices(): + """Get all input devices which identify as 'Power Button' and have a power key.""" + + devices = [evdev.InputDevice(path) for path in evdev.list_devices()] + + power_button_devices = [] + for device in devices: + try: + if device.name == 'Power Button': + ev_capabilities = device.capabilities().get(evdev.ecodes.EV_KEY, []) + if evdev.ecodes.KEY_POWER in ev_capabilities: + power_button_devices.append(device) + except Exception as e: + logging.warning(f'Failed to inspect {device.path}: {e}') + + return power_button_devices + +# Identify Power Button devices +power_button_devices = get_power_button_devices() +if not power_button_devices: + logging.error('No Power Button devices found') + sys.exit(1) +logging.info(f'Found {len(power_button_devices)} Power Button device(s)') + +# Start a thread with a handler for each identified device +handler_threads = [] +for device in power_button_devices: + thread = threading.Thread(target=handle_device, args=(device,)) + thread.start() + handler_threads.append(thread) + +# Exit gracefully if a handler executes +try: + for thread in handler_threads: + thread.join() +except KeyboardInterrupt: + sys.exit(0) -logging.info(f'Listening to Power Button on device {power_off_device.path}') -for event in power_off_device.read_loop(): - if event.type == evdev.ecodes.EV_KEY and evdev.ecodes.KEY[event.code] == 'KEY_POWER' and event.value: - logging.info('KEY_POWER detected on Power Button device, shutting down') - os.system('systemctl start poweroff.target') diff --git a/controller/Changelog.md b/controller/Changelog.md index 3f595dfb..a4f98b76 100644 --- a/controller/Changelog.md +++ b/controller/Changelog.md @@ -17,6 +17,7 @@ - controller: Suppress password prompt for open WiFi networks - controller: Explicitly mark WiFi networks with unsupported authentication methods - controller: Improve error messages when connecting to WiFi networks fails +- os: Extend Power Button handling to multiple devices ## Removed diff --git a/testing/integration/power-button-shutdown.nix b/testing/integration/power-button-shutdown.nix new file mode 100644 index 00000000..43a9fc93 --- /dev/null +++ b/testing/integration/power-button-shutdown.nix @@ -0,0 +1,44 @@ +let + pkgs = import ../../pkgs { }; +in +pkgs.testers.runNixOSTest { + name = "ACPI power button handling"; + + nodes = { + machine = { config, pkgs, ... }: { + imports = [ ../../application/power-management ]; + }; + }; + + extraPythonPackages = ps: [ + ps.colorama + ps.types-colorama + ]; + + testScript = {nodes}: +'' +${builtins.readFile ../helpers/nixos-test-script-helpers.py} +import time + +with TestPrecondition("Power Button has been recognized"): + machine.start() + machine.wait_for_unit("multi-user.target") + machine.wait_for_console_text("Power Button") + + print(machine.succeed("cat /proc/bus/input/devices")) + +with TestCase("Short press on power and sleep from regular keyboard are ignored"): + # https://github.com/qemu/qemu/blob/master/pc-bios/keymaps/en-us + machine.send_monitor_command("sendkey 0xde") # XF86PowerOff + machine.send_monitor_command("sendkey 0xdf") # XF86Sleep + time.sleep(5) + machine.succeed("echo still alive", timeout=1) + +with TestCase("ACPI shutdown command invokes shutdown"): + machine.send_monitor_command("system_powerdown") + machine.wait_for_console_text("Stopped target Multi-User System") + +# Test script does not finish on its own after shutdown +exit(0) +''; +}