@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,395 @@
|
|
|
1
|
+
"""Bluetooth device management via BlueZ 5 D-Bus interface.
|
|
2
|
+
|
|
3
|
+
Provides auto-connect, device discovery, pairing, and status monitoring.
|
|
4
|
+
Falls back to a mock implementation when D-Bus (dasbus) is not available
|
|
5
|
+
(e.g. development on Windows / macOS).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from .. import config as cfg
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("bluetooth.manager")
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Conditional D-Bus imports — mock when unavailable
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
_DBUS_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from dasbus.connection import SystemMessageBus
|
|
26
|
+
from dasbus.identifier import DBusServiceIdentifier
|
|
27
|
+
from dasbus.loop import EventLoop
|
|
28
|
+
from dasbus.typing import get_variant, Str
|
|
29
|
+
|
|
30
|
+
_DBUS_AVAILABLE = True
|
|
31
|
+
except ImportError:
|
|
32
|
+
logger.warning(
|
|
33
|
+
"dasbus not available — using mock BluetoothManager for development"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# BlueZ D-Bus constants
|
|
37
|
+
_BLUEZ_SERVICE = "org.bluez"
|
|
38
|
+
_ADAPTER_IFACE = "org.bluez.Adapter1"
|
|
39
|
+
_DEVICE_IFACE = "org.bluez.Device1"
|
|
40
|
+
_BATTERY_IFACE = "org.bluez.Battery1"
|
|
41
|
+
_OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
|
|
42
|
+
_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BluetoothManager:
|
|
46
|
+
"""Manage a Bluetooth connection to a phone via BlueZ 5 D-Bus."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
# Connection state
|
|
50
|
+
self._connected: bool = False
|
|
51
|
+
self._device_name: str = ""
|
|
52
|
+
self._device_address: str = ""
|
|
53
|
+
self._battery_level: int = -1
|
|
54
|
+
self._signal_strength: int = 0
|
|
55
|
+
self._operator_name: str = ""
|
|
56
|
+
self._in_call: bool = False
|
|
57
|
+
|
|
58
|
+
# Internal
|
|
59
|
+
self._bus: Any | None = None
|
|
60
|
+
self._adapter_proxy: Any | None = None
|
|
61
|
+
self._device_proxy: Any | None = None
|
|
62
|
+
self._auto_reconnect_task: asyncio.Task | None = None
|
|
63
|
+
self._running: bool = False
|
|
64
|
+
|
|
65
|
+
# Callbacks
|
|
66
|
+
self.on_connected: Callable[[], Any] | None = None
|
|
67
|
+
self.on_disconnected: Callable[[], Any] | None = None
|
|
68
|
+
|
|
69
|
+
# -- Properties ----------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def connected(self) -> bool:
|
|
73
|
+
return self._connected
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def device_name(self) -> str:
|
|
77
|
+
return self._device_name
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def device_address(self) -> str:
|
|
81
|
+
return self._device_address
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def battery_level(self) -> int:
|
|
85
|
+
return self._battery_level
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def signal_strength(self) -> int:
|
|
89
|
+
return self._signal_strength
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def operator_name(self) -> str:
|
|
93
|
+
return self._operator_name
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def in_call(self) -> bool:
|
|
97
|
+
return self._in_call
|
|
98
|
+
|
|
99
|
+
@in_call.setter
|
|
100
|
+
def in_call(self, value: bool) -> None:
|
|
101
|
+
self._in_call = value
|
|
102
|
+
|
|
103
|
+
# -- Lifecycle -----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def start(self) -> None:
|
|
106
|
+
"""Initialise D-Bus connection and start auto-connect if configured."""
|
|
107
|
+
self._running = True
|
|
108
|
+
|
|
109
|
+
if _DBUS_AVAILABLE:
|
|
110
|
+
try:
|
|
111
|
+
self._bus = SystemMessageBus()
|
|
112
|
+
self._adapter_proxy = self._bus.get_proxy(
|
|
113
|
+
_BLUEZ_SERVICE, "/org/bluez/hci0"
|
|
114
|
+
)
|
|
115
|
+
logger.info("BlueZ adapter proxy acquired on /org/bluez/hci0")
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
logger.error("Failed to connect to BlueZ D-Bus: %s", exc)
|
|
118
|
+
self._bus = None
|
|
119
|
+
else:
|
|
120
|
+
logger.info("D-Bus not available — running in mock mode")
|
|
121
|
+
|
|
122
|
+
# Auto-connect to a configured device
|
|
123
|
+
address = cfg.get("bluetooth.device_address", "")
|
|
124
|
+
if address and cfg.get("bluetooth.auto_connect", True):
|
|
125
|
+
self._device_address = address
|
|
126
|
+
self._auto_reconnect_task = asyncio.create_task(
|
|
127
|
+
self._auto_reconnect_loop()
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def stop(self) -> None:
|
|
131
|
+
"""Disconnect and clean up resources."""
|
|
132
|
+
self._running = False
|
|
133
|
+
if self._auto_reconnect_task and not self._auto_reconnect_task.done():
|
|
134
|
+
self._auto_reconnect_task.cancel()
|
|
135
|
+
try:
|
|
136
|
+
await self._auto_reconnect_task
|
|
137
|
+
except asyncio.CancelledError:
|
|
138
|
+
pass
|
|
139
|
+
await self.disconnect()
|
|
140
|
+
logger.info("BluetoothManager stopped")
|
|
141
|
+
|
|
142
|
+
# -- Connection ----------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
async def connect(self, address: str) -> bool:
|
|
145
|
+
"""Connect to a Bluetooth device by MAC address.
|
|
146
|
+
|
|
147
|
+
Returns True on success, False otherwise.
|
|
148
|
+
"""
|
|
149
|
+
self._device_address = address
|
|
150
|
+
logger.info("Connecting to %s ...", address)
|
|
151
|
+
|
|
152
|
+
if not _DBUS_AVAILABLE or self._bus is None:
|
|
153
|
+
return await self._mock_connect(address)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
device_path = self._address_to_path(address)
|
|
157
|
+
self._device_proxy = self._bus.get_proxy(_BLUEZ_SERVICE, device_path)
|
|
158
|
+
self._device_proxy.Connect()
|
|
159
|
+
await asyncio.sleep(1) # allow D-Bus signals to settle
|
|
160
|
+
|
|
161
|
+
self._connected = True
|
|
162
|
+
self._device_name = self._safe_get_property(
|
|
163
|
+
self._device_proxy, "Name", address
|
|
164
|
+
)
|
|
165
|
+
self._read_battery()
|
|
166
|
+
logger.info(
|
|
167
|
+
"Connected to %s (%s)", self._device_name, address
|
|
168
|
+
)
|
|
169
|
+
if self.on_connected:
|
|
170
|
+
self.on_connected()
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
logger.error("Connection to %s failed: %s", address, exc)
|
|
175
|
+
self._connected = False
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
async def disconnect(self) -> bool:
|
|
179
|
+
"""Disconnect the currently connected device."""
|
|
180
|
+
if not self._connected:
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
if not _DBUS_AVAILABLE or self._device_proxy is None:
|
|
184
|
+
return await self._mock_disconnect()
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
self._device_proxy.Disconnect()
|
|
188
|
+
self._connected = False
|
|
189
|
+
self._device_name = ""
|
|
190
|
+
self._battery_level = -1
|
|
191
|
+
self._signal_strength = 0
|
|
192
|
+
self._operator_name = ""
|
|
193
|
+
logger.info("Disconnected from %s", self._device_address)
|
|
194
|
+
if self.on_disconnected:
|
|
195
|
+
self.on_disconnected()
|
|
196
|
+
return True
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
logger.error("Disconnect failed: %s", exc)
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
# -- Discovery -----------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
async def scan_devices(self, duration: float = 8.0) -> list[dict[str, Any]]:
|
|
204
|
+
"""Scan for nearby Bluetooth devices.
|
|
205
|
+
|
|
206
|
+
Returns a list of dicts with keys: address, name, paired, connected, rssi.
|
|
207
|
+
"""
|
|
208
|
+
if not _DBUS_AVAILABLE or self._bus is None:
|
|
209
|
+
return self._mock_scan_devices()
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
self._adapter_proxy.StartDiscovery()
|
|
213
|
+
logger.info("Discovery started, scanning for %.1fs ...", duration)
|
|
214
|
+
await asyncio.sleep(duration)
|
|
215
|
+
self._adapter_proxy.StopDiscovery()
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
logger.error("Discovery error: %s", exc)
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
return self._collect_discovered_devices()
|
|
221
|
+
|
|
222
|
+
def _collect_discovered_devices(self) -> list[dict[str, Any]]:
|
|
223
|
+
"""Read discovered devices from BlueZ ObjectManager."""
|
|
224
|
+
devices: list[dict[str, Any]] = []
|
|
225
|
+
try:
|
|
226
|
+
obj_manager = self._bus.get_proxy(
|
|
227
|
+
_BLUEZ_SERVICE, "/"
|
|
228
|
+
)
|
|
229
|
+
managed = obj_manager.GetManagedObjects()
|
|
230
|
+
for path, ifaces in managed.items():
|
|
231
|
+
if _DEVICE_IFACE not in ifaces:
|
|
232
|
+
continue
|
|
233
|
+
props = ifaces[_DEVICE_IFACE]
|
|
234
|
+
devices.append({
|
|
235
|
+
"address": str(props.get("Address", "")),
|
|
236
|
+
"name": str(props.get("Name", props.get("Alias", "Unknown"))),
|
|
237
|
+
"paired": bool(props.get("Paired", False)),
|
|
238
|
+
"connected": bool(props.get("Connected", False)),
|
|
239
|
+
"rssi": int(props.get("RSSI", 0)),
|
|
240
|
+
})
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
logger.error("Failed to collect devices: %s", exc)
|
|
243
|
+
return devices
|
|
244
|
+
|
|
245
|
+
# -- Pairing -------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
async def pair_device(self, address: str) -> bool:
|
|
248
|
+
"""Pair with a device at the given address.
|
|
249
|
+
|
|
250
|
+
Returns True on success.
|
|
251
|
+
"""
|
|
252
|
+
logger.info("Pairing with %s ...", address)
|
|
253
|
+
|
|
254
|
+
if not _DBUS_AVAILABLE or self._bus is None:
|
|
255
|
+
logger.info("[mock] Paired with %s", address)
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
device_path = self._address_to_path(address)
|
|
260
|
+
device = self._bus.get_proxy(_BLUEZ_SERVICE, device_path)
|
|
261
|
+
device.Pair()
|
|
262
|
+
device.Trusted = True
|
|
263
|
+
logger.info("Paired and trusted: %s", address)
|
|
264
|
+
return True
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.error("Pairing with %s failed: %s", address, exc)
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
# -- Auto-reconnect ------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async def _auto_reconnect_loop(self) -> None:
|
|
272
|
+
"""Background loop: reconnect with exponential backoff."""
|
|
273
|
+
base_interval = cfg.get("bluetooth.reconnect_interval", 5)
|
|
274
|
+
max_interval = cfg.get("bluetooth.reconnect_max_interval", 60)
|
|
275
|
+
interval = base_interval
|
|
276
|
+
|
|
277
|
+
while self._running:
|
|
278
|
+
if not self._connected and self._device_address:
|
|
279
|
+
logger.info(
|
|
280
|
+
"Auto-reconnect attempt to %s (interval=%ds) ...",
|
|
281
|
+
self._device_address, interval,
|
|
282
|
+
)
|
|
283
|
+
ok = await self.connect(self._device_address)
|
|
284
|
+
if ok:
|
|
285
|
+
interval = base_interval # reset backoff
|
|
286
|
+
else:
|
|
287
|
+
interval = min(interval * 2, max_interval)
|
|
288
|
+
|
|
289
|
+
await asyncio.sleep(interval)
|
|
290
|
+
|
|
291
|
+
# -- Status --------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def get_status(self) -> dict[str, Any]:
|
|
294
|
+
"""Return a snapshot of all phone / bluetooth status fields."""
|
|
295
|
+
return {
|
|
296
|
+
"connected": self._connected,
|
|
297
|
+
"device_name": self._device_name,
|
|
298
|
+
"device_address": self._device_address,
|
|
299
|
+
"battery_level": self._battery_level,
|
|
300
|
+
"signal_strength": self._signal_strength,
|
|
301
|
+
"operator_name": self._operator_name,
|
|
302
|
+
"in_call": self._in_call,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# -- Internal helpers ----------------------------------------------------
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _address_to_path(address: str) -> str:
|
|
309
|
+
"""Convert a MAC address like ``AA:BB:CC:DD:EE:FF`` to a BlueZ object
|
|
310
|
+
path like ``/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF``.
|
|
311
|
+
"""
|
|
312
|
+
return "/org/bluez/hci0/dev_" + address.replace(":", "_")
|
|
313
|
+
|
|
314
|
+
def _safe_get_property(self, proxy: Any, name: str, default: Any = "") -> Any:
|
|
315
|
+
"""Read a D-Bus property, returning *default* on failure."""
|
|
316
|
+
try:
|
|
317
|
+
return getattr(proxy, name, default)
|
|
318
|
+
except Exception:
|
|
319
|
+
return default
|
|
320
|
+
|
|
321
|
+
def _read_battery(self) -> None:
|
|
322
|
+
"""Try to read battery level from org.bluez.Battery1."""
|
|
323
|
+
if not _DBUS_AVAILABLE or self._bus is None or not self._device_address:
|
|
324
|
+
return
|
|
325
|
+
try:
|
|
326
|
+
device_path = self._address_to_path(self._device_address)
|
|
327
|
+
battery_proxy = self._bus.get_proxy(_BLUEZ_SERVICE, device_path)
|
|
328
|
+
self._battery_level = int(battery_proxy.Percentage)
|
|
329
|
+
except Exception:
|
|
330
|
+
self._battery_level = -1
|
|
331
|
+
|
|
332
|
+
# -- Delegated helpers (SMS / Contacts) ----------------------------------
|
|
333
|
+
|
|
334
|
+
async def send_sms(self, phone_number: str, content: str) -> bool:
|
|
335
|
+
"""Send an SMS via the MAP client."""
|
|
336
|
+
from bluetooth.sms import MAPClient
|
|
337
|
+
client = MAPClient()
|
|
338
|
+
return await client.send_sms(phone_number, content)
|
|
339
|
+
|
|
340
|
+
async def sync_contacts(self) -> list[dict[str, Any]]:
|
|
341
|
+
"""Sync contacts from the phone via PBAP."""
|
|
342
|
+
from bluetooth.contacts import PBAPClient
|
|
343
|
+
client = PBAPClient()
|
|
344
|
+
return await client.sync_contacts(self._device_address)
|
|
345
|
+
|
|
346
|
+
# -- Mock helpers (development on Windows / macOS) -----------------------
|
|
347
|
+
|
|
348
|
+
async def _mock_connect(self, address: str) -> bool:
|
|
349
|
+
logger.info("[mock] Connected to device %s", address)
|
|
350
|
+
self._connected = True
|
|
351
|
+
self._device_name = f"Mock Phone ({address[-5:]})"
|
|
352
|
+
self._battery_level = 85
|
|
353
|
+
self._signal_strength = -55
|
|
354
|
+
self._operator_name = "Mock Carrier"
|
|
355
|
+
if self.on_connected:
|
|
356
|
+
self.on_connected()
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
async def _mock_disconnect(self) -> bool:
|
|
360
|
+
logger.info("[mock] Disconnected")
|
|
361
|
+
self._connected = False
|
|
362
|
+
self._device_name = ""
|
|
363
|
+
self._battery_level = -1
|
|
364
|
+
self._signal_strength = 0
|
|
365
|
+
self._operator_name = ""
|
|
366
|
+
if self.on_disconnected:
|
|
367
|
+
self.on_disconnected()
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _mock_scan_devices() -> list[dict[str, Any]]:
|
|
372
|
+
logger.info("[mock] Returning fake discovered devices")
|
|
373
|
+
return [
|
|
374
|
+
{
|
|
375
|
+
"address": "AA:BB:CC:DD:EE:01",
|
|
376
|
+
"name": "Mock iPhone 15",
|
|
377
|
+
"paired": False,
|
|
378
|
+
"connected": False,
|
|
379
|
+
"rssi": -45,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"address": "AA:BB:CC:DD:EE:02",
|
|
383
|
+
"name": "Mock Pixel 8",
|
|
384
|
+
"paired": True,
|
|
385
|
+
"connected": False,
|
|
386
|
+
"rssi": -62,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"address": "AA:BB:CC:DD:EE:03",
|
|
390
|
+
"name": "Mock Galaxy S24",
|
|
391
|
+
"paired": False,
|
|
392
|
+
"connected": False,
|
|
393
|
+
"rssi": -78,
|
|
394
|
+
},
|
|
395
|
+
]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Message Access Profile (MAP) client via BlueZ / obexd D-Bus.
|
|
2
|
+
|
|
3
|
+
Provides SMS send / receive / listing through a connected Bluetooth phone.
|
|
4
|
+
Falls back to a mock implementation when D-Bus is not available.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("bluetooth.sms")
|
|
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 MAPClient for development"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# obexd MAP 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_MAP_IFACE = "org.bluez.obex.MessageAccess1"
|
|
36
|
+
_OBEX_MESSAGE_IFACE = "org.bluez.obex.Message1"
|
|
37
|
+
_OBEX_TRANSFER_IFACE = "org.bluez.obex.Transfer1"
|
|
38
|
+
|
|
39
|
+
# MAP folder names
|
|
40
|
+
_FOLDER_INBOX = "inbox"
|
|
41
|
+
_FOLDER_SENT = "sent"
|
|
42
|
+
_FOLDER_OUTBOX = "outbox"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MAPClient:
|
|
46
|
+
"""Send and receive SMS through Bluetooth MAP profile."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._bus: Any | None = None
|
|
50
|
+
self._session_path: str | None = None
|
|
51
|
+
|
|
52
|
+
# -- Public API ----------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async def send_sms(self, number: str, text: str) -> bool:
|
|
55
|
+
"""Send an SMS to *number* with body *text*.
|
|
56
|
+
|
|
57
|
+
Returns True on success, False otherwise.
|
|
58
|
+
"""
|
|
59
|
+
logger.info("Sending SMS to %s (%d chars)", number, len(text))
|
|
60
|
+
|
|
61
|
+
if not _DBUS_AVAILABLE:
|
|
62
|
+
return self._mock_send_sms(number, text)
|
|
63
|
+
|
|
64
|
+
return await self._map_send_message(number, text)
|
|
65
|
+
|
|
66
|
+
async def get_messages(
|
|
67
|
+
self, folder: str = _FOLDER_INBOX
|
|
68
|
+
) -> list[dict[str, Any]]:
|
|
69
|
+
"""Retrieve messages from the specified folder.
|
|
70
|
+
|
|
71
|
+
Each message is a dict with keys: id, sender, recipient, text,
|
|
72
|
+
timestamp, read, folder.
|
|
73
|
+
"""
|
|
74
|
+
logger.info("Fetching messages from folder '%s'", folder)
|
|
75
|
+
|
|
76
|
+
if not _DBUS_AVAILABLE:
|
|
77
|
+
return self._mock_get_messages(folder)
|
|
78
|
+
|
|
79
|
+
return await self._map_get_messages(folder)
|
|
80
|
+
|
|
81
|
+
async def get_unread_count(self) -> int:
|
|
82
|
+
"""Return the number of unread messages in the inbox."""
|
|
83
|
+
messages = await self.get_messages(_FOLDER_INBOX)
|
|
84
|
+
return sum(1 for m in messages if not m.get("read", True))
|
|
85
|
+
|
|
86
|
+
# -- obexd MAP D-Bus interaction -----------------------------------------
|
|
87
|
+
|
|
88
|
+
async def _map_create_session(self, device_address: str = "") -> bool:
|
|
89
|
+
"""Create a MAP session with the phone via obexd."""
|
|
90
|
+
try:
|
|
91
|
+
if self._bus is None:
|
|
92
|
+
self._bus = SessionMessageBus()
|
|
93
|
+
|
|
94
|
+
client = self._bus.get_proxy(_OBEX_SERVICE, _OBEX_CLIENT_PATH)
|
|
95
|
+
session_path = client.CreateSession(
|
|
96
|
+
device_address,
|
|
97
|
+
{"Target": "MAP"},
|
|
98
|
+
)
|
|
99
|
+
self._session_path = str(session_path)
|
|
100
|
+
logger.info("MAP session created: %s", self._session_path)
|
|
101
|
+
return True
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
logger.error("Failed to create MAP session: %s", exc)
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
async def _map_close_session(self) -> None:
|
|
107
|
+
"""Close the current MAP session."""
|
|
108
|
+
if self._session_path is None:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
client = self._bus.get_proxy(_OBEX_SERVICE, _OBEX_CLIENT_PATH)
|
|
112
|
+
client.RemoveSession(self._session_path)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
self._session_path = None
|
|
116
|
+
|
|
117
|
+
async def _map_send_message(self, number: str, text: str) -> bool:
|
|
118
|
+
"""Send a message via the MAP profile."""
|
|
119
|
+
try:
|
|
120
|
+
if self._session_path is None:
|
|
121
|
+
if not await self._map_create_session():
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
map_proxy = self._bus.get_proxy(
|
|
125
|
+
_OBEX_SERVICE, self._session_path
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Navigate to outbox
|
|
129
|
+
map_proxy.SetFolder("telecom/msg/outbox")
|
|
130
|
+
|
|
131
|
+
# Push message
|
|
132
|
+
# The MAP PushMessage method takes a filename with the message
|
|
133
|
+
# content and a destination folder. We build a basic bMessage.
|
|
134
|
+
bmsg = self._build_bmessage(number, text)
|
|
135
|
+
|
|
136
|
+
transfer_path, properties = map_proxy.PushMessage(
|
|
137
|
+
bmsg,
|
|
138
|
+
"outbox",
|
|
139
|
+
{"Transparent": False},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Wait for transfer to complete
|
|
143
|
+
transfer = self._bus.get_proxy(
|
|
144
|
+
_OBEX_SERVICE, str(transfer_path)
|
|
145
|
+
)
|
|
146
|
+
for _ in range(30):
|
|
147
|
+
status = str(transfer.Status)
|
|
148
|
+
if status == "complete":
|
|
149
|
+
logger.info("SMS sent to %s", number)
|
|
150
|
+
return True
|
|
151
|
+
if status == "error":
|
|
152
|
+
logger.error("SMS send transfer failed")
|
|
153
|
+
return False
|
|
154
|
+
await asyncio.sleep(0.5)
|
|
155
|
+
|
|
156
|
+
logger.error("SMS send transfer timed out")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
logger.error("MAP send failed: %s", exc)
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
finally:
|
|
164
|
+
await self._map_close_session()
|
|
165
|
+
|
|
166
|
+
async def _map_get_messages(
|
|
167
|
+
self, folder: str
|
|
168
|
+
) -> list[dict[str, Any]]:
|
|
169
|
+
"""Retrieve messages from a MAP folder."""
|
|
170
|
+
messages: list[dict[str, Any]] = []
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
if self._session_path is None:
|
|
174
|
+
if not await self._map_create_session():
|
|
175
|
+
return messages
|
|
176
|
+
|
|
177
|
+
map_proxy = self._bus.get_proxy(
|
|
178
|
+
_OBEX_SERVICE, self._session_path
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Navigate to the requested folder
|
|
182
|
+
map_proxy.SetFolder(f"telecom/msg/{folder}")
|
|
183
|
+
|
|
184
|
+
# Get message listing
|
|
185
|
+
listing = map_proxy.ListMessages(
|
|
186
|
+
folder,
|
|
187
|
+
{"MaxListCount": 50},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for msg_path, props in listing.items():
|
|
191
|
+
messages.append({
|
|
192
|
+
"id": str(msg_path).split("/")[-1],
|
|
193
|
+
"sender": str(props.get("Sender", "")),
|
|
194
|
+
"recipient": str(props.get("Recipient", "")),
|
|
195
|
+
"text": str(props.get("Subject", "")),
|
|
196
|
+
"timestamp": str(props.get("Timestamp", "")),
|
|
197
|
+
"read": bool(props.get("Read", False)),
|
|
198
|
+
"folder": folder,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.error("MAP get messages failed: %s", exc)
|
|
203
|
+
|
|
204
|
+
finally:
|
|
205
|
+
await self._map_close_session()
|
|
206
|
+
|
|
207
|
+
return messages
|
|
208
|
+
|
|
209
|
+
# -- bMessage construction -----------------------------------------------
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _build_bmessage(number: str, text: str) -> str:
|
|
213
|
+
"""Build a minimal bMessage 1.0 envelope for an SMS."""
|
|
214
|
+
return (
|
|
215
|
+
"BEGIN:BMSG\r\n"
|
|
216
|
+
"VERSION:1.0\r\n"
|
|
217
|
+
"STATUS:UNREAD\r\n"
|
|
218
|
+
"TYPE:SMS_GSM\r\n"
|
|
219
|
+
"FOLDER:telecom/msg/outbox\r\n"
|
|
220
|
+
"BEGIN:VCARD\r\n"
|
|
221
|
+
f"TEL:{number}\r\n"
|
|
222
|
+
"END:VCARD\r\n"
|
|
223
|
+
"BEGIN:BENV\r\n"
|
|
224
|
+
"BEGIN:BBODY\r\n"
|
|
225
|
+
"CHARSET:UTF-8\r\n"
|
|
226
|
+
f"LENGTH:{len(text.encode('utf-8'))}\r\n"
|
|
227
|
+
"BEGIN:MSG\r\n"
|
|
228
|
+
f"{text}\r\n"
|
|
229
|
+
"END:MSG\r\n"
|
|
230
|
+
"END:BBODY\r\n"
|
|
231
|
+
"END:BENV\r\n"
|
|
232
|
+
"END:BMSG\r\n"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# -- Mock helpers (development) ------------------------------------------
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _mock_send_sms(number: str, text: str) -> bool:
|
|
239
|
+
logger.info("[mock] SMS sent to %s: %s", number, text[:50])
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _mock_get_messages(folder: str) -> list[dict[str, Any]]:
|
|
244
|
+
logger.info("[mock] Returning fake messages for folder '%s'", folder)
|
|
245
|
+
|
|
246
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
247
|
+
|
|
248
|
+
if folder == _FOLDER_INBOX:
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
"id": "mock-msg-001",
|
|
252
|
+
"sender": "+8613800000001",
|
|
253
|
+
"recipient": "",
|
|
254
|
+
"text": "Hello, are you available for a meeting tomorrow?",
|
|
255
|
+
"timestamp": now,
|
|
256
|
+
"read": False,
|
|
257
|
+
"folder": folder,
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"id": "mock-msg-002",
|
|
261
|
+
"sender": "+8613900000002",
|
|
262
|
+
"recipient": "",
|
|
263
|
+
"text": "Your package has been delivered.",
|
|
264
|
+
"timestamp": now,
|
|
265
|
+
"read": True,
|
|
266
|
+
"folder": folder,
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"id": "mock-msg-003",
|
|
270
|
+
"sender": "+8613700000003",
|
|
271
|
+
"recipient": "",
|
|
272
|
+
"text": "Reminder: appointment at 3pm today.",
|
|
273
|
+
"timestamp": now,
|
|
274
|
+
"read": False,
|
|
275
|
+
"folder": folder,
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
elif folder == _FOLDER_SENT:
|
|
279
|
+
return [
|
|
280
|
+
{
|
|
281
|
+
"id": "mock-msg-101",
|
|
282
|
+
"sender": "",
|
|
283
|
+
"recipient": "+8613800000001",
|
|
284
|
+
"text": "Sure, let me check my schedule.",
|
|
285
|
+
"timestamp": now,
|
|
286
|
+
"read": True,
|
|
287
|
+
"folder": folder,
|
|
288
|
+
},
|
|
289
|
+
]
|
|
290
|
+
return []
|