aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--dotfiles/hyprland/.config/waybar/config8
-rw-r--r--dotfiles/hyprland/.config/waybar/style.css6
-rwxr-xr-xdotfiles/hyprland/.local/bin/airplane-mode110
-rwxr-xr-xdotfiles/hyprland/.local/bin/waybar-airplane33
-rw-r--r--tests/airplane-mode/test_airplane_mode.py324
-rw-r--r--tests/waybar-airplane/test_waybar_airplane.py156
-rw-r--r--todo.org15
7 files changed, 652 insertions, 0 deletions
diff --git a/dotfiles/hyprland/.config/waybar/config b/dotfiles/hyprland/.config/waybar/config
index 2ae43fe..97fb3b7 100644
--- a/dotfiles/hyprland/.config/waybar/config
+++ b/dotfiles/hyprland/.config/waybar/config
@@ -18,6 +18,7 @@
"custom/netspeed",
"pulseaudio",
"custom/touchpad",
+ "custom/airplane",
"idle_inhibitor",
"custom/pocketbook",
"tray",
@@ -149,6 +150,13 @@
"on-click": "toggle-touchpad"
},
+ "custom/airplane": {
+ "exec": "waybar-airplane",
+ "return-type": "json",
+ "signal": 10,
+ "on-click": "airplane-mode"
+ },
+
"idle_inhibitor": {
"format": "<span size='large'>{icon}</span>",
"format-icons": {
diff --git a/dotfiles/hyprland/.config/waybar/style.css b/dotfiles/hyprland/.config/waybar/style.css
index 3a849c5..cd158d0 100644
--- a/dotfiles/hyprland/.config/waybar/style.css
+++ b/dotfiles/hyprland/.config/waybar/style.css
@@ -77,6 +77,7 @@ window#waybar {
#custom-worldclock,
#custom-layout,
#custom-touchpad,
+#custom-airplane,
#window {
padding: 0.45rem;
margin: 0.3rem;
@@ -98,6 +99,7 @@ window#waybar {
#pulseaudio:hover,
#sysmonitor:hover,
#custom-touchpad:hover,
+#custom-airplane:hover,
#custom-layout:hover {
background-color: #474544;
border-radius: 1rem;
@@ -112,6 +114,10 @@ window#waybar {
color: #d47c59;
}
+#custom-airplane.active {
+ color: #d7af5f;
+}
+
#temperature.warning {
color: #d7af5f;
}
diff --git a/dotfiles/hyprland/.local/bin/airplane-mode b/dotfiles/hyprland/.local/bin/airplane-mode
new file mode 100755
index 0000000..4f5ed9c
--- /dev/null
+++ b/dotfiles/hyprland/.local/bin/airplane-mode
@@ -0,0 +1,110 @@
+#!/bin/sh
+# airplane-mode — toggle a low-power "airplane" state for a laptop.
+#
+# Engage: record the current state of each lever, then apply low-power values:
+# - wifi off (nmcli; bluetooth is left alone, on purpose — earbuds)
+# - CPU energy-performance preference -> power (intel_pstate, via sysfs)
+# - display brightness dimmed
+# - stop network-only services (VPNs, sync, discovery, inbound SSH)
+# Disengage: read the recorded state and restore exactly what was there. A
+# lever already in its low-power position before engaging (e.g. wifi already
+# off, a service already stopped) is left untouched on disengage.
+#
+# State lives at $XDG_RUNTIME_DIR/airplane-state as key=value lines. The
+# waybar-airplane indicator reads `mode` from it.
+#
+# Env knobs (defaults are the real system; tests override them):
+# AIRPLANE_EPP_GLOB glob of EPP sysfs files
+# AIRPLANE_BRIGHTNESS_LOW brightnessctl target while engaged
+# AIRPLANE_SYSTEM_SERVICES system units to stop (sudo)
+# AIRPLANE_USER_SERVICES --user units to stop
+
+STATE_FILE="${XDG_RUNTIME_DIR:-/tmp}/airplane-state"
+SUDO="${AIRPLANE_SUDO:-sudo}"
+EPP_GLOB="${AIRPLANE_EPP_GLOB:-/sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference}"
+BRIGHTNESS_LOW="${AIRPLANE_BRIGHTNESS_LOW:-35%}"
+SYSTEM_SERVICES="${AIRPLANE_SYSTEM_SERVICES:-tailscaled.service proton.VPN.service avahi-daemon.service cups.service wsdd.service geoclue.service sshd.service fail2ban.service}"
+USER_SERVICES="${AIRPLANE_USER_SERVICES:-syncthing.service}"
+
+read_key() { sed -n "s/^$1=//p" "$STATE_FILE" 2>/dev/null | head -n1; }
+
+set_epp() {
+ # Write $1 to every EPP file. Needs root; glob expands inside the subshell.
+ $SUDO sh -c "for f in $EPP_GLOB; do [ -e \"\$f\" ] && echo $1 > \"\$f\"; done" 2>/dev/null
+}
+
+first_epp() {
+ for f in $EPP_GLOB; do
+ [ -e "$f" ] && { cat "$f"; return; }
+ done
+}
+
+engage() {
+ # --- record current state ---
+ wifi_was=$(nmcli radio wifi 2>/dev/null)
+ epp_was=$(first_epp)
+ bright_was=$(brightnessctl get 2>/dev/null)
+
+ stopped_system=""
+ for s in $SYSTEM_SERVICES; do
+ if systemctl is-active --quiet "$s" 2>/dev/null; then
+ $SUDO systemctl stop "$s" 2>/dev/null && stopped_system="$stopped_system $s"
+ fi
+ done
+ stopped_user=""
+ for s in $USER_SERVICES; do
+ if systemctl --user is-active --quiet "$s" 2>/dev/null; then
+ systemctl --user stop "$s" 2>/dev/null && stopped_user="$stopped_user $s"
+ fi
+ done
+
+ # --- apply low-power settings ---
+ nmcli radio wifi off 2>/dev/null
+ set_epp power
+ brightnessctl set "$BRIGHTNESS_LOW" >/dev/null 2>&1
+
+ # --- persist what we recorded ---
+ {
+ echo "mode=on"
+ echo "wifi=$wifi_was"
+ echo "epp=$epp_was"
+ echo "brightness=$bright_was"
+ echo "stopped_system=$stopped_system"
+ echo "stopped_user=$stopped_user"
+ } > "$STATE_FILE"
+
+ notify info "Airplane mode" "ON — wifi off, low power" 2>/dev/null
+}
+
+disengage() {
+ wifi_was=$(read_key wifi)
+ epp_was=$(read_key epp)
+ bright_was=$(read_key brightness)
+ stopped_system=$(read_key stopped_system)
+ stopped_user=$(read_key stopped_user)
+
+ # Only restore a lever that was NOT already in its low-power state.
+ [ "$wifi_was" = "enabled" ] && nmcli radio wifi on 2>/dev/null
+ [ -n "$epp_was" ] && set_epp "$epp_was"
+ [ -n "$bright_was" ] && brightnessctl set "$bright_was" >/dev/null 2>&1
+
+ for s in $stopped_system; do
+ $SUDO systemctl start "$s" 2>/dev/null
+ done
+ for s in $stopped_user; do
+ systemctl --user start "$s" 2>/dev/null
+ done
+
+ echo "mode=off" > "$STATE_FILE"
+ notify info "Airplane mode" "OFF — settings restored" 2>/dev/null
+}
+
+case "$(read_key mode)" in
+ on) disengage ;;
+ *) engage ;;
+esac
+
+# Refresh the waybar indicator immediately (custom/airplane listens on signal 10).
+pkill -RTMIN+10 waybar 2>/dev/null
+
+exit 0
diff --git a/dotfiles/hyprland/.local/bin/waybar-airplane b/dotfiles/hyprland/.local/bin/waybar-airplane
new file mode 100755
index 0000000..21f869c
--- /dev/null
+++ b/dotfiles/hyprland/.local/bin/waybar-airplane
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Airplane-mode indicator for waybar.
+# Reads the state file the airplane-mode toggle maintains; emits one JSON line
+# (text + tooltip + class) for the custom/airplane module. The file holds
+# key=value lines; only `mode` (on/off) matters here. Anything other than
+# "on" reads as off, so a missing or garbled state file fails safe (airplane
+# mode shown as inactive — i.e. radios assumed on).
+#
+# Laptop-only: airplane mode is meaningless on a desktop, so the module hides
+# itself (emits nothing → waybar drops it) on machines with no battery.
+
+STATE_FILE="${XDG_RUNTIME_DIR:-/tmp}/airplane-state"
+PS_DIR="${AIRPLANE_POWER_SUPPLY_DIR:-/sys/class/power_supply}"
+
+# Laptop check: a battery present means this is a portable machine.
+is_laptop() {
+ for b in "$PS_DIR"/BAT*; do
+ [ -e "$b" ] && return 0
+ done
+ return 1
+}
+
+is_laptop || exit 0
+
+mode=$(sed -n 's/^mode=//p' "$STATE_FILE" 2>/dev/null | head -n1)
+
+# Same clear plane glyph in both states; the class drives the color (gold when
+# engaged, default gray when not) so there's no slash to obscure the wings.
+if [ "$mode" = "on" ]; then
+ echo "{\"text\": \"<span size='large'></span>\", \"tooltip\": \"Airplane mode ON — wifi off, low power\", \"class\": \"active\"}"
+else
+ echo "{\"text\": \"<span size='large'></span>\", \"tooltip\": \"Airplane mode OFF\", \"class\": \"inactive\"}"
+fi
diff --git a/tests/airplane-mode/test_airplane_mode.py b/tests/airplane-mode/test_airplane_mode.py
new file mode 100644
index 0000000..5db0ed1
--- /dev/null
+++ b/tests/airplane-mode/test_airplane_mode.py
@@ -0,0 +1,324 @@
+"""Tests for dotfiles/hyprland/.local/bin/airplane-mode.
+
+airplane-mode is a stateful toggle. On engage it RECORDS the current state of
+each lever (wifi on/off, CPU EPP value, brightness, which services were
+running) to a state file, then applies the low-power settings. On disengage it
+reads that file and RESTORES exactly what was recorded — so a lever that was
+already in its low-power position before engaging is left untouched on
+disengage. That save-and-replay logic is what these tests pin down.
+
+The real script runs against command stubs (sudo / nmcli / brightnessctl /
+systemctl / notify / pkill) placed on PATH, plus fake EPP sysfs files in a
+temp dir. The stubs log every invocation and report state driven by STUB_*
+env vars, so the test controls "what was running" without touching the host.
+No reimplementation of the script — the production body executes.
+
+Run from repo root:
+ python3 -m unittest tests.airplane-mode.test_airplane_mode
+"""
+
+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/airplane-mode")
+
+SYSTEM_SERVICES = "svc-a.service svc-b.service svc-c.service"
+USER_SERVICES = "svc-user.service"
+
+
+class AirplaneModeHarness(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="airplane-mode-test-")
+ self.state_file = os.path.join(self.tmp, "airplane-state")
+ self.stub_log = os.path.join(self.tmp, "stub.log")
+
+ # Fake EPP sysfs files for two CPUs, pre-set to the normal value.
+ self.epp_dir = os.path.join(self.tmp, "epp")
+ self.epp_files = []
+ for cpu in ("cpu0", "cpu1"):
+ d = os.path.join(self.epp_dir, cpu)
+ os.makedirs(d)
+ f = os.path.join(d, "energy_performance_preference")
+ self._write(f, "balance_performance\n")
+ self.epp_files.append(f)
+ self.epp_glob = os.path.join(self.epp_dir, "cpu*", "energy_performance_preference")
+
+ self.stub_dir = os.path.join(self.tmp, "stubs")
+ os.makedirs(self.stub_dir)
+ self._make_stubs()
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ # -- helpers --------------------------------------------------------------
+
+ def _write(self, path, contents):
+ with open(path, "w") as f:
+ f.write(contents)
+
+ def _stub(self, name, body):
+ path = os.path.join(self.stub_dir, name)
+ self._write(path, "#!/bin/sh\n" + body)
+ os.chmod(path, 0o755)
+
+ def _make_stubs(self):
+ # sudo just runs the rest of the command line (no privilege needed in
+ # the test; the fake EPP files are writable by the test user).
+ self._stub("sudo", 'exec "$@"\n')
+
+ self._stub("nmcli", (
+ 'echo "nmcli $*" >> "$STUB_LOG"\n'
+ 'if [ "$1" = radio ] && [ "$2" = wifi ] && [ -z "$3" ]; then\n'
+ ' echo "${STUB_WIFI:-enabled}"\n'
+ 'fi\n'
+ 'exit 0\n'
+ ))
+
+ self._stub("brightnessctl", (
+ 'echo "brightnessctl $*" >> "$STUB_LOG"\n'
+ 'case "$1" in\n'
+ ' get) echo "${STUB_BRIGHTNESS:-96000}" ;;\n'
+ ' max) echo 96000 ;;\n'
+ 'esac\n'
+ 'exit 0\n'
+ ))
+
+ self._stub("systemctl", (
+ 'echo "systemctl $*" >> "$STUB_LOG"\n'
+ 'user=0; sub=""; svc=""\n'
+ 'for a in "$@"; do\n'
+ ' case "$a" in\n'
+ ' --user) user=1 ;;\n'
+ ' --quiet) ;;\n'
+ ' is-active|stop|start) sub="$a" ;;\n'
+ ' *) svc="$a" ;;\n'
+ ' esac\n'
+ 'done\n'
+ 'if [ "$sub" = is-active ]; then\n'
+ ' if [ "$user" = 1 ]; then list="$STUB_ACTIVE_USER"; else list="$STUB_ACTIVE_SYSTEM"; fi\n'
+ ' case " $list " in *" $svc "*) exit 0 ;; *) exit 3 ;; esac\n'
+ 'fi\n'
+ 'exit 0\n'
+ ))
+
+ self._stub("notify", 'echo "notify $*" >> "$STUB_LOG"\nexit 0\n')
+ self._stub("pkill", 'echo "pkill $*" >> "$STUB_LOG"\nexit 0\n')
+
+ def run_toggle(self, wifi="enabled", brightness="96000",
+ active_system=SYSTEM_SERVICES, active_user=USER_SERVICES):
+ env = os.environ.copy()
+ env["PATH"] = self.stub_dir + os.pathsep + env["PATH"]
+ env["XDG_RUNTIME_DIR"] = self.tmp
+ env["STUB_LOG"] = self.stub_log
+ env["STUB_WIFI"] = wifi
+ env["STUB_BRIGHTNESS"] = brightness
+ env["STUB_ACTIVE_SYSTEM"] = active_system
+ env["STUB_ACTIVE_USER"] = active_user
+ env["AIRPLANE_EPP_GLOB"] = self.epp_glob
+ env["AIRPLANE_SYSTEM_SERVICES"] = SYSTEM_SERVICES
+ env["AIRPLANE_USER_SERVICES"] = USER_SERVICES
+ env["AIRPLANE_BRIGHTNESS_LOW"] = "35%"
+ return subprocess.run(
+ [SCRIPT], env=env, capture_output=True, text=True, timeout=15,
+ )
+
+ def state(self):
+ out = {}
+ with open(self.state_file) as f:
+ for line in f:
+ if "=" in line:
+ k, v = line.rstrip("\n").split("=", 1)
+ out[k] = v
+ return out
+
+ def log(self):
+ try:
+ with open(self.stub_log) as f:
+ return f.read()
+ except FileNotFoundError:
+ return ""
+
+ def epp_values(self):
+ vals = []
+ for f in self.epp_files:
+ with open(f) as fh:
+ vals.append(fh.read().strip())
+ return vals
+
+
+# -----------------------------------------------------------------------------
+# Normal cases — engage from a clean (everything-on) state
+# -----------------------------------------------------------------------------
+
+class TestEngage(AirplaneModeHarness):
+
+ def test_engage_writes_mode_on(self):
+ r = self.run_toggle()
+ self.assertEqual(r.returncode, 0, msg=r.stderr)
+ self.assertEqual(self.state()["mode"], "on")
+
+ def test_engage_turns_wifi_off(self):
+ self.run_toggle(wifi="enabled")
+ self.assertIn("nmcli radio wifi off", self.log())
+
+ def test_engage_records_prior_wifi_state(self):
+ self.run_toggle(wifi="enabled")
+ self.assertEqual(self.state()["wifi"], "enabled")
+
+ def test_engage_sets_epp_to_power_on_all_cpus(self):
+ self.run_toggle()
+ self.assertEqual(self.epp_values(), ["power", "power"])
+
+ def test_engage_records_prior_epp(self):
+ self.run_toggle()
+ self.assertEqual(self.state()["epp"], "balance_performance")
+
+ def test_engage_dims_brightness_and_saves_prior(self):
+ self.run_toggle(brightness="96000")
+ self.assertIn("brightnessctl set 35%", self.log())
+ self.assertEqual(self.state()["brightness"], "96000")
+
+ def test_engage_stops_active_services_and_records_them(self):
+ self.run_toggle(active_system="svc-a.service svc-c.service",
+ active_user="svc-user.service")
+ log = self.log()
+ self.assertIn("systemctl stop svc-a.service", log)
+ self.assertIn("systemctl stop svc-c.service", log)
+ self.assertIn("systemctl --user stop svc-user.service", log)
+ self.assertIn("svc-a.service", self.state()["stopped_system"])
+ self.assertIn("svc-c.service", self.state()["stopped_system"])
+ self.assertIn("svc-user.service", self.state()["stopped_user"])
+
+ def test_engage_does_not_stop_already_inactive_service(self):
+ # svc-b is not in the active list → never stopped, never recorded.
+ self.run_toggle(active_system="svc-a.service", active_user="")
+ log = self.log()
+ self.assertNotIn("systemctl stop svc-b.service", log)
+ self.assertNotIn("svc-b.service", self.state().get("stopped_system", ""))
+
+ def test_engage_refreshes_waybar(self):
+ self.run_toggle()
+ self.assertIn("pkill -RTMIN+10 waybar", self.log())
+
+
+# -----------------------------------------------------------------------------
+# Normal cases — disengage restores recorded state
+# -----------------------------------------------------------------------------
+
+class TestDisengage(AirplaneModeHarness):
+
+ def seed_on(self, wifi="enabled", epp="balance_performance",
+ brightness="96000", stopped_system="svc-a.service svc-c.service",
+ stopped_user="svc-user.service"):
+ self._write(self.state_file, (
+ f"mode=on\nwifi={wifi}\nepp={epp}\nbrightness={brightness}\n"
+ f"stopped_system={stopped_system}\nstopped_user={stopped_user}\n"
+ ))
+ # EPP files are in low-power state while engaged.
+ for f in self.epp_files:
+ self._write(f, "power\n")
+
+ def test_disengage_writes_mode_off(self):
+ self.seed_on()
+ r = self.run_toggle()
+ self.assertEqual(r.returncode, 0, msg=r.stderr)
+ self.assertEqual(self.state()["mode"], "off")
+
+ def test_disengage_restores_wifi_when_it_was_on(self):
+ self.seed_on(wifi="enabled")
+ self.run_toggle()
+ self.assertIn("nmcli radio wifi on", self.log())
+
+ def test_disengage_restores_epp(self):
+ self.seed_on(epp="balance_performance")
+ self.run_toggle()
+ self.assertEqual(self.epp_values(), ["balance_performance", "balance_performance"])
+
+ def test_disengage_restores_brightness_to_saved_value(self):
+ self.seed_on(brightness="80000")
+ self.run_toggle()
+ self.assertIn("brightnessctl set 80000", self.log())
+
+ def test_disengage_restarts_recorded_services(self):
+ self.seed_on(stopped_system="svc-a.service svc-c.service",
+ stopped_user="svc-user.service")
+ log = self.log() # before
+ self.run_toggle()
+ log = self.log()
+ self.assertIn("systemctl start svc-a.service", log)
+ self.assertIn("systemctl start svc-c.service", log)
+ self.assertIn("systemctl --user start svc-user.service", log)
+
+
+# -----------------------------------------------------------------------------
+# Boundary — "leave it as it was" cases
+# -----------------------------------------------------------------------------
+
+class TestPreserveExistingState(AirplaneModeHarness):
+
+ def test_engage_with_wifi_already_off_records_disabled(self):
+ self.run_toggle(wifi="disabled")
+ self.assertEqual(self.state()["wifi"], "disabled")
+
+ def test_disengage_does_not_reenable_wifi_that_was_already_off(self):
+ # Seed an engaged state where wifi was already off before engaging.
+ self._write(self.state_file, (
+ "mode=on\nwifi=disabled\nepp=balance_performance\n"
+ "brightness=96000\nstopped_system=\nstopped_user=\n"
+ ))
+ for f in self.epp_files:
+ self._write(f, "power\n")
+ self.run_toggle()
+ self.assertNotIn("nmcli radio wifi on", self.log())
+
+ def test_disengage_only_restarts_recorded_services_not_all_known(self):
+ # Only svc-a was recorded as stopped → svc-b/svc-c must not be started.
+ self._write(self.state_file, (
+ "mode=on\nwifi=enabled\nepp=balance_performance\n"
+ "brightness=96000\nstopped_system=svc-a.service\nstopped_user=\n"
+ ))
+ for f in self.epp_files:
+ self._write(f, "power\n")
+ self.run_toggle()
+ log = self.log()
+ self.assertIn("systemctl start svc-a.service", log)
+ self.assertNotIn("systemctl start svc-b.service", log)
+ self.assertNotIn("systemctl start svc-c.service", log)
+
+ def test_disengage_with_no_services_recorded_starts_nothing(self):
+ self._write(self.state_file, (
+ "mode=on\nwifi=enabled\nepp=balance_performance\n"
+ "brightness=96000\nstopped_system=\nstopped_user=\n"
+ ))
+ for f in self.epp_files:
+ self._write(f, "power\n")
+ self.run_toggle()
+ self.assertNotIn("systemctl start", self.log())
+
+
+# -----------------------------------------------------------------------------
+# Boundary — toggle dispatch
+# -----------------------------------------------------------------------------
+
+class TestToggleDispatch(AirplaneModeHarness):
+
+ def test_missing_state_file_engages(self):
+ # No state file → not engaged → first run turns airplane mode ON.
+ self.assertFalse(os.path.exists(self.state_file))
+ self.run_toggle()
+ self.assertEqual(self.state()["mode"], "on")
+
+ def test_mode_off_file_engages(self):
+ self._write(self.state_file, "mode=off\n")
+ self.run_toggle()
+ self.assertEqual(self.state()["mode"], "on")
+
+
+if __name__ == "__main__":
+ unittest.main()
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()
diff --git a/todo.org b/todo.org
index e8fd240..a2c106d 100644
--- a/todo.org
+++ b/todo.org
@@ -60,6 +60,21 @@ Built per spec + decisions above. Committed the two formerly-live-only scripts i
Discovered =.local/bin= is stow-symlinked (waybar-layout/netspeed point into the repo); the two touchpad scripts were real files only because they weren't committed. Replaced both real files with repo symlinks and symlinked the new =waybar-touchpad= (matching the existing relative-symlink form). velox needed no hyprland.conf change — =exec-once = touchpad-auto= and the =$mod+F9= bind were already present. waybar =config= / =style.css= are real local files on velox (config diverges: standalone battery, no sysmonitor group), so applied targeted edits there rather than a copy.
Verified end-to-end after a waybar restart: config loads with no parse errors; toggle round-trips state enabled → disabled (󰍾, class disabled) → enabled (󰍽), and the =pkill -RTMIN+9 waybar= refresh fires into the running bar. Touchpad left enabled. Visual confirmation (icon in bar, orange when off) is Craig's to eyeball. Other machines (ratio) pick this up on =git pull && make restow hyprland= — their =.local/bin= and waybar configs are symlinks, so no real-file conflict there.
+** DONE [#B] Airplane-mode toggle + waybar indicator
+CLOSED: [2026-05-21 Thu]
+Laptop-only low-power toggle, modeled on the touchpad indicator. Wifi off (bluetooth left alone for earbuds), CPU EPP → power, brightness → 35%, and stops network-only services. Disengage restores only what it recorded, so anything already off stays off.
+*** 2026-05-21 Thu @ 17:43:07 -0400 Built the toggle, indicator, and tests
+- =dotfiles/hyprland/.local/bin/airplane-mode= (new) — toggle. Engage records prior state (wifi enabled/disabled, EPP value, brightness, which services were active) to =$XDG_RUNTIME_DIR/airplane-state=, then applies low-power: =nmcli radio wifi off=, EPP → power on all CPUs (sudo sysfs write), =brightnessctl set 35%=, and stops Tier 1+2 services (tailscaled, proton.VPN, avahi-daemon, cups, wsdd, geoclue, sshd, fail2ban + user syncthing). Disengage replays the recorded state — only re-enables wifi if it was on, only restarts services it stopped. Refreshes the bar via =pkill -RTMIN+10 waybar=.
+- =dotfiles/hyprland/.local/bin/waybar-airplane= (new) — indicator. Reads =mode= from the state file; fail-safe to inactive. Laptop-gated: exits silently (module hidden) when no battery is present (=/sys/class/power_supply/BAT*=). One clear plane glyph (FA U+F072) for both states; color carries state (gold active / gray inactive).
+- =waybar/config= — =custom/airplane= module (signal 10, on-click airplane-mode), placed after custom/touchpad. =waybar/style.css= — =#custom-airplane= in padding + hover lists; =.active { color: #d7af5f }= (dupre gold).
+- Tests: =tests/airplane-mode/= (20 — engage/disengage/preserve-existing-state/dispatch, via command stubs + fake EPP sysfs) and =tests/waybar-airplane/= (10 — states/boundary/laptop-gating). All green; shellcheck clean.
+- Deployed + live-verified on velox (engage → disengage round-trip works). Other machines pick it up via git pull && make restow hyprland.
+** TODO [#C] Waybar indicators unevenly spaced
+The right-side module icons don't sit at even intervals — spacing reads as inconsistent across the group. Tune the per-module margin/padding in =dotfiles/hyprland/.config/waybar/style.css= so the icons are evenly distributed. Noticed 2026-05-21 after adding the airplane indicator.
+** TODO [#C] Airplane-mode toggle robustness follow-ups
+Two minor robustness gaps in =dotfiles/hyprland/.local/bin/airplane-mode= surfaced when the feature shipped (2026-05-21). Neither is a live bug — both are defense-in-depth.
+- No laptop guard on the toggle itself. The =waybar-airplane= indicator hides on battery-less machines, but =airplane-mode= would still run if invoked directly (e.g. a future keybind on a desktop). Mirror the =is_laptop= check at the top of the toggle so it no-ops off a laptop.
+- Brightness-restore edge. If =brightnessctl get= returns empty at engage time, disengage skips the restore (guarded by =[ -n "$bright_was" ]=) and the screen stays at 35%. Fall back to a sane brightness (e.g. 100%) when no prior value was recorded.
** DOING [#A] Separate dotfiles from archsetup
*** 2026-05-11 Mon @ 13:01:29 -0500 AI Response: Dotfile separation plan
Approach: keep =dotfiles/= committed in this repo as the working default (Craig's machines and CI keep functioning untouched), but make the *source location* a config variable. The install script learns one new conf key — =DOTFILES_REPO= / =DOTFILES_BRANCH= — and when set, clones that repo into =~/.dotfiles= and stows from there instead of from =dotfiles/= inside archsetup. The Makefile gets a =DOTFILES= override env var so the same stow targets work whether dotfiles live in-repo or elsewhere. No submodule (adds fragility for a curl|bash installer); a separate published =archsetup-dotfiles= repo is optional follow-up, not a blocker.