Files
playwish/player/lautsprecher.py
T
99admandClaude Opus 5.5 3610de7bae Audiofy – Musikwünsche für Partys
Server (Website, Admin-Bereich, Player-API) und Player (raspotify +
Controller) mit Install-Skripten für Debian-Systeme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:16:39 +01:00

111 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Audiofy – 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("audiofy.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?"