DEV Community

prodbymarcu
prodbymarcu

Posted on Edited on

Pinning hci1 in your config is a latent bug

cover

Linux hands the kernel a Bluetooth radio number at boot, and nothing promises that number survives the night. The USB stack reassigns radios whenever it resets a device, so any integration pinned to hci1 carries a bug that only shows up after the machine sleeps. It bit twice within fourteen days on the same dongle, and the second bite is the interesting one, because the guard I installed to stop the first had stopped holding.

The chain that broke my lights

October 18. A USB dongle sitting on my desk was running the default power policy, power/control=auto with autosuspend=2, two seconds. Two seconds of idle is nobody's idea of a sane timeout for something that has to hold discovery state, but it's what upstream ships by default, and it woke up glitched after the display went down. Two dmesg lines bracketed it, the transfer failure reading urb failed to resubmit (2) and then a reset line for port 1-5 carrying an endpoint number of 12.

A reset means renumbering. Home Assistant's Bluetooth integration then threw ScannerStartError reading adapter 'hci1' not found, in a loop, while the very same hardware answered as hci0. Two BLE smart-light strips stopped taking writes for the rest of the night, and every symptom pointed at an adapter that clearly existed.

The fix people post in that thread pins the policy, and that's what I did:

echo on > /sys/bus/usb/devices/1-5/power/control
printf '%s\n' 'SUBSYSTEM=="usb", ATTR{idVendor}=="<vendor>", ATTR{idProduct}=="<product>", \
  ATTR{power/control}="on"' > /etc/udev/rules.d/99-bt-no-autosuspend.rules
udevadm control --reload-rules
Enter fullscreen mode Exit fullscreen mode

I read the value back as on and confirmed the rules file was installed, so on paper the dongle could never suspend again. Lights came back and I called it fixed that night.

Fourteen days later the guard had stopped holding

November 1, a different session chasing a dashboard action checked that same sysfs path and read auto. The rules file was still sitting exactly where I'd verified it. The rule sat there installed and loadable, doing nothing, and reporting nothing. Chasing that took three more commands before anything moved: set the live value to on, restart bluetooth.service, reload the HA Bluetooth config entry. BLE writes returned HTTP 200 after that.

The persistent cause of the flip is still open, I never pinned down what rewrote the policy, and I'm not going to pretend the udev rule was ever sufficient. What I do know is the shape of the check that should have been there from the start. A power-policy fix needs the live value read back every boot, not once at install time. Install-time verification answers "did my rule land", the question that matters is "is the device actually unsuspended right now", and those two drift apart on any box with a network manager or another daemon touching the same attributes.

Resolve the radio by capability

Once the renumbering was understood the rest of the debugging fell into place, and none of it involved the number.

bluetoothctl devices          # which radios exist, and which one holds state
bluetoothctl scan | grep -E 'RSSI|XX:XX'   # who's audible, and how far
Enter fullscreen mode Exit fullscreen mode

Two gotchas cost me hours, both about the tools themselves. hcitool lescan fails with an Input/output error while bluetoothd is holding discovery, because the older CLI wants the exclusive slot and the daemon already owns it, so the working scanner is bluetoothctl. The other one is reading an unloaded integration as a broken one. Home Assistant restarted six times in that night, 20:16, 20:18, 22:37, 22:41, 22:44, then again near 22:56, from a parallel session iterating themes and configuration.yaml, and each restart severs BLE sessions. The BLE integration loads late at boot, up to five minutes, and during those boots the entries showed not_loaded while the radio underneath was healthy. A config entry that hasn't started yet looks identical to one that failed, and the only honest readout is the adapter list plus whether any radio currently holds discovery state.

Distance ended the backup-radio plan

The same scan settled a question I'd been carrying for weeks. Both strips were audible straight from the host's fixed radio at -68 dBm, one of them ranging out to -78 across the room.

scan

That number ended an argument about buying spare radios, because a second Bluetooth proxy in another room would have been solving nothing. The host hears the devices, so the flaky part was never reach, it was lifecycle.

A udev rule alone doesn't clear this class of fault, and half the accepted answers online read like it does. Kernel renumbering is not a Home Assistant problem, it's a USB power-policy problem with a name pinned downstream. Any stack that caches a device identifier, and that includes pulseaudio session configs, udev rules matching by KERNEL, or scripts grepping hcix out of /sys/class, inherits the same fault. The guard has three parts and each one fails independently: pin the policy, verify the live value after boot, look the radio up by capability. Mine passed all three checks on October 18 and failed one of them on November 1, which is why the middle check is the one worth automating.

I think the blame splits in two places, and I could be wrong about the split. Upstream shipping autosuspend=2 for a Bluetooth dongle is a bug in the kernel's default, not in the integration, and pinning hciN anywhere in config is a bug in whoever wrote the config. Both are cheap to avoid once you know the rule, and both are invisible until something sleeps. If your box keeps a BLE scanner awake around the clock you may never see this at all, mine hit it on the first night the display went to sleep, which is why the class deserves a guard even when the trigger is rare.

What do you do when a config entry reports healthy-but-dead on Linux, and does anyone have the actual mechanism behind a sysfs attribute reverting under an installed udev rule? My hunt stopped at "something rewrote it", if you know better I'd like that more than another workaround.

Top comments (0)