diff options
Diffstat (limited to 'tests/bluetooth-resume')
| -rw-r--r-- | tests/bluetooth-resume/test_bluetooth_resume.py | 139 |
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() |
