@agentunion/kite 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +127 -25
- package/core/event_hub/entry.py +384 -61
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/module.md +0 -1
- package/core/event_hub/server.py +169 -38
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +1306 -425
- package/core/launcher/module_scanner.py +10 -9
- package/core/launcher/process_manager.py +555 -121
- package/core/registry/entry.py +335 -30
- package/core/registry/server.py +339 -256
- package/core/registry/store.py +13 -2
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/{core → extensions}/event_hub_bench/entry.py +664 -371
- package/{core → extensions}/event_hub_bench/module.md +4 -2
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -143
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +344 -4
- package/package.json +11 -2
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
- package/core/data_dir.py +0 -62
- package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
- package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
- package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
- package/core/launcher/data/log/lifecycle.jsonl +0 -1158
- package/core/launcher/data/token.txt +0 -1
- package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
- package/core/registry/data/port.txt +0 -1
- package/core/registry/data/port_484.txt +0 -1
- package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
- /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vendored dependencies from AI Phone Agent project."""
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Audio I/O pipeline over Bluetooth HFP via PulseAudio / PipeWire.
|
|
2
|
+
|
|
3
|
+
Uses ``pasimple`` (PulseAudio simple API binding) for low-latency capture
|
|
4
|
+
and playback on the HFP audio source / sink.
|
|
5
|
+
Falls back to a mock implementation when pasimple is not available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import struct
|
|
13
|
+
import time
|
|
14
|
+
import wave
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from .. import config as cfg
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("bluetooth.audio")
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Audio format constants (must match config.yaml defaults)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
SAMPLE_RATE = 16_000
|
|
26
|
+
CHANNELS = 1
|
|
27
|
+
SAMPLE_WIDTH = 2 # S16LE = 2 bytes per sample
|
|
28
|
+
CHUNK_DURATION_MS = 20 # 20 ms chunks
|
|
29
|
+
CHUNK_SAMPLES = SAMPLE_RATE * CHUNK_DURATION_MS // 1000 # 320 samples
|
|
30
|
+
CHUNK_BYTES = CHUNK_SAMPLES * CHANNELS * SAMPLE_WIDTH # 640 bytes
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Conditional pasimple import
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_PASIMPLE_AVAILABLE = False
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
import pasimple
|
|
40
|
+
|
|
41
|
+
_PASIMPLE_AVAILABLE = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
logger.warning(
|
|
44
|
+
"pasimple not available — using mock AudioPipeline for development"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# PulseAudio format constants (mirror pa_sample_format)
|
|
48
|
+
_PA_SAMPLE_S16LE = 3 # pasimple.PA_SAMPLE_S16LE when available
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AudioPipeline:
|
|
52
|
+
"""Capture and play audio over Bluetooth HFP sink/source."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._recording: bool = False
|
|
56
|
+
self._playing: bool = False
|
|
57
|
+
self._record_task: asyncio.Task | None = None
|
|
58
|
+
self._play_task: asyncio.Task | None = None
|
|
59
|
+
|
|
60
|
+
# PulseAudio simple streams
|
|
61
|
+
self._pa_record: Any | None = None
|
|
62
|
+
self._pa_play: Any | None = None
|
|
63
|
+
|
|
64
|
+
# WAV recording
|
|
65
|
+
self._wav_writer: wave.Wave_write | None = None
|
|
66
|
+
self._wav_path: Path | None = None
|
|
67
|
+
|
|
68
|
+
# -- Properties ----------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_playing(self) -> bool:
|
|
72
|
+
return self._playing
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_recording(self) -> bool:
|
|
76
|
+
return self._recording
|
|
77
|
+
|
|
78
|
+
# -- Recording (capture from HFP source) ---------------------------------
|
|
79
|
+
|
|
80
|
+
async def start_recording(
|
|
81
|
+
self, callback: Callable[[bytes], Any]
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Start capturing audio from the Bluetooth HFP source.
|
|
84
|
+
|
|
85
|
+
*callback* is called with each chunk of raw PCM bytes (S16LE, 16 kHz,
|
|
86
|
+
mono). The callback may be a regular function or a coroutine.
|
|
87
|
+
"""
|
|
88
|
+
if self._recording:
|
|
89
|
+
logger.warning("Recording already in progress")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
self._recording = True
|
|
93
|
+
|
|
94
|
+
if _PASIMPLE_AVAILABLE:
|
|
95
|
+
self._record_task = asyncio.create_task(
|
|
96
|
+
self._pa_record_loop(callback)
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
self._record_task = asyncio.create_task(
|
|
100
|
+
self._mock_record_loop(callback)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
logger.info("Audio recording started")
|
|
104
|
+
|
|
105
|
+
async def stop_recording(self) -> None:
|
|
106
|
+
"""Stop the current audio capture."""
|
|
107
|
+
if not self._recording:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
self._recording = False
|
|
111
|
+
if self._record_task and not self._record_task.done():
|
|
112
|
+
self._record_task.cancel()
|
|
113
|
+
try:
|
|
114
|
+
await self._record_task
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Close PulseAudio record stream
|
|
119
|
+
if self._pa_record is not None:
|
|
120
|
+
try:
|
|
121
|
+
self._pa_record.close()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
self._pa_record = None
|
|
125
|
+
|
|
126
|
+
# Close any open WAV file
|
|
127
|
+
self.stop_wav_recording()
|
|
128
|
+
|
|
129
|
+
logger.info("Audio recording stopped")
|
|
130
|
+
|
|
131
|
+
# -- Playback (play to HFP sink) ----------------------------------------
|
|
132
|
+
|
|
133
|
+
async def play_audio(self, pcm_data: bytes) -> None:
|
|
134
|
+
"""Play raw PCM data (S16LE, 16 kHz, mono) to the Bluetooth HFP sink.
|
|
135
|
+
|
|
136
|
+
Blocks (asynchronously) until all data is written.
|
|
137
|
+
"""
|
|
138
|
+
if self._playing:
|
|
139
|
+
logger.warning("Playback already in progress — stopping first")
|
|
140
|
+
await self.stop_playback()
|
|
141
|
+
|
|
142
|
+
self._playing = True
|
|
143
|
+
|
|
144
|
+
if _PASIMPLE_AVAILABLE:
|
|
145
|
+
self._play_task = asyncio.create_task(
|
|
146
|
+
self._pa_play_data(pcm_data)
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
self._play_task = asyncio.create_task(
|
|
150
|
+
self._mock_play_data(pcm_data)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
await self._play_task
|
|
154
|
+
self._playing = False
|
|
155
|
+
|
|
156
|
+
async def stop_playback(self) -> None:
|
|
157
|
+
"""Stop any in-progress playback."""
|
|
158
|
+
if not self._playing:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
self._playing = False
|
|
162
|
+
if self._play_task and not self._play_task.done():
|
|
163
|
+
self._play_task.cancel()
|
|
164
|
+
try:
|
|
165
|
+
await self._play_task
|
|
166
|
+
except asyncio.CancelledError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
# Close PulseAudio play stream
|
|
170
|
+
if self._pa_play is not None:
|
|
171
|
+
try:
|
|
172
|
+
self._pa_play.close()
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
self._pa_play = None
|
|
176
|
+
|
|
177
|
+
logger.info("Audio playback stopped")
|
|
178
|
+
|
|
179
|
+
# -- WAV recording -------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def start_wav_recording(self, filepath: str | Path) -> None:
|
|
182
|
+
"""Begin writing captured audio to a WAV file.
|
|
183
|
+
|
|
184
|
+
Must be called while recording is active — the captured audio chunks
|
|
185
|
+
will be written to the WAV alongside being sent to the callback.
|
|
186
|
+
"""
|
|
187
|
+
filepath = Path(filepath)
|
|
188
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
self._wav_path = filepath
|
|
191
|
+
self._wav_writer = wave.open(str(filepath), "wb")
|
|
192
|
+
self._wav_writer.setnchannels(CHANNELS)
|
|
193
|
+
self._wav_writer.setsampwidth(SAMPLE_WIDTH)
|
|
194
|
+
self._wav_writer.setframerate(SAMPLE_RATE)
|
|
195
|
+
logger.info("WAV recording started: %s", filepath)
|
|
196
|
+
|
|
197
|
+
def stop_wav_recording(self) -> Path | None:
|
|
198
|
+
"""Close the WAV file and return the path.
|
|
199
|
+
|
|
200
|
+
Returns None if no WAV recording was in progress.
|
|
201
|
+
"""
|
|
202
|
+
if self._wav_writer is None:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self._wav_writer.close()
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.error("Error closing WAV file: %s", exc)
|
|
209
|
+
|
|
210
|
+
path = self._wav_path
|
|
211
|
+
self._wav_writer = None
|
|
212
|
+
self._wav_path = None
|
|
213
|
+
logger.info("WAV recording stopped: %s", path)
|
|
214
|
+
return path
|
|
215
|
+
|
|
216
|
+
def _write_wav_chunk(self, data: bytes) -> None:
|
|
217
|
+
"""Write a PCM chunk to the WAV file if recording is active."""
|
|
218
|
+
if self._wav_writer is not None:
|
|
219
|
+
try:
|
|
220
|
+
self._wav_writer.writeframes(data)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logger.error("WAV write error: %s", exc)
|
|
223
|
+
|
|
224
|
+
# -- PulseAudio record loop ----------------------------------------------
|
|
225
|
+
|
|
226
|
+
async def _pa_record_loop(
|
|
227
|
+
self, callback: Callable[[bytes], Any]
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Capture audio from PulseAudio/PipeWire HFP source."""
|
|
230
|
+
loop = asyncio.get_running_loop()
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
self._pa_record = pasimple.PaSimple(
|
|
234
|
+
pasimple.PA_STREAM_RECORD,
|
|
235
|
+
_PA_SAMPLE_S16LE,
|
|
236
|
+
CHANNELS,
|
|
237
|
+
SAMPLE_RATE,
|
|
238
|
+
app_name="ai-phone-agent",
|
|
239
|
+
stream_name="hfp-capture",
|
|
240
|
+
)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
logger.error("Failed to open PulseAudio record stream: %s", exc)
|
|
243
|
+
self._recording = False
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
while self._recording:
|
|
248
|
+
# Read is blocking — run in thread pool
|
|
249
|
+
data: bytes = await loop.run_in_executor(
|
|
250
|
+
None, self._pa_record.read, CHUNK_BYTES
|
|
251
|
+
)
|
|
252
|
+
if not data:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
self._write_wav_chunk(data)
|
|
256
|
+
|
|
257
|
+
if asyncio.iscoroutinefunction(callback):
|
|
258
|
+
await callback(data)
|
|
259
|
+
else:
|
|
260
|
+
callback(data)
|
|
261
|
+
except asyncio.CancelledError:
|
|
262
|
+
pass
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
logger.error("Record loop error: %s", exc)
|
|
265
|
+
finally:
|
|
266
|
+
self._recording = False
|
|
267
|
+
|
|
268
|
+
# -- PulseAudio playback -------------------------------------------------
|
|
269
|
+
|
|
270
|
+
async def _pa_play_data(self, pcm_data: bytes) -> None:
|
|
271
|
+
"""Play PCM data via PulseAudio/PipeWire HFP sink."""
|
|
272
|
+
loop = asyncio.get_running_loop()
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
self._pa_play = pasimple.PaSimple(
|
|
276
|
+
pasimple.PA_STREAM_PLAYBACK,
|
|
277
|
+
_PA_SAMPLE_S16LE,
|
|
278
|
+
CHANNELS,
|
|
279
|
+
SAMPLE_RATE,
|
|
280
|
+
app_name="ai-phone-agent",
|
|
281
|
+
stream_name="hfp-playback",
|
|
282
|
+
)
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
logger.error("Failed to open PulseAudio playback stream: %s", exc)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
offset = 0
|
|
289
|
+
while offset < len(pcm_data) and self._playing:
|
|
290
|
+
chunk = pcm_data[offset : offset + CHUNK_BYTES]
|
|
291
|
+
await loop.run_in_executor(None, self._pa_play.write, chunk)
|
|
292
|
+
offset += CHUNK_BYTES
|
|
293
|
+
|
|
294
|
+
# Drain remaining buffered audio
|
|
295
|
+
if self._playing:
|
|
296
|
+
await loop.run_in_executor(None, self._pa_play.drain)
|
|
297
|
+
except asyncio.CancelledError:
|
|
298
|
+
pass
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
logger.error("Playback error: %s", exc)
|
|
301
|
+
finally:
|
|
302
|
+
if self._pa_play is not None:
|
|
303
|
+
try:
|
|
304
|
+
self._pa_play.close()
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
self._pa_play = None
|
|
308
|
+
|
|
309
|
+
# -- Mock helpers (development) ------------------------------------------
|
|
310
|
+
|
|
311
|
+
async def _mock_record_loop(
|
|
312
|
+
self, callback: Callable[[bytes], Any]
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Generate silent audio chunks as a stand-in for real capture."""
|
|
315
|
+
logger.info("[mock] Recording — generating silence at %d Hz", SAMPLE_RATE)
|
|
316
|
+
silence = b"\x00" * CHUNK_BYTES
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
while self._recording:
|
|
320
|
+
self._write_wav_chunk(silence)
|
|
321
|
+
|
|
322
|
+
if asyncio.iscoroutinefunction(callback):
|
|
323
|
+
await callback(silence)
|
|
324
|
+
else:
|
|
325
|
+
callback(silence)
|
|
326
|
+
|
|
327
|
+
# Pace to real-time
|
|
328
|
+
await asyncio.sleep(CHUNK_DURATION_MS / 1000.0)
|
|
329
|
+
except asyncio.CancelledError:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
async def _mock_play_data(self, pcm_data: bytes) -> None:
|
|
333
|
+
"""Simulate playback timing based on data length."""
|
|
334
|
+
duration = len(pcm_data) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
|
|
335
|
+
logger.info(
|
|
336
|
+
"[mock] Playing %.2f seconds of audio (%d bytes)",
|
|
337
|
+
duration, len(pcm_data),
|
|
338
|
+
)
|
|
339
|
+
try:
|
|
340
|
+
# Simulate real-time playback
|
|
341
|
+
remaining = duration
|
|
342
|
+
while remaining > 0 and self._playing:
|
|
343
|
+
step = min(remaining, 0.05)
|
|
344
|
+
await asyncio.sleep(step)
|
|
345
|
+
remaining -= step
|
|
346
|
+
except asyncio.CancelledError:
|
|
347
|
+
pass
|
|
348
|
+
logger.info("[mock] Playback finished")
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Phone Book Access Profile (PBAP) client via BlueZ / obexd D-Bus.
|
|
2
|
+
|
|
3
|
+
Synchronises contacts from a connected phone. Falls back to a mock
|
|
4
|
+
implementation when D-Bus is not available.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("bluetooth.contacts")
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Conditional D-Bus imports
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
_DBUS_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from dasbus.connection import SessionMessageBus
|
|
24
|
+
|
|
25
|
+
_DBUS_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
logger.warning(
|
|
28
|
+
"dasbus not available — using mock PBAPClient for development"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# obexd PBAP D-Bus constants
|
|
32
|
+
_OBEX_SERVICE = "org.bluez.obex"
|
|
33
|
+
_OBEX_CLIENT_PATH = "/org/bluez/obex"
|
|
34
|
+
_OBEX_CLIENT_IFACE = "org.bluez.obex.Client1"
|
|
35
|
+
_OBEX_PBAP_IFACE = "org.bluez.obex.PhonebookAccess1"
|
|
36
|
+
_OBEX_TRANSFER_IFACE = "org.bluez.obex.Transfer1"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_vcard(vcard_text: str) -> list[dict[str, Any]]:
|
|
40
|
+
"""Parse raw vCard text (v2.1 / v3.0) into a list of contact dicts.
|
|
41
|
+
|
|
42
|
+
Each dict contains: name, first_name, last_name, phone (list), email
|
|
43
|
+
(list), company, title.
|
|
44
|
+
"""
|
|
45
|
+
contacts: list[dict[str, Any]] = []
|
|
46
|
+
current: dict[str, Any] | None = None
|
|
47
|
+
|
|
48
|
+
for line in vcard_text.splitlines():
|
|
49
|
+
line = line.strip()
|
|
50
|
+
|
|
51
|
+
if line.upper() == "BEGIN:VCARD":
|
|
52
|
+
current = {
|
|
53
|
+
"name": "",
|
|
54
|
+
"first_name": "",
|
|
55
|
+
"last_name": "",
|
|
56
|
+
"phone": [],
|
|
57
|
+
"email": [],
|
|
58
|
+
"company": "",
|
|
59
|
+
"title": "",
|
|
60
|
+
}
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if line.upper() == "END:VCARD":
|
|
64
|
+
if current is not None:
|
|
65
|
+
# Build display name if not explicitly set
|
|
66
|
+
if not current["name"]:
|
|
67
|
+
parts = [current["last_name"], current["first_name"]]
|
|
68
|
+
current["name"] = " ".join(p for p in parts if p).strip()
|
|
69
|
+
contacts.append(current)
|
|
70
|
+
current = None
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if current is None:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Split property name from value (handle parameters like ;TYPE=CELL)
|
|
77
|
+
if ":" not in line:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
prop_part, _, value = line.partition(":")
|
|
81
|
+
prop_name = prop_part.split(";")[0].upper()
|
|
82
|
+
|
|
83
|
+
if prop_name == "FN":
|
|
84
|
+
current["name"] = value.strip()
|
|
85
|
+
|
|
86
|
+
elif prop_name == "N":
|
|
87
|
+
# N:LastName;FirstName;MiddleName;Prefix;Suffix
|
|
88
|
+
parts = value.split(";")
|
|
89
|
+
current["last_name"] = parts[0].strip() if len(parts) > 0 else ""
|
|
90
|
+
current["first_name"] = parts[1].strip() if len(parts) > 1 else ""
|
|
91
|
+
|
|
92
|
+
elif prop_name == "TEL":
|
|
93
|
+
phone = _normalise_phone(value.strip())
|
|
94
|
+
if phone and phone not in current["phone"]:
|
|
95
|
+
current["phone"].append(phone)
|
|
96
|
+
|
|
97
|
+
elif prop_name == "EMAIL":
|
|
98
|
+
email = value.strip()
|
|
99
|
+
if email and email not in current["email"]:
|
|
100
|
+
current["email"].append(email)
|
|
101
|
+
|
|
102
|
+
elif prop_name == "ORG":
|
|
103
|
+
current["company"] = value.split(";")[0].strip()
|
|
104
|
+
|
|
105
|
+
elif prop_name == "TITLE":
|
|
106
|
+
current["title"] = value.strip()
|
|
107
|
+
|
|
108
|
+
return contacts
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _normalise_phone(raw: str) -> str:
|
|
112
|
+
"""Strip common formatting from phone numbers, keep digits and leading +."""
|
|
113
|
+
cleaned = re.sub(r"[^\d+]", "", raw)
|
|
114
|
+
return cleaned
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PBAPClient:
|
|
118
|
+
"""Synchronise the phone book from a connected Bluetooth device."""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
self._bus: Any | None = None
|
|
122
|
+
self._session_path: str | None = None
|
|
123
|
+
|
|
124
|
+
# -- Public API ----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async def sync_contacts(self, device_address: str = "") -> list[dict[str, Any]]:
|
|
127
|
+
"""Download contacts from the phone and return parsed list.
|
|
128
|
+
|
|
129
|
+
Each contact is a dict with: name, first_name, last_name, phone,
|
|
130
|
+
email, company, title.
|
|
131
|
+
"""
|
|
132
|
+
vcard_data = await self.get_phonebook(device_address)
|
|
133
|
+
if not vcard_data:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
contacts = _parse_vcard(vcard_data)
|
|
137
|
+
logger.info("Synced %d contacts from phone", len(contacts))
|
|
138
|
+
return contacts
|
|
139
|
+
|
|
140
|
+
async def get_phonebook(self, device_address: str = "") -> str:
|
|
141
|
+
"""Retrieve the raw vCard data for the entire phonebook.
|
|
142
|
+
|
|
143
|
+
Returns the vCard text, or an empty string on failure.
|
|
144
|
+
"""
|
|
145
|
+
if not _DBUS_AVAILABLE:
|
|
146
|
+
return self._mock_get_phonebook()
|
|
147
|
+
|
|
148
|
+
return await self._obex_pull_phonebook(device_address)
|
|
149
|
+
|
|
150
|
+
# -- obexd D-Bus interaction ---------------------------------------------
|
|
151
|
+
|
|
152
|
+
async def _obex_pull_phonebook(self, device_address: str) -> str:
|
|
153
|
+
"""Pull phonebook via obexd PBAP over D-Bus."""
|
|
154
|
+
loop = asyncio.get_running_loop()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
if self._bus is None:
|
|
158
|
+
self._bus = SessionMessageBus()
|
|
159
|
+
|
|
160
|
+
client = self._bus.get_proxy(_OBEX_SERVICE, _OBEX_CLIENT_PATH)
|
|
161
|
+
|
|
162
|
+
# Create PBAP session
|
|
163
|
+
session_path = client.CreateSession(
|
|
164
|
+
device_address,
|
|
165
|
+
{"Target": "PBAP"},
|
|
166
|
+
)
|
|
167
|
+
self._session_path = str(session_path)
|
|
168
|
+
logger.info("PBAP session created: %s", self._session_path)
|
|
169
|
+
|
|
170
|
+
pbap = self._bus.get_proxy(_OBEX_SERVICE, self._session_path)
|
|
171
|
+
|
|
172
|
+
# Select internal phonebook
|
|
173
|
+
pbap.Select("int", "pb")
|
|
174
|
+
|
|
175
|
+
# Pull all contacts
|
|
176
|
+
transfer_path, properties = pbap.PullAll(
|
|
177
|
+
"",
|
|
178
|
+
{"Format": "vcard30"},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Wait for transfer to complete
|
|
182
|
+
transfer = self._bus.get_proxy(_OBEX_SERVICE, str(transfer_path))
|
|
183
|
+
filename = str(properties.get("Filename", ""))
|
|
184
|
+
|
|
185
|
+
# Poll transfer status
|
|
186
|
+
for _ in range(60): # up to ~30 seconds
|
|
187
|
+
status = str(transfer.Status)
|
|
188
|
+
if status == "complete":
|
|
189
|
+
break
|
|
190
|
+
if status == "error":
|
|
191
|
+
logger.error("PBAP transfer failed")
|
|
192
|
+
return ""
|
|
193
|
+
await asyncio.sleep(0.5)
|
|
194
|
+
|
|
195
|
+
# Read the downloaded vCard file
|
|
196
|
+
if filename:
|
|
197
|
+
with open(filename, "r", encoding="utf-8") as f:
|
|
198
|
+
return f.read()
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.error("PBAP phonebook pull failed: %s", exc)
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
finally:
|
|
206
|
+
# Clean up session
|
|
207
|
+
if self._session_path:
|
|
208
|
+
try:
|
|
209
|
+
client = self._bus.get_proxy(
|
|
210
|
+
_OBEX_SERVICE, _OBEX_CLIENT_PATH
|
|
211
|
+
)
|
|
212
|
+
client.RemoveSession(self._session_path)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
self._session_path = None
|
|
216
|
+
|
|
217
|
+
# -- Mock helpers --------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _mock_get_phonebook() -> str:
|
|
221
|
+
logger.info("[mock] Returning fake phonebook vCard data")
|
|
222
|
+
return (
|
|
223
|
+
"BEGIN:VCARD\r\n"
|
|
224
|
+
"VERSION:3.0\r\n"
|
|
225
|
+
"FN:Zhang San\r\n"
|
|
226
|
+
"N:Zhang;San;;;\r\n"
|
|
227
|
+
"TEL;TYPE=CELL:+86 138 0000 0001\r\n"
|
|
228
|
+
"EMAIL:zhangsan@example.com\r\n"
|
|
229
|
+
"ORG:Example Corp\r\n"
|
|
230
|
+
"TITLE:Engineer\r\n"
|
|
231
|
+
"END:VCARD\r\n"
|
|
232
|
+
"\r\n"
|
|
233
|
+
"BEGIN:VCARD\r\n"
|
|
234
|
+
"VERSION:3.0\r\n"
|
|
235
|
+
"FN:Li Si\r\n"
|
|
236
|
+
"N:Li;Si;;;\r\n"
|
|
237
|
+
"TEL;TYPE=CELL:+86 139 0000 0002\r\n"
|
|
238
|
+
"TEL;TYPE=WORK:+86 10 8888 0002\r\n"
|
|
239
|
+
"EMAIL:lisi@example.com\r\n"
|
|
240
|
+
"ORG:Another Inc\r\n"
|
|
241
|
+
"TITLE:Manager\r\n"
|
|
242
|
+
"END:VCARD\r\n"
|
|
243
|
+
"\r\n"
|
|
244
|
+
"BEGIN:VCARD\r\n"
|
|
245
|
+
"VERSION:3.0\r\n"
|
|
246
|
+
"FN:Wang Wu\r\n"
|
|
247
|
+
"N:Wang;Wu;;;\r\n"
|
|
248
|
+
"TEL;TYPE=CELL:+86 137 0000 0003\r\n"
|
|
249
|
+
"ORG:Tech Ltd\r\n"
|
|
250
|
+
"END:VCARD\r\n"
|
|
251
|
+
)
|