Add OBEX profile support (OPP, FTP, PBAP, MAP)
23 new tools for file transfer, phonebook access, and messaging via obexd D-Bus API. Includes session management, transfer monitoring, and daemon lifecycle tools.
This commit is contained in:
parent
8cbbcfa286
commit
e9f06173c5
106
README.md
106
README.md
@ -39,6 +39,7 @@
|
|||||||
- **Smart Pairing Agent** — Handles PIN codes, passkeys, and confirmations automatically
|
- **Smart Pairing Agent** — Handles PIN codes, passkeys, and confirmations automatically
|
||||||
- **Protocol Analysis** — Capture and analyze Bluetooth traffic with btmon integration
|
- **Protocol Analysis** — Capture and analyze Bluetooth traffic with btmon integration
|
||||||
- **Audio Intelligence** — PipeWire/PulseAudio integration for seamless audio control
|
- **Audio Intelligence** — PipeWire/PulseAudio integration for seamless audio control
|
||||||
|
- **OBEX Profiles** — File transfer, phonebook access, and message access
|
||||||
|
|
||||||
## Example Conversation
|
## Example Conversation
|
||||||
|
|
||||||
@ -115,6 +116,24 @@ For HCI packet capture (btmon), additional permissions are needed:
|
|||||||
sudo setcap cap_net_raw+ep /usr/bin/btmon
|
sudo setcap cap_net_raw+ep /usr/bin/btmon
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### OBEX Setup (for file transfer, phonebook, messages)
|
||||||
|
|
||||||
|
OBEX features require the `obexd` daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install obexd (if not included with BlueZ)
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S bluez-obex
|
||||||
|
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt install bluez-obex
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
bt_obex_status # Use this tool to verify setup
|
||||||
|
```
|
||||||
|
|
||||||
|
The `bt_obex_status` tool will check if obexd is installed and running, and provide guidance if setup is needed.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Adapter Management
|
### Adapter Management
|
||||||
@ -165,6 +184,24 @@ Capture and analyze Bluetooth traffic for debugging and reverse engineering.
|
|||||||
"Analyze the capture file for errors"
|
"Analyze the capture file for errors"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### OBEX File Transfer
|
||||||
|
Send files, browse phone storage, and download photos using OBEX profiles.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Send this PDF to my phone"
|
||||||
|
"Browse the files on my phone's storage"
|
||||||
|
"Download all photos from DCIM folder"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phonebook & Messages
|
||||||
|
Access contacts and SMS messages from connected phones via PBAP and MAP profiles.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Download my phone's contacts"
|
||||||
|
"Search for John in the phonebook"
|
||||||
|
"List unread text messages"
|
||||||
|
```
|
||||||
|
|
||||||
## Tools Reference
|
## Tools Reference
|
||||||
|
|
||||||
### Adapter Tools (6)
|
### Adapter Tools (6)
|
||||||
@ -225,7 +262,56 @@ Capture and analyze Bluetooth traffic for debugging and reverse engineering.
|
|||||||
| `bt_capture_analyze` | Statistical analysis of capture |
|
| `bt_capture_analyze` | Statistical analysis of capture |
|
||||||
| `bt_capture_read_raw` | Human-readable packet decode |
|
| `bt_capture_read_raw` | Human-readable packet decode |
|
||||||
|
|
||||||
**Total: 38 tools**
|
### OBEX Tools (23)
|
||||||
|
|
||||||
|
#### Setup & Session Management
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_obex_status` | Check obexd daemon status and requirements |
|
||||||
|
| `bt_obex_start_daemon` | Start the obexd daemon |
|
||||||
|
| `bt_obex_connect` | Create OBEX session (OPP/FTP/PBAP/MAP) |
|
||||||
|
| `bt_obex_disconnect` | Close OBEX session |
|
||||||
|
| `bt_obex_sessions` | List active OBEX sessions |
|
||||||
|
|
||||||
|
#### Object Push (OPP)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_obex_send_file` | Send file to device |
|
||||||
|
| `bt_obex_get_vcard` | Pull business card from device |
|
||||||
|
|
||||||
|
#### File Transfer (FTP)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_obex_browse` | List files/folders on device |
|
||||||
|
| `bt_obex_get` | Download file from device |
|
||||||
|
| `bt_obex_put` | Upload file to device |
|
||||||
|
| `bt_obex_delete` | Delete file/folder on device |
|
||||||
|
| `bt_obex_mkdir` | Create folder on device |
|
||||||
|
|
||||||
|
#### Phonebook Access (PBAP)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_phonebook_pull` | Download entire phonebook |
|
||||||
|
| `bt_phonebook_list` | List phonebook entries |
|
||||||
|
| `bt_phonebook_get` | Download single contact |
|
||||||
|
| `bt_phonebook_search` | Search phonebook |
|
||||||
|
| `bt_phonebook_count` | Count phonebook entries |
|
||||||
|
|
||||||
|
#### Message Access (MAP)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_messages_folders` | List message folders |
|
||||||
|
| `bt_messages_list` | List messages in folder |
|
||||||
|
| `bt_messages_get` | Download message content |
|
||||||
|
| `bt_messages_send` | Send SMS via MAP |
|
||||||
|
|
||||||
|
#### Transfer Management
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_obex_transfer_status` | Check transfer progress |
|
||||||
|
| `bt_obex_transfer_cancel` | Cancel active transfer |
|
||||||
|
|
||||||
|
**Total: 61 tools**
|
||||||
|
|
||||||
## MCP Resources
|
## MCP Resources
|
||||||
|
|
||||||
@ -252,10 +338,10 @@ Live state queries without tool calls:
|
|||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────── FastMCP Server ──────────────────────┐ │
|
│ ┌──────────────────── FastMCP Server ──────────────────────┐ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐│
|
||||||
│ │ │ Adapter │ │ Device │ │ Audio │ │ BLE │ │ │
|
│ │ │ Adapter │ │ Device │ │ Audio │ │ BLE │ │ OBEX ││
|
||||||
│ │ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │ │
|
│ │ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │ Tools ││
|
||||||
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
|
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘│
|
||||||
│ │ │ │ │ │ │ │
|
│ │ │ │ │ │ │ │
|
||||||
│ │ ┌────┴───────────┴───────────┴───────────┴────┐ │ │
|
│ │ ┌────┴───────────┴───────────┴───────────┴────┐ │ │
|
||||||
│ │ │ Pairing Agent (Agent1) │ │ │
|
│ │ │ Pairing Agent (Agent1) │ │ │
|
||||||
@ -343,6 +429,7 @@ mcbluetooth/
|
|||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── server.py # FastMCP server entry point
|
│ ├── server.py # FastMCP server entry point
|
||||||
│ ├── dbus_client.py # BlueZ D-Bus wrapper
|
│ ├── dbus_client.py # BlueZ D-Bus wrapper
|
||||||
|
│ ├── obex_client.py # obexd D-Bus wrapper
|
||||||
│ ├── audio.py # PipeWire/Pulse integration
|
│ ├── audio.py # PipeWire/Pulse integration
|
||||||
│ ├── agent.py # Pairing agent (Agent1)
|
│ ├── agent.py # Pairing agent (Agent1)
|
||||||
│ └── tools/
|
│ └── tools/
|
||||||
@ -350,7 +437,8 @@ mcbluetooth/
|
|||||||
│ ├── device.py # Device management + pairing
|
│ ├── device.py # Device management + pairing
|
||||||
│ ├── audio.py # Audio profile tools
|
│ ├── audio.py # Audio profile tools
|
||||||
│ ├── ble.py # BLE/GATT tools
|
│ ├── ble.py # BLE/GATT tools
|
||||||
│ └── monitor.py # btmon integration
|
│ ├── monitor.py # btmon integration
|
||||||
|
│ └── obex.py # OBEX profile tools
|
||||||
├── tests/
|
├── tests/
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
└── README.md
|
└── README.md
|
||||||
@ -364,9 +452,9 @@ mcbluetooth/
|
|||||||
- [x] Audio profiles (A2DP/HFP)
|
- [x] Audio profiles (A2DP/HFP)
|
||||||
- [x] BLE/GATT operations
|
- [x] BLE/GATT operations
|
||||||
- [x] btmon packet capture
|
- [x] btmon packet capture
|
||||||
- [ ] OBEX file transfer (OPP/FTP)
|
- [x] OBEX file transfer (OPP/FTP)
|
||||||
- [ ] Phonebook access (PBAP)
|
- [x] Phonebook access (PBAP)
|
||||||
- [ ] Message access (MAP)
|
- [x] Message access (MAP)
|
||||||
- [ ] Bluetooth Mesh (experimental)
|
- [ ] Bluetooth Mesh (experimental)
|
||||||
|
|
||||||
## Related Projects
|
## Related Projects
|
||||||
|
|||||||
887
src/mcbluetooth/obex_client.py
Normal file
887
src/mcbluetooth/obex_client.py
Normal file
@ -0,0 +1,887 @@
|
|||||||
|
"""obexd D-Bus client wrapper using dbus-fast.
|
||||||
|
|
||||||
|
This module provides an async interface to the BlueZ OBEX daemon via D-Bus.
|
||||||
|
obexd handles Object Exchange (OBEX) profiles:
|
||||||
|
|
||||||
|
- OPP (Object Push Profile): Simple file transfer
|
||||||
|
- FTP (File Transfer Profile): File browsing and transfer
|
||||||
|
- PBAP (Phonebook Access Profile): Contact/phonebook access
|
||||||
|
- MAP (Message Access Profile): SMS/MMS/email access
|
||||||
|
|
||||||
|
Object paths follow this pattern:
|
||||||
|
- /org/bluez/obex - Client
|
||||||
|
- /org/bluez/obex/client/sessionN - Session
|
||||||
|
- /org/bluez/obex/client/sessionN/transferM - Transfer
|
||||||
|
|
||||||
|
Note: obexd runs as a user service and uses the SESSION bus, not system bus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from dbus_fast import BusType, Variant
|
||||||
|
from dbus_fast.aio import MessageBus, ProxyInterface
|
||||||
|
|
||||||
|
# obexd constants
|
||||||
|
OBEX_SERVICE = "org.bluez.obex"
|
||||||
|
OBEX_CLIENT_PATH = "/org/bluez/obex"
|
||||||
|
OBEX_CLIENT_IFACE = "org.bluez.obex.Client1"
|
||||||
|
OBEX_SESSION_IFACE = "org.bluez.obex.Session1"
|
||||||
|
OBEX_TRANSFER_IFACE = "org.bluez.obex.Transfer1"
|
||||||
|
OBEX_OPP_IFACE = "org.bluez.obex.ObjectPush1"
|
||||||
|
OBEX_FTP_IFACE = "org.bluez.obex.FileTransfer1"
|
||||||
|
OBEX_PBAP_IFACE = "org.bluez.obex.PhonebookAccess1"
|
||||||
|
OBEX_MAP_IFACE = "org.bluez.obex.MessageAccess1"
|
||||||
|
|
||||||
|
DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
|
||||||
|
|
||||||
|
# OBEX target UUIDs
|
||||||
|
OBEX_TARGETS = {
|
||||||
|
"opp": "00001105-0000-1000-8000-00805f9b34fb", # Object Push
|
||||||
|
"ftp": "00001106-0000-1000-8000-00805f9b34fb", # File Transfer
|
||||||
|
"pbap": "0000112f-0000-1000-8000-00805f9b34fb", # PBAP PCE
|
||||||
|
"map": "00001132-0000-1000-8000-00805f9b34fb", # MAP MCE
|
||||||
|
}
|
||||||
|
|
||||||
|
# Target type alias
|
||||||
|
ObexTarget = Literal["opp", "ftp", "pbap", "map"]
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_variant(value: Any) -> Any:
|
||||||
|
"""Recursively unwrap dbus-fast Variant objects to plain Python types."""
|
||||||
|
if isinstance(value, Variant):
|
||||||
|
return unwrap_variant(value.value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
return {k: unwrap_variant(v) for k, v in value.items()}
|
||||||
|
elif isinstance(value, list):
|
||||||
|
return [unwrap_variant(v) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id(address: str, target: str) -> str:
|
||||||
|
"""Generate a friendly session ID like 'ftp_C87B235568E8'."""
|
||||||
|
addr_short = address.replace(":", "").upper()
|
||||||
|
return f"{target}_{addr_short}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObexSession:
|
||||||
|
"""Information about an active OBEX session."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
path: str
|
||||||
|
address: str
|
||||||
|
target: ObexTarget
|
||||||
|
source: str = "" # Local adapter address
|
||||||
|
root: str = "" # Root folder (FTP only)
|
||||||
|
created: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObexTransfer:
|
||||||
|
"""Information about an OBEX transfer."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
status: str # "queued", "active", "suspended", "complete", "error"
|
||||||
|
name: str = ""
|
||||||
|
size: int = 0
|
||||||
|
transferred: int = 0
|
||||||
|
filename: str = ""
|
||||||
|
session: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FolderEntry:
|
||||||
|
"""Entry in an FTP folder listing."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
type: str # "folder" or "file"
|
||||||
|
size: int = 0
|
||||||
|
permissions: str = ""
|
||||||
|
modified: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PhonebookEntry:
|
||||||
|
"""Entry in a phonebook listing."""
|
||||||
|
|
||||||
|
handle: str
|
||||||
|
name: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageEntry:
|
||||||
|
"""Entry in a message listing."""
|
||||||
|
|
||||||
|
handle: str
|
||||||
|
subject: str = ""
|
||||||
|
datetime: str = ""
|
||||||
|
sender_name: str = ""
|
||||||
|
sender_address: str = ""
|
||||||
|
type: str = "" # SMS, MMS, EMAIL
|
||||||
|
read: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ObexClient:
|
||||||
|
"""Async client for obexd D-Bus API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._bus: MessageBus | None = None
|
||||||
|
self._client: ProxyInterface | None = None
|
||||||
|
self._sessions: dict[str, ObexSession] = {}
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Connect to the session D-Bus."""
|
||||||
|
if self._bus is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
||||||
|
|
||||||
|
# Get the Client interface
|
||||||
|
introspection = await self._bus.introspect(OBEX_SERVICE, OBEX_CLIENT_PATH)
|
||||||
|
proxy = self._bus.get_proxy_object(OBEX_SERVICE, OBEX_CLIENT_PATH, introspection)
|
||||||
|
self._client = proxy.get_interface(OBEX_CLIENT_IFACE)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from D-Bus and clean up sessions."""
|
||||||
|
# Remove all active sessions
|
||||||
|
for session_id in list(self._sessions.keys()):
|
||||||
|
try:
|
||||||
|
await self.remove_session(session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._bus:
|
||||||
|
self._bus.disconnect()
|
||||||
|
self._bus = None
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def _ensure_connected(self) -> None:
|
||||||
|
"""Ensure we're connected to D-Bus."""
|
||||||
|
if self._bus is None:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
async def _get_interface(self, path: str, interface: str) -> ProxyInterface:
|
||||||
|
"""Get a proxy interface for an object."""
|
||||||
|
await self._ensure_connected()
|
||||||
|
assert self._bus is not None
|
||||||
|
|
||||||
|
introspection = await self._bus.introspect(OBEX_SERVICE, path)
|
||||||
|
proxy = self._bus.get_proxy_object(OBEX_SERVICE, path, introspection)
|
||||||
|
return proxy.get_interface(interface)
|
||||||
|
|
||||||
|
async def _get_property(self, path: str, interface: str, prop: str) -> Any:
|
||||||
|
"""Get a single property from an object."""
|
||||||
|
props_iface = await self._get_interface(path, DBUS_PROPS_IFACE)
|
||||||
|
result = await props_iface.call_get(interface, prop)
|
||||||
|
return unwrap_variant(result)
|
||||||
|
|
||||||
|
async def _get_all_properties(self, path: str, interface: str) -> dict[str, Any]:
|
||||||
|
"""Get all properties from an object."""
|
||||||
|
props_iface = await self._get_interface(path, DBUS_PROPS_IFACE)
|
||||||
|
props = await props_iface.call_get_all(interface)
|
||||||
|
return {k: unwrap_variant(v) for k, v in props.items()}
|
||||||
|
|
||||||
|
# ==================== Session Management ====================
|
||||||
|
|
||||||
|
async def create_session(self, address: str, target: ObexTarget) -> ObexSession:
|
||||||
|
"""Create an OBEX session to a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bluetooth device address
|
||||||
|
target: OBEX target profile (opp, ftp, pbap, map)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ObexSession with session details
|
||||||
|
"""
|
||||||
|
await self._ensure_connected()
|
||||||
|
assert self._client is not None
|
||||||
|
|
||||||
|
# Build session options
|
||||||
|
options: dict[str, Variant] = {
|
||||||
|
"Target": Variant("s", target),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create the session
|
||||||
|
session_path = await self._client.call_create_session(address, options)
|
||||||
|
|
||||||
|
# Get session properties
|
||||||
|
props = await self._get_all_properties(session_path, OBEX_SESSION_IFACE)
|
||||||
|
|
||||||
|
# Generate session ID and store
|
||||||
|
session_id = generate_session_id(address, target)
|
||||||
|
|
||||||
|
# Handle duplicate sessions by appending timestamp
|
||||||
|
if session_id in self._sessions:
|
||||||
|
session_id = f"{session_id}_{int(time.time())}"
|
||||||
|
|
||||||
|
session = ObexSession(
|
||||||
|
session_id=session_id,
|
||||||
|
path=session_path,
|
||||||
|
address=address,
|
||||||
|
target=target,
|
||||||
|
source=props.get("Source", ""),
|
||||||
|
root=props.get("Root", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def remove_session(self, session_id: str) -> None:
|
||||||
|
"""Remove/close an OBEX session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID from create_session
|
||||||
|
"""
|
||||||
|
if session_id not in self._sessions:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
|
||||||
|
session = self._sessions[session_id]
|
||||||
|
await self._ensure_connected()
|
||||||
|
assert self._client is not None
|
||||||
|
|
||||||
|
await self._client.call_remove_session(session.path)
|
||||||
|
del self._sessions[session_id]
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> ObexSession | None:
|
||||||
|
"""Get a session by ID."""
|
||||||
|
return self._sessions.get(session_id)
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[ObexSession]:
|
||||||
|
"""List all active sessions."""
|
||||||
|
return list(self._sessions.values())
|
||||||
|
|
||||||
|
async def get_session_info(self, session_id: str) -> dict[str, Any]:
|
||||||
|
"""Get detailed session info from D-Bus."""
|
||||||
|
if session_id not in self._sessions:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
|
||||||
|
session = self._sessions[session_id]
|
||||||
|
props = await self._get_all_properties(session.path, OBEX_SESSION_IFACE)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"path": session.path,
|
||||||
|
"address": session.address,
|
||||||
|
"target": session.target,
|
||||||
|
**props,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== Transfer Operations ====================
|
||||||
|
|
||||||
|
async def get_transfer_status(self, transfer_path: str) -> ObexTransfer:
|
||||||
|
"""Get the status of a transfer."""
|
||||||
|
props = await self._get_all_properties(transfer_path, OBEX_TRANSFER_IFACE)
|
||||||
|
|
||||||
|
return ObexTransfer(
|
||||||
|
path=transfer_path,
|
||||||
|
status=props.get("Status", "unknown"),
|
||||||
|
name=props.get("Name", ""),
|
||||||
|
size=props.get("Size", 0),
|
||||||
|
transferred=props.get("Transferred", 0),
|
||||||
|
filename=props.get("Filename", ""),
|
||||||
|
session=props.get("Session", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait_for_transfer(
|
||||||
|
self,
|
||||||
|
transfer_path: str,
|
||||||
|
timeout: int = 300,
|
||||||
|
poll_interval: float = 0.5,
|
||||||
|
) -> ObexTransfer:
|
||||||
|
"""Wait for a transfer to complete.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transfer_path: D-Bus path of the transfer
|
||||||
|
timeout: Maximum wait time in seconds
|
||||||
|
poll_interval: How often to check status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final transfer status
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
transfer = await self.get_transfer_status(transfer_path)
|
||||||
|
|
||||||
|
if transfer.status in ("complete", "error"):
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
if time.time() - start_time > timeout:
|
||||||
|
raise TimeoutError(f"Transfer timed out after {timeout}s")
|
||||||
|
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
|
||||||
|
async def cancel_transfer(self, transfer_path: str) -> None:
|
||||||
|
"""Cancel an active transfer."""
|
||||||
|
iface = await self._get_interface(transfer_path, OBEX_TRANSFER_IFACE)
|
||||||
|
await iface.call_cancel()
|
||||||
|
|
||||||
|
# ==================== OPP (Object Push) Operations ====================
|
||||||
|
|
||||||
|
async def opp_send_file(self, session_id: str, local_path: str) -> str:
|
||||||
|
"""Send a file via Object Push Profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be OPP session)
|
||||||
|
local_path: Path to local file to send
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "opp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an OPP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_OPP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_send_file(local_path)
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def opp_pull_business_card(self, session_id: str, target_path: str) -> str:
|
||||||
|
"""Pull the default business card (vCard) from device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be OPP session)
|
||||||
|
target_path: Local path to save the vCard
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "opp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an OPP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_OPP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_pull_business_card(target_path)
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def opp_exchange_business_cards(
|
||||||
|
self, session_id: str, client_vcard: str, target_path: str
|
||||||
|
) -> str:
|
||||||
|
"""Exchange business cards with device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID
|
||||||
|
client_vcard: Path to local vCard to send
|
||||||
|
target_path: Path to save received vCard
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "opp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an OPP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_OPP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_exchange_business_cards(client_vcard, target_path)
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
# ==================== FTP (File Transfer) Operations ====================
|
||||||
|
|
||||||
|
async def ftp_change_folder(self, session_id: str, folder: str) -> None:
|
||||||
|
"""Change the current folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
folder: Folder name, ".." for parent, or "/" for root
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
|
||||||
|
# obexd uses different methods for navigation
|
||||||
|
if folder == "/":
|
||||||
|
# Go to root
|
||||||
|
await iface.call_change_folder("/")
|
||||||
|
elif folder == "..":
|
||||||
|
# Go to parent
|
||||||
|
await iface.call_change_folder("..")
|
||||||
|
else:
|
||||||
|
await iface.call_change_folder(folder)
|
||||||
|
|
||||||
|
async def ftp_list_folder(self, session_id: str) -> list[FolderEntry]:
|
||||||
|
"""List contents of current folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folder entries
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
result = await iface.call_list_folder()
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in result:
|
||||||
|
item = unwrap_variant(item)
|
||||||
|
entries.append(
|
||||||
|
FolderEntry(
|
||||||
|
name=item.get("Name", ""),
|
||||||
|
type=item.get("Type", "file"),
|
||||||
|
size=item.get("Size", 0),
|
||||||
|
permissions=item.get("User-perm", ""),
|
||||||
|
modified=item.get("Modified", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def ftp_get_file(
|
||||||
|
self, session_id: str, remote_path: str, local_path: str
|
||||||
|
) -> str:
|
||||||
|
"""Download a file from the device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
remote_path: Remote file name in current folder
|
||||||
|
local_path: Local path to save the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_get_file(local_path, remote_path)
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def ftp_put_file(
|
||||||
|
self, session_id: str, local_path: str, remote_path: str
|
||||||
|
) -> str:
|
||||||
|
"""Upload a file to the device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
local_path: Local file path
|
||||||
|
remote_path: Remote file name in current folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_put_file(local_path, remote_path)
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def ftp_copy_file(
|
||||||
|
self, session_id: str, source: str, destination: str
|
||||||
|
) -> None:
|
||||||
|
"""Copy a file on the device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
source: Source file name
|
||||||
|
destination: Destination file name
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
await iface.call_copy_file(source, destination)
|
||||||
|
|
||||||
|
async def ftp_move_file(
|
||||||
|
self, session_id: str, source: str, destination: str
|
||||||
|
) -> None:
|
||||||
|
"""Move/rename a file on the device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
source: Source file name
|
||||||
|
destination: Destination file name
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
await iface.call_move_file(source, destination)
|
||||||
|
|
||||||
|
async def ftp_delete(self, session_id: str, name: str) -> None:
|
||||||
|
"""Delete a file or folder on the device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
name: File or folder name to delete
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
await iface.call_delete(name)
|
||||||
|
|
||||||
|
async def ftp_create_folder(self, session_id: str, name: str) -> None:
|
||||||
|
"""Create a folder on the device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be FTP session)
|
||||||
|
name: Folder name to create
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "ftp":
|
||||||
|
raise ValueError(f"Session {session_id} is not an FTP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
|
||||||
|
await iface.call_create_folder(name)
|
||||||
|
|
||||||
|
# ==================== PBAP (Phonebook) Operations ====================
|
||||||
|
|
||||||
|
async def pbap_select(self, session_id: str, location: str, phonebook: str) -> None:
|
||||||
|
"""Select a phonebook for subsequent operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be PBAP session)
|
||||||
|
location: "int" (internal) or "sim" (SIM card)
|
||||||
|
phonebook: Phonebook type - "pb" (contacts), "ich" (incoming calls),
|
||||||
|
"och" (outgoing calls), "mch" (missed calls), "cch" (combined)
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "pbap":
|
||||||
|
raise ValueError(f"Session {session_id} is not a PBAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
|
||||||
|
await iface.call_select(location, phonebook)
|
||||||
|
|
||||||
|
async def pbap_pull_all(self, session_id: str, target_path: str) -> str:
|
||||||
|
"""Download the entire phonebook as vCard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be PBAP session)
|
||||||
|
target_path: Local path to save the vCards
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "pbap":
|
||||||
|
raise ValueError(f"Session {session_id} is not a PBAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_pull_all(target_path, {})
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def pbap_list(self, session_id: str) -> list[PhonebookEntry]:
|
||||||
|
"""List phonebook entries (handles and names only).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be PBAP session)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of phonebook entries with handles
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "pbap":
|
||||||
|
raise ValueError(f"Session {session_id} is not a PBAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
|
||||||
|
result = await iface.call_list({})
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in result:
|
||||||
|
item = unwrap_variant(item)
|
||||||
|
entries.append(
|
||||||
|
PhonebookEntry(
|
||||||
|
handle=item.get("Handle", ""),
|
||||||
|
name=item.get("Name", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def pbap_pull(self, session_id: str, handle: str, target_path: str) -> str:
|
||||||
|
"""Download a specific phonebook entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be PBAP session)
|
||||||
|
handle: Entry handle from pbap_list
|
||||||
|
target_path: Local path to save the vCard
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "pbap":
|
||||||
|
raise ValueError(f"Session {session_id} is not a PBAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_pull(handle, target_path, {})
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def pbap_search(
|
||||||
|
self, session_id: str, field: str, value: str
|
||||||
|
) -> list[PhonebookEntry]:
|
||||||
|
"""Search the phonebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be PBAP session)
|
||||||
|
field: Field to search - "name", "number", or "sound"
|
||||||
|
value: Value to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching entries
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "pbap":
|
||||||
|
raise ValueError(f"Session {session_id} is not a PBAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
|
||||||
|
result = await iface.call_search(field, value, {})
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in result:
|
||||||
|
item = unwrap_variant(item)
|
||||||
|
entries.append(
|
||||||
|
PhonebookEntry(
|
||||||
|
handle=item.get("Handle", ""),
|
||||||
|
name=item.get("Name", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def pbap_get_size(self, session_id: str) -> int:
|
||||||
|
"""Get the number of entries in the selected phonebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be PBAP session)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "pbap":
|
||||||
|
raise ValueError(f"Session {session_id} is not a PBAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
|
||||||
|
size = await iface.call_get_size()
|
||||||
|
return size
|
||||||
|
|
||||||
|
# ==================== MAP (Message Access) Operations ====================
|
||||||
|
|
||||||
|
async def map_set_folder(self, session_id: str, folder: str) -> None:
|
||||||
|
"""Set the current message folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be MAP session)
|
||||||
|
folder: Folder path (e.g., "/telecom/msg/inbox")
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "map":
|
||||||
|
raise ValueError(f"Session {session_id} is not a MAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
|
||||||
|
await iface.call_set_folder(folder)
|
||||||
|
|
||||||
|
async def map_list_folders(self, session_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""List message folders.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be MAP session)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folder dictionaries
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "map":
|
||||||
|
raise ValueError(f"Session {session_id} is not a MAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
|
||||||
|
result = await iface.call_list_folders({})
|
||||||
|
|
||||||
|
folders = []
|
||||||
|
for item in result:
|
||||||
|
folders.append(unwrap_variant(item))
|
||||||
|
return folders
|
||||||
|
|
||||||
|
async def map_list_messages(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
folder: str,
|
||||||
|
filters: dict[str, Any] | None = None,
|
||||||
|
) -> list[MessageEntry]:
|
||||||
|
"""List messages in a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be MAP session)
|
||||||
|
folder: Folder name
|
||||||
|
filters: Optional filters (max_count, start_offset, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of message entries
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "map":
|
||||||
|
raise ValueError(f"Session {session_id} is not a MAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
|
||||||
|
|
||||||
|
# Convert filters to D-Bus format
|
||||||
|
dbus_filters: dict[str, Variant] = {}
|
||||||
|
if filters:
|
||||||
|
for key, value in filters.items():
|
||||||
|
if isinstance(value, bool):
|
||||||
|
dbus_filters[key] = Variant("b", value)
|
||||||
|
elif isinstance(value, int):
|
||||||
|
dbus_filters[key] = Variant("u", value)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
dbus_filters[key] = Variant("s", value)
|
||||||
|
|
||||||
|
result = await iface.call_list_messages(folder, dbus_filters)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in result:
|
||||||
|
item = unwrap_variant(item)
|
||||||
|
entries.append(
|
||||||
|
MessageEntry(
|
||||||
|
handle=item.get("Handle", ""),
|
||||||
|
subject=item.get("Subject", ""),
|
||||||
|
datetime=item.get("DateTime", ""),
|
||||||
|
sender_name=item.get("SenderName", ""),
|
||||||
|
sender_address=item.get("SenderAddress", ""),
|
||||||
|
type=item.get("Type", ""),
|
||||||
|
read=item.get("Read", False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def map_get_message(
|
||||||
|
self, session_id: str, handle: str, target_path: str, attachment: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Download a specific message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be MAP session)
|
||||||
|
handle: Message handle
|
||||||
|
target_path: Local path to save the message
|
||||||
|
attachment: Whether to include attachments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transfer path for monitoring
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "map":
|
||||||
|
raise ValueError(f"Session {session_id} is not a MAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
|
||||||
|
transfer_path, _ = await iface.call_get_message(
|
||||||
|
handle, target_path, {"Attachment": Variant("b", attachment)}
|
||||||
|
)
|
||||||
|
return transfer_path
|
||||||
|
|
||||||
|
async def map_push_message(
|
||||||
|
self, session_id: str, folder: str, message_path: str
|
||||||
|
) -> str:
|
||||||
|
"""Push/send a message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be MAP session)
|
||||||
|
folder: Target folder
|
||||||
|
message_path: Path to message file (bMessage format)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Message handle
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "map":
|
||||||
|
raise ValueError(f"Session {session_id} is not a MAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
|
||||||
|
handle = await iface.call_push_message(message_path, folder, {})
|
||||||
|
return handle
|
||||||
|
|
||||||
|
async def map_update_inbox(self, session_id: str) -> None:
|
||||||
|
"""Request the server to refresh the inbox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID (must be MAP session)
|
||||||
|
"""
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"Unknown session: {session_id}")
|
||||||
|
if session.target != "map":
|
||||||
|
raise ValueError(f"Session {session_id} is not a MAP session")
|
||||||
|
|
||||||
|
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
|
||||||
|
await iface.call_update_inbox()
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: ObexClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_obex_client() -> ObexClient:
|
||||||
|
"""Get or create the global OBEX client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = ObexClient()
|
||||||
|
await _client.connect()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_stale_sessions() -> None:
|
||||||
|
"""Clean up sessions that may be stale."""
|
||||||
|
if _client:
|
||||||
|
# Check each session is still valid
|
||||||
|
for session_id in list(_client._sessions.keys()):
|
||||||
|
try:
|
||||||
|
await _client.get_session_info(session_id)
|
||||||
|
except Exception:
|
||||||
|
# Session no longer exists, remove from tracking
|
||||||
|
del _client._sessions[session_id]
|
||||||
@ -3,7 +3,7 @@
|
|||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from mcbluetooth import resources
|
from mcbluetooth import resources
|
||||||
from mcbluetooth.tools import adapter, audio, ble, device, monitor
|
from mcbluetooth.tools import adapter, audio, ble, device, monitor, obex
|
||||||
|
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
name="mcbluetooth",
|
name="mcbluetooth",
|
||||||
@ -14,6 +14,7 @@ This server provides comprehensive control over the Linux Bluetooth stack:
|
|||||||
- Device discovery and management (scan, pair, connect)
|
- Device discovery and management (scan, pair, connect)
|
||||||
- Audio profiles (A2DP, HFP) with PipeWire/PulseAudio integration
|
- Audio profiles (A2DP, HFP) with PipeWire/PulseAudio integration
|
||||||
- BLE/GATT services (read/write characteristics, notifications)
|
- BLE/GATT services (read/write characteristics, notifications)
|
||||||
|
- OBEX profiles (file transfer, phonebook access, messages)
|
||||||
|
|
||||||
## Resources (live state queries)
|
## Resources (live state queries)
|
||||||
- bluetooth://adapters - All Bluetooth adapters
|
- bluetooth://adapters - All Bluetooth adapters
|
||||||
@ -44,6 +45,7 @@ device.register_tools(mcp)
|
|||||||
audio.register_tools(mcp)
|
audio.register_tools(mcp)
|
||||||
ble.register_tools(mcp)
|
ble.register_tools(mcp)
|
||||||
monitor.register_tools(mcp)
|
monitor.register_tools(mcp)
|
||||||
|
obex.register_tools(mcp)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""MCP tool modules for Bluetooth management."""
|
"""MCP tool modules for Bluetooth management."""
|
||||||
|
|
||||||
from mcbluetooth.tools import adapter, audio, ble, device, monitor
|
from mcbluetooth.tools import adapter, audio, ble, device, monitor, obex
|
||||||
|
|
||||||
__all__ = ["adapter", "device", "audio", "ble", "monitor"]
|
__all__ = ["adapter", "device", "audio", "ble", "monitor", "obex"]
|
||||||
|
|||||||
1395
src/mcbluetooth/tools/obex.py
Normal file
1395
src/mcbluetooth/tools/obex.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user