↓ Skip to main content

Toggling Bluetooth on Linux Login Status

·7 mins
Andrew Walsh
Author
Andrew Walsh
Coder with a passion for most all things technological.

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
    ;;
esac

Then 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.sh

Checking 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 -f

If 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 status

Caveats
#

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.


comments powered by Disqus