#!/usr/bin/env bash set -u # ------------------------------------------------- # CONFIGURATION # ------------------------------------------------- WIFI_5G="cintra-5g" WIFI_24G="cintra-5g" BT_CONFIG_DIR="$HOME/.config/btswitch" BT_CONFIG_FILE="$BT_CONFIG_DIR/devices.conf" BT_SERVICE="bluetooth" BT_RETRIES=6 BT_DELAY=3 BT_SCAN_SECONDS=15 # Defaults written to devices.conf on first run. Afterwards the config # file is the single source of truth (manage it with add/remove/auto). BT_DEFAULT_DEVICES=( "TAH8506 00:1E:7C:CA:77:00" "SHB7150 00:1E:7C:30:F3:69" "HD450BT 00:16:94:33:91:51" "XTREME 41:42:5D:FA:26:0A" "EM03 F1:5A:D5:16:57:9D" "X5 47:0D:FF:C9:62:E1" ) BT_DEFAULT_AUTO=("TAH8506" "SHB7150") # Sentinel file to track that the sudoers rule was installed BT_SUDOERS_SENTINEL="$BT_CONFIG_DIR/.sudoers_rule_installed" declare -A BT_DEVICES BT_AUTO_DEVICES=() DEVICES=() CMD_ARGS=() COMMAND="" TIMED_MINUTES=0 HARD_MODE=0 # ------------------------------------------------- # HELPERS # ------------------------------------------------- wifi_switch_needed() { [[ -n "$1" && -n "$2" && "$1" != "$2" ]] } wifi_switch() { local target="$1" if ! wifi_switch_needed "$WIFI_5G" "$WIFI_24G"; then return 0 fi echo "Switching Wi-Fi to '$target'..." nmcli connection up "$target" > /dev/null 2>&1 || echo "⚠ nmcli: could not switch to '$target'" >&2 } ensure_sudoers_rule() { if [[ -f "$BT_SUDOERS_SENTINEL" ]] && grep -q "^SUDOERS_RULE_VERSION=2" "$BT_SUDOERS_SENTINEL"; then return 0 fi cat >&2 << EOF NOTE: 'stop --hard' (full system-wide shutdown) and 'restore' after it need rights to control the bluetooth service and rfkill. You will be prompted for your password ONCE to install a passwordless sudoers rule: /etc/sudoers.d/49-btswitch Regular start/stop/scan/list do NOT need sudo while Bluetooth is already running. EOF read -r -p "Install the sudoers rule now? [y/N] " answer case "$answer" in y | Y | yes | YES) ;; *) echo "Skipping. 'stop --hard' and 'restore' will not work without it." >&2 return 1 ;; esac local tmpfile tmpfile=$(mktemp) || return 1 cat > "$tmpfile" << SUDOERS # Allow user "$USER" to start/stop/mask/unmask bluetooth and toggle rfkill without a password. # Generated by btswitch on $(date -Is). $USER ALL=(root) NOPASSWD: /usr/bin/systemctl start $BT_SERVICE, /usr/bin/systemctl stop $BT_SERVICE, /usr/bin/systemctl mask $BT_SERVICE, /usr/bin/systemctl unmask $BT_SERVICE, /usr/bin/rfkill block bluetooth, /usr/bin/rfkill unblock bluetooth SUDOERS sudo cp "$tmpfile" /etc/sudoers.d/49-btswitch || { echo "ERROR: Failed to write /etc/sudoers.d/49-btswitch." >&2 rm -f "$tmpfile" return 1 } rm -f "$tmpfile" sudo chown root:root /etc/sudoers.d/49-btswitch sudo chmod 0440 /etc/sudoers.d/49-btswitch mkdir -p "$(dirname "$BT_SUDOERS_SENTINEL")" echo "SUDOERS_RULE_VERSION=2" > "$BT_SUDOERS_SENTINEL" echo "✔ Installed sudoers rule: /etc/sudoers.d/49-btswitch" >&2 return 0 } systemctl_bt() { ensure_sudoers_rule || { echo "ERROR: Cannot control $BT_SERVICE.service without the sudoers rule." >&2 exit 1 } if ! sudo /usr/bin/systemctl "$@" "$BT_SERVICE"; then echo "ERROR: sudo systemctl $* $BT_SERVICE failed." >&2 return 1 fi } rfkill_bt() { ensure_sudoers_rule || return 1 sudo /usr/bin/rfkill "$1" bluetooth > /dev/null 2>&1 } bt_start_service() { ensure_sudoers_rule || return 1 if sudo /usr/bin/systemctl start "$BT_SERVICE" 2>/dev/null; then return 0 fi sudo /usr/bin/systemctl unmask "$BT_SERVICE" || return 1 sudo /usr/bin/systemctl start "$BT_SERVICE" } # All bluetoothctl queries go through this: if bluetoothd is not running, # bluetoothctl would block forever ("Waiting to connect to bluetoothd..."). bt_info() { bt_service_active || return 1 timeout 5 bluetoothctl info "$1" 2> /dev/null } bt_is_paired() { bt_info "$1" | grep -q "Paired: yes" } bt_is_trusted() { bt_info "$1" | grep -q "Trusted: yes" } bt_is_connected() { bt_info "$1" | grep -q "Connected: yes" } bt_is_mac_address() { [[ "$1" =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]] } bt_resolve_device() { local device="$1" if bt_is_mac_address "$device"; then echo "$device" else echo "${BT_DEVICES[$device]:-}" fi } bt_name_for_mac() { local mac="${1^^}" name for name in "${!BT_DEVICES[@]}"; do if [[ "${BT_DEVICES[$name]}" == "$mac" ]]; then echo "$name" return 0 fi done echo "$mac" } auto_contains() { local dev for dev in "${BT_AUTO_DEVICES[@]}"; do [[ "$dev" == "$1" ]] && return 0 done return 1 } # State checks that do NOT need sudo bt_rfkill_blocked() { rfkill list bluetooth 2>/dev/null | grep -qi "blocked: yes" } bt_service_masked() { [[ "$(systemctl is-enabled "$BT_SERVICE" 2>/dev/null)" == "masked" ]] } bt_service_active() { systemctl is-active --quiet "$BT_SERVICE" 2>/dev/null } # Devices to act on: explicit -d/positional list, or the auto list target_devices() { if [[ ${#DEVICES[@]} -gt 0 ]]; then printf '%s\n' "${DEVICES[@]}" elif [[ ${#BT_AUTO_DEVICES[@]} -gt 0 ]]; then printf '%s\n' "${BT_AUTO_DEVICES[@]}" fi } # ------------------------------------------------- # DEVICE CONFIG FILE # ------------------------------------------------- config_seed() { mkdir -p "$BT_CONFIG_DIR" { echo "# btswitch device configuration" echo "# Format: dev NAME MAC (a known device)" echo "# auto NAME (member of the auto-connect list)" echo "# Prefer managing this via: btswitch add/remove/auto" local entry for entry in "${BT_DEFAULT_DEVICES[@]}"; do echo "dev $entry"; done for entry in "${BT_DEFAULT_AUTO[@]}"; do echo "auto $entry"; done } > "$BT_CONFIG_FILE" || { echo "ERROR: cannot write $BT_CONFIG_FILE" >&2 exit 1 } echo "Created device configuration: $BT_CONFIG_FILE" >&2 } config_load() { if [[ ! -f "$BT_CONFIG_FILE" ]]; then config_seed fi BT_DEVICES=() BT_AUTO_DEVICES=() local line _ name mac while read -r line || [[ -n "$line" ]]; do case "$line" in dev\ *) read -r _ name mac <<< "$line" [[ -n "$name" && -n "$mac" ]] && BT_DEVICES["$name"]="$mac" ;; esac done < "$BT_CONFIG_FILE" while read -r line || [[ -n "$line" ]]; do case "$line" in auto\ *) read -r _ name <<< "$line" if [[ -n "${BT_DEVICES[$name]:-}" ]] && ! auto_contains "$name"; then BT_AUTO_DEVICES+=("$name") fi ;; esac done < "$BT_CONFIG_FILE" } config_save() { local tmp name tmp=$(mktemp) || return 1 { echo "# btswitch device configuration" echo "# Format: dev NAME MAC (a known device)" echo "# auto NAME (member of the auto-connect list)" echo "# Prefer managing this via: btswitch add/remove/auto" for name in "${!BT_DEVICES[@]}"; do echo "dev $name ${BT_DEVICES[$name]}"; done for name in "${BT_AUTO_DEVICES[@]}"; do echo "auto $name"; done } > "$tmp" || { rm -f "$tmp"; return 1; } mv "$tmp" "$BT_CONFIG_FILE" || { rm -f "$tmp"; return 1; } } # ------------------------------------------------- # BLUETOOTH CONTROL # ------------------------------------------------- # Makes Bluetooth usable again (after a hard stop or if it was off). # Only invokes sudo for the steps that are actually needed. bt_ensure_available() { if bt_rfkill_blocked; then echo "Unblocking Bluetooth radio..." rfkill_bt unblock || return 1 fi if bt_service_masked; then echo "Unmasking $BT_SERVICE service (it was hard-stopped)..." systemctl_bt unmask || return 1 fi if ! bt_service_active; then echo "Starting $BT_SERVICE service..." bt_start_service || { echo "ERROR: could not start $BT_SERVICE.service." >&2 return 1 } fi if ! timeout 5 bluetoothctl show 2>/dev/null | grep -q "Powered: yes"; then echo "Powering on adapter..." local i for i in {1..10}; do timeout 5 bluetoothctl power on > /dev/null 2>&1 sleep 1 if timeout 5 bluetoothctl show 2>/dev/null | grep -q "Powered: yes"; then return 0 fi done echo "ERROR: Bluetooth adapter not found or could not be powered on." >&2 return 1 fi return 0 } # Full system-wide shutdown (opt-in via --hard) bt_hard_off() { timeout 5 bluetoothctl power off > /dev/null 2>&1 bt_service_active && systemctl_bt stop bt_service_masked || systemctl_bt mask bt_rfkill_blocked || rfkill_bt block } bt_pair_device() { local mac="$1" if bt_is_paired "$mac"; then return 0 fi echo "✖ Device $mac is not paired. Pair it once via your desktop Bluetooth settings or 'bluetoothctl pair $mac' (use 'btswitch scan' to discover devices)." >&2 return 1 } bt_ensure_connected() { local mac="$1" echo "----------------------------------------" echo "Checking device: $mac" timeout 5 bluetoothctl unblock "$mac" > /dev/null 2>&1 if ! bt_is_trusted "$mac"; then echo "→ Trusting $mac" timeout 5 bluetoothctl trust "$mac" > /dev/null 2>&1 fi local i for ((i = 1; i <= BT_RETRIES; i++)); do if bt_is_connected "$mac"; then echo "✔ Connected: $mac" return 0 fi echo "→ Attempt $i/$BT_RETRIES: Connecting to $mac..." if ! bt_is_paired "$mac"; then bt_pair_device "$mac" || return 1 fi timeout $((BT_DELAY * 4)) bluetoothctl connect "$mac" > /dev/null 2>&1 sleep "$BT_DELAY" if bt_is_connected "$mac"; then echo "✔ Success!" return 0 fi done echo "✖ Failed to connect $mac after $BT_RETRIES attempts" return 1 } # Disconnects and blocks the device so it cannot auto-reconnect, # while leaving Bluetooth running for everything else. bt_safe_disconnect() { local mac="$1" if ! bt_is_connected "$mac"; then return 0 fi echo "Disconnecting $mac..." timeout 5 bluetoothctl disconnect "$mac" > /dev/null 2>&1 sleep 1 if bt_is_connected "$mac"; then echo "⚠ $mac is still connected." >&2 return 0 fi echo "Blocking $mac to prevent auto-reconnect ('btswitch start' unblocks it automatically)." timeout 5 bluetoothctl block "$mac" > /dev/null 2>&1 } bt_start_all() { local dev mac failed=0 while read -r dev; do [[ -n "$dev" ]] || continue mac=$(bt_resolve_device "$dev") if [[ -z "$mac" ]]; then echo "⚠ Unknown device '$dev' (see 'btswitch list')" >&2 continue fi bt_ensure_connected "$mac" || failed=1 done < <(target_devices) return "$failed" } bt_stop_all() { local dev mac while read -r dev; do [[ -n "$dev" ]] || continue mac=$(bt_resolve_device "$dev") if [[ -z "$mac" ]]; then echo "⚠ Unknown device '$dev' (see 'btswitch list')" >&2 continue fi bt_safe_disconnect "$mac" done < <(target_devices) } bt_status_one() { local dev="$1" mac mac=$(bt_resolve_device "$dev") if [[ -z "$mac" ]]; then echo "⚠ Unknown device '$dev' (see 'btswitch list')" >&2 return 0 fi echo "--- $(bt_name_for_mac "$mac") ($mac) ---" if ! bt_service_active; then echo "Status: unknown (Bluetooth service is off)" return 0 fi if bt_is_connected "$mac"; then echo "Status: CONNECTED" elif bt_is_paired "$mac"; then echo "Status: Paired (Offline)" else echo "Status: Not Paired / Unknown" fi } # ------------------------------------------------- # COMMANDS # ------------------------------------------------- cmd_start() { if [[ ${#DEVICES[@]} -eq 0 && ${#BT_AUTO_DEVICES[@]} -eq 0 ]]; then echo "No devices selected and the auto list is empty (see 'btswitch auto NAME on')." fi wifi_switch "$WIFI_5G" bt_ensure_available || exit 1 bt_start_all if [[ "$TIMED_MINUTES" -gt 0 ]]; then local action="disconnect devices" [[ "$HARD_MODE" -eq 1 ]] && action="hard stop (full Bluetooth shutdown)" echo "Timed mode: will $action in $TIMED_MINUTES minute(s)..." sleep $((TIMED_MINUTES * 60)) echo "Time elapsed. Stopping..." cmd_stop fi } cmd_stop() { if ! bt_service_active; then echo "Bluetooth service is not running." if [[ "$HARD_MODE" -eq 1 ]]; then bt_hard_off wifi_switch "$WIFI_24G" else echo "If it was hard-stopped earlier, run 'btswitch restore' to re-enable it." fi return 0 fi bt_stop_all if [[ "$HARD_MODE" -ne 1 ]]; then echo "Bluetooth left running so other applications can keep using it." echo "(For a full system-wide shutdown use: btswitch stop --hard)" return 0 fi echo "Hard stop: powering off adapter, stopping/masking service and blocking radio." echo "Bluetooth is disabled system-wide until 'btswitch start' or 'btswitch restore'." bt_hard_off wifi_switch "$WIFI_24G" } cmd_restore() { bt_ensure_available && echo "✔ Bluetooth restored, other applications can use it again." } cmd_status() { echo "=== Bluetooth adapter ===" if bt_rfkill_blocked; then echo "Radio: blocked (rfkill)" fi if bt_service_masked; then echo "Service: masked (hard-stopped; run 'btswitch restore' to re-enable)" elif bt_service_active; then timeout 5 bluetoothctl show 2>/dev/null | grep -E "Name|Powered|Discoverable" | sed 's/^[[:space:]]*/ /' else echo "Service: inactive (run 'btswitch start' or 'btswitch restore')" fi echo "=== Devices ===" local devs=("${DEVICES[@]}") dev if [[ ${#devs[@]} -eq 0 ]]; then devs=("${!BT_DEVICES[@]}") fi if [[ ${#devs[@]} -eq 0 ]]; then echo "(no devices configured; add one with 'btswitch add NAME MAC')" return 0 fi for dev in "${devs[@]}"; do bt_status_one "$dev" done } cmd_scan() { local duration="${CMD_ARGS[0]:-$BT_SCAN_SECONDS}" if ! [[ "$duration" =~ ^[0-9]+$ ]] || (( duration < 1 || duration > 300 )); then echo "ERROR: scan duration must be between 1 and 300 seconds." >&2 exit 1 fi bt_ensure_available || exit 1 echo "Scanning for $duration seconds (put devices in range/pairing mode)..." timeout $((duration + 10)) bluetoothctl --timeout "$duration" scan on > /dev/null 2>&1 timeout 5 bluetoothctl scan off > /dev/null 2>&1 local listing listing=$(timeout 5 bluetoothctl devices 2>/dev/null | sort -k3) if [[ -z "$listing" ]]; then echo "No devices found. Make sure they are turned on and in range." return 0 fi local -A mac_to_name=() local name for name in "${!BT_DEVICES[@]}"; do mac_to_name["${BT_DEVICES[$name]}"]="$name" done printf "%-24s %-19s %s\n" "NAME" "MAC" "FLAGS" local _ mac flags flagstr while read -r _ mac name; do [[ -n "$mac" ]] || continue flags=() [[ -n "${mac_to_name[$mac]:-}" ]] && flags+=("configured") bt_is_paired "$mac" && flags+=("paired") bt_is_connected "$mac" && flags+=("connected") flagstr="new" [[ ${#flags[@]} -gt 0 ]] && flagstr=$(IFS=,; echo "${flags[*]}") printf "%-24s %-19s %s\n" "${name:-(unknown)}" "$mac" "$flagstr" done <<< "$listing" echo echo "To manage a found device: btswitch add NAME MAC" } cmd_list() { if [[ ${#BT_DEVICES[@]} -eq 0 ]]; then echo "No devices configured. Add one with: btswitch add NAME MAC" return 0 fi local online=1 bt_service_active || online=0 printf "%-12s %-19s %-18s %s\n" "NAME" "MAC" "STATUS" "AUTO" local names name mac status auto names=$(printf '%s\n' "${!BT_DEVICES[@]}" | sort) while read -r name; do mac="${BT_DEVICES[$name]}" if [[ "$online" -eq 1 ]]; then if bt_is_connected "$mac"; then status="connected" elif bt_is_paired "$mac"; then status="paired (offline)" else status="not paired" fi else status="unknown (bt off)" fi if auto_contains "$name"; then auto="yes"; else auto="-"; fi printf "%-12s %-19s %-18s %s\n" "$name" "$mac" "$status" "$auto" done <<< "$names" } cmd_add() { if [[ ${#CMD_ARGS[@]} -ne 2 ]]; then echo "Usage: btswitch add NAME MAC" >&2 exit 1 fi local name="${CMD_ARGS[0]}" mac="${CMD_ARGS[1]^^}" if ! [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then echo "ERROR: invalid NAME '$name' (use letters, digits, '_' and '-')." >&2 exit 1 fi if ! bt_is_mac_address "$mac"; then echo "ERROR: invalid MAC address '${CMD_ARGS[1]}'." >&2 exit 1 fi if [[ -n "${BT_DEVICES[$name]:-}" ]]; then echo "ERROR: device '$name' is already configured (${BT_DEVICES[$name]})." >&2 exit 1 fi local other other=$(bt_name_for_mac "$mac") if [[ "$other" != "$mac" ]]; then echo "ERROR: MAC $mac is already configured as '$other'." >&2 exit 1 fi BT_DEVICES["$name"]="$mac" config_save || exit 1 echo "✔ Added $name ($mac)" if bt_service_active && ! bt_is_paired "$mac"; then echo "Note: not paired yet. Pair it once (desktop Bluetooth settings or 'bluetoothctl pair $mac') while it is in pairing mode." elif ! bt_service_active; then echo "Note: Bluetooth service is off; pairing status could not be checked." fi echo "Add it to the auto-connect list with: btswitch auto $name on" } cmd_remove() { if [[ ${#CMD_ARGS[@]} -ne 1 ]]; then echo "Usage: btswitch remove NAME" >&2 exit 1 fi local name="${CMD_ARGS[0]}" if [[ -z "${BT_DEVICES[$name]:-}" ]]; then echo "ERROR: device '$name' is not configured. See 'btswitch list'." >&2 exit 1 fi local mac="${BT_DEVICES[$name]}" unset "BT_DEVICES[$name]" local filtered=() dev for dev in "${BT_AUTO_DEVICES[@]}"; do [[ "$dev" != "$name" ]] && filtered+=("$dev") done BT_AUTO_DEVICES=("${filtered[@]}") config_save || exit 1 echo "✔ Removed $name ($mac) from the btswitch config." echo "It stays paired in BlueZ; to fully remove it: bluetoothctl remove $mac" } cmd_auto() { if [[ ${#CMD_ARGS[@]} -eq 0 ]]; then echo "Auto-connect devices: ${BT_AUTO_DEVICES[*]:-}" return 0 fi if [[ ${#CMD_ARGS[@]} -ne 2 ]]; then echo "Usage: btswitch auto NAME on|off" >&2 exit 1 fi local name="${CMD_ARGS[0]}" state="${CMD_ARGS[1]}" if [[ -z "${BT_DEVICES[$name]:-}" ]]; then echo "ERROR: device '$name' is not configured (add it first)." >&2 exit 1 fi case "$state" in on) if auto_contains "$name"; then echo "'$name' is already in the auto list." return 0 fi BT_AUTO_DEVICES+=("$name") ;; off) local filtered=() dev for dev in "${BT_AUTO_DEVICES[@]}"; do [[ "$dev" != "$name" ]] && filtered+=("$dev") done BT_AUTO_DEVICES=("${filtered[@]}") ;; *) echo "Usage: btswitch auto NAME on|off" >&2 exit 1 ;; esac config_save || exit 1 echo "✔ Auto-connect list: ${BT_AUTO_DEVICES[*]:-}" } usage() { cat << EOF Usage: btswitch [options] COMMAND [args...] Connect and manage predefined Bluetooth devices. Commands: start [devices...] Make Bluetooth available and connect the auto devices (or only the given devices: names or MACs) stop [devices...] Disconnect the devices and block them so they do not auto-reconnect. Bluetooth stays on for other apps. stop --hard Additionally: power off adapter, stop+mask service, rfkill block (Bluetooth off system-wide) restore Undo a hard stop (unblock radio, unmask/start service) status [devices...] Adapter state + device status (default: all configured) list List configured devices and their status scan [seconds] Scan for nearby devices (default: $BT_SCAN_SECONDS s) add NAME MAC Add a device to the configuration remove NAME Remove a device from the configuration auto [NAME on|off] Show the auto-connect list, or modify it help Show this help Options: -d, --device NAME|MAC Act only on this device (repeatable) -t, --timed MINUTES Stop automatically after MINUTES minutes --hard With stop/-t: full system-wide Bluetooth shutdown -h, --help Show this help Devices are stored in $BT_CONFIG_FILE. Devices must be paired once (e.g. via desktop Bluetooth settings) before btswitch can connect them. Sudo is only needed for --hard/restore, and only when the service or radio state actually has to be changed. EOF } # ------------------------------------------------- # MAIN # ------------------------------------------------- parse_args() { while [[ $# -gt 0 ]]; do case "$1" in -d | --device) DEVICES+=("$2") shift 2 ;; -t | --timed) TIMED_MINUTES="$2" shift 2 ;; --hard) HARD_MODE=1 shift ;; -h | --help | help) COMMAND="help" shift ;; start | stop | status | scan | list | add | remove | auto | restore) if [[ -z "$COMMAND" || "$COMMAND" == "help" ]]; then COMMAND="$1" else CMD_ARGS+=("$1") fi shift ;; *) if [[ -z "$COMMAND" ]]; then echo "Unknown option or command: $1" >&2 usage >&2 exit 1 fi CMD_ARGS+=("$1") shift ;; esac done } parse_args "$@" if [[ -z "$COMMAND" ]]; then usage >&2 exit 1 fi if [[ "$COMMAND" == "help" ]]; then usage exit 0 fi if ! [[ "$TIMED_MINUTES" =~ ^[0-9]+$ ]]; then echo "ERROR: --timed expects a number of minutes." >&2 exit 1 fi case "$COMMAND" in start | stop | status) DEVICES+=("${CMD_ARGS[@]}") ;; esac config_load case "$COMMAND" in start) cmd_start ;; stop) cmd_stop ;; restore) cmd_restore ;; status) cmd_status ;; scan) cmd_scan ;; list) cmd_list ;; add) cmd_add ;; remove) cmd_remove ;; auto) cmd_auto ;; esac