aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup14
-rwxr-xr-xscripts/zz-bluetooth-resume86
-rw-r--r--tests/bluetooth-resume/test_bluetooth_resume.py139
3 files changed, 239 insertions, 0 deletions
diff --git a/archsetup b/archsetup
index 18024c5..58102a2 100755
--- a/archsetup
+++ b/archsetup
@@ -3603,6 +3603,20 @@ EOF
run_task "enabling TLP service" systemctl enable tlp.service
systemctl mask systemd-rfkill.service systemd-rfkill.socket >> "$logfile" 2>&1 || \
error_warn "masking systemd-rfkill for TLP" "$?"
+ # Masking systemd-rfkill leaves the resume edge with no owner. TLP's own
+ # sleep hook runs `tlp resume`, but DEVICES_TO_ENABLE_ON_STARTUP means
+ # startup and TLP has no ON_RESUME, so radio state is not restored after
+ # a sleep cycle. WiFi survives because NetworkManager unblocks itself;
+ # bluetooth stays soft-blocked, and after a hibernate its controller
+ # comes back wedged as well. This hook closes both, and it belongs here
+ # rather than beside the other installs because the mask above is what
+ # creates the gap it fills.
+ # Arch does not ship /etc/systemd/system-sleep, and install_executable
+ # is a plain cp, so without this the install warns and leaves no hook.
+ mkdir -p /etc/systemd/system-sleep >> "$logfile" 2>&1 || \
+ error_warn "creating /etc/systemd/system-sleep" "$?"
+ install_executable "$user_archsetup_dir/scripts/zz-bluetooth-resume" \
+ /etc/systemd/system-sleep/zz-bluetooth-resume
# power-profiles-daemon.service declares
# "Conflicts=tuned.service tlp.service auto-cpufreq.service ..." (note
# the direction: the line is in ppd's unit, NOT tlp's — grepping
diff --git a/scripts/zz-bluetooth-resume b/scripts/zz-bluetooth-resume
new file mode 100755
index 0000000..4273339
--- /dev/null
+++ b/scripts/zz-bluetooth-resume
@@ -0,0 +1,86 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# zz-bluetooth-resume - put bluetooth back after a sleep cycle.
+#
+# A systemd-sleep hook. Two things break bluetooth across sleep on a TLP
+# laptop, and nothing else on the machine fixes either one.
+#
+# 1. The rfkill soft-block is not restored. systemd-rfkill would do it, and
+# it is masked here deliberately -- it fights TLP's radio handling, so
+# configure_tlp_power masks it and TLP owns radios instead. TLP's own
+# sleep hook runs `tlp resume`, but its setting is
+# DEVICES_TO_ENABLE_ON_STARTUP: startup, not resume. TLP has no ON_RESUME
+# at all, so the resume edge has no owner. WiFi survives only because
+# NetworkManager unblocks itself; bluetooth has no equivalent.
+#
+# 2. The controller comes back wedged from a hibernate. It reports powered
+# and unblocked while scanning finds nothing whatever -- zero devices
+# where the same room gave seventeen a minute later -- and bluetoothd
+# logs "Failed to set mode" and "Failed to add device <mac>" at the
+# instant of resume. Reloading btusb clears it.
+#
+# Both observed on velox 2026-08-21, on the first suspend-then-hibernate cycle
+# after hibernate was switched back on. The second symptom is why unblocking
+# alone is not enough: rfkill was cleared by hand and scanning still returned
+# nothing until the driver was reloaded.
+#
+# The hook re-asserts TLP's own declared intent rather than inventing a policy.
+# A machine whose TLP config does not ask for bluetooth keeps it off, which is
+# what stops this from overriding a deliberate block at every wakeup.
+#
+# The zz- prefix orders it after TLP's own hook, so `tlp resume` has finished
+# before this runs.
+#
+# Test seams: BTR_RFKILL, BTR_MODPROBE, BTR_TLP_CONF, BTR_TLP_CONF_DIR,
+# BTR_SETTLE (seconds to wait between driver unload and load).
+
+set -u
+
+RFKILL="${BTR_RFKILL:-rfkill}"
+MODPROBE="${BTR_MODPROBE:-modprobe}"
+TLP_CONF="${BTR_TLP_CONF:-/etc/tlp.conf}"
+TLP_CONF_DIR="${BTR_TLP_CONF_DIR:-/etc/tlp.d}"
+SETTLE="${BTR_SETTLE:-1}"
+
+# post only. The pre phase has nothing to do, and acting there would fight the
+# suspend it is about to run.
+[ "${1:-}" = "post" ] || exit 0
+
+# Does TLP ask for bluetooth on this machine? Comments are stripped first, so a
+# commented-out example in the stock config cannot be read as a policy. Both
+# the main file and any drop-in count, and the last assignment wins the same
+# way TLP itself resolves them.
+wants_bluetooth() {
+ cat "$TLP_CONF" "$TLP_CONF_DIR"/*.conf 2>/dev/null \
+ | sed 's/#.*//' \
+ | awk -F= '/DEVICES_TO_ENABLE_ON_STARTUP/ { v = $2 } END { print v }' \
+ | tr -d '"' \
+ | tr ' ' '\n' \
+ | grep -qx "bluetooth"
+}
+
+wants_bluetooth || exit 0
+
+# The wedge follows a hibernate, which reinitialises the controller from a
+# saved image. A plain suspend brings USB back intact, so reloading there would
+# tear down a working adapter for nothing.
+#
+# suspend-then-hibernate reports that name whether or not it reached the
+# hibernate stage, so this reloads on a cycle that only suspended. That is the
+# cheap side of the trade: a couple of seconds against an adapter that answers
+# nothing until someone notices and reloads it by hand.
+case "${2:-}" in
+ hibernate|suspend-then-hibernate)
+ "$MODPROBE" -r btusb 2>/dev/null || true
+ [ "$SETTLE" = "0" ] || sleep "$SETTLE"
+ "$MODPROBE" btusb 2>/dev/null || true
+ ;;
+esac
+
+# After the reload, not before: a freshly loaded btusb can come up soft-blocked
+# and would undo an earlier unblock.
+"$RFKILL" unblock bluetooth 2>/dev/null || true
+
+# Never fail. systemd-sleep logs a failing hook, and that noise outlives the
+# cause it describes; nothing here is worth alarming a resume over.
+exit 0
diff --git a/tests/bluetooth-resume/test_bluetooth_resume.py b/tests/bluetooth-resume/test_bluetooth_resume.py
new file mode 100644
index 0000000..6d8ed87
--- /dev/null
+++ b/tests/bluetooth-resume/test_bluetooth_resume.py
@@ -0,0 +1,139 @@
+"""Tests for scripts/zz-bluetooth-resume.
+
+Two things break bluetooth across a sleep cycle on a TLP laptop, and nothing
+else on the machine fixes either.
+
+The rfkill soft-block is not restored. systemd-rfkill would do it, but it is
+masked deliberately -- it fights TLP's radio handling, so TLP owns radios
+instead. TLP's own sleep hook runs `tlp resume`, and its setting is
+DEVICES_TO_ENABLE_ON_STARTUP: startup, not resume. There is no ON_RESUME in
+TLP's vocabulary, so the resume edge has no owner at all. WiFi survives only
+because NetworkManager unblocks itself; bluetooth has no equivalent.
+
+The controller also comes back wedged from a hibernate. It reports powered and
+unblocked while scanning finds nothing whatever -- zero devices where the same
+room gave seventeen a minute later. bluetoothd logs "Failed to set mode" and
+"Failed to add device <mac>" at the instant of resume. Reloading btusb clears
+it.
+
+Both observed on velox 2026-08-21, on its first suspend-then-hibernate cycle
+after hibernate was switched back on.
+
+The hook re-asserts TLP's own declared intent rather than inventing a policy,
+so a machine that deliberately blocks bluetooth keeps it blocked.
+
+Run from repo root:
+ python3 -m unittest tests.bluetooth-resume.test_bluetooth_resume
+"""
+
+import os
+import stat
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+HOOK = os.path.join(REPO_ROOT, "scripts", "zz-bluetooth-resume")
+
+TLP_WANTS_BT = 'DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"\n'
+TLP_WIFI_ONLY = 'DEVICES_TO_ENABLE_ON_STARTUP="wifi"\n'
+
+
+def run(phase="post", kind="suspend-then-hibernate", tlp_conf=TLP_WANTS_BT,
+ conf_present=True):
+ """Drive the hook with rfkill and modprobe faked, and read back the calls."""
+ with tempfile.TemporaryDirectory() as d:
+ calls = os.path.join(d, "calls.log")
+ bindir = os.path.join(d, "bin")
+ os.makedirs(bindir)
+ for tool in ("rfkill", "modprobe"):
+ p = os.path.join(bindir, tool)
+ with open(p, "w") as fh:
+ fh.write(f'#!/bin/sh\necho "{tool} $*" >> "{calls}"\nexit 0\n')
+ os.chmod(p, 0o755)
+ conf = os.path.join(d, "tlp.conf")
+ if conf_present:
+ with open(conf, "w") as fh:
+ fh.write(tlp_conf)
+ env = dict(os.environ)
+ env.update({
+ "BTR_RFKILL": os.path.join(bindir, "rfkill"),
+ "BTR_MODPROBE": os.path.join(bindir, "modprobe"),
+ "BTR_TLP_CONF": conf,
+ "BTR_TLP_CONF_DIR": os.path.join(d, "tlp.d"),
+ "BTR_SETTLE": "0",
+ })
+ r = subprocess.run(["sh", HOOK, phase, kind], env=env,
+ capture_output=True, text=True, timeout=20)
+ log = ""
+ if os.path.exists(calls):
+ with open(calls) as fh:
+ log = fh.read()
+ return r, log
+
+
+class BluetoothResume(unittest.TestCase):
+ # --- Normal ---------------------------------------------------------
+ def test_hibernate_reloads_the_driver_and_unblocks(self):
+ _, log = run(kind="suspend-then-hibernate")
+ self.assertIn("modprobe -r btusb", log)
+ self.assertIn("modprobe btusb", log)
+ self.assertIn("rfkill unblock bluetooth", log)
+
+ def test_the_unblock_comes_after_the_reload(self):
+ # A freshly loaded btusb can come up soft-blocked, so unblocking first
+ # would be undone by the reload that follows it.
+ _, log = run()
+ self.assertLess(log.index("modprobe btusb"),
+ log.index("rfkill unblock"))
+
+ def test_plain_suspend_unblocks_without_reloading(self):
+ # The wedge was seen coming out of hibernate, which reinitialises the
+ # controller from a saved image. A plain suspend restores USB intact,
+ # so reloading there would cost a working adapter for nothing.
+ _, log = run(kind="suspend")
+ self.assertIn("rfkill unblock bluetooth", log)
+ self.assertNotIn("btusb", log)
+
+ # --- Boundary -------------------------------------------------------
+ def test_the_pre_phase_does_nothing(self):
+ _, log = run(phase="pre")
+ self.assertEqual(log, "")
+
+ def test_a_tlp_policy_without_bluetooth_is_left_alone(self):
+ # The hook re-asserts TLP's stated intent. It must not invent one, or
+ # a machine that deliberately keeps bluetooth off gets it turned on at
+ # every wakeup.
+ _, log = run(tlp_conf=TLP_WIFI_ONLY)
+ self.assertEqual(log, "")
+
+ def test_a_commented_out_policy_does_not_count(self):
+ _, log = run(tlp_conf='#DEVICES_TO_ENABLE_ON_STARTUP="bluetooth"\n')
+ self.assertEqual(log, "")
+
+ def test_hibernate_proper_also_reloads(self):
+ _, log = run(kind="hibernate")
+ self.assertIn("modprobe -r btusb", log)
+
+ # --- Error ----------------------------------------------------------
+ def test_a_missing_tlp_config_is_left_alone(self):
+ # No declared policy means no intent to re-assert. Failing safe here
+ # means doing nothing, not guessing.
+ _, log = run(conf_present=False)
+ self.assertEqual(log, "")
+
+ def test_the_hook_always_exits_zero(self):
+ # systemd-sleep logs a failing hook and the noise outlives the cause.
+ # Nothing here is worth delaying or alarming a resume over.
+ for kind in ("suspend", "hibernate", "suspend-then-hibernate"):
+ with self.subTest(kind=kind):
+ r, _ = run(kind=kind)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_it_is_executable(self):
+ self.assertTrue(os.stat(HOOK).st_mode & stat.S_IXUSR,
+ "systemd-sleep only runs executables")
+
+
+if __name__ == "__main__":
+ unittest.main()