aboutsummaryrefslogtreecommitdiff
path: root/tests/bluetooth-resume/test_bluetooth_resume.py
blob: 6d8ed87f40b86d04b02dd144572086d65a9c8f71 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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()