@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.
Files changed (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /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
+ )