-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtransports_bluetooth.py
More file actions
259 lines (228 loc) · 10.1 KB
/
Copy pathtransports_bluetooth.py
File metadata and controls
259 lines (228 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
#!/usr/bin/env python3
"""Vinyl AirPlay: Bluetooth device discovery/pairing/connection via bluetoothctl.
Extracted from main.py. The AppState is injected at construction so this module
does not import main (avoids a circular import); it only reads settings and
writes the discovered-device cache through that handle.
"""
import asyncio
import subprocess
from typing import ClassVar
class BluetoothManager:
"""Manage Bluetooth device discovery, pairing, and connection via bluetoothctl."""
def __init__(self, state):
self._scanning = False
self._state = state
@staticmethod
def _run_ctl(*args, timeout=10) -> str:
"""Run a bluetoothctl command and return stdout."""
try:
result = subprocess.run(
['bluetoothctl', *list(args)],
capture_output=True, text=True, timeout=timeout
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
return ""
except Exception as e:
print(f"[bluetooth] bluetoothctl error: {e}")
return ""
@staticmethod
def _parse_device_line(line: str) -> tuple[str, str]:
"""Parse 'Device XX:XX:XX:XX:XX:XX Name' → (address, name)."""
parts = line.strip().split(" ", 2)
if len(parts) >= 3 and parts[0] == "Device":
return parts[1], parts[2]
elif len(parts) == 2 and parts[0] == "Device":
return parts[1], parts[1] # no name, use address
return "", ""
# A2DP-related Bluetooth UUIDs that indicate audio capability
AUDIO_UUIDS: ClassVar[set[str]] = {
"0000110a", # Audio Source
"0000110b", # Audio Sink
"0000110c", # A/V Remote Control Target
"0000110d", # Advanced Audio Distribution
"0000110e", # A/V Remote Control
}
AUDIO_ICONS: ClassVar[set[str]] = {"audio-card", "audio-headphones", "audio-headset", "audio-speakers"}
@staticmethod
def _parse_info(address: str) -> dict:
"""Get detailed info about a device from 'bluetoothctl info'."""
output = BluetoothManager._run_ctl("info", address)
if not output or "Missing device address" in output:
return None # ephemeral BLE device, no info available
info = {"address": address, "name": address, "paired": False,
"trusted": False, "connected": False, "icon": "", "uuids": []}
for line in output.splitlines():
line = line.strip()
if line.startswith(('Name:', 'Alias:')):
info["name"] = line.split(":", 1)[1].strip()
elif line.startswith("Paired:"):
info["paired"] = "yes" in line.lower()
elif line.startswith("Trusted:"):
info["trusted"] = "yes" in line.lower()
elif line.startswith("Connected:"):
info["connected"] = "yes" in line.lower()
elif line.startswith("Icon:"):
info["icon"] = line.split(":", 1)[1].strip()
elif (
line.startswith("UUID:") and "(" in line and ")" in line
):
# Extract UUID hex from parenthesized value
uuid = line.split("(")[1].split(")")[0].strip()
info["uuids"].append(uuid)
return info
async def scan(self, timeout=12) -> list[dict]:
"""Discover nearby Bluetooth devices. Returns list of device dicts."""
loop = asyncio.get_event_loop()
# Enable the adapter and make it discoverable
await loop.run_in_executor(None, lambda: self._run_ctl("power", "on"))
# Start scanning in background
await loop.run_in_executor(
None, lambda: self._run_ctl("--timeout", str(timeout), "scan", "on",
timeout=timeout + 5)
)
# Get all known devices (includes paired + newly discovered)
output = await loop.run_in_executor(None, lambda: self._run_ctl("devices"))
devices = []
custom_names = self._state.settings.get("device_names", {})
hidden = set(self._state.settings.get("hidden_devices", []))
for line in output.splitlines():
address, name = self._parse_device_line(line)
if not address:
continue
info = await loop.run_in_executor(
None, lambda addr=address: self._parse_info(addr)
)
# Skip ephemeral BLE devices with no info
if info is None:
continue
# Only include devices that look like audio devices:
# must have an audio icon OR at least one A2DP-related UUID
icon = info.get("icon", "")
uuids = {u[:8] for u in info.get("uuids", [])}
has_audio_icon = icon in self.AUDIO_ICONS
has_audio_uuid = bool(uuids & self.AUDIO_UUIDS)
if not has_audio_icon and not has_audio_uuid:
continue
dev_id = f"bt:{address}"
devices.append({
"id": dev_id,
"name": info.get("name", name),
"custom_name": custom_names.get(dev_id),
"address": address,
"paired": info.get("paired", False),
"trusted": info.get("trusted", False),
"connected": info.get("connected", False),
"hidden": dev_id in hidden,
"needs_pairing": not info.get("paired", False),
"type": "bluetooth",
})
self._state.available_bt_devices = devices
return devices
async def pair(self, address: str) -> dict:
"""Pair and trust a Bluetooth device."""
loop = asyncio.get_event_loop()
# Pair
output = await loop.run_in_executor(
None, lambda: self._run_ctl("pair", address, timeout=30)
)
if "Failed" in output and "AlreadyExists" not in output:
return {"ok": False, "error": f"Pairing failed: {output}"}
# Trust (auto-reconnect)
await loop.run_in_executor(None, lambda: self._run_ctl("trust", address))
return {"ok": True, "message": "Paired and trusted"}
async def connect(self, address: str) -> dict:
"""Connect to a paired Bluetooth device."""
loop = asyncio.get_event_loop()
output = await loop.run_in_executor(
None, lambda: self._run_ctl("connect", address, timeout=15)
)
if "Failed" in output:
return {"ok": False, "error": f"Connection failed: {output}"}
# Verify connection
info = await loop.run_in_executor(
None, lambda: self._parse_info(address)
)
if info and info.get("connected"):
return {"ok": True, "message": f"Connected to {info.get('name', address)}"}
return {"ok": False, "error": "Connection attempt finished but device not connected"}
async def disconnect(self, address: str) -> dict:
"""Disconnect from a Bluetooth device."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, lambda: self._run_ctl("disconnect", address)
)
return {"ok": True, "message": "Disconnected"}
async def remove(self, address: str) -> dict:
"""Unpair and remove a Bluetooth device."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, lambda: self._run_ctl("remove", address)
)
return {"ok": True, "message": "Device removed"}
async def get_paired_devices(self) -> list[dict]:
"""Get list of paired Bluetooth devices with current status."""
loop = asyncio.get_event_loop()
output = await loop.run_in_executor(
None, lambda: self._run_ctl("devices", "Paired")
)
custom_names = self._state.settings.get("device_names", {})
hidden = set(self._state.settings.get("hidden_devices", []))
devices = []
for line in output.splitlines():
address, name = self._parse_device_line(line)
if not address:
continue
info = await loop.run_in_executor(
None, lambda addr=address: self._parse_info(addr)
)
if info is None:
continue
dev_id = f"bt:{address}"
devices.append({
"id": dev_id,
"name": info.get("name", name),
"custom_name": custom_names.get(dev_id),
"address": address,
"paired": True,
"trusted": info.get("trusted", False),
"connected": info.get("connected", False),
"hidden": dev_id in hidden,
"needs_pairing": False,
"type": "bluetooth",
})
return devices
@staticmethod
def get_bt_codec_info() -> dict:
"""Get active Bluetooth codec info from bluealsa-cli."""
try:
pcms = subprocess.run(
["bluealsa-cli", "list-pcms"],
capture_output=True, text=True, timeout=3
).stdout.strip()
if not pcms:
return {"codec": None, "pcms": []}
result = {"pcms": [], "codec": None}
for pcm_path in pcms.splitlines():
pcm_path = pcm_path.strip()
if not pcm_path:
continue
try:
info = subprocess.run(
["bluealsa-cli", "info", pcm_path],
capture_output=True, text=True, timeout=3
).stdout
codec = None
for line in info.splitlines():
if "Codec" in line:
codec = line.split(":", 1)[-1].strip()
break
result["pcms"].append({"path": pcm_path, "codec": codec})
if codec and not result["codec"]:
result["codec"] = codec
except Exception:
pass
return result
except Exception as e:
print(f"[bluetooth] Failed to get codec info: {e}")
return {"codec": None, "pcms": []}