From 09f4d205fe463faf676f95e798d08e8bf498be96 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 21 May 2026 17:48:47 -0400 Subject: feat(hyprland): add airplane-mode waybar toggle I added a laptop-only waybar button that drops the machine into a low-power state and restores it on a second click. Engaging turns wifi off, sets the CPU energy-performance preference to power, dims the backlight to 35%, and stops network-only services (tailscale, proton-vpn, avahi, cups, wsdd, geoclue, sshd, fail2ban, syncthing). Bluetooth is left alone so earbuds keep working. Disengaging replays the state recorded when airplane mode was engaged rather than writing hardcoded defaults. A lever already in its low-power position is left untouched: wifi that was already off stays off, and a service that was already stopped isn't restarted. The indicator hides itself on machines with no battery, so desktops never show the button. State lives in $XDG_RUNTIME_DIR/airplane-state, and the bar refreshes the moment the toggle fires via a realtime signal. --- tests/waybar-airplane/test_waybar_airplane.py | 156 ++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/waybar-airplane/test_waybar_airplane.py (limited to 'tests/waybar-airplane') diff --git a/tests/waybar-airplane/test_waybar_airplane.py b/tests/waybar-airplane/test_waybar_airplane.py new file mode 100644 index 0000000..b3b9c06 --- /dev/null +++ b/tests/waybar-airplane/test_waybar_airplane.py @@ -0,0 +1,156 @@ +"""Tests for dotfiles/hyprland/.local/bin/waybar-airplane. + +The script emits one JSON line for waybar's custom/airplane module, derived +from the airplane-mode state file that the airplane-mode toggle maintains at +$XDG_RUNTIME_DIR/airplane-state. The file holds key=value lines; the only +key the indicator reads is `mode` (on/off). Tests point XDG_RUNTIME_DIR at a +temp dir and assert on the emitted JSON. No mocking: the real script runs +against a real state file. + +Run from repo root: + python3 -m unittest tests.waybar-airplane.test_waybar_airplane +""" + +import json +import os +import shutil +import subprocess +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "dotfiles/hyprland/.local/bin/waybar-airplane") + + +class WaybarAirplaneHarness(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="waybar-airplane-test-") + self.state_file = os.path.join(self.tmp, "airplane-state") + # Fake power-supply dir with a battery → the script treats the host as + # a laptop. Desktop tests point this at a battery-less dir. + self.ps_dir = os.path.join(self.tmp, "power_supply") + os.makedirs(os.path.join(self.ps_dir, "BAT0")) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def set_state(self, contents): + with open(self.state_file, "w") as f: + f.write(contents) + + def run_script(self): + env = os.environ.copy() + env["XDG_RUNTIME_DIR"] = self.tmp + env["AIRPLANE_POWER_SUPPLY_DIR"] = self.ps_dir + return subprocess.run( + [SCRIPT], env=env, capture_output=True, text=True, timeout=10, + ) + + def emitted_json(self): + result = self.run_script() + self.assertEqual(result.returncode, 0, msg=result.stderr) + return json.loads(result.stdout) + + +# ----------------------------------------------------------------------------- +# Normal cases +# ----------------------------------------------------------------------------- + +class TestWaybarAirplaneNormal(WaybarAirplaneHarness): + + def test_mode_on_emits_active_class_and_plane_icon(self): + # State is carried by class/color, not a slashed glyph: the same clear + # plane shows in both states (gold when active, gray when inactive). + self.set_state("mode=on\nwifi=enabled\n") + data = self.emitted_json() + self.assertEqual(data["class"], "active") + self.assertIn("", data["text"]) # plane + self.assertIn("on", data["tooltip"].lower()) + + def test_mode_off_emits_inactive_class_and_plane_icon(self): + self.set_state("mode=off\n") + data = self.emitted_json() + self.assertEqual(data["class"], "inactive") + self.assertIn("", data["text"]) # plane + self.assertIn("off", data["tooltip"].lower()) + + +# ----------------------------------------------------------------------------- +# Boundary cases +# ----------------------------------------------------------------------------- + +class TestWaybarAirplaneBoundary(WaybarAirplaneHarness): + + def test_missing_state_file_defaults_to_inactive(self): + # No state file → airplane mode is not engaged (radios on = safe default). + data = self.emitted_json() + self.assertEqual(data["class"], "inactive") + + def test_mode_on_with_other_keys_present(self): + # The toggle writes several keys; the indicator only cares about mode. + self.set_state( + "mode=on\nwifi=enabled\nepp=balance_performance\n" + "brightness=96000\nstopped_system=sshd.service tailscaled.service\n" + ) + data = self.emitted_json() + self.assertEqual(data["class"], "active") + + def test_unknown_mode_value_treated_as_inactive(self): + # Anything that isn't "on" reads as off (fail-safe: radios shown as on). + self.set_state("mode=garbage\n") + data = self.emitted_json() + self.assertEqual(data["class"], "inactive") + + def test_empty_state_file_defaults_to_inactive(self): + self.set_state("") + data = self.emitted_json() + self.assertEqual(data["class"], "inactive") + + def test_output_is_a_single_json_object(self): + self.set_state("mode=on\n") + result = self.run_script() + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + self.assertEqual(len(lines), 1, msg=f"expected one line, got {lines!r}") + + +# ----------------------------------------------------------------------------- +# Laptop gating — the module only shows on machines with a battery +# ----------------------------------------------------------------------------- + +class TestWaybarAirplaneLaptopGating(WaybarAirplaneHarness): + + def run_desktop(self): + # Point the power-supply dir at a battery-less location (a desktop). + env = os.environ.copy() + env["XDG_RUNTIME_DIR"] = self.tmp + empty = os.path.join(self.tmp, "power_supply_desktop") + os.makedirs(empty, exist_ok=True) # AC adapter only, no BAT* + os.makedirs(os.path.join(empty, "ADP1"), exist_ok=True) + env["AIRPLANE_POWER_SUPPLY_DIR"] = empty + return subprocess.run( + [SCRIPT], env=env, capture_output=True, text=True, timeout=10, + ) + + def test_desktop_emits_nothing(self): + # No battery → module hidden → script prints no output. + self.set_state("mode=off\n") + result = self.run_desktop() + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertEqual(result.stdout.strip(), "") + + def test_desktop_emits_nothing_even_when_engaged(self): + self.set_state("mode=on\n") + result = self.run_desktop() + self.assertEqual(result.stdout.strip(), "") + + def test_laptop_with_battery_emits_module(self): + # Sanity: the battery-present harness (default) does emit JSON. + self.set_state("mode=off\n") + data = self.emitted_json() + self.assertEqual(data["class"], "inactive") + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3