diff options
| author | Craig Jennings <c@cjennings.net> | 2026-08-21 07:25:23 -0700 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-08-21 07:25:23 -0700 |
| commit | d1b5ee1fdc07400e4308db7d346c5a8be0309c7a (patch) | |
| tree | fdfbbe52b0fee290bc8d9cca281e64dd24c1fd9a /tests/bluetooth-resume/test_bluetooth_resume.py | |
| parent | 7ed8e5701c83c5e26c2416b9a0365c12f801f7ed (diff) | |
| download | archsetup-d1b5ee1fdc07400e4308db7d346c5a8be0309c7a.tar.gz archsetup-d1b5ee1fdc07400e4308db7d346c5a8be0309c7a.zip | |
feat(bluetooth): restore the radio after a sleep cycle
Masking systemd-rfkill so TLP can own radios leaves the resume edge with no owner at all. TLP's sleep hook runs `tlp resume`, but its setting is DEVICES_TO_ENABLE_ON_STARTUP, and TLP has no ON_RESUME to pair with it. WiFi survives anyway because NetworkManager unblocks itself. Bluetooth has no equivalent, so it comes back soft-blocked and stays that way.
After a hibernate the controller is wedged as well, which is why unblocking alone is not the fix. I cleared rfkill by hand on velox this morning and scanning still returned nothing. Zero devices, in a room that gave seventeen a minute after the driver was reloaded. bluetoothd had logged "Failed to set mode" and "Failed to add device" at the instant of resume.
So the hook reloads btusb on a hibernate-class wake, then unblocks. That order matters: a freshly loaded btusb can come up blocked and would undo an earlier unblock. A plain suspend only unblocks, since it brings USB back intact and reloading there would tear down a working adapter for nothing.
It acts only where TLP's own config asks for bluetooth, so it re-asserts a declared intent instead of inventing one and a machine that deliberately keeps the radio off keeps it off. It exits zero on every path, because a failing sleep hook logs noise that outlives the cause.
The install sits next to the mask that creates the gap rather than beside the other installs, and creates /etc/systemd/system-sleep first. Arch does not ship it and install_executable is a plain cp, so without the mkdir a fresh machine would warn and end up with no hook.
Ten tests, and I checked they bite: dropping the TLP intent guard fails three, moving the unblock before the reload fails one.
This surfaced because hibernate was switched back on. The gap predates that and would have shown up the first time the laptop slept.
Diffstat (limited to 'tests/bluetooth-resume/test_bluetooth_resume.py')
| -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() |
