"""Playwish – Bluetooth-Lautsprecher suchen, koppeln und verbinden (bluetoothctl, ohne sudo). Gekoppelt wird immer MIT Agent in derselben bluetoothctl-Sitzung – sonst speichert BlueZ den Schlüssel nicht dauerhaft ("Bonded: no"), und der Lautsprecher lässt sich nach dem Aus- und Einschalten nicht mehr verbinden ("Permission denied"). """ import logging import re import shutil import subprocess import time log = logging.getLogger("playwish.lautsprecher") MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$") SUCHDAUER_S = 15 MAX_GERAETE = 30 def verfuegbar() -> bool: return shutil.which("bluetoothctl") is not None def _ctl(*args: str, timeout: int = 20) -> str: try: return subprocess.run(["bluetoothctl", *args], capture_output=True, text=True, timeout=timeout).stdout except (OSError, subprocess.SubprocessError) as e: log.warning("bluetoothctl %s fehlgeschlagen: %s", args[0] if args else "", e) return "" def info(mac: str) -> dict: """Name und Zustand eines Geräts; "audio" = kann Musik abspielen (A2DP-Sink).""" text = _ctl("info", mac, timeout=10) name = re.search(r"^\s*Name:\s*(.+)$", text, re.M) ja = lambda feld: re.search(rf"^\s*{feld}:\s*yes", text, re.M) is not None # noqa: E731 return { "mac": mac, "name": (name.group(1).strip() if name else "")[:100], "paired": ja("Paired"), "bonded": ja("Bonded"), "connected": ja("Connected"), "audio": "Audio Sink" in text or re.search(r"^\s*Icon:\s*audio", text, re.M) is not None, } def oeffentlich(geraet: dict) -> dict: """Nur die Felder, die an den Server gemeldet werden.""" return {k: geraet[k] for k in ("mac", "name", "paired", "connected")} def suchen(dauer: int = SUCHDAUER_S) -> list[dict]: """Sucht Bluetooth-Geräte und liefert nur Audiogeräte (verbundene und gekoppelte zuerst).""" _ctl("--timeout", str(dauer), "scan", "on", timeout=dauer + 10) macs = re.findall(r"^Device\s+(([0-9A-F]{2}:){5}[0-9A-F]{2})", _ctl("devices"), re.M) geraete = [g for g in (info(m[0]) for m in macs) if g["audio"]] geraete.sort(key=lambda g: (not g["connected"], not g["paired"], g["name"].casefold())) return geraete[:MAX_GERAETE] def trennen(mac: str) -> None: _ctl("disconnect", mac, timeout=15) def verbinden(mac: str) -> bool: _ctl("--timeout", "15", "connect", mac, timeout=25) return info(mac)["connected"] def koppeln(mac: str) -> tuple[bool, str]: """Koppelt (falls nötig, mit Agent), vertraut und verbindet. Liefert (ok, Meldung).""" if not MAC_RE.match(mac): return False, "Ungültige Adresse" vorher = info(mac) if not vorher["bonded"]: # Eine interaktive Sitzung, damit der Agent während des Koppelns registriert bleibt befehle = [] if vorher["paired"]: befehle.append((f"remove {mac}", 2)) # halbe Kopplung ohne Schlüssel verwerfen befehle += [("agent NoInputNoOutput", 1), ("default-agent", 1), ("pairable on", 1), ("scan on", 8), ("scan off", 1), (f"pair {mac}", 12), (f"trust {mac}", 2), (f"connect {mac}", 10), ("quit", 1)] p = None try: p = subprocess.Popen(["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True) for befehl, warten in befehle: p.stdin.write(befehl + "\n") p.stdin.flush() time.sleep(warten) p.stdin.close() p.wait(timeout=10) except (OSError, subprocess.SubprocessError, BrokenPipeError) as e: return False, f"Kopplung fehlgeschlagen ({type(e).__name__})" finally: if p is not None and p.poll() is None: p.kill() else: _ctl("trust", mac) jetzt = info(mac) if not jetzt["connected"] and jetzt["bonded"]: verbinden(mac) jetzt = info(mac) if jetzt["connected"] and jetzt["bonded"]: return True, f"Verbunden mit {jetzt['name'] or mac}" if not jetzt["bonded"]: return False, "Kopplung fehlgeschlagen – ist der Lautsprecher im Kopplungsmodus?" return False, "Gekoppelt, aber keine Verbindung – Lautsprecher eingeschaltet und in Reichweite?"