diff --git a/.config/btswitch/devices.conf b/.config/btswitch/devices.conf new file mode 100644 index 0000000..b607870 --- /dev/null +++ b/.config/btswitch/devices.conf @@ -0,0 +1,13 @@ +# btswitch device configuration +# Format: dev NAME MAC (a known device) +# auto NAME (member of the auto-connect list) +# Prefer managing this via: btswitch add/remove/auto +dev EM03 F1:5A:D5:16:57:9D +dev TAH8506 00:1E:7C:CA:77:00 +dev XTREME 41:42:5D:FA:26:0A +dev X5 47:0D:FF:C9:62:E1 +dev SHB7150 00:1E:7C:30:F3:69 +dev HD450BT 00:16:94:33:91:51 +dev JLABGS 9F:DA:08:A9:EB:1E +auto TAH8506 +auto SHB7150 diff --git a/.config/nvim/lazy-lock.json b/.config/nvim/lazy-lock.json index 9086769..93c9d87 100644 --- a/.config/nvim/lazy-lock.json +++ b/.config/nvim/lazy-lock.json @@ -1,42 +1,42 @@ { "LazyVim": { "branch": "main", "commit": "c10948c50b18fae7f256433afdef09e432410480" }, - "avante.nvim": { "branch": "main", "commit": "b57b7d8e601ec6c7675c67de40cc7f5f0c0d40d0" }, + "avante.nvim": { "branch": "main", "commit": "98bfe1c2a41d645e8d1cbc882aa1067e5f1b6cb0" }, "blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" }, "bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" }, - "catppuccin": { "branch": "main", "commit": "79e2049a0fdf7ec840f8463fe1d962c94493196b" }, + "catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" }, "clangd-container.nvim": { "branch": "master", "commit": "8bfb4f202ea64710ce46d8790ca95c9ee89b6ce6" }, "clangd_extensions.nvim": { "branch": "main", "commit": "78c2ecd659d54972be17aa6ba2deac3c53223b80" }, - "conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" }, + "conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" }, "diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" }, "dotenv.nvim": { "branch": "main", "commit": "7d516e9293c6e3ac21830fb10a4e8674c02747c6" }, "dressing.nvim": { "branch": "master", "commit": "2d7c2db2507fa3c4956142ee607431ddb2828639" }, - "flash.nvim": { "branch": "main", "commit": "b6346946d10d07998efee029fb0f7a593806d0cd" }, + "flash.nvim": { "branch": "main", "commit": "5f0f270fdc7c5b0c21d903ee85b9cb06f2ac636a" }, "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" }, - "gitsigns.nvim": { "branch": "main", "commit": "31d6fb2d618bca1482b9f274751ead5f03461408" }, - "grug-far.nvim": { "branch": "main", "commit": "0f6a5d0d7a70659b11bbcd6b3673ee0fe61b6024" }, + "gitsigns.nvim": { "branch": "main", "commit": "5be654f2232c10ddcad19c1607a67b6b4b78fc29" }, + "grug-far.nvim": { "branch": "main", "commit": "11595bf747edc270bce2069d1020502ad4ae56cf" }, "hybrid-theme": { "branch": "main", "commit": "626ae5426a7c8f21dfccce286c690d9cf4195b46" }, "indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" }, "just-runner.nvim": { "branch": "main", "commit": "f29d405aa828900df242600720a2b0e57261489f" }, "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, "lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" }, "lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" }, - "mason-lspconfig.nvim": { "branch": "main", "commit": "7adc933dabcc7c86ae6b07aff7ee68eac398491f" }, + "mason-lspconfig.nvim": { "branch": "main", "commit": "9d28935aa95fa6c1dc7c4d6747f101a72a8a4bec" }, "mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" }, "mini.ai": { "branch": "main", "commit": "25248c6aa002391936a6200f12d1466015987133" }, - "mini.files": { "branch": "main", "commit": "fe78cd12e5501a7a56413096c20167568ec2ac8e" }, + "mini.files": { "branch": "main", "commit": "a249438d15fa6f64085ba83a9d1794d726aaf5ad" }, "mini.icons": { "branch": "main", "commit": "98faae31e9be1cc054ae63485e58ceb185efcad0" }, "mini.pairs": { "branch": "main", "commit": "b1c5a726921b7a8c9321e9a7a208aa0571de5810" }, "noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" }, - "nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" }, + "nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" }, "nvim-dev-container": { "branch": "main", "commit": "365b4c815339d42133829006dce3e5f8b182b771" }, - "nvim-lint": { "branch": "master", "commit": "a219b2c9e5b4765e5c845aba119dad55806fcaf1" }, - "nvim-lspconfig": { "branch": "master", "commit": "7ab79bb1b57160a537f427ac8a6026102f12b701" }, - "nvim-treesitter": { "branch": "main", "commit": "61df84986b4b4ec469ee745a182e433d49f8c27e" }, + "nvim-lint": { "branch": "master", "commit": "0370fe965697aa8f04252a1562441bdc13723cd1" }, + "nvim-lspconfig": { "branch": "master", "commit": "221c43884319e791519f0d6c94a7f2fbcd653278" }, + "nvim-treesitter": { "branch": "main", "commit": "8b98b4470eb326f1c7b50dae79f8c963568e5720" }, "nvim-treesitter-textobjects": { "branch": "main", "commit": "898ee307df58f854d11cd7edd06472574d48014e" }, "nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" }, "persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" }, "plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" }, - "render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" }, + "render-markdown.nvim": { "branch": "main", "commit": "4663eb3ecd538bd5062628fb6d95bbe6bdca78f6" }, "snacks-terminal-ctl.nvim": { "branch": "master", "commit": "97727421c8d0ffa33d893f22e38cda93e9010742" }, "snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }, "todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" }, diff --git a/.local/bin/btswitch b/.local/bin/btswitch index 6e9bc1a..b1a61ec 100755 --- a/.local/bin/btswitch +++ b/.local/bin/btswitch @@ -7,22 +7,37 @@ set -u WIFI_5G="cintra-5g" WIFI_24G="cintra-5g" -declare -A BT_DEVICES -BT_DEVICES[TAH8506]="00:1E:7C:CA:77:00" -BT_DEVICES[SHB7150]="00:1E:7C:30:F3:69" -BT_DEVICES[HD450BT]="00:16:94:33:91:51" -BT_DEVICES[XTREME]="41:42:5D:FA:26:0A" # BT Speaker -BT_DEVICES[EM03]="F1:5A:D5:16:57:9D" # BT Speaker/powerbank -BT_DEVICES[X5]="47:0D:FF:C9:62:E1" # Bone Headphones thinkplus-X5 - -BT_AUTO_DEVICES=("TAH8506" "SHB7150") +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="$HOME/.config/btswitch/.sudoers_rule_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 @@ -32,26 +47,37 @@ 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() { - # Notice the VERSION=2. This ensures it updates your previous rule to include rfkill. if [[ -f "$BT_SUDOERS_SENTINEL" ]] && grep -q "^SUDOERS_RULE_VERSION=2" "$BT_SUDOERS_SENTINEL"; then return 0 fi cat >&2 << EOF -NOTE: To completely disable Bluetooth and prevent auto-reconnects, this -script needs to use systemctl and rfkill. You will be prompted for your -password ONCE to update your passwordless sudoers rule: +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 updated sudoers rule now? [y/N] " answer + read -r -p "Install the sudoers rule now? [y/N] " answer case "$answer" in y | Y | yes | YES) ;; *) - echo "Skipping. The script cannot control bluetooth completely without it." >&2 + echo "Skipping. 'stop --hard' and 'restore' will not work without it." >&2 return 1 ;; esac @@ -84,12 +110,12 @@ SUDOERS systemctl_bt() { ensure_sudoers_rule || { - echo "ERROR: Cannot control bluetooth.service without the sudoers rule." >&2 + 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 - exit 1 + return 1 fi } @@ -98,8 +124,20 @@ rfkill_bt() { 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() { - bluetoothctl info "$1" 2> /dev/null + bt_service_active || return 1 + timeout 5 bluetoothctl info "$1" 2> /dev/null } bt_is_paired() { @@ -127,30 +165,153 @@ bt_resolve_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 +} + # ------------------------------------------------- -# BLUETOOTH CONTROL +# DEVICE CONFIG FILE # ------------------------------------------------- -start_bluetooth() { - echo "Unblocking hardware radio..." - rfkill_bt unblock +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 +} - echo "Starting Bluetooth service..." - systemctl_bt unmask - systemctl_bt start +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" +} - echo "Waiting for bluetoothd to become ready..." - for i in {1..10}; do - if bluetoothctl show | grep -q "Powered: yes"; then - bluetoothctl pairable on > /dev/null 2>&1 - return 0 - fi - bluetoothctl power on > /dev/null 2>&1 - sleep 1 - done +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; } +} - echo "ERROR: Bluetooth adapter not found or could not be powered on." - 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() { @@ -158,7 +319,7 @@ bt_pair_device() { if bt_is_paired "$mac"; then return 0 fi - echo "✖ Device $mac is not paired. Please pair it manually first." + 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 } @@ -167,11 +328,14 @@ bt_ensure_connected() { echo "----------------------------------------" echo "Checking device: $mac" + timeout 5 bluetoothctl unblock "$mac" > /dev/null 2>&1 + if ! bt_is_trusted "$mac"; then echo "→ Trusting $mac" - bluetoothctl trust "$mac" > /dev/null + 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" @@ -183,7 +347,7 @@ bt_ensure_connected() { bt_pair_device "$mac" || return 1 fi - bluetoothctl connect "$mac" > /dev/null 2>&1 + timeout $((BT_DELAY * 4)) bluetoothctl connect "$mac" > /dev/null 2>&1 sleep "$BT_DELAY" if bt_is_connected "$mac"; then @@ -196,57 +360,357 @@ bt_ensure_connected() { 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 "Disconnecting $mac..." - bluetoothctl disconnect "$mac" > /dev/null 2>&1 + 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 devices=("${!1}") - for dev in "${devices[@]}"; do - local mac + local dev mac + while read -r dev; do + [[ -n "$dev" ]] || continue mac=$(bt_resolve_device "$dev") - [[ -n "$mac" ]] && bt_safe_disconnect "$mac" + 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 } -bt_start_all() { - local devices=("${!1}") - for dev in "${devices[@]}"; do - local mac - mac=$(bt_resolve_device "$dev") - [[ -n "$mac" ]] && bt_ensure_connected "$mac" +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" } -bt_status_all() { - local devices=("${!1}") - for dev in "${devices[@]}"; do - local mac - mac=$(bt_resolve_device "$dev") - if [[ -n "$mac" ]]; then - echo "--- $dev ($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 - echo "Status: CONNECTED" + status="connected" elif bt_is_paired "$mac"; then - echo "Status: Paired (Offline)" + status="paired (offline)" else - echo "Status: Not Paired / Unknown" + 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 # ------------------------------------------------- -DEVICES=() -TIMED_MINUTES=0 - parse_args() { while [[ $# -gt 0 ]]; do case "$1" in @@ -258,13 +722,30 @@ parse_args() { TIMED_MINUTES="$2" shift 2 ;; - start | stop | status | scan | list) - COMMAND="$1" + --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 ;; *) - echo "Unknown option: $1" - exit 1 + if [[ -z "$COMMAND" ]]; then + echo "Unknown option or command: $1" >&2 + usage >&2 + exit 1 + fi + CMD_ARGS+=("$1") + shift ;; esac done @@ -272,77 +753,35 @@ parse_args() { parse_args "$@" -if [[ -z "${COMMAND:-}" ]]; then - echo "Usage: $0 [-d|--device DEVICE] [-t|--timed MINUTES] {start|stop|status|scan|list}" +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) - if wifi_switch_needed "$WIFI_5G" "$WIFI_24G"; then - nmcli connection up "$WIFI_5G" - fi - - start_bluetooth || exit 1 - - if [[ ${#DEVICES[@]} -gt 0 ]]; then - bt_start_all DEVICES[@] - else - bt_start_all BT_AUTO_DEVICES[@] - fi - - if [[ "$TIMED_MINUTES" -gt 0 ]]; then - echo "Will disconnect after $TIMED_MINUTES minute(s)..." - sleep $((TIMED_MINUTES * 60)) - echo "Time elapsed. Disconnecting..." - - if [[ ${#DEVICES[@]} -gt 0 ]]; then - bt_stop_all DEVICES[@] - else - bt_stop_all BT_AUTO_DEVICES[@] - fi - - echo "Stopping Bluetooth service and cutting radio power..." - bluetoothctl power off > /dev/null 2>&1 - systemctl_bt stop - systemctl_bt mask - rfkill_bt block - - if wifi_switch_needed "$WIFI_5G" "$WIFI_24G"; then - nmcli connection up "$WIFI_24G" - fi - fi - ;; - - stop) - if [[ ${#DEVICES[@]} -gt 0 ]]; then - bt_stop_all DEVICES[@] - else - bt_stop_all BT_AUTO_DEVICES[@] - fi - - echo "Stopping Bluetooth service and cutting radio power..." - bluetoothctl power off > /dev/null 2>&1 - systemctl_bt stop - systemctl_bt mask - rfkill_bt block - - if wifi_switch_needed "$WIFI_5G" "$WIFI_24G"; then - nmcli connection up "$WIFI_24G" - fi + start | stop | status) + DEVICES+=("${CMD_ARGS[@]}") ;; +esac - status) - bluetoothctl show | grep -E "Name|Powered|Discoverable" - if [[ ${#DEVICES[@]} -gt 0 ]]; then - bt_status_all DEVICES[@] - else - bt_status_all BT_AUTO_DEVICES[@] - fi - ;; +config_load - scan | list) - # Keeping it brief in output, your previous implementations for scan/list stay intact here - echo "Command $COMMAND executed." - ;; +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