@agentunion/kite 1.0.7 → 1.3.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 (100) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/__init__.py +1 -0
  5. package/extensions/agents/assistant/__init__.py +1 -0
  6. package/extensions/agents/assistant/entry.py +329 -0
  7. package/extensions/agents/assistant/module.md +22 -0
  8. package/extensions/agents/assistant/server.py +197 -0
  9. package/extensions/channels/__init__.py +1 -0
  10. package/extensions/channels/acp_channel/__init__.py +1 -0
  11. package/extensions/channels/acp_channel/entry.py +329 -0
  12. package/extensions/channels/acp_channel/module.md +22 -0
  13. package/extensions/channels/acp_channel/server.py +197 -0
  14. package/extensions/event_hub_bench/entry.py +624 -379
  15. package/extensions/event_hub_bench/module.md +2 -1
  16. package/extensions/services/backup/__init__.py +1 -0
  17. package/extensions/services/backup/entry.py +508 -0
  18. package/extensions/services/backup/module.md +22 -0
  19. package/extensions/services/model_service/__init__.py +1 -0
  20. package/extensions/services/model_service/entry.py +508 -0
  21. package/extensions/services/model_service/module.md +22 -0
  22. package/extensions/services/watchdog/entry.py +468 -102
  23. package/extensions/services/watchdog/module.md +3 -0
  24. package/extensions/services/watchdog/monitor.py +170 -69
  25. package/extensions/services/web/__init__.py +1 -0
  26. package/extensions/services/web/config.yaml +149 -0
  27. package/extensions/services/web/entry.py +390 -0
  28. package/extensions/services/web/module.md +24 -0
  29. package/extensions/services/web/routes/__init__.py +1 -0
  30. package/extensions/services/web/routes/routes_call.py +189 -0
  31. package/extensions/services/web/routes/routes_config.py +512 -0
  32. package/extensions/services/web/routes/routes_contacts.py +98 -0
  33. package/extensions/services/web/routes/routes_devlog.py +99 -0
  34. package/extensions/services/web/routes/routes_phone.py +81 -0
  35. package/extensions/services/web/routes/routes_sms.py +48 -0
  36. package/extensions/services/web/routes/routes_stats.py +17 -0
  37. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  38. package/extensions/services/web/routes/schemas.py +216 -0
  39. package/extensions/services/web/server.py +375 -0
  40. package/extensions/services/web/static/css/style.css +1064 -0
  41. package/extensions/services/web/static/index.html +1445 -0
  42. package/extensions/services/web/static/js/app.js +4671 -0
  43. package/extensions/services/web/vendor/__init__.py +1 -0
  44. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  45. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  46. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  47. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  48. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  49. package/extensions/services/web/vendor/config.py +139 -0
  50. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  51. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  52. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  53. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  54. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  55. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  56. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  57. package/extensions/services/web/vendor/storage/identity.py +312 -0
  58. package/extensions/services/web/vendor/storage/store.py +507 -0
  59. package/extensions/services/web/vendor/task/manager.py +864 -0
  60. package/extensions/services/web/vendor/task/models.py +45 -0
  61. package/extensions/services/web/vendor/task/webhook.py +263 -0
  62. package/extensions/services/web/vendor/tools/registry.py +321 -0
  63. package/kernel/__init__.py +0 -0
  64. package/kernel/entry.py +407 -0
  65. package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
  66. package/kernel/module.md +33 -0
  67. package/{core/registry/store.py → kernel/registry_store.py} +23 -8
  68. package/kernel/rpc_router.py +388 -0
  69. package/kernel/server.py +267 -0
  70. package/launcher/__init__.py +10 -0
  71. package/launcher/__main__.py +6 -0
  72. package/launcher/count_lines.py +258 -0
  73. package/launcher/entry.py +1778 -0
  74. package/launcher/logging_setup.py +289 -0
  75. package/{core/launcher → launcher}/module_scanner.py +11 -6
  76. package/launcher/process_manager.py +880 -0
  77. package/main.py +11 -210
  78. package/package.json +6 -9
  79. package/__init__.py +0 -1
  80. package/__main__.py +0 -15
  81. package/core/event_hub/BENCHMARK.md +0 -94
  82. package/core/event_hub/bench.py +0 -459
  83. package/core/event_hub/bench_extreme.py +0 -308
  84. package/core/event_hub/bench_perf.py +0 -350
  85. package/core/event_hub/entry.py +0 -157
  86. package/core/event_hub/module.md +0 -20
  87. package/core/event_hub/server.py +0 -206
  88. package/core/launcher/entry.py +0 -1158
  89. package/core/launcher/process_manager.py +0 -470
  90. package/core/registry/entry.py +0 -110
  91. package/core/registry/module.md +0 -30
  92. package/core/registry/server.py +0 -289
  93. package/extensions/services/watchdog/server.py +0 -167
  94. /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
  95. /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
  96. /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
  97. /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
  98. /package/{core/event_hub → kernel}/dedup.py +0 -0
  99. /package/{core/event_hub → kernel}/router.py +0 -0
  100. /package/{core/launcher → launcher}/module.md +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 []