aboutsummaryrefslogtreecommitdiff
path: root/tests/bluetooth-resume
diff options
context:
space:
mode:
Diffstat (limited to 'tests/bluetooth-resume')
-rw-r--r--tests/bluetooth-resume/test_bluetooth_resume.py139
1 files changed, 139 insertions, 0 deletions
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()