I have a few Bluetooth enabled devices like my laptop, desktop, and phone. Frequently I would try to connect to connect to my Bluetooth headset from my phone after jumping off the desktop and it would just refuse. After a few minutes I realized I was still connected to my locked desktop and felt like a complete idiot.
So like any lazy person, I ended up automating it on my Linux desktop.
Implementation#
First the script to toggle the Bluetooth.
Toggling scriptshDownload
#!/usr/bin/env bash
# Block Bluetooth unless someone is at the desktop with an active, unlocked session.
#
# bt-lock-toggle.sh daemon watch logind and apply the rule after every event (run by bt-lock-toggle.service)
# bt-lock-toggle.sh status print each session, whether it counts, and the resulting Bluetooth state
#
# Works with any login screen (GDM, SDDM, LightDM, ...) because it reads logind, not display manager hooks.
# Install it as a root service with bt-lock-toggle-install.sh.
set -uo pipefail
# Make rfkill (in /usr/sbin) available when started with a minimal PATH.
PATH="/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
# Print "yes" if a session keeps Bluetooth on, "no" otherwise.
session_counts() {
local seat="" class="" type="" active="" locked="" state="" key value
while IFS='=' read -r key value; do
case "$key" in
Seat) seat="$value" ;;
Class) class="$value" ;;
Type) type="$value" ;;
Active) active="$value" ;;
LockedHint) locked="$value" ;;
State) state="$value" ;;
esac
done < <(loginctl show-session "$1" -p Seat -p Class -p Type -p Active -p LockedHint -p State 2>/dev/null)
# A local desktop session (not SSH, not the login screen) that is on screen, unlocked, and not closing.
if [[ "$seat" == seat0 && "$class" == user && ("$type" == x11 || "$type" == wayland) &&
"$active" == yes && "$locked" == no && ("$state" == active || "$state" == online) ]]; then
echo yes
else
echo no
fi
}
# Print "unblock" if any session keeps Bluetooth on, "block" otherwise.
wanted_state() {
local id
for id in $(loginctl list-sessions --no-legend | awk '{print $1}'); do
if [[ "$(session_counts "$id")" == yes ]]; then
echo unblock
return
fi
done
echo block
}
# Devices to reconnect on unblock. RUNTIME_DIRECTORY is set by systemd (RuntimeDirectory= in the unit).
DEVICES_FILE="${RUNTIME_DIRECTORY:-/run/bt-lock-toggle}/devices"
# Disconnect devices cleanly before cutting the radio. A bare rfkill block drops the link without telling the device,
# so a headset keeps thinking it is connected and won't pair with a phone until the stale link times out.
block_bluetooth() {
local addr connected
connected="$(bluetoothctl devices Connected | awk '{print $2}')"
# Keep the old list when nothing is connected (e.g. the service restarted while already blocked).
if [[ -n "$connected" ]]; then
mkdir -p "$(dirname "$DEVICES_FILE")"
echo "$connected" > "$DEVICES_FILE"
fi
for addr in $connected; do
timeout 10 bluetoothctl disconnect "$addr" >/dev/null && echo "Disconnected $addr"
done
rfkill block bluetooth && echo "Bluetooth: block"
}
# Unblock, then reconnect what was connected at block time. BlueZ doesn't do this on its own after an rfkill unblock.
unblock_bluetooth() {
local addr i
rfkill unblock bluetooth && echo "Bluetooth: unblock"
[[ -s "$DEVICES_FILE" ]] || return 0
for ((i = 0; i < 20; i++)); do
bluetoothctl show | grep -q 'Powered: yes' && break
sleep 0.5
done
while read -r addr; do
timeout 15 bluetoothctl connect "$addr" >/dev/null && echo "Reconnected $addr" || echo "Couldn't reconnect $addr"
done < "$DEVICES_FILE"
rm -f "$DEVICES_FILE"
}
daemon() {
local last="" want line
apply() {
want="$(wanted_state)"
if [[ "$want" != "$last" ]]; then
"${want}_bluetooth"
last="$want"
fi
}
apply
while read -r line; do
# One lock or login fires a burst of signals; let it finish, then work the rule out once.
while read -r -t 0.5 line; do :; done
apply
done < <(gdbus monitor --system --dest org.freedesktop.login1)
echo "gdbus monitor exited" >&2
exit 1
}
status() {
local id
printf '%-8s %-6s %-8s %-12s %-7s %-7s %-8s %s\n' SESSION SEAT CLASS TYPE ACTIVE LOCKED STATE COUNTS
for id in $(loginctl list-sessions --no-legend | awk '{print $1}'); do
printf '%-8s %-6s %-8s %-12s %-7s %-7s %-8s %s\n' "$id" \
"$(loginctl show-session "$id" --value -p Seat)" \
"$(loginctl show-session "$id" --value -p Class)" \
"$(loginctl show-session "$id" --value -p Type)" \
"$(loginctl show-session "$id" --value -p Active)" \
"$(loginctl show-session "$id" --value -p LockedHint)" \
"$(loginctl show-session "$id" --value -p State)" \
"$(session_counts "$id")"
done
echo "Bluetooth should be: $(wanted_state)ed"
}
case "${1:-}" in
daemon) daemon ;;
status) status ;;
*)
sed -n '4,5s/^# *//p' "$0" >&2
exit 2
;;
esacThen the one-time script to install it as a systemd service, which works with any login screen (GDM, SDDM, LightDM, …).
Install toggling serviceshDownload
#!/usr/bin/env bash
# Install bt-lock-toggle.sh as a root service, so Bluetooth is blocked unless someone is at the desktop with an active,
# unlocked session. Works with any login screen; needs systemd (logind) and rfkill.
#
# bt-lock-toggle-install.sh install or refresh
# bt-lock-toggle-install.sh --uninstall stop the service and remove everything this installed
set -Eeuo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bt-lock-toggle.sh"
# The service runs as root, so it runs a root-owned copy rather than a file a user can edit.
BIN="/usr/local/bin/bt-lock-toggle.sh"
UNIT="/etc/systemd/system/bt-lock-toggle.service"
if [[ "${1:-}" == --uninstall ]]; then
sudo systemctl disable --now bt-lock-toggle.service || true
sudo rm -f "$UNIT" "$BIN"
sudo systemctl daemon-reload
echo "Removed bt-lock-toggle"
exit
fi
sudo install -m 0755 -o root -g root "$SRC" "$BIN"
echo "Installed $BIN"
sudo tee "$UNIT" >/dev/null <<EOF
[Unit]
Description=Block Bluetooth when no unlocked desktop session is active
After=systemd-logind.service bluetooth.service
[Service]
ExecStart=$BIN daemon
Restart=always
RestartSec=2
# Holds the devices to reconnect on unlock; kept across restarts.
RuntimeDirectory=bt-lock-toggle
RuntimeDirectoryPreserve=yes
[Install]
WantedBy=multi-user.target
EOF
echo "Installed $UNIT"
sudo systemctl daemon-reload
sudo systemctl enable bt-lock-toggle.service
# Restart rather than start, so a re-run picks up a changed script.
sudo systemctl restart bt-lock-toggle.service
echo "Started bt-lock-toggle; check it with: $BIN status"
echo "** You can remove these files now via rm $SRC/bt-lock-toggle*.sh **"Here is the command line to install it. Run it from wherever you keep scripts (e.g. ~/bin). It downloads both
scripts, checks them against hashes computed when this site was built, and only runs the installer if both match.
curl -q --proto '=https' --tlsv1.2 -fsSLO http://awalsh128.com/bt-toggle-linux-logins/bt-lock-toggle.sh &&
curl -q --proto '=https' --tlsv1.2 -fsSLO http://awalsh128.com/bt-toggle-linux-logins/bt-lock-toggle-install.sh &&
printf '%s %s\n' \
'41a8b96d7d441d073c0f5cf34f9d42eb2f333ca4f78e7b4dfae3d706de1b37c4' 'bt-lock-toggle.sh' \
'5f4ef2ba4d378bb3e08d3443339c582bf3ff6079329c342350e336c9c7cb9c38' 'bt-lock-toggle-install.sh' | sha256sum --check &&
bash bt-lock-toggle-install.shChecking It Works#
Lock your screen, then unlock it and look at the service log. You should see the headset disconnect, Bluetooth get blocked, then the reverse on unlock.
journalctl -u bt-lock-toggle -fIf Bluetooth doesn’t change when you lock, run status over SSH (or from a text console) while the desktop is locked.
It shows every session
logind knows about and whether
it keeps Bluetooth on.
bt-lock-toggle.sh statusCaveats#
The script never talks to the login manager. It only asks logind (i.e. systemd’s session tracker) which sessions are open, so GDM, SDDM, and LightDM should all work the same way. I’ve only run it on GNOME with GDM though.
- Locking depends on your screen locker. The script sees a lock through logind’s
LockedHint, which the locker has to set. GNOME and KDE set it. Standalone lockers (e.g. i3lock, swaylock, xscreensaver) usually don’t, so Bluetooth stays on while locked. Logging in and out still works. - You need systemd. Distros without it (e.g. Devuan, or Artix and Gentoo with OpenRC) can’t use the installer as-is. The script itself may work with elogind, but you’d have to start it some other way.
- You need a fairly recent BlueZ. Disconnecting the headset first relies on
bluetoothctl devices Connected, which is only in BlueZ from around 2022 on. On older versions Bluetooth still gets blocked, but the headset won’t hear about it and will think it’s still connected. - Bluetooth is off at the login screen. It stays blocked until you log in, so a Bluetooth keyboard can’t type your password. A login manager that doesn’t mark its login screen as a greeter session would leave Bluetooth on there instead.
- Unlocking reconnects what was connected. If your phone grabbed the headset while you were away, unlocking may pull it back to the desktop.
That’s it. I hope it saves you from the same moment I had with my headset.