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 | |
| 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.
| -rwxr-xr-x | archsetup | 14 | ||||
| -rwxr-xr-x | scripts/zz-bluetooth-resume | 86 | ||||
| -rw-r--r-- | tests/bluetooth-resume/test_bluetooth_resume.py | 139 |
3 files changed, 239 insertions, 0 deletions
@@ -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() |
