#!/usr/bin/env python3 """QuickShell bluetooth manager backend. Talks to bluez via `bluetoothctl` (text/shell parsing — the tool has no JSON mode). State is read live from the adapter (`bluetoothctl show`) and per device (`bluetoothctl info `), so it reflects reality even after pairing from another app. The `qs-bt` wrapper (home-manager hyprland module) passes the absolute bluetoothctl path in $BLUECTL. Commands -------- status -> JSON adapter + devices {powered,name,alias,pairable,discoverable, discovering,devices:[{mac,name,icon,paired, trusted,connected,battery}]} power on|off -> toggle adapter power -> JSON ok scan -> discovery for SCAN_SECONDS, then stop; JSON list of {mac,name} found during the scan connect / disconnect pair -> pair (bounded; pairing prompts end it) trust / untrust remove -> unpair / forget All non-status commands return {"ok": bool, "error": "..."}. """ import json import os import re import subprocess import sys BLUECTL = os.environ.get("BLUECTL", "bluetoothctl") SCAN_SECONDS = 8 PAIR_TIMEOUT = 30 CONNECT_TIMEOUT = 12 WAIT_TIMEOUT = 10 def run(args, timeout=WAIT_TIMEOUT): try: p = subprocess.run( [BLUECTL] + args, capture_output=True, text=True, timeout=timeout, ) return p.returncode, p.stdout, p.stderr except subprocess.TimeoutExpired as exc: tail = (exc.stdout or exc.stderr or b"") if isinstance(tail, bytes): tail = tail.decode(errors="replace") return -1, tail, "command timed out" except OSError as exc: return -2, "", str(exc) def field(line, key): low = line.lstrip() return low.split(":", 1)[1].strip() if low.startswith(key + ":") else None def parse_adapter(out): """Adapter sections from `bluetoothctl show`; prefer the [default] one.""" sections = [] cur = None for line in out.splitlines(): if line.startswith("Controller "): bits = line.split() cur = {"mac": bits[1], "default": "[default]" in line} sections.append(cur) elif cur is not None: v = field(line, "Powered") if v: cur["powered"] = v == "yes" v = field(line, "Discovering") if v: cur["discovering"] = v == "yes" v = field(line, "Pairable") if v: cur["pairable"] = v == "yes" v = field(line, "Discoverable") if v: cur["discoverable"] = v == "yes" v = field(line, "Name") if v: cur["name"] = v v = field(line, "Alias") if v: cur["alias"] = v for s in sections: if s.get("default"): return s return sections[0] if sections else None def parse_device(mac): rc, out, _ = run(["info", mac], timeout=WAIT_TIMEOUT) dev = { "mac": mac, "name": "", "icon": "", "paired": False, "trusted": False, "connected": False, "battery": None, } if rc != 0: return dev for line in out.splitlines(): v = field(line, "Name") if v: dev["name"] = v v = field(line, "Icon") if v: dev["icon"] = v v = field(line, "Paired") if v: dev["paired"] = v == "yes" v = field(line, "Trusted") if v: dev["trusted"] = v == "yes" v = field(line, "Connected") if v: dev["connected"] = v == "yes" m = re.search(r"Battery Percentage:\s*0x[0-9A-Fa-f]+\s*\((\d+)\)", line) if m: dev["battery"] = int(m.group(1)) return dev def list_devices(): rc, out, _ = run(["devices"], timeout=WAIT_TIMEOUT) macs = [] if rc == 0: for line in out.splitlines(): bits = line.split() if len(bits) >= 2 and bits[0] == "Device": mac = bits[1] if mac not in macs: macs.append(mac) return [parse_device(m) for m in macs] def status(): rc, out, _ = run(["show"], timeout=WAIT_TIMEOUT) adapter = parse_adapter(out) if rc == 0 else None devs = list_devices() # connected first, then alphabetical — the stable order the popup renders. devs.sort(key=lambda d: (not d["connected"], (d["name"] or d["mac"]).lower())) return { "powered": bool(adapter and adapter.get("powered")), "name": adapter.get("name") if adapter else "", "alias": adapter.get("alias") if adapter else "", "pairable": bool(adapter and adapter.get("pairable")), "discoverable": bool(adapter and adapter.get("discoverable")), "discovering": bool(adapter and adapter.get("discovering")), "devices": devs, } def scan(): """Discovery for SCAN_SECONDS; bluetoothctl parses NEW/CHG device lines.""" rc, out, _ = run( ["--timeout", str(SCAN_SECONDS), "scan", "on"], timeout=SCAN_SECONDS + 8, ) found = {} for line in out.splitlines(): if line.startswith("[NEW] Device"): mac, _, name = line.split()[2:5] found[mac] = name elif line.startswith("[CHG] Device"): bits = line.split() if len(bits) >= 5 and bits[3] == "Name:": found[bits[2]] = line.split("Name:", 1)[1].strip() return [{"mac": m, "name": n} for m, n in sorted(found.items())] def action(args): rc, out, err = run(args, timeout=PAIR_TIMEOUT if args and args[0] == "pair" else CONNECT_TIMEOUT) if rc == 0: return {"ok": True, "error": ""} # bluetoothctl prints failures like "Failed to connect: ..." to stdout. detail = "" for line in (out or "").splitlines(): if "Failed" in line or "not" in line.lower() or "error" in line.lower(): detail = line.strip() break return {"ok": False, "error": detail or (err or "command failed")} def main(): args = sys.argv[1:] cmd = args[0] if args else "status" if cmd == "status": print(json.dumps(status())) elif cmd == "power": val = args[1] if len(args) > 1 else "" print(json.dumps(action(["power", val]) if val in ("on", "off") else {"ok": False, "error": "power on|off"})) elif cmd == "scan": print(json.dumps(scan())) elif cmd in ("pair", "connect", "disconnect", "trust", "untrust", "remove"): mac = args[1] if len(args) > 1 else "" if not mac: print(json.dumps({"ok": False, "error": "missing device address"})) else: print(json.dumps(action([cmd, mac]))) else: print(json.dumps({"ok": False, "error": "unknown command: " + cmd})) if __name__ == "__main__": main()