Compare commits
10 Commits
cd03fa9253
...
41306bb36f
| Author | SHA1 | Date | |
|---|---|---|---|
| 41306bb36f | |||
| 0799067a1a | |||
| 2597c8b8b4 | |||
| 1bfb7b57ee | |||
| 31b911febd | |||
| f6b2ac40fb | |||
| 9d1c0f3e0f | |||
| e9f06173c5 | |||
| 8cbbcfa286 | |||
| 7f3b096c83 |
619
README.md
619
README.md
@ -1,180 +1,501 @@
|
|||||||
# mcbluetooth
|
<p align="center">
|
||||||
|
<pre>
|
||||||
|
╔╗ ╔╗ ╔╗ ╔╗ ╔╗
|
||||||
|
║║ ║║ ║║ ╔╗ ║║ ║║
|
||||||
|
╔╗╔╗╔══╗╔══╗ ║╚═╗║║ ╔╗ ╔╗╔══╝║╔══╗╔═╝║╔══╗╔══╝║╔═╝║╔══╗
|
||||||
|
║╚╝║║╔═╝║╔╗║ ║╔╗║║║ ║║ ║║║╔══╝║╔╗║║╔╗║║╔╗║║╔╗║║╔╗║║╔╗║
|
||||||
|
║║║║║╚═╗║╚╝╚╗ ║╚╝║║╚╗║╚═╝║║╚══╗║╚╝║║╚╝║║╚╝║║╚╝║║╚╝║║║║║
|
||||||
|
╚╩╩╝╚══╝╚═══╝ ╚══╝╚═╝╚═══╝╚═══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚╝╚╝
|
||||||
|
</pre>
|
||||||
|
</p>
|
||||||
|
|
||||||
A comprehensive MCP server exposing the Linux Bluetooth stack (BlueZ) to LLMs.
|
<p align="center">
|
||||||
|
<strong>Give LLMs control of your Linux Bluetooth stack</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#installation">Installation</a> •
|
||||||
|
<a href="#quick-start">Quick Start</a> •
|
||||||
|
<a href="#features">Features</a> •
|
||||||
|
<a href="#tools-reference">Tools</a> •
|
||||||
|
<a href="#architecture">Architecture</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/python-3.11+-blue.svg" alt="Python 3.11+">
|
||||||
|
<img src="https://img.shields.io/badge/BlueZ-5.x-blue.svg" alt="BlueZ 5.x">
|
||||||
|
<img src="https://img.shields.io/badge/MCP-FastMCP-green.svg" alt="FastMCP">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**mcbluetooth** is a comprehensive [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes the Linux Bluetooth stack to LLMs. Ask Claude to scan for devices, pair your headphones, adjust volume, read BLE sensors, or capture Bluetooth traffic — all through natural language.
|
||||||
|
|
||||||
|
## Why mcbluetooth?
|
||||||
|
|
||||||
|
- **Natural Language Control** — "Connect to my headphones and set volume to 80%"
|
||||||
|
- **Full BlueZ Integration** — Adapters, devices, pairing, audio profiles, BLE/GATT
|
||||||
|
- **Smart Pairing Agent** — Handles PIN codes, passkeys, and confirmations automatically
|
||||||
|
- **Protocol Analysis** — Capture and analyze Bluetooth traffic with btmon integration
|
||||||
|
- **Audio Intelligence** — PipeWire/PulseAudio integration for seamless audio control
|
||||||
|
- **HFP Audio Gateway** — Act as a phone for headset testing and call simulation
|
||||||
|
- **OBEX Profiles** — File transfer, phonebook access, and message access
|
||||||
|
|
||||||
|
## Example Conversation
|
||||||
|
|
||||||
|
```
|
||||||
|
You: Scan for Bluetooth devices and connect to my Bose headphones
|
||||||
|
|
||||||
|
Claude: I'll scan for nearby Bluetooth devices.
|
||||||
|
|
||||||
|
[Calling bt_scan with adapter="hci0", timeout=10, mode="both"]
|
||||||
|
|
||||||
|
Found 5 devices:
|
||||||
|
• Bombay (C8:7B:23:55:68:E8) - Bose NCH700, paired
|
||||||
|
• DS18 BT-TWO (20:23:05:26:02:31) - Audio device
|
||||||
|
• iPhone (AA:BB:CC:DD:EE:FF) - Phone
|
||||||
|
...
|
||||||
|
|
||||||
|
I found your Bose headphones "Bombay". Connecting now...
|
||||||
|
|
||||||
|
[Calling bt_connect with adapter="hci0", address="C8:7B:23:55:68:E8"]
|
||||||
|
|
||||||
|
✓ Connected to Bombay! The headphones are ready. Would you like me
|
||||||
|
to set them as the default audio output?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Using uvx (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run directly
|
||||||
|
uvx mcbluetooth
|
||||||
|
|
||||||
|
# Or install globally
|
||||||
|
uv tool install mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/mcbluetooth
|
||||||
|
cd mcbluetooth
|
||||||
|
uv sync
|
||||||
|
uv run mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Add to Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From PyPI
|
||||||
|
claude mcp add mcbluetooth -- uvx mcbluetooth
|
||||||
|
|
||||||
|
# From source
|
||||||
|
claude mcp add mcbluetooth -- uv run --directory /path/to/mcbluetooth mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions Setup
|
||||||
|
|
||||||
|
mcbluetooth needs access to the BlueZ D-Bus interface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Add user to bluetooth group (requires re-login)
|
||||||
|
sudo usermod -aG bluetooth $USER
|
||||||
|
|
||||||
|
# Option 2: Run with current permissions (if polkit is configured)
|
||||||
|
# Most desktop Linux distributions allow this by default
|
||||||
|
```
|
||||||
|
|
||||||
|
For HCI packet capture (btmon), additional permissions are needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allow btmon without sudo
|
||||||
|
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
|
||||||
- List, power, and configure Bluetooth adapters
|
Control your Bluetooth hardware — power, discovery, pairing acceptance.
|
||||||
- Control discoverable and pairable states
|
|
||||||
- Set adapter aliases
|
|
||||||
|
|
||||||
### Device Management
|
```
|
||||||
- Classic Bluetooth and BLE scanning with filters
|
"Turn on Bluetooth"
|
||||||
- Pairing with multi-modal support (elicit, interactive, auto)
|
"Make my computer discoverable for 2 minutes"
|
||||||
- Connect/disconnect/trust/block devices
|
"List all Bluetooth adapters"
|
||||||
- View device properties including RSSI, UUIDs, manufacturer data
|
|
||||||
|
|
||||||
### Audio Profiles (A2DP/HFP)
|
|
||||||
- List audio devices (sinks, sources, cards)
|
|
||||||
- Connect/disconnect audio profiles
|
|
||||||
- Switch between A2DP (high quality) and HFP (calls with mic)
|
|
||||||
- Volume control and muting
|
|
||||||
- Set default audio device (PipeWire/PulseAudio integration)
|
|
||||||
|
|
||||||
### Bluetooth Low Energy (BLE)
|
|
||||||
- BLE-specific scanning with name/service filters
|
|
||||||
- GATT service discovery
|
|
||||||
- Read/write characteristics
|
|
||||||
- Enable/disable notifications
|
|
||||||
- Battery level reading (standard Battery Service)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install with uv (recommended)
|
|
||||||
uvx mcbluetooth
|
|
||||||
|
|
||||||
# Or install from source
|
|
||||||
uv pip install -e .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage with Claude Code
|
### Device Discovery & Pairing
|
||||||
|
Scan for devices with Classic Bluetooth or BLE filters. Smart pairing agent handles PIN codes, numeric comparison, and passkey entry automatically.
|
||||||
|
|
||||||
```bash
|
```
|
||||||
# Add to Claude Code (from source)
|
"Scan for nearby Bluetooth devices"
|
||||||
claude mcp add mcbluetooth-local -- uv run --directory /path/to/mcbluetooth mcbluetooth
|
"Pair with the Sony headphones"
|
||||||
|
"Show me all paired devices"
|
||||||
# Or if published to PyPI
|
"Remove the old keyboard from paired devices"
|
||||||
claude mcp add mcbluetooth -- uvx mcbluetooth
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
### Audio Control
|
||||||
|
Full PipeWire/PulseAudio integration for Bluetooth audio devices.
|
||||||
|
|
||||||
- Linux with BlueZ 5.x
|
|
||||||
- Python 3.11+
|
|
||||||
- PipeWire or PulseAudio (for audio features)
|
|
||||||
- User must be in `bluetooth` group or have polkit permissions
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add user to bluetooth group
|
|
||||||
sudo usermod -aG bluetooth $USER
|
|
||||||
|
|
||||||
# Or configure polkit for BlueZ D-Bus access
|
|
||||||
```
|
```
|
||||||
|
"Connect my headphones and set them as default output"
|
||||||
|
"Switch to HFP mode for a phone call"
|
||||||
|
"Set headphone volume to 70%"
|
||||||
|
"Mute Bluetooth audio"
|
||||||
|
```
|
||||||
|
|
||||||
|
### BLE / GATT
|
||||||
|
Interact with Bluetooth Low Energy devices — read sensors, write commands, subscribe to notifications.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Connect to my fitness tracker and read the battery level"
|
||||||
|
"List all GATT services on the heart rate monitor"
|
||||||
|
"Read the temperature from the BLE thermometer"
|
||||||
|
"Subscribe to heart rate notifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
### HFP Audio Gateway
|
||||||
|
Act as a phone (Audio Gateway) for testing Bluetooth headsets. Simulate calls, control volume, and update status indicators over the HFP AT command protocol.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Enable HFP Audio Gateway mode"
|
||||||
|
"Simulate an incoming call to the connected headset"
|
||||||
|
"Set the headset speaker volume to 12"
|
||||||
|
"Update the battery indicator to level 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol Analysis
|
||||||
|
Capture and analyze Bluetooth traffic for debugging and reverse engineering.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Start capturing Bluetooth traffic"
|
||||||
|
"Show me the last 50 HCI packets"
|
||||||
|
"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
|
||||||
|
|
||||||
|
### Adapter Tools (6)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_list_adapters` | List all Bluetooth adapters with status |
|
||||||
|
| `bt_adapter_info` | Get detailed adapter properties |
|
||||||
|
| `bt_adapter_power` | Power adapter on/off |
|
||||||
|
| `bt_adapter_discoverable` | Set discoverable state and timeout |
|
||||||
|
| `bt_adapter_pairable` | Enable/disable pairing acceptance |
|
||||||
|
| `bt_adapter_set_alias` | Set adapter friendly name |
|
||||||
|
|
||||||
|
### Device Tools (12)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_scan` | Scan for devices (classic/BLE/both) |
|
||||||
|
| `bt_list_devices` | List known devices with filters |
|
||||||
|
| `bt_device_info` | Get device properties and services |
|
||||||
|
| `bt_device_set_alias` | Set device friendly name |
|
||||||
|
| `bt_pair` | Initiate pairing with agent support |
|
||||||
|
| `bt_pair_confirm` | Confirm/reject pending pairing request |
|
||||||
|
| `bt_pairing_status` | Check for pending pairing requests |
|
||||||
|
| `bt_unpair` | Remove device and pairing info |
|
||||||
|
| `bt_connect` | Connect to paired device |
|
||||||
|
| `bt_disconnect` | Disconnect active connection |
|
||||||
|
| `bt_trust` | Set device trust status |
|
||||||
|
| `bt_block` | Block/unblock device |
|
||||||
|
|
||||||
|
### Audio Tools (7)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_audio_list` | List audio devices with profiles |
|
||||||
|
| `bt_audio_connect` | Connect audio profiles |
|
||||||
|
| `bt_audio_disconnect` | Disconnect audio |
|
||||||
|
| `bt_audio_set_profile` | Switch A2DP/HFP/HSP/off |
|
||||||
|
| `bt_audio_set_default` | Set as default audio sink |
|
||||||
|
| `bt_audio_volume` | Set volume (0-150%) |
|
||||||
|
| `bt_audio_mute` | Mute/unmute audio |
|
||||||
|
|
||||||
|
### HFP Audio Gateway Tools (8)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_hfp_ag_enable` | Register HFP AG profile with BlueZ |
|
||||||
|
| `bt_hfp_ag_disable` | Unregister HFP AG profile |
|
||||||
|
| `bt_hfp_ag_status` | Get AG connections, SLC state, indicators |
|
||||||
|
| `bt_hfp_ag_simulate_call` | Simulate incoming call (RING + CLIP) |
|
||||||
|
| `bt_hfp_ag_end_call` | Terminate call from AG side |
|
||||||
|
| `bt_hfp_ag_set_volume` | Set speaker or microphone volume (0-15) |
|
||||||
|
| `bt_hfp_ag_set_signal` | Update signal strength indicator (0-5) |
|
||||||
|
| `bt_hfp_ag_set_battery` | Update battery level indicator (0-5) |
|
||||||
|
|
||||||
|
### BLE Tools (7)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_ble_scan` | BLE-specific scan with filters |
|
||||||
|
| `bt_ble_services` | List GATT services |
|
||||||
|
| `bt_ble_characteristics` | List service characteristics |
|
||||||
|
| `bt_ble_read` | Read characteristic value |
|
||||||
|
| `bt_ble_write` | Write characteristic value |
|
||||||
|
| `bt_ble_notify` | Enable/disable notifications |
|
||||||
|
| `bt_ble_battery` | Read standard battery level |
|
||||||
|
|
||||||
|
### Monitor Tools (6)
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_capture_start` | Start HCI traffic capture |
|
||||||
|
| `bt_capture_stop` | Stop running capture |
|
||||||
|
| `bt_capture_list_active` | List active captures |
|
||||||
|
| `bt_capture_parse` | Parse btsnoop to structured data |
|
||||||
|
| `bt_capture_analyze` | Statistical analysis of capture |
|
||||||
|
| `bt_capture_read_raw` | Human-readable packet decode |
|
||||||
|
|
||||||
|
### 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: 69 tools**
|
||||||
|
|
||||||
## MCP Resources
|
## MCP Resources
|
||||||
|
|
||||||
The server exposes dynamic resources for live state queries:
|
Live state queries without tool calls:
|
||||||
|
|
||||||
| Resource URI | Description |
|
| Resource URI | Description |
|
||||||
|--------------|-------------|
|
|--------------|-------------|
|
||||||
| `bluetooth://adapters` | All Bluetooth adapters |
|
| `bluetooth://adapters` | All Bluetooth adapters |
|
||||||
| `bluetooth://paired` | Paired devices |
|
| `bluetooth://paired` | Paired devices |
|
||||||
| `bluetooth://connected` | Connected devices |
|
| `bluetooth://connected` | Currently connected devices |
|
||||||
| `bluetooth://visible` | All known devices |
|
| `bluetooth://visible` | All discovered devices |
|
||||||
| `bluetooth://trusted` | Trusted devices |
|
| `bluetooth://trusted` | Trusted devices |
|
||||||
| `bluetooth://adapter/{name}` | Specific adapter details |
|
| `bluetooth://adapter/{name}` | Specific adapter (e.g., `hci0`) |
|
||||||
| `bluetooth://device/{address}` | Specific device details |
|
| `bluetooth://device/{address}` | Specific device details |
|
||||||
|
|
||||||
## MCP Tools
|
|
||||||
|
|
||||||
### Adapter Tools
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `bt_list_adapters` | List all Bluetooth adapters |
|
|
||||||
| `bt_adapter_info` | Get adapter details |
|
|
||||||
| `bt_adapter_power` | Power on/off |
|
|
||||||
| `bt_adapter_discoverable` | Set visible to other devices |
|
|
||||||
| `bt_adapter_pairable` | Enable/disable pairing acceptance |
|
|
||||||
| `bt_adapter_set_alias` | Set friendly name |
|
|
||||||
|
|
||||||
### Device Tools
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `bt_scan` | Scan for devices (classic/BLE/both) |
|
|
||||||
| `bt_list_devices` | List known devices with filters |
|
|
||||||
| `bt_device_info` | Get device details |
|
|
||||||
| `bt_pair` | Initiate pairing |
|
|
||||||
| `bt_pair_confirm` | Confirm/reject pairing |
|
|
||||||
| `bt_unpair` | Remove device |
|
|
||||||
| `bt_connect` | Connect to paired device |
|
|
||||||
| `bt_disconnect` | Disconnect device |
|
|
||||||
| `bt_trust` | Trust/untrust device |
|
|
||||||
| `bt_block` | Block/unblock device |
|
|
||||||
|
|
||||||
### Audio Tools
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `bt_audio_list` | List all audio devices |
|
|
||||||
| `bt_audio_connect` | Connect audio profiles |
|
|
||||||
| `bt_audio_disconnect` | Disconnect audio |
|
|
||||||
| `bt_audio_set_profile` | Switch A2DP/HFP/off |
|
|
||||||
| `bt_audio_set_default` | Set as default sink |
|
|
||||||
| `bt_audio_volume` | Set volume (0-150) |
|
|
||||||
| `bt_audio_mute` | Mute/unmute |
|
|
||||||
|
|
||||||
### BLE Tools
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `bt_ble_scan` | BLE scan with filters |
|
|
||||||
| `bt_ble_services` | List GATT services |
|
|
||||||
| `bt_ble_characteristics` | List characteristics |
|
|
||||||
| `bt_ble_read` | Read characteristic value |
|
|
||||||
| `bt_ble_write` | Write characteristic value |
|
|
||||||
| `bt_ble_notify` | Enable/disable notifications |
|
|
||||||
| `bt_ble_battery` | Read battery level |
|
|
||||||
|
|
||||||
### Monitor Tools (btmon integration)
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `bt_capture_start` | Start HCI traffic capture to btsnoop file |
|
|
||||||
| `bt_capture_stop` | Stop a running capture |
|
|
||||||
| `bt_capture_list_active` | List active captures |
|
|
||||||
| `bt_capture_parse` | Parse btsnoop file into structured packets |
|
|
||||||
| `bt_capture_analyze` | Analyze capture with btmon statistics |
|
|
||||||
| `bt_capture_read_raw` | Read human-readable decoded packets |
|
|
||||||
|
|
||||||
> **Note:** Live capture requires elevated privileges. Run `sudo setcap cap_net_raw+ep /usr/bin/btmon` to enable without sudo.
|
|
||||||
|
|
||||||
## Example Prompts
|
|
||||||
|
|
||||||
```
|
|
||||||
# Discover and connect headphones
|
|
||||||
"Scan for Bluetooth devices and connect to my Sony headphones"
|
|
||||||
|
|
||||||
# Switch audio profile for calls
|
|
||||||
"Switch my headphones to HFP mode for a phone call"
|
|
||||||
|
|
||||||
# Read from a fitness tracker
|
|
||||||
"Connect to my fitness band and read the battery level"
|
|
||||||
|
|
||||||
# Set up audio output
|
|
||||||
"List all Bluetooth audio devices and set my speaker as the default"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ FastMCP Server │
|
│ Claude / LLM │
|
||||||
├─────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ Tool Categories │
|
│ MCP Protocol (stdio) │
|
||||||
│ ┌─────────┬─────────┬─────────┬─────────┬────────┐ │
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ │ Adapter │ Device │ Audio │ BLE │Monitor │ │
|
│ │
|
||||||
│ │ Tools │ Tools │ Tools │ Tools │ Tools │ │
|
│ ┌──────────────────── FastMCP Server ──────────────────────┐ │
|
||||||
│ └─────────┴─────────┴─────────┴─────────┴────────┘ │
|
│ │ │ │
|
||||||
├─────────────────────────────────────────────────────┤
|
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐│
|
||||||
│ BlueZ D-Bus Client │ btmon (HCI capture) │
|
│ │ │ Adapter │ │ Device │ │Audio/HFP│ │ BLE │ │ OBEX ││
|
||||||
│ (dbus-fast) │ (btsnoop format) │
|
│ │ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │ Tools ││
|
||||||
├─────────────────────────────────────────────────────┤
|
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘│
|
||||||
│ PipeWire/PulseAudio Integration │
|
│ │ │ │ │ │ │ │
|
||||||
│ (pulsectl-asyncio) │
|
│ │ ┌────┴───────────┴───────────┴───────────┴────┐ │ │
|
||||||
└─────────────────────────────────────────────────────┘
|
│ │ │ Pairing Agent (Agent1) │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────┐ ┌──────────────────────────┐ │ │
|
||||||
|
│ │ │ Monitor Tools │ │ Resource Providers │ │ │
|
||||||
|
│ │ └────────┬────────┘ └──────────────────────────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └───────────┼──────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
├───────────────┼──────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────┴────────────┐ ┌─────────────────────────────┐ │
|
||||||
|
│ │ BlueZ D-Bus Client │ │ PipeWire / PulseAudio │ │
|
||||||
|
│ │ (dbus-fast) │ │ (pulsectl-asyncio) │ │
|
||||||
|
│ └────────────┬────────────┘ └──────────────┬──────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
├───────────────┼─────────────────────────────────┼────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────────┴────────────┐ ┌──────────────┴──────────────┐ │
|
||||||
|
│ │ BlueZ (bluetoothd) │ │ btmon (HCI capture) │ │
|
||||||
|
│ │ D-Bus API │ │ btsnoop format │ │
|
||||||
|
│ └─────────────────────────┘ └─────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Linux Kernel │
|
||||||
|
│ (Bluetooth subsystem) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Pairing Agent
|
||||||
|
|
||||||
|
mcbluetooth implements the full BlueZ `org.bluez.Agent1` interface for handling pairing operations:
|
||||||
|
|
||||||
|
| Pairing Method | Description | Agent Callback |
|
||||||
|
|----------------|-------------|----------------|
|
||||||
|
| Just Works | No user interaction | `RequestAuthorization` |
|
||||||
|
| Numeric Comparison | Confirm 6-digit code matches | `RequestConfirmation` |
|
||||||
|
| Passkey Entry | Enter code shown on other device | `RequestPasskey` |
|
||||||
|
| Legacy PIN | Enter 4-6 digit PIN | `RequestPinCode` |
|
||||||
|
|
||||||
|
**Pairing modes:**
|
||||||
|
- `interactive` (default) — Returns pending status, use `bt_pair_confirm` to respond
|
||||||
|
- `auto` — Automatically accepts all pairings (use in trusted environments)
|
||||||
|
- `elicit` — Uses MCP elicitation for direct user prompts (if client supports)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Requirement | Version | Notes |
|
||||||
|
|-------------|---------|-------|
|
||||||
|
| Linux | Any | BlueZ is Linux-only |
|
||||||
|
| Python | 3.11+ | Async/await, type hints |
|
||||||
|
| BlueZ | 5.x | Bluetooth daemon |
|
||||||
|
| PipeWire or PulseAudio | Any | For audio features |
|
||||||
|
|
||||||
|
### Tested Configurations
|
||||||
|
|
||||||
|
- Arch Linux + BlueZ 5.85 + PipeWire 1.4
|
||||||
|
- Ubuntu 22.04 + BlueZ 5.64 + PulseAudio
|
||||||
|
- Fedora 39 + BlueZ 5.70 + PipeWire
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and setup
|
||||||
|
git clone https://github.com/yourusername/mcbluetooth
|
||||||
|
cd mcbluetooth
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
uv run ruff check src/
|
||||||
|
|
||||||
|
# Run server locally
|
||||||
|
uv run mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mcbluetooth/
|
||||||
|
├── src/mcbluetooth/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── server.py # FastMCP server entry point
|
||||||
|
│ ├── dbus_client.py # BlueZ D-Bus wrapper
|
||||||
|
│ ├── obex_client.py # obexd D-Bus wrapper
|
||||||
|
│ ├── audio.py # PipeWire/Pulse integration
|
||||||
|
│ ├── agent.py # Pairing agent (Agent1)
|
||||||
|
│ ├── hfp_ag.py # HFP Audio Gateway (Profile1)
|
||||||
|
│ └── tools/
|
||||||
|
│ ├── adapter.py # Adapter management
|
||||||
|
│ ├── device.py # Device management + pairing
|
||||||
|
│ ├── audio.py # Audio profile tools
|
||||||
|
│ ├── hfp.py # HFP AG call simulation
|
||||||
|
│ ├── ble.py # BLE/GATT tools
|
||||||
|
│ ├── monitor.py # btmon integration
|
||||||
|
│ └── obex.py # OBEX profile tools
|
||||||
|
├── tests/
|
||||||
|
├── pyproject.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] Adapter management
|
||||||
|
- [x] Device discovery and management
|
||||||
|
- [x] Pairing with Agent1 support
|
||||||
|
- [x] Audio profiles (A2DP/HFP)
|
||||||
|
- [x] HFP Audio Gateway (call simulation, indicators)
|
||||||
|
- [x] BLE/GATT operations
|
||||||
|
- [x] btmon packet capture
|
||||||
|
- [x] OBEX file transfer (OPP/FTP)
|
||||||
|
- [x] Phonebook access (PBAP)
|
||||||
|
- [x] Message access (MAP)
|
||||||
|
- [ ] Bluetooth Mesh (experimental)
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [mcbluetooth-esp32](https://github.com/supported-systems/mcbluetooth-esp32) — ESP32 test harness for automated E2E Bluetooth testing with mcbluetooth
|
||||||
|
- [FastMCP](https://gofastmcp.com) — The MCP framework powering this server
|
||||||
|
- [BlueZ](http://www.bluez.org/) — The official Linux Bluetooth stack
|
||||||
|
- [dbus-fast](https://github.com/bluetooth-devices/dbus-fast) — Fast D-Bus client for Python
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT License — See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Built with FastMCP • Powered by BlueZ • Made for LLMs
|
||||||
|
</p>
|
||||||
|
|||||||
21
docs-site/.gitignore
vendored
Normal file
21
docs-site/.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
49
docs-site/README.md
Normal file
49
docs-site/README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Starlight Starter Kit: Basics
|
||||||
|
|
||||||
|
[](https://starlight.astro.build)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm create astro@latest -- --template starlight
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ ├── assets/
|
||||||
|
│ ├── content/
|
||||||
|
│ │ └── docs/
|
||||||
|
│ └── content.config.ts
|
||||||
|
├── astro.config.mjs
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||||
|
|
||||||
|
Static assets, like favicons, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||||
87
docs-site/astro.config.mjs
Normal file
87
docs-site/astro.config.mjs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import starlight from '@astrojs/starlight';
|
||||||
|
import icon from 'astro-icon';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
// Site URL for sitemap generation
|
||||||
|
site: 'https://mcbluetooth.example.com',
|
||||||
|
|
||||||
|
// Disable telemetry
|
||||||
|
telemetry: false,
|
||||||
|
|
||||||
|
// Disable devToolbar
|
||||||
|
devToolbar: { enabled: false },
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
icon(),
|
||||||
|
starlight({
|
||||||
|
title: 'mcbluetooth',
|
||||||
|
description: 'Give LLMs control of your Linux Bluetooth stack',
|
||||||
|
logo: {
|
||||||
|
light: './src/assets/logo-light.svg',
|
||||||
|
dark: './src/assets/logo-dark.svg',
|
||||||
|
replacesTitle: false,
|
||||||
|
},
|
||||||
|
social: [
|
||||||
|
{ icon: 'github', label: 'GitHub', href: 'https://github.com/yourusername/mcbluetooth' },
|
||||||
|
],
|
||||||
|
customCss: [
|
||||||
|
'./src/styles/custom.css',
|
||||||
|
],
|
||||||
|
head: [
|
||||||
|
// Favicon
|
||||||
|
{ tag: 'link', attrs: { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' } },
|
||||||
|
],
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
label: 'Getting Started',
|
||||||
|
items: [
|
||||||
|
{ label: 'Introduction', slug: 'getting-started/introduction' },
|
||||||
|
{ label: 'Installation', slug: 'getting-started/installation' },
|
||||||
|
{ label: 'Quick Start', slug: 'getting-started/quick-start' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Guides',
|
||||||
|
items: [
|
||||||
|
{ label: 'Adapter Management', slug: 'guides/adapters' },
|
||||||
|
{ label: 'Device Pairing', slug: 'guides/pairing' },
|
||||||
|
{ label: 'Audio Control', slug: 'guides/audio' },
|
||||||
|
{ label: 'HFP Audio Gateway', slug: 'guides/hfp-ag' },
|
||||||
|
{ label: 'BLE & GATT', slug: 'guides/ble' },
|
||||||
|
{ label: 'OBEX File Transfer', slug: 'guides/obex' },
|
||||||
|
{ label: 'Phonebook & Messages', slug: 'guides/phonebook-messages' },
|
||||||
|
{ label: 'Protocol Capture', slug: 'guides/capture' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reference',
|
||||||
|
items: [
|
||||||
|
{ label: 'All Tools', slug: 'reference/tools' },
|
||||||
|
{ label: 'Adapter Tools', slug: 'reference/adapter-tools' },
|
||||||
|
{ label: 'Device Tools', slug: 'reference/device-tools' },
|
||||||
|
{ label: 'Audio Tools', slug: 'reference/audio-tools' },
|
||||||
|
{ label: 'HFP AG Tools', slug: 'reference/hfp-ag-tools' },
|
||||||
|
{ label: 'BLE Tools', slug: 'reference/ble-tools' },
|
||||||
|
{ label: 'OBEX Tools', slug: 'reference/obex-tools' },
|
||||||
|
{ label: 'Monitor Tools', slug: 'reference/monitor-tools' },
|
||||||
|
{ label: 'MCP Resources', slug: 'reference/resources' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Explanation',
|
||||||
|
items: [
|
||||||
|
{ label: 'Architecture', slug: 'explanation/architecture' },
|
||||||
|
{ label: 'Pairing Agent', slug: 'explanation/pairing-agent' },
|
||||||
|
{ label: 'OBEX Profiles', slug: 'explanation/obex-profiles' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editLink: {
|
||||||
|
baseUrl: 'https://github.com/yourusername/mcbluetooth/edit/main/docs-site/',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
6988
docs-site/package-lock.json
generated
Normal file
6988
docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
docs-site/package.json
Normal file
19
docs-site/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "docs-site",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/starlight": "^0.37.6",
|
||||||
|
"@iconify-json/lucide": "^1.2.87",
|
||||||
|
"astro": "^5.6.1",
|
||||||
|
"astro-icon": "^1.1.5",
|
||||||
|
"sharp": "^0.34.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
docs-site/public/favicon.svg
Normal file
5
docs-site/public/favicon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="#0066cc"/>
|
||||||
|
<path d="M16 6 L16 26 M16 6 L22 12 L16 16 L22 22 L16 26 M16 16 L10 10 M16 16 L10 22"
|
||||||
|
stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
BIN
docs-site/src/assets/houston.webp
Normal file
BIN
docs-site/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
5
docs-site/src/assets/logo-dark.svg
Normal file
5
docs-site/src/assets/logo-dark.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="#3399ff"/>
|
||||||
|
<path d="M16 6 L16 26 M16 6 L22 12 L16 16 L22 22 L16 26 M16 16 L10 10 M16 16 L10 22"
|
||||||
|
stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
5
docs-site/src/assets/logo-light.svg
Normal file
5
docs-site/src/assets/logo-light.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="#0066cc"/>
|
||||||
|
<path d="M16 6 L16 26 M16 6 L22 12 L16 16 L22 22 L16 26 M16 16 L10 10 M16 16 L10 22"
|
||||||
|
stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
7
docs-site/src/content.config.ts
Normal file
7
docs-site/src/content.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||||
|
import { docsSchema } from '@astrojs/starlight/schema';
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
};
|
||||||
278
docs-site/src/content/docs/explanation/architecture.md
Normal file
278
docs-site/src/content/docs/explanation/architecture.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
---
|
||||||
|
title: Architecture
|
||||||
|
description: How mcbluetooth integrates with BlueZ, D-Bus, and the Linux Bluetooth stack
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Understanding mcbluetooth's architecture helps you troubleshoot issues and extend functionality.
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ MCP Client (Claude, etc.) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ mcbluetooth │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ MCP Tools │ │ MCP Resources│ │ Pairing Agent │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ D-Bus Clients (async) ││
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────────────┐ ││
|
||||||
|
│ │ │ BlueZClient │ │ ObexClient │ ││
|
||||||
|
│ │ │ (system bus) │ │ (session bus) │ ││
|
||||||
|
│ │ └──────────────┘ └──────────────────────┘ ││
|
||||||
|
│ └─────────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ BlueZ │ │ obexd │ │ PipeWire/ │
|
||||||
|
│ (bluetoothd) │ │ (session) │ │ PulseAudio │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │ │
|
||||||
|
└───────────────┼───────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Linux Kernel │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Bluetooth │ │ btusb │ │ HCI driver │ │
|
||||||
|
│ │ subsystem │ │ module │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Bluetooth Hardware │
|
||||||
|
│ (USB dongle, built-in) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Responsibilities
|
||||||
|
|
||||||
|
### mcbluetooth (MCP Server)
|
||||||
|
|
||||||
|
The MCP server layer that exposes Bluetooth functionality:
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| **MCP Tools** | 69 tools for Bluetooth operations |
|
||||||
|
| **MCP Resources** | Live state queries via URIs |
|
||||||
|
| **Pairing Agent** | Handles PIN/passkey negotiation |
|
||||||
|
| **D-Bus Clients** | Async communication with BlueZ/obexd |
|
||||||
|
|
||||||
|
### BlueZ (bluetoothd)
|
||||||
|
|
||||||
|
The official Linux Bluetooth stack daemon:
|
||||||
|
|
||||||
|
- Manages adapter hardware
|
||||||
|
- Handles device discovery and pairing
|
||||||
|
- Implements Bluetooth profiles (A2DP, HFP, etc.)
|
||||||
|
- Manages custom profile registrations (HFP AG via ProfileManager1)
|
||||||
|
- Exposes D-Bus API on **system bus**
|
||||||
|
|
||||||
|
### obexd
|
||||||
|
|
||||||
|
OBEX protocol daemon for file transfer:
|
||||||
|
|
||||||
|
- Runs per-user (session daemon)
|
||||||
|
- Implements OPP, FTP, PBAP, MAP profiles
|
||||||
|
- Exposes D-Bus API on **session bus**
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
obexd requires an active desktop session because it runs on the session D-Bus.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### PipeWire/PulseAudio
|
||||||
|
|
||||||
|
Audio server integration:
|
||||||
|
|
||||||
|
- Receives audio streams from BlueZ
|
||||||
|
- Provides volume, mute, routing controls
|
||||||
|
- Handles codec negotiation
|
||||||
|
|
||||||
|
## D-Bus Architecture
|
||||||
|
|
||||||
|
mcbluetooth communicates with two D-Bus buses:
|
||||||
|
|
||||||
|
### System Bus (BlueZ)
|
||||||
|
|
||||||
|
```
|
||||||
|
Service: org.bluez
|
||||||
|
Paths: /org/bluez
|
||||||
|
/org/bluez/hci0
|
||||||
|
/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
|
||||||
|
|
||||||
|
Interfaces:
|
||||||
|
org.bluez.Adapter1 - Adapter control
|
||||||
|
org.bluez.Device1 - Device management
|
||||||
|
org.bluez.GattService1 - BLE services
|
||||||
|
org.bluez.GattCharacteristic1 - BLE characteristics
|
||||||
|
org.bluez.AgentManager1 - Pairing agent registration
|
||||||
|
org.bluez.ProfileManager1 - Custom profile registration (HFP AG)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Bus (OBEX)
|
||||||
|
|
||||||
|
```
|
||||||
|
Service: org.bluez.obex
|
||||||
|
Paths: /org/bluez/obex
|
||||||
|
/org/bluez/obex/client/session0
|
||||||
|
/org/bluez/obex/client/session0/transfer0
|
||||||
|
|
||||||
|
Interfaces:
|
||||||
|
org.bluez.obex.Client1 - Session management
|
||||||
|
org.bluez.obex.Session1 - Session properties
|
||||||
|
org.bluez.obex.ObjectPush1 - OPP file sending
|
||||||
|
org.bluez.obex.FileTransfer1 - FTP operations
|
||||||
|
org.bluez.obex.PhonebookAccess1 - PBAP
|
||||||
|
org.bluez.obex.MessageAccess1 - MAP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Design
|
||||||
|
|
||||||
|
mcbluetooth uses **dbus-fast** for non-blocking D-Bus communication:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Singleton pattern ensures one connection
|
||||||
|
class BlueZClient:
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_instance(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
await cls._instance._connect()
|
||||||
|
return cls._instance
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Multiple concurrent operations
|
||||||
|
- No blocking the MCP event loop
|
||||||
|
- Efficient resource usage
|
||||||
|
|
||||||
|
## Tool Registration
|
||||||
|
|
||||||
|
Tools are organized by functional area:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server.py
|
||||||
|
from mcbluetooth.tools import adapter, audio, ble, device, hfp, monitor, obex
|
||||||
|
|
||||||
|
def create_server():
|
||||||
|
mcp = FastMCP("mcbluetooth")
|
||||||
|
|
||||||
|
adapter.register_tools(mcp)
|
||||||
|
device.register_tools(mcp)
|
||||||
|
audio.register_tools(mcp)
|
||||||
|
hfp.register_tools(mcp)
|
||||||
|
ble.register_tools(mcp)
|
||||||
|
monitor.register_tools(mcp)
|
||||||
|
obex.register_tools(mcp)
|
||||||
|
|
||||||
|
return mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Each module follows the pattern:
|
||||||
|
```python
|
||||||
|
# tools/device.py
|
||||||
|
def register_tools(mcp: FastMCP) -> None:
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_connect(adapter: str, address: str) -> dict:
|
||||||
|
"""Connect to a paired device."""
|
||||||
|
client = await BlueZClient.get_instance()
|
||||||
|
return await client.connect_device(adapter, address)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
D-Bus errors are translated to user-friendly messages:
|
||||||
|
|
||||||
|
| D-Bus Error | mcbluetooth Response |
|
||||||
|
|-------------|---------------------|
|
||||||
|
| `org.bluez.Error.Failed` | `{"error": "Operation failed", ...}` |
|
||||||
|
| `org.bluez.Error.NotReady` | `{"error": "Device not ready", ...}` |
|
||||||
|
| `org.bluez.Error.AuthenticationFailed` | `{"error": "Authentication failed", ...}` |
|
||||||
|
| `org.freedesktop.DBus.Error.ServiceUnknown` | `{"error": "BlueZ not running", ...}` |
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### BlueZ State
|
||||||
|
|
||||||
|
mcbluetooth doesn't cache BlueZ state — each query goes to D-Bus:
|
||||||
|
|
||||||
|
- Ensures fresh data
|
||||||
|
- Avoids stale state issues
|
||||||
|
- BlueZ handles the caching
|
||||||
|
|
||||||
|
### OBEX Sessions
|
||||||
|
|
||||||
|
OBEX sessions are tracked locally:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_active_sessions: dict[str, dict] = {}
|
||||||
|
# session_id -> {path, address, target, created}
|
||||||
|
|
||||||
|
def generate_session_id(address: str, target: str) -> str:
|
||||||
|
"""Generate friendly ID: ftp_C87B235568E8"""
|
||||||
|
clean_addr = address.replace(":", "")
|
||||||
|
return f"{target}_{clean_addr}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pairing Agent
|
||||||
|
|
||||||
|
The pairing agent registers with BlueZ and handles callbacks:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Agent registers with AgentManager1
|
||||||
|
2. BlueZ calls agent methods for pairing events
|
||||||
|
3. Agent handles PIN/passkey based on pairing_mode
|
||||||
|
4. Results returned to BlueZ
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### D-Bus Call Overhead
|
||||||
|
|
||||||
|
Each tool call involves:
|
||||||
|
1. MCP request parsing
|
||||||
|
2. D-Bus method call (async)
|
||||||
|
3. Response serialization
|
||||||
|
|
||||||
|
Typical latency: 1-10ms per call.
|
||||||
|
|
||||||
|
### Scanning
|
||||||
|
|
||||||
|
Discovery is resource-intensive:
|
||||||
|
- `bt_scan` starts/stops discovery explicitly
|
||||||
|
- Avoids continuous scanning
|
||||||
|
- Timeout prevents runaway scans
|
||||||
|
|
||||||
|
### Large Transfers
|
||||||
|
|
||||||
|
OBEX transfers use polling for progress:
|
||||||
|
```python
|
||||||
|
while True:
|
||||||
|
status = await get_transfer_status(transfer_path)
|
||||||
|
if status["status"] in ("complete", "error"):
|
||||||
|
return status
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
| Layer | Security |
|
||||||
|
|-------|----------|
|
||||||
|
| MCP | Trust boundary at MCP client |
|
||||||
|
| D-Bus | PolicyKit for privileged ops |
|
||||||
|
| BlueZ | Pairing provides encryption |
|
||||||
|
| OBEX | Session-based access |
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
mcbluetooth inherits the permissions of its running user. System-level operations require appropriate D-Bus policies.
|
||||||
|
</Aside>
|
||||||
353
docs-site/src/content/docs/explanation/obex-profiles.md
Normal file
353
docs-site/src/content/docs/explanation/obex-profiles.md
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
---
|
||||||
|
title: OBEX Profiles
|
||||||
|
description: Understanding OPP, FTP, PBAP, and MAP — the OBEX family of Bluetooth profiles
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
OBEX (Object Exchange) is a protocol for transferring objects over Bluetooth. Four profiles build on OBEX for specific use cases.
|
||||||
|
|
||||||
|
## What is OBEX?
|
||||||
|
|
||||||
|
OBEX is a binary protocol originally designed for infrared (IrDA) that was adapted for Bluetooth. It provides:
|
||||||
|
|
||||||
|
- Session-based connections
|
||||||
|
- Request/response pattern
|
||||||
|
- Headers for metadata (name, type, length)
|
||||||
|
- Support for large object transfers
|
||||||
|
|
||||||
|
Think of it as "HTTP for Bluetooth" — a simple way to push and pull objects between devices.
|
||||||
|
|
||||||
|
## The Four OBEX Profiles
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ OBEX Protocol │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ OPP │ FTP │ PBAP │ MAP │
|
||||||
|
│ (Push) │ (Files) │ (Contacts) │ (Messages) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## OPP — Object Push Profile
|
||||||
|
|
||||||
|
**Purpose:** Simple file sending, like handing someone a file.
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- No session management needed
|
||||||
|
- One-shot transfers
|
||||||
|
- Receiver can accept or reject
|
||||||
|
- Minimal device interaction required
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Push | Send a file to device |
|
||||||
|
| Pull | Get device's business card |
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- Send a photo to a friend
|
||||||
|
- Share a document
|
||||||
|
- Exchange contact cards
|
||||||
|
|
||||||
|
### mcbluetooth Tools
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_send_file address="..." file_path="~/photo.jpg"
|
||||||
|
bt_obex_get_vcard address="..." save_path="~/contact.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
Almost universal — OPP is the most widely supported OBEX profile.
|
||||||
|
|
||||||
|
| Device | Support |
|
||||||
|
|--------|---------|
|
||||||
|
| Android | ✓ |
|
||||||
|
| iPhone | ✓ |
|
||||||
|
| Feature phones | ✓ |
|
||||||
|
| Windows | ✓ |
|
||||||
|
| macOS | ✓ |
|
||||||
|
|
||||||
|
## FTP — File Transfer Profile
|
||||||
|
|
||||||
|
**Purpose:** Browse and manage remote file systems.
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- Session-based (connect → browse → disconnect)
|
||||||
|
- Full file system operations
|
||||||
|
- Folder navigation
|
||||||
|
- Two-way transfers
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Connect | Create FTP session |
|
||||||
|
| List | Get folder contents |
|
||||||
|
| Get | Download file |
|
||||||
|
| Put | Upload file |
|
||||||
|
| Delete | Remove file/folder |
|
||||||
|
| Mkdir | Create folder |
|
||||||
|
| Setpath | Navigate folders |
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- Backup phone photos
|
||||||
|
- Browse device storage
|
||||||
|
- Manage files on remote device
|
||||||
|
|
||||||
|
### mcbluetooth Tools
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_connect address="..." target="ftp"
|
||||||
|
bt_obex_browse session_id="ftp_..."
|
||||||
|
bt_obex_get session_id="..." remote_path="photo.jpg" local_path="~/photo.jpg"
|
||||||
|
bt_obex_put session_id="..." local_path="~/doc.pdf" remote_path="doc.pdf"
|
||||||
|
bt_obex_disconnect session_id="ftp_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
More limited than OPP:
|
||||||
|
|
||||||
|
| Device | Support |
|
||||||
|
|--------|---------|
|
||||||
|
| Android | Varies by version/manufacturer |
|
||||||
|
| iPhone | ✗ |
|
||||||
|
| Feature phones | ✓ Usually |
|
||||||
|
| Windows | ✓ |
|
||||||
|
| macOS | ✗ |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Android FTP support varies. Some versions require enabling "File Transfer" mode when connected via Bluetooth.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## PBAP — Phonebook Access Profile
|
||||||
|
|
||||||
|
**Purpose:** Read contacts from a phone.
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- Read-only access (mostly)
|
||||||
|
- Standardized folder structure
|
||||||
|
- vCard format output
|
||||||
|
- Search capabilities
|
||||||
|
|
||||||
|
### Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
telecom/
|
||||||
|
├── pb/ ← Main phonebook
|
||||||
|
├── ich/ ← Incoming call history
|
||||||
|
├── och/ ← Outgoing call history
|
||||||
|
├── mch/ ← Missed call history
|
||||||
|
└── cch/ ← Combined call history
|
||||||
|
|
||||||
|
SIM1/
|
||||||
|
└── telecom/
|
||||||
|
└── pb/ ← SIM card contacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| PullAll | Download entire phonebook |
|
||||||
|
| List | Get contact handles |
|
||||||
|
| Pull | Get specific contact |
|
||||||
|
| Search | Find contacts by field |
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- Sync contacts to car system
|
||||||
|
- Backup phone contacts
|
||||||
|
- Contact management applications
|
||||||
|
|
||||||
|
### mcbluetooth Tools
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_pull address="..." save_path="~/contacts.vcf"
|
||||||
|
bt_phonebook_list address="..." folder="telecom/pb"
|
||||||
|
bt_phonebook_search address="..." field="name" value="Smith"
|
||||||
|
bt_phonebook_count address="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
| Device | Support |
|
||||||
|
|--------|---------|
|
||||||
|
| Android | ✓ Full |
|
||||||
|
| iPhone | ✓ (when properly paired) |
|
||||||
|
| Feature phones | ✓ Usually |
|
||||||
|
| Car systems | ✓ Often |
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
PBAP is widely supported because car infotainment systems rely on it for contact syncing.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## MAP — Message Access Profile
|
||||||
|
|
||||||
|
**Purpose:** Access SMS/MMS messages.
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- Read messages from phone
|
||||||
|
- Some devices support sending
|
||||||
|
- Folder-based organization
|
||||||
|
- Notification of new messages
|
||||||
|
|
||||||
|
### Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
telecom/
|
||||||
|
└── msg/
|
||||||
|
├── inbox/
|
||||||
|
├── outbox/
|
||||||
|
├── sent/
|
||||||
|
├── drafts/
|
||||||
|
└── deleted/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| GetFolderListing | List message folders |
|
||||||
|
| GetMessagesListing | List messages in folder |
|
||||||
|
| GetMessage | Download message content |
|
||||||
|
| PushMessage | Send message (if supported) |
|
||||||
|
| SetMessageStatus | Mark read/unread |
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- Read texts through car system
|
||||||
|
- Message backup
|
||||||
|
- SMS integration applications
|
||||||
|
|
||||||
|
### mcbluetooth Tools
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_messages_folders address="..."
|
||||||
|
bt_messages_list address="..." folder="inbox" unread_only=true
|
||||||
|
bt_messages_get address="..." handle="msg001" save_path="~/message.txt"
|
||||||
|
bt_messages_send address="..." recipient="+1555..." message="Hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
Most limited of the OBEX profiles:
|
||||||
|
|
||||||
|
| Device | Read | Send |
|
||||||
|
|--------|------|------|
|
||||||
|
| Android | ✓ | Varies |
|
||||||
|
| iPhone | ✗ | ✗ |
|
||||||
|
| Car systems | ✓ (receive) | ✗ |
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
iPhone does not support MAP. Apple uses proprietary protocols for message sync.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Session Architecture
|
||||||
|
|
||||||
|
### OPP (Stateless)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
│ │
|
||||||
|
│────── CONNECT ─────────→│
|
||||||
|
│←───── SUCCESS ──────────│
|
||||||
|
│────── PUT file ────────→│
|
||||||
|
│←───── SUCCESS ──────────│
|
||||||
|
│────── DISCONNECT ──────→│
|
||||||
|
│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
OPP creates temporary sessions for each transfer.
|
||||||
|
|
||||||
|
### FTP/PBAP/MAP (Session-Based)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
│ │
|
||||||
|
│────── CONNECT ─────────→│ ← Session created
|
||||||
|
│←───── SUCCESS ──────────│
|
||||||
|
│ │
|
||||||
|
│────── LIST ────────────→│
|
||||||
|
│←───── folder contents ──│
|
||||||
|
│ │
|
||||||
|
│────── GET file ────────→│
|
||||||
|
│←───── file data ────────│
|
||||||
|
│ │
|
||||||
|
│────── DISCONNECT ──────→│ ← Session closed
|
||||||
|
│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
Sessions persist for multiple operations.
|
||||||
|
|
||||||
|
## obexd Architecture
|
||||||
|
|
||||||
|
mcbluetooth uses BlueZ's **obexd** daemon:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ mcbluetooth │
|
||||||
|
│ │ │
|
||||||
|
│ D-Bus (session) │
|
||||||
|
│ │ │
|
||||||
|
└────────────────────────┼────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ obexd │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ OPP │ │ FTP │ │ PBAP │ │ MAP │ │
|
||||||
|
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ RFCOMM/L2CAP │
|
||||||
|
└────────────────────────┼────────────────────────────┘
|
||||||
|
▼
|
||||||
|
Bluetooth Stack
|
||||||
|
```
|
||||||
|
|
||||||
|
obexd runs on the **session D-Bus** (not system bus), which means:
|
||||||
|
- Requires active desktop session
|
||||||
|
- Per-user isolation
|
||||||
|
- Integrates with user's file permissions
|
||||||
|
|
||||||
|
## Transfer Progress
|
||||||
|
|
||||||
|
OBEX transfers report progress via D-Bus properties:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Status": "active",
|
||||||
|
"Transferred": 52428800,
|
||||||
|
"Size": 104857600,
|
||||||
|
"Filename": "large_file.mp4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
mcbluetooth polls these properties to provide progress updates.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Common OBEX errors:
|
||||||
|
|
||||||
|
| Error | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `NotSupported` | Profile not available on device |
|
||||||
|
| `NotAuthorized` | Permission denied |
|
||||||
|
| `NotFound` | File/folder doesn't exist |
|
||||||
|
| `Forbidden` | Operation not allowed |
|
||||||
|
| `Failed` | Generic failure |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- OBEX uses Bluetooth encryption (if paired)
|
||||||
|
- Phone typically prompts for permission on first access
|
||||||
|
- Permission can be revoked in phone settings
|
||||||
|
- Sessions are tied to pairing relationship
|
||||||
276
docs-site/src/content/docs/explanation/pairing-agent.md
Normal file
276
docs-site/src/content/docs/explanation/pairing-agent.md
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
---
|
||||||
|
title: Pairing Agent
|
||||||
|
description: How mcbluetooth handles Bluetooth pairing with its smart agent
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Bluetooth pairing establishes trust between devices. mcbluetooth includes a pairing agent that handles all pairing methods automatically.
|
||||||
|
|
||||||
|
## What is a Pairing Agent?
|
||||||
|
|
||||||
|
In BlueZ, a **pairing agent** is a program that:
|
||||||
|
|
||||||
|
1. Registers with BlueZ's AgentManager
|
||||||
|
2. Receives callbacks during pairing
|
||||||
|
3. Responds to PIN/passkey requests
|
||||||
|
4. Confirms or rejects pairings
|
||||||
|
|
||||||
|
Without an agent, pairing requests fail because there's no program to handle the negotiation.
|
||||||
|
|
||||||
|
## Secure Simple Pairing (SSP)
|
||||||
|
|
||||||
|
Modern Bluetooth uses SSP with four association models:
|
||||||
|
|
||||||
|
### Just Works
|
||||||
|
|
||||||
|
**No user interaction required.**
|
||||||
|
|
||||||
|
- Used when one device has no display/keyboard
|
||||||
|
- Provides encryption but no MITM protection
|
||||||
|
- mcbluetooth auto-accepts these
|
||||||
|
|
||||||
|
**Example devices:** Simple speakers, mice, keyboards
|
||||||
|
|
||||||
|
### Numeric Comparison
|
||||||
|
|
||||||
|
**Both devices show 6-digit code; user confirms they match.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Your device: 123456
|
||||||
|
Their device: 123456
|
||||||
|
Do they match? [Y/N]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Both devices have displays
|
||||||
|
- User verifies the numbers match
|
||||||
|
- Protects against MITM attacks
|
||||||
|
|
||||||
|
**Example devices:** Phones, computers, smart watches
|
||||||
|
|
||||||
|
### Passkey Entry
|
||||||
|
|
||||||
|
**One device displays code; user enters it on the other.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Their device shows: 123456
|
||||||
|
Enter on your device: [______]
|
||||||
|
```
|
||||||
|
|
||||||
|
- One device has display, other has keyboard
|
||||||
|
- Code entry proves physical access
|
||||||
|
|
||||||
|
**Example devices:** Phone pairing with keyboard
|
||||||
|
|
||||||
|
### Legacy PIN
|
||||||
|
|
||||||
|
**Old-style 4-6 digit PIN entry.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Enter PIN: [____]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pre-SSP devices (Bluetooth 2.0 and earlier)
|
||||||
|
- Often uses "0000" or "1234"
|
||||||
|
|
||||||
|
**Example devices:** Older headsets, car systems
|
||||||
|
|
||||||
|
## mcbluetooth Pairing Modes
|
||||||
|
|
||||||
|
The `bt_pair` tool supports three modes:
|
||||||
|
|
||||||
|
### Interactive Mode (Default)
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="..." pairing_mode="interactive"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. mcbluetooth initiates pairing
|
||||||
|
2. Returns immediately with status
|
||||||
|
3. If confirmation needed, returns passkey/info
|
||||||
|
4. Use `bt_pair_confirm` to respond
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
bt_pair → {"status": "awaiting_confirmation", "passkey": 123456}
|
||||||
|
↓
|
||||||
|
bt_pair_confirm passkey=123456 accept=true → {"status": "paired"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Best for: LLM-driven pairing where you want control over each step.
|
||||||
|
|
||||||
|
### Auto Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="..." pairing_mode="auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Automatically accepts all pairing requests
|
||||||
|
- No user confirmation required
|
||||||
|
- Just Works and auto-confirm numeric comparison
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Auto mode should only be used in trusted environments. It accepts pairings without verification.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
Best for: Automated testing, trusted lab environments.
|
||||||
|
|
||||||
|
### Elicit Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="..." pairing_mode="elicit"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses MCP elicitation to prompt user directly
|
||||||
|
- If MCP client supports elicitation, user sees prompt
|
||||||
|
- Falls back to interactive if not supported
|
||||||
|
|
||||||
|
Best for: Human-in-the-loop pairing with MCP clients that support elicitation.
|
||||||
|
|
||||||
|
## Agent Implementation
|
||||||
|
|
||||||
|
### Registration
|
||||||
|
|
||||||
|
mcbluetooth registers its agent at startup:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Register with BlueZ
|
||||||
|
agent_manager = await bus.get_proxy_object(
|
||||||
|
"org.bluez",
|
||||||
|
"/org/bluez"
|
||||||
|
).get_interface("org.bluez.AgentManager1")
|
||||||
|
|
||||||
|
await agent_manager.call_register_agent(
|
||||||
|
agent_path,
|
||||||
|
"KeyboardDisplay" # Capability
|
||||||
|
)
|
||||||
|
|
||||||
|
await agent_manager.call_request_default_agent(agent_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capability Declaration
|
||||||
|
|
||||||
|
The agent declares "KeyboardDisplay" capability:
|
||||||
|
- Can display codes (for numeric comparison)
|
||||||
|
- Can accept input (for passkey entry)
|
||||||
|
- Maximizes pairing compatibility
|
||||||
|
|
||||||
|
### Callback Methods
|
||||||
|
|
||||||
|
The agent implements these D-Bus methods:
|
||||||
|
|
||||||
|
| Method | Called When |
|
||||||
|
|--------|-------------|
|
||||||
|
| `RequestPinCode` | Legacy PIN needed |
|
||||||
|
| `RequestPasskey` | Passkey entry needed |
|
||||||
|
| `DisplayPasskey` | Show passkey to user |
|
||||||
|
| `DisplayPinCode` | Show PIN to user |
|
||||||
|
| `RequestConfirmation` | Numeric comparison |
|
||||||
|
| `RequestAuthorization` | Just Works confirmation |
|
||||||
|
| `AuthorizeService` | Service-level auth |
|
||||||
|
| `Cancel` | Pairing cancelled |
|
||||||
|
| `Release` | Agent released |
|
||||||
|
|
||||||
|
## Handling Pending Requests
|
||||||
|
|
||||||
|
When pairing requires confirmation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Store pending request
|
||||||
|
_pending_pairings[address] = {
|
||||||
|
"method": "numeric_comparison",
|
||||||
|
"passkey": 123456,
|
||||||
|
"timestamp": datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return to bt_pair caller
|
||||||
|
return {
|
||||||
|
"status": "awaiting_confirmation",
|
||||||
|
"method": "numeric_comparison",
|
||||||
|
"passkey": 123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Later, `bt_pair_confirm` resolves it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_pair_confirm(adapter: str, address: str, accept: bool, passkey: int = None):
|
||||||
|
pending = _pending_pairings.get(address)
|
||||||
|
if pending:
|
||||||
|
if accept:
|
||||||
|
pending["resolve"](passkey)
|
||||||
|
else:
|
||||||
|
pending["reject"]()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting Pairing
|
||||||
|
|
||||||
|
### "No agent registered"
|
||||||
|
|
||||||
|
mcbluetooth's agent didn't register properly:
|
||||||
|
|
||||||
|
1. Check mcbluetooth is running
|
||||||
|
2. Verify D-Bus connectivity
|
||||||
|
3. Check for existing agents (some DEs register their own)
|
||||||
|
|
||||||
|
### "Authentication rejected"
|
||||||
|
|
||||||
|
The other device rejected pairing:
|
||||||
|
|
||||||
|
1. Device may have reached pairing limit
|
||||||
|
2. Try removing existing pairing on both sides
|
||||||
|
3. Restart Bluetooth on the other device
|
||||||
|
|
||||||
|
### Pairing timeout
|
||||||
|
|
||||||
|
No response within timeout period:
|
||||||
|
|
||||||
|
1. Extend timeout: `bt_pair ... timeout=120`
|
||||||
|
2. Check `bt_pairing_status` for pending requests
|
||||||
|
3. Other device may need user interaction
|
||||||
|
|
||||||
|
### Wrong passkey
|
||||||
|
|
||||||
|
For Passkey Entry mode:
|
||||||
|
|
||||||
|
1. Ensure you're entering the exact code shown
|
||||||
|
2. Some devices show passkey only briefly
|
||||||
|
3. Use `bt_pairing_status` to see expected passkey
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### MITM Protection
|
||||||
|
|
||||||
|
| Mode | MITM Protected |
|
||||||
|
|------|----------------|
|
||||||
|
| Just Works | ✗ No |
|
||||||
|
| Numeric Comparison | ✓ Yes |
|
||||||
|
| Passkey Entry | ✓ Yes |
|
||||||
|
| Legacy PIN | Partial |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Just Works provides encryption but doesn't verify you're pairing with the intended device. An attacker could intercept the pairing (MITM).
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Prefer Numeric Comparison** - Verify codes match on both devices
|
||||||
|
2. **Avoid auto mode in production** - Human verification is important
|
||||||
|
3. **Check device identity** - Verify name/address before confirming
|
||||||
|
4. **Remove unused pairings** - Reduce attack surface
|
||||||
|
|
||||||
|
## Agent Conflicts
|
||||||
|
|
||||||
|
Desktop environments often register their own agents:
|
||||||
|
|
||||||
|
| Environment | Agent |
|
||||||
|
|-------------|-------|
|
||||||
|
| GNOME | gnome-shell |
|
||||||
|
| KDE | bluedevil |
|
||||||
|
| XFCE | blueman |
|
||||||
|
|
||||||
|
mcbluetooth requests to be the **default agent**, which usually works. If you have issues:
|
||||||
|
|
||||||
|
1. Temporarily stop the DE's Bluetooth applet
|
||||||
|
2. Or use mcbluetooth's agent alongside (may cause double prompts)
|
||||||
191
docs-site/src/content/docs/getting-started/installation.md
Normal file
191
docs-site/src/content/docs/getting-started/installation.md
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
title: Installation
|
||||||
|
description: Install mcbluetooth and configure permissions
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
## Install mcbluetooth
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="uvx (recommended)">
|
||||||
|
```bash
|
||||||
|
# Run directly without installing
|
||||||
|
uvx mcbluetooth
|
||||||
|
|
||||||
|
# Or install globally
|
||||||
|
uv tool install mcbluetooth
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="pip">
|
||||||
|
```bash
|
||||||
|
pip install mcbluetooth
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="From source">
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/mcbluetooth
|
||||||
|
cd mcbluetooth
|
||||||
|
uv sync
|
||||||
|
uv run mcbluetooth
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Add to Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using uvx
|
||||||
|
claude mcp add mcbluetooth -- uvx mcbluetooth
|
||||||
|
|
||||||
|
# From source
|
||||||
|
claude mcp add mcbluetooth -- uv run --directory /path/to/mcbluetooth mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure Permissions
|
||||||
|
|
||||||
|
### BlueZ Access
|
||||||
|
|
||||||
|
mcbluetooth needs access to the BlueZ D-Bus interface. Most desktop Linux distributions allow this by default.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Add user to bluetooth group (requires re-login)
|
||||||
|
sudo usermod -aG bluetooth $USER
|
||||||
|
|
||||||
|
# Option 2: Verify polkit allows access (check for errors when running)
|
||||||
|
uvx mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### HCI Packet Capture (Optional)
|
||||||
|
|
||||||
|
For `bt_capture_*` tools (btmon integration):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allow btmon without sudo
|
||||||
|
sudo setcap cap_net_raw+ep /usr/bin/btmon
|
||||||
|
```
|
||||||
|
|
||||||
|
### OBEX Profiles (Optional)
|
||||||
|
|
||||||
|
For file transfer, phonebook, and message access:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Arch Linux">
|
||||||
|
```bash
|
||||||
|
sudo pacman -S bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Debian/Ubuntu">
|
||||||
|
```bash
|
||||||
|
sudo apt install bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Fedora">
|
||||||
|
```bash
|
||||||
|
sudo dnf install bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
After installing, verify with:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
obexd runs as a user service on the session D-Bus. It requires an active desktop session (X11 or Wayland).
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Verify Installation
|
||||||
|
|
||||||
|
### Check the Server Starts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Should show startup banner and wait for MCP connections
|
||||||
|
uvx mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
🔵 mcbluetooth v0.1.0
|
||||||
|
BlueZ MCP Server - Bluetooth management for LLMs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Claude Code, try:
|
||||||
|
bt_list_adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return your Bluetooth adapter(s):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "hci0",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"powered": true,
|
||||||
|
"discoverable": false,
|
||||||
|
"discovering": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "org.bluez was not provided"
|
||||||
|
|
||||||
|
BlueZ daemon isn't running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check status
|
||||||
|
systemctl status bluetooth
|
||||||
|
|
||||||
|
# Start if stopped
|
||||||
|
sudo systemctl start bluetooth
|
||||||
|
sudo systemctl enable bluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Permission denied" on D-Bus
|
||||||
|
|
||||||
|
Add user to bluetooth group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG bluetooth $USER
|
||||||
|
# Log out and back in
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Bluetooth adapter found
|
||||||
|
|
||||||
|
Check if adapter is detected by the kernel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List USB devices
|
||||||
|
lsusb | grep -i bluetooth
|
||||||
|
|
||||||
|
# Check kernel module
|
||||||
|
lsmod | grep btusb
|
||||||
|
|
||||||
|
# Check dmesg
|
||||||
|
dmesg | grep -i bluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### obexd not found
|
||||||
|
|
||||||
|
Install the bluez-obex package for your distribution (see OBEX section above).
|
||||||
|
|
||||||
|
## Tested Configurations
|
||||||
|
|
||||||
|
| Distribution | BlueZ | Audio | Status |
|
||||||
|
|--------------|-------|-------|--------|
|
||||||
|
| Arch Linux | 5.85 | PipeWire 1.4 | ✓ Full support |
|
||||||
|
| Ubuntu 22.04 | 5.64 | PulseAudio | ✓ Full support |
|
||||||
|
| Ubuntu 24.04 | 5.72 | PipeWire | ✓ Full support |
|
||||||
|
| Fedora 39 | 5.70 | PipeWire | ✓ Full support |
|
||||||
|
| Debian 12 | 5.66 | PipeWire | ✓ Full support |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Quick Start Guide](/getting-started/quick-start/) — Try your first commands
|
||||||
|
- [Adapter Management](/guides/adapters/) — Control your Bluetooth hardware
|
||||||
105
docs-site/src/content/docs/getting-started/introduction.md
Normal file
105
docs-site/src/content/docs/getting-started/introduction.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
description: What is mcbluetooth and why use it?
|
||||||
|
---
|
||||||
|
|
||||||
|
**mcbluetooth** is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that gives LLMs full control over the Linux Bluetooth stack via BlueZ.
|
||||||
|
|
||||||
|
## What Can You Do?
|
||||||
|
|
||||||
|
Ask Claude (or any MCP-compatible LLM) to:
|
||||||
|
|
||||||
|
- **"Turn on Bluetooth and make my computer discoverable"**
|
||||||
|
- **"Scan for devices and pair with my Sony headphones"**
|
||||||
|
- **"Connect my headphones and set volume to 80%"**
|
||||||
|
- **"Read the battery level from my fitness tracker"**
|
||||||
|
- **"Send this PDF to my phone via Bluetooth"**
|
||||||
|
- **"Download my phone's contacts"**
|
||||||
|
- **"Enable HFP Audio Gateway and simulate an incoming call"**
|
||||||
|
- **"Start capturing Bluetooth traffic for debugging"**
|
||||||
|
|
||||||
|
## Why mcbluetooth?
|
||||||
|
|
||||||
|
### Natural Language Control
|
||||||
|
|
||||||
|
Instead of memorizing `bluetoothctl` commands or navigating GUI menus, just describe what you want:
|
||||||
|
|
||||||
|
```
|
||||||
|
"My headphones disconnected, reconnect them and set them as the default audio output"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete BlueZ Coverage
|
||||||
|
|
||||||
|
mcbluetooth exposes the full power of BlueZ:
|
||||||
|
|
||||||
|
- **Adapters** — Power, discovery, pairing acceptance
|
||||||
|
- **Devices** — Scanning, pairing, connection management
|
||||||
|
- **Audio** — A2DP/HFP profiles, volume, routing
|
||||||
|
- **HFP Audio Gateway** — Phone simulation, call control, indicators
|
||||||
|
- **BLE/GATT** — Services, characteristics, notifications
|
||||||
|
- **OBEX** — File transfer, phonebook, messages
|
||||||
|
- **Monitoring** — HCI packet capture and analysis
|
||||||
|
|
||||||
|
### Smart Pairing
|
||||||
|
|
||||||
|
The built-in pairing agent handles all Bluetooth pairing methods:
|
||||||
|
|
||||||
|
| Method | Description | Agent Response |
|
||||||
|
|--------|-------------|----------------|
|
||||||
|
| Just Works | No user interaction | Auto-accept |
|
||||||
|
| Numeric Comparison | Confirm 6-digit code | Interactive or auto |
|
||||||
|
| Passkey Entry | Enter code from device | PIN prompt |
|
||||||
|
| Legacy PIN | 4-6 digit PIN | PIN prompt |
|
||||||
|
|
||||||
|
### MCP Resources
|
||||||
|
|
||||||
|
Live state queries without tool calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
bluetooth://adapters → All Bluetooth adapters
|
||||||
|
bluetooth://paired → Paired devices
|
||||||
|
bluetooth://connected → Connected devices
|
||||||
|
bluetooth://device/AA:BB:CC:DD:EE:FF → Specific device
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Claude / LLM │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ MCP Protocol (stdio) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ mcbluetooth (FastMCP) │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ Adapter │ │ Device │ │ Audio │ │ OBEX │ ... │
|
||||||
|
│ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │
|
||||||
|
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||||
|
│ └──────────┬┴──────────┬┴───────────┘ │
|
||||||
|
│ ┌────────┴────────┐ │ │
|
||||||
|
│ │ BlueZ D-Bus │ │ PipeWire/Pulse │
|
||||||
|
│ │ (dbus-fast) │ │ (pulsectl-asyncio) │
|
||||||
|
│ └────────┬────────┘ └──────────────────────────────┤
|
||||||
|
├──────────────────┼──────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ BlueZ (bluetoothd) obexd │
|
||||||
|
│ │ │
|
||||||
|
│ Linux Kernel (Bluetooth subsystem) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Requirement | Version | Notes |
|
||||||
|
|-------------|---------|-------|
|
||||||
|
| Linux | Any | BlueZ is Linux-only |
|
||||||
|
| Python | 3.11+ | Async/await, type hints |
|
||||||
|
| BlueZ | 5.x | Bluetooth daemon |
|
||||||
|
| PipeWire or PulseAudio | Any | For audio features |
|
||||||
|
| bluez-obex | Any | For OBEX features (optional) |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. [Install mcbluetooth](/getting-started/installation/)
|
||||||
|
2. [Try the quick start guide](/getting-started/quick-start/)
|
||||||
|
3. [Explore the tool reference](/reference/tools/)
|
||||||
150
docs-site/src/content/docs/getting-started/quick-start.md
Normal file
150
docs-site/src/content/docs/getting-started/quick-start.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
---
|
||||||
|
title: Quick Start
|
||||||
|
description: Get up and running with mcbluetooth in 5 minutes
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
This guide walks you through basic Bluetooth operations with mcbluetooth.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- mcbluetooth installed and added to Claude Code
|
||||||
|
- A Bluetooth adapter (built-in or USB dongle)
|
||||||
|
- A Bluetooth device to test with (headphones, phone, etc.)
|
||||||
|
|
||||||
|
## Basic Operations
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. **Check your adapter**
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_list_adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see your Bluetooth adapter:
|
||||||
|
```json
|
||||||
|
[{"name": "hci0", "address": "...", "powered": true, ...}]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Power on Bluetooth (if needed)**
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_power adapter="hci0" on=true
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Scan for devices**
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_scan adapter="hci0" timeout=10 mode="both"
|
||||||
|
```
|
||||||
|
|
||||||
|
This scans for 10 seconds and returns all discovered devices.
|
||||||
|
|
||||||
|
4. **Pair with a device**
|
||||||
|
|
||||||
|
Put your device in pairing mode, then:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent handles PIN/passkey automatically for most devices.
|
||||||
|
|
||||||
|
5. **Connect to the paired device**
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Connect Headphones
|
||||||
|
|
||||||
|
```
|
||||||
|
# Scan for devices
|
||||||
|
bt_scan adapter="hci0"
|
||||||
|
|
||||||
|
# Pair (first time only)
|
||||||
|
bt_pair adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
|
||||||
|
# Connect
|
||||||
|
bt_connect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
|
||||||
|
# Set as default audio and adjust volume
|
||||||
|
bt_audio_set_default address="C8:7B:23:55:68:E8"
|
||||||
|
bt_audio_volume address="C8:7B:23:55:68:E8" volume=75
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read a BLE Sensor
|
||||||
|
|
||||||
|
```
|
||||||
|
# Scan for BLE devices
|
||||||
|
bt_ble_scan adapter="hci0" timeout=10
|
||||||
|
|
||||||
|
# Connect and discover services
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
bt_ble_services adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Read battery level (standard BLE service)
|
||||||
|
bt_ble_battery adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Read a specific characteristic
|
||||||
|
bt_ble_read adapter="hci0" address="AA:BB:CC:DD:EE:FF" char_uuid="00002a19-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send a File to Your Phone
|
||||||
|
|
||||||
|
```
|
||||||
|
# Check OBEX is ready
|
||||||
|
bt_obex_status
|
||||||
|
|
||||||
|
# Send file (phone will show accept prompt)
|
||||||
|
bt_obex_send_file address="AA:BB:CC:DD:EE:FF" file_path="~/Documents/report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download Contacts from Phone
|
||||||
|
|
||||||
|
```
|
||||||
|
# Pull entire phonebook
|
||||||
|
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/contacts.vcf"
|
||||||
|
|
||||||
|
# Or search for specific contact
|
||||||
|
bt_phonebook_search address="AA:BB:CC:DD:EE:FF" field="name" value="John"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using MCP Resources
|
||||||
|
|
||||||
|
Resources provide live state without tool calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
# In your MCP client, read these URIs:
|
||||||
|
bluetooth://adapters # All adapters
|
||||||
|
bluetooth://paired # Paired devices
|
||||||
|
bluetooth://connected # Connected devices
|
||||||
|
bluetooth://device/AA:BB:... # Specific device details
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
**Use device aliases**: Set friendly names with `bt_device_set_alias` so you don't have to remember MAC addresses.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
**Trust devices**: Use `bt_trust` to allow devices to auto-connect without explicit authorization.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
**Check OBEX first**: Before using file transfer or phonebook tools, run `bt_obex_status` to verify obexd is running.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Adapter Management](/guides/adapters/) — Control Bluetooth hardware
|
||||||
|
- [Device Pairing](/guides/pairing/) — Deep dive into pairing modes
|
||||||
|
- [Audio Control](/guides/audio/) — Master Bluetooth audio
|
||||||
|
- [Tool Reference](/reference/tools/) — Complete tool documentation
|
||||||
201
docs-site/src/content/docs/guides/adapters.md
Normal file
201
docs-site/src/content/docs/guides/adapters.md
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
title: Adapter Management
|
||||||
|
description: Control your Bluetooth hardware — power, discovery, and pairing modes
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Bluetooth adapters are the hardware interfaces that enable wireless communication. mcbluetooth provides full control over adapter configuration.
|
||||||
|
|
||||||
|
## List Adapters
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_list_adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all Bluetooth adapters with their current state:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "hci0",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"alias": "My Laptop",
|
||||||
|
"powered": true,
|
||||||
|
"discoverable": false,
|
||||||
|
"discoverable_timeout": 180,
|
||||||
|
"pairable": true,
|
||||||
|
"pairable_timeout": 0,
|
||||||
|
"discovering": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get Adapter Details
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_info adapter="hci0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns detailed information including supported features and UUIDs.
|
||||||
|
|
||||||
|
## Power Control
|
||||||
|
|
||||||
|
### Turn Bluetooth On
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_power adapter="hci0" on=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Turn Bluetooth Off
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_power adapter="hci0" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Powering off disconnects all devices and stops any ongoing discovery.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Discovery Settings
|
||||||
|
|
||||||
|
### Make Discoverable
|
||||||
|
|
||||||
|
Allow other devices to see your computer:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=true timeout=180
|
||||||
|
```
|
||||||
|
|
||||||
|
- `timeout=0` means discoverable forever (use carefully)
|
||||||
|
- `timeout=180` means 3 minutes (default)
|
||||||
|
|
||||||
|
### Stop Being Discoverable
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pairing Acceptance
|
||||||
|
|
||||||
|
### Enable Pairing
|
||||||
|
|
||||||
|
Allow devices to pair with your computer:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_pairable adapter="hci0" on=true timeout=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Pairing
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_pairable adapter="hci0" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Leaving pairable enabled indefinitely (`timeout=0`) may be a security risk in public environments.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Set Adapter Name
|
||||||
|
|
||||||
|
Change the friendly name other devices see:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_set_alias adapter="hci0" alias="Ryan's Desktop"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Prepare for Incoming Connection
|
||||||
|
|
||||||
|
When you want another device to find and connect to your computer:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Make discoverable and pairable
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=true timeout=300
|
||||||
|
bt_adapter_pairable adapter="hci0" on=true timeout=300
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens a 5-minute window for pairing.
|
||||||
|
|
||||||
|
### Secure Configuration
|
||||||
|
|
||||||
|
For everyday use with known devices:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Hidden but accepting connections from paired devices
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=false
|
||||||
|
bt_adapter_pairable adapter="hci0" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adapter Properties Reference
|
||||||
|
|
||||||
|
| Property | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `name` | System name (e.g., `hci0`) |
|
||||||
|
| `address` | MAC address |
|
||||||
|
| `alias` | Friendly name |
|
||||||
|
| `powered` | On/off state |
|
||||||
|
| `discoverable` | Visible to other devices |
|
||||||
|
| `discoverable_timeout` | Seconds until auto-hidden (0=forever) |
|
||||||
|
| `pairable` | Accepting new pairings |
|
||||||
|
| `pairable_timeout` | Seconds until auto-disable (0=forever) |
|
||||||
|
| `discovering` | Currently scanning |
|
||||||
|
| `uuids` | Supported Bluetooth profiles |
|
||||||
|
| `modalias` | Hardware identifier |
|
||||||
|
|
||||||
|
## Multiple Adapters
|
||||||
|
|
||||||
|
If you have multiple Bluetooth adapters (e.g., built-in + USB dongle):
|
||||||
|
|
||||||
|
```
|
||||||
|
# List all adapters
|
||||||
|
bt_list_adapters
|
||||||
|
|
||||||
|
# Configure each separately
|
||||||
|
bt_adapter_power adapter="hci0" on=true
|
||||||
|
bt_adapter_power adapter="hci1" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
Use different adapters for different purposes — one for audio, one for BLE sensors.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Adapter Not Found
|
||||||
|
|
||||||
|
Check if the kernel sees your hardware:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List USB Bluetooth devices
|
||||||
|
lsusb | grep -i bluetooth
|
||||||
|
|
||||||
|
# Check kernel messages
|
||||||
|
dmesg | grep -i bluetooth
|
||||||
|
|
||||||
|
# Verify btusb module is loaded
|
||||||
|
lsmod | grep btusb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adapter Won't Power On
|
||||||
|
|
||||||
|
BlueZ might be blocked by rfkill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check rfkill status
|
||||||
|
rfkill list bluetooth
|
||||||
|
|
||||||
|
# Unblock if blocked
|
||||||
|
rfkill unblock bluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discovery Stops Unexpectedly
|
||||||
|
|
||||||
|
Discovery has a default timeout. For continuous scanning:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_scan adapter="hci0" timeout=60 mode="both"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `bt_scan` tool handles starting and stopping discovery automatically.
|
||||||
199
docs-site/src/content/docs/guides/audio.md
Normal file
199
docs-site/src/content/docs/guides/audio.md
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
---
|
||||||
|
title: Audio Control
|
||||||
|
description: Manage Bluetooth audio devices with PipeWire/PulseAudio integration
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
mcbluetooth integrates with PipeWire and PulseAudio to provide seamless Bluetooth audio control.
|
||||||
|
|
||||||
|
## List Audio Devices
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_audio_list
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all audio devices including Bluetooth:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sinks": [
|
||||||
|
{
|
||||||
|
"name": "bluez_sink.C8_7B_23_55_68_E8.a2dp_sink",
|
||||||
|
"description": "Bombay",
|
||||||
|
"bluetooth_address": "C8:7B:23:55:68:E8",
|
||||||
|
"volume": 65536,
|
||||||
|
"volume_percent": 75,
|
||||||
|
"muted": false,
|
||||||
|
"state": "running"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sources": [...],
|
||||||
|
"cards": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connect Audio
|
||||||
|
|
||||||
|
After pairing a device, connect its audio profiles:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_audio_connect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
This connects A2DP (high-quality audio) and/or HFP (hands-free) profiles.
|
||||||
|
|
||||||
|
## Audio Profiles
|
||||||
|
|
||||||
|
Bluetooth audio devices support different profiles:
|
||||||
|
|
||||||
|
| Profile | Quality | Microphone | Use Case |
|
||||||
|
|---------|---------|------------|----------|
|
||||||
|
| **A2DP** | High (stereo) | No | Music, videos |
|
||||||
|
| **HFP** | Low (mono) | Yes | Phone calls |
|
||||||
|
| **HSP** | Low (mono) | Yes | Legacy headsets |
|
||||||
|
|
||||||
|
### Switch Profiles
|
||||||
|
|
||||||
|
```
|
||||||
|
# High-quality stereo for music
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
|
||||||
|
|
||||||
|
# Hands-free for calls (enables microphone)
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="hfp"
|
||||||
|
|
||||||
|
# Disable audio (but stay connected for other profiles)
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="off"
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
Switch to HFP before a video call to enable the headset microphone, then back to A2DP for music.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Set Default Output
|
||||||
|
|
||||||
|
Make a Bluetooth device the default audio output:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_audio_set_default address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volume Control
|
||||||
|
|
||||||
|
### Set Volume
|
||||||
|
|
||||||
|
```
|
||||||
|
# Set to 75%
|
||||||
|
bt_audio_volume address="C8:7B:23:55:68:E8" volume=75
|
||||||
|
|
||||||
|
# Boost to 120% (use carefully)
|
||||||
|
bt_audio_volume address="C8:7B:23:55:68:E8" volume=120
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Volume above 100% may cause distortion or hearing damage.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### Mute/Unmute
|
||||||
|
|
||||||
|
```
|
||||||
|
# Mute
|
||||||
|
bt_audio_mute address="C8:7B:23:55:68:E8" muted=true
|
||||||
|
|
||||||
|
# Unmute
|
||||||
|
bt_audio_mute address="C8:7B:23:55:68:E8" muted=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Disconnect Audio
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_audio_disconnect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
This disconnects audio profiles but keeps the device connected for other services.
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Connect Headphones for Music
|
||||||
|
|
||||||
|
```
|
||||||
|
# Connect and set up for music
|
||||||
|
bt_connect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
bt_audio_connect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
|
||||||
|
bt_audio_set_default address="C8:7B:23:55:68:E8"
|
||||||
|
bt_audio_volume address="C8:7B:23:55:68:E8" volume=70
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prepare for Video Call
|
||||||
|
|
||||||
|
```
|
||||||
|
# Switch to HFP for microphone support
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="hfp"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Return to Music After Call
|
||||||
|
|
||||||
|
```
|
||||||
|
# Switch back to A2DP for high-quality audio
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No Sound from Device
|
||||||
|
|
||||||
|
1. Check the device is connected: `bt_device_info adapter="hci0" address="..."`
|
||||||
|
2. Verify audio profile is connected: `bt_audio_list`
|
||||||
|
3. Ensure it's set as default: `bt_audio_set_default address="..."`
|
||||||
|
4. Check volume isn't zero: `bt_audio_volume address="..." volume=70`
|
||||||
|
|
||||||
|
### Audio Cuts Out
|
||||||
|
|
||||||
|
- **Interference**: Move away from WiFi routers, microwaves
|
||||||
|
- **Distance**: Stay within 10 meters of the adapter
|
||||||
|
- **Battery**: Low battery can cause audio issues
|
||||||
|
- **Profile**: Try switching profiles and back
|
||||||
|
|
||||||
|
### Poor Audio Quality
|
||||||
|
|
||||||
|
```
|
||||||
|
# Ensure A2DP (not HFP) is active
|
||||||
|
bt_audio_set_profile address="..." profile="a2dp"
|
||||||
|
```
|
||||||
|
|
||||||
|
HFP is mono and lower quality — it's for calls, not music.
|
||||||
|
|
||||||
|
### Microphone Not Working
|
||||||
|
|
||||||
|
```
|
||||||
|
# Switch to HFP or HSP
|
||||||
|
bt_audio_set_profile address="..." profile="hfp"
|
||||||
|
```
|
||||||
|
|
||||||
|
A2DP doesn't support microphone input.
|
||||||
|
|
||||||
|
### Device Shows "Off" Profile
|
||||||
|
|
||||||
|
The device may need reconnection:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_audio_disconnect adapter="hci0" address="..."
|
||||||
|
bt_audio_connect adapter="hci0" address="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Codecs
|
||||||
|
|
||||||
|
Audio quality depends on the codec negotiated between devices:
|
||||||
|
|
||||||
|
| Codec | Quality | Latency | Notes |
|
||||||
|
|-------|---------|---------|-------|
|
||||||
|
| SBC | Good | Medium | Universal, default |
|
||||||
|
| AAC | Better | Medium | Apple devices |
|
||||||
|
| aptX | Better | Low | Qualcomm devices |
|
||||||
|
| aptX HD | Best | Low | High-res audio |
|
||||||
|
| LDAC | Best | Medium | Sony devices |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Codec selection is automatic based on device capabilities. mcbluetooth doesn't directly control codec selection — this is handled by BlueZ and PipeWire.
|
||||||
|
</Aside>
|
||||||
240
docs-site/src/content/docs/guides/ble.md
Normal file
240
docs-site/src/content/docs/guides/ble.md
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
title: BLE & GATT
|
||||||
|
description: Interact with Bluetooth Low Energy devices — sensors, fitness trackers, and IoT
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Bluetooth Low Energy (BLE) devices use GATT (Generic Attribute Profile) to expose services and characteristics. mcbluetooth provides tools to discover, read, write, and subscribe to BLE data.
|
||||||
|
|
||||||
|
## BLE Scanning
|
||||||
|
|
||||||
|
### Basic Scan
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_ble_scan adapter="hci0" timeout=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtered Scan
|
||||||
|
|
||||||
|
```
|
||||||
|
# Filter by name
|
||||||
|
bt_ble_scan adapter="hci0" name_filter="Fitness"
|
||||||
|
|
||||||
|
# Filter by service UUID
|
||||||
|
bt_ble_scan adapter="hci0" service_filter="0000180d-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
## GATT Structure
|
||||||
|
|
||||||
|
BLE devices organize data hierarchically:
|
||||||
|
|
||||||
|
```
|
||||||
|
Device
|
||||||
|
└── Service (UUID: 0000180d-...) ← Heart Rate Service
|
||||||
|
├── Characteristic (UUID: 00002a37-...) ← Heart Rate Measurement
|
||||||
|
│ └── Descriptor ← Client Configuration
|
||||||
|
└── Characteristic (UUID: 00002a38-...) ← Body Sensor Location
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discover Services
|
||||||
|
|
||||||
|
After connecting:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_ble_services adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "0000180f-0000-1000-8000-00805f9b34fb",
|
||||||
|
"primary": true,
|
||||||
|
"description": "Battery Service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "0000180d-0000-1000-8000-00805f9b34fb",
|
||||||
|
"primary": true,
|
||||||
|
"description": "Heart Rate Service"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Characteristics
|
||||||
|
|
||||||
|
```
|
||||||
|
# All characteristics
|
||||||
|
bt_ble_characteristics adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Filter by service
|
||||||
|
bt_ble_characteristics adapter="hci0" address="..." service_uuid="0000180f-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "00002a19-0000-1000-8000-00805f9b34fb",
|
||||||
|
"flags": ["read", "notify"],
|
||||||
|
"description": "Battery Level"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Read Values
|
||||||
|
|
||||||
|
### Read Battery Level (Shortcut)
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_ble_battery adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns battery percentage (0-100).
|
||||||
|
|
||||||
|
### Read Any Characteristic
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_ble_read adapter="hci0" address="..." char_uuid="00002a19-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hex": "4b",
|
||||||
|
"decoded": 75,
|
||||||
|
"description": "Battery Level: 75%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write Values
|
||||||
|
|
||||||
|
```
|
||||||
|
# Write hex bytes
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="0102ff" value_type="hex"
|
||||||
|
|
||||||
|
# Write string
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="hello" value_type="string"
|
||||||
|
|
||||||
|
# Write integer
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="42" value_type="int"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write with/without Response
|
||||||
|
|
||||||
|
```
|
||||||
|
# With response (default) - waits for acknowledgment
|
||||||
|
bt_ble_write ... with_response=true
|
||||||
|
|
||||||
|
# Without response (faster, less reliable)
|
||||||
|
bt_ble_write ... with_response=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
Subscribe to value changes:
|
||||||
|
|
||||||
|
### Enable Notifications
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_ble_notify adapter="hci0" address="..." char_uuid="00002a37-..." enable=true
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
After enabling notifications, the device will send updates when values change. Currently, mcbluetooth enables the notification mode but doesn't provide a callback mechanism — use protocol capture to see the actual notifications.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
### Disable Notifications
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_ble_notify adapter="hci0" address="..." char_uuid="..." enable=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common UUIDs
|
||||||
|
|
||||||
|
### Standard Services
|
||||||
|
|
||||||
|
| Service | UUID | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| Generic Access | `0x1800` | Device name, appearance |
|
||||||
|
| Generic Attribute | `0x1801` | Service change indication |
|
||||||
|
| Battery | `0x180F` | Battery level |
|
||||||
|
| Device Information | `0x180A` | Manufacturer, model, etc. |
|
||||||
|
| Heart Rate | `0x180D` | Heart rate measurement |
|
||||||
|
| Health Thermometer | `0x1809` | Temperature |
|
||||||
|
| Blood Pressure | `0x1810` | Blood pressure |
|
||||||
|
|
||||||
|
### Standard Characteristics
|
||||||
|
|
||||||
|
| Characteristic | UUID | Service |
|
||||||
|
|----------------|------|---------|
|
||||||
|
| Battery Level | `0x2A19` | Battery |
|
||||||
|
| Heart Rate Measurement | `0x2A37` | Heart Rate |
|
||||||
|
| Temperature Measurement | `0x2A1C` | Health Thermometer |
|
||||||
|
| Manufacturer Name | `0x2A29` | Device Information |
|
||||||
|
| Model Number | `0x2A24` | Device Information |
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
Full UUIDs are `0000XXXX-0000-1000-8000-00805f9b34fb` where `XXXX` is the short UUID.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Example: Heart Rate Monitor
|
||||||
|
|
||||||
|
```
|
||||||
|
# Scan for heart rate monitors
|
||||||
|
bt_ble_scan adapter="hci0" service_filter="0000180d-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
|
# Connect
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# List services
|
||||||
|
bt_ble_services adapter="hci0" address="..."
|
||||||
|
|
||||||
|
# Enable heart rate notifications
|
||||||
|
bt_ble_notify adapter="hci0" address="..." char_uuid="00002a37-0000-1000-8000-00805f9b34fb" enable=true
|
||||||
|
|
||||||
|
# Read body sensor location
|
||||||
|
bt_ble_read adapter="hci0" address="..." char_uuid="00002a38-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Smart Light Bulb
|
||||||
|
|
||||||
|
```
|
||||||
|
# Connect to bulb
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Find the control characteristic (vendor-specific)
|
||||||
|
bt_ble_characteristics adapter="hci0" address="..."
|
||||||
|
|
||||||
|
# Write command to turn on (example - actual commands vary by device)
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="01" value_type="hex"
|
||||||
|
|
||||||
|
# Set color (RGB example)
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="ff0000" value_type="hex"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "ServicesResolved: false"
|
||||||
|
|
||||||
|
Services aren't discovered yet. Wait a moment after connecting:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_connect adapter="hci0" address="..."
|
||||||
|
# Wait 2-3 seconds
|
||||||
|
bt_ble_services adapter="hci0" address="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can't Read Characteristic
|
||||||
|
|
||||||
|
Check the characteristic flags:
|
||||||
|
- `read` must be present for reading
|
||||||
|
- `write` or `write-without-response` for writing
|
||||||
|
- `notify` for notifications
|
||||||
|
|
||||||
|
### Connection Drops Frequently
|
||||||
|
|
||||||
|
BLE has limited connection capacity. Try:
|
||||||
|
- Disconnecting other BLE devices
|
||||||
|
- Moving closer to the adapter
|
||||||
|
- Checking device battery level
|
||||||
278
docs-site/src/content/docs/guides/capture.md
Normal file
278
docs-site/src/content/docs/guides/capture.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
---
|
||||||
|
title: Protocol Capture
|
||||||
|
description: Capture and analyze Bluetooth HCI traffic for debugging
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
mcbluetooth can capture raw Bluetooth HCI (Host Controller Interface) traffic for protocol analysis and debugging.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
HCI captures record all communication between the Bluetooth stack and hardware adapter. This includes:
|
||||||
|
|
||||||
|
- Device discovery packets
|
||||||
|
- Pairing and authentication
|
||||||
|
- Connection management
|
||||||
|
- Audio streaming data
|
||||||
|
- BLE GATT operations
|
||||||
|
|
||||||
|
## Starting a Capture
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_capture_start output_file="/tmp/bluetooth.btsnoop"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"capture_id": "capture_abc123",
|
||||||
|
"output_file": "/tmp/bluetooth.btsnoop"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capture Options
|
||||||
|
|
||||||
|
```
|
||||||
|
# Capture from specific adapter
|
||||||
|
bt_capture_start output_file="/tmp/hci0.btsnoop" adapter="0"
|
||||||
|
|
||||||
|
# Include voice data (SCO)
|
||||||
|
bt_capture_start output_file="/tmp/calls.btsnoop" include_sco=true
|
||||||
|
|
||||||
|
# Include audio streaming (A2DP)
|
||||||
|
bt_capture_start output_file="/tmp/audio.btsnoop" include_a2dp=true
|
||||||
|
|
||||||
|
# Include LE Audio (ISO)
|
||||||
|
bt_capture_start output_file="/tmp/le_audio.btsnoop" include_iso=true
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Audio captures (`include_sco`, `include_a2dp`, `include_iso`) generate large files quickly.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Stopping a Capture
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_capture_stop capture_id="capture_abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "stopped",
|
||||||
|
"output_file": "/tmp/bluetooth.btsnoop",
|
||||||
|
"packets_captured": 1542,
|
||||||
|
"file_size": 245760
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Listing Active Captures
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_capture_list_active
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analyzing Captures
|
||||||
|
|
||||||
|
### Quick Parse
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_capture_parse filepath="/tmp/bluetooth.btsnoop"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns packet summaries:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_packets": 1542,
|
||||||
|
"packet_types": {
|
||||||
|
"HCI_CMD": 234,
|
||||||
|
"HCI_EVENT": 456,
|
||||||
|
"ACL_DATA": 852
|
||||||
|
},
|
||||||
|
"packets": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"timestamp": "2024-01-15T10:30:00.123456",
|
||||||
|
"type": "HCI_CMD",
|
||||||
|
"direction": "TX",
|
||||||
|
"summary": "Inquiry"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtered Parse
|
||||||
|
|
||||||
|
```
|
||||||
|
# Only HCI commands
|
||||||
|
bt_capture_parse filepath="..." packet_type_filter="HCI_CMD"
|
||||||
|
|
||||||
|
# Only received packets
|
||||||
|
bt_capture_parse filepath="..." direction_filter="RX"
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
bt_capture_parse filepath="..." max_packets=100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detailed Analysis
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_capture_analyze filepath="/tmp/bluetooth.btsnoop"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns high-level statistics:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"duration_seconds": 45.2,
|
||||||
|
"total_packets": 1542,
|
||||||
|
"protocols": {
|
||||||
|
"L2CAP": 423,
|
||||||
|
"RFCOMM": 156,
|
||||||
|
"SDP": 34,
|
||||||
|
"ATT": 289
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"packets": 892,
|
||||||
|
"bytes": 45678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raw Packet Decoding
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_capture_read_raw filepath="/tmp/bluetooth.btsnoop" offset=0 count=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns btmon-style decoded output for detailed inspection.
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Debug Pairing Issues
|
||||||
|
|
||||||
|
```
|
||||||
|
# Start capture
|
||||||
|
bt_capture_start output_file="/tmp/pairing_debug.btsnoop"
|
||||||
|
|
||||||
|
# Attempt pairing
|
||||||
|
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Stop and analyze
|
||||||
|
bt_capture_stop capture_id="..."
|
||||||
|
bt_capture_parse filepath="/tmp/pairing_debug.btsnoop" packet_type_filter="HCI_EVENT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Investigate Audio Glitches
|
||||||
|
|
||||||
|
```
|
||||||
|
# Capture with A2DP data
|
||||||
|
bt_capture_start output_file="/tmp/audio_debug.btsnoop" include_a2dp=true
|
||||||
|
|
||||||
|
# Play audio for 30 seconds
|
||||||
|
|
||||||
|
# Stop and check for errors
|
||||||
|
bt_capture_stop capture_id="..."
|
||||||
|
bt_capture_analyze filepath="/tmp/audio_debug.btsnoop"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitor BLE Sensor
|
||||||
|
|
||||||
|
```
|
||||||
|
# Start capture
|
||||||
|
bt_capture_start output_file="/tmp/ble_sensor.btsnoop"
|
||||||
|
|
||||||
|
# Connect and interact with sensor
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
bt_ble_read adapter="hci0" address="..." char_uuid="..."
|
||||||
|
|
||||||
|
# Analyze ATT/GATT traffic
|
||||||
|
bt_capture_stop capture_id="..."
|
||||||
|
bt_capture_parse filepath="/tmp/ble_sensor.btsnoop"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
Captures are saved in **btsnoop** format, compatible with:
|
||||||
|
|
||||||
|
| Tool | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| **Wireshark** | Full GUI analysis |
|
||||||
|
| **btmon** | Command-line decode |
|
||||||
|
| **hcidump** | Legacy analysis |
|
||||||
|
|
||||||
|
### Open in Wireshark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wireshark /tmp/bluetooth.btsnoop
|
||||||
|
```
|
||||||
|
|
||||||
|
Wireshark provides:
|
||||||
|
- Protocol dissection
|
||||||
|
- Conversation tracking
|
||||||
|
- Expert analysis
|
||||||
|
- Export options
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Capture requires elevated privileges (root or `CAP_NET_RAW`).
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
The capture functionality uses `btmon` which needs access to the Bluetooth monitor socket:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Run as root
|
||||||
|
sudo btmon
|
||||||
|
|
||||||
|
# Option 2: Add capability (one-time setup)
|
||||||
|
sudo setcap cap_net_raw+ep /usr/bin/btmon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Permission Denied"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check btmon capability
|
||||||
|
getcap /usr/bin/btmon
|
||||||
|
|
||||||
|
# Add if missing
|
||||||
|
sudo setcap cap_net_raw+ep /usr/bin/btmon
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capture File Empty
|
||||||
|
|
||||||
|
- Ensure adapter is active: `bt_adapter_power adapter="hci0" on=true`
|
||||||
|
- Verify Bluetooth activity is occurring
|
||||||
|
- Check btmon is running: `bt_capture_list_active`
|
||||||
|
|
||||||
|
### Very Large Files
|
||||||
|
|
||||||
|
Audio data generates significant traffic:
|
||||||
|
|
||||||
|
| Content | Approximate Size |
|
||||||
|
|---------|------------------|
|
||||||
|
| Discovery/pairing | ~10 KB/min |
|
||||||
|
| BLE sensors | ~50 KB/min |
|
||||||
|
| A2DP audio | ~2 MB/min |
|
||||||
|
| HFP calls | ~500 KB/min |
|
||||||
|
|
||||||
|
Use without audio flags for general debugging:
|
||||||
|
```
|
||||||
|
bt_capture_start output_file="..." include_sco=false include_a2dp=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can't Open in Wireshark
|
||||||
|
|
||||||
|
Verify file format:
|
||||||
|
```bash
|
||||||
|
file /tmp/capture.btsnoop
|
||||||
|
# Should show: BTSnoop version 1, HCI UART (H4)
|
||||||
|
```
|
||||||
|
|
||||||
|
If corrupted, the capture may have been interrupted. Always use `bt_capture_stop` to properly close files.
|
||||||
174
docs-site/src/content/docs/guides/hfp-ag.md
Normal file
174
docs-site/src/content/docs/guides/hfp-ag.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
title: HFP Audio Gateway
|
||||||
|
description: Use Linux as a phone to test Bluetooth headsets with call simulation
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
mcbluetooth can act as an HFP Audio Gateway (the "phone" role), allowing you to test Bluetooth headsets and hands-free devices by simulating calls and controlling audio indicators.
|
||||||
|
|
||||||
|
## HFP Roles
|
||||||
|
|
||||||
|
HFP (Hands-Free Profile) defines two roles:
|
||||||
|
|
||||||
|
| Role | Description | Example Device |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| **AG** (Audio Gateway) | The phone side — originates calls, sends ring signals | Phone, Linux (with mcbluetooth) |
|
||||||
|
| **HF** (Hands-Free) | The headset side — answers calls, controls volume | Headset, car kit, ESP32 test device |
|
||||||
|
|
||||||
|
mcbluetooth implements the AG role, so headsets connect to Linux as if it were a phone.
|
||||||
|
|
||||||
|
## Enable the AG Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_hfp_ag_enable
|
||||||
|
```
|
||||||
|
|
||||||
|
This registers a custom HFP AG profile with BlueZ via the ProfileManager1 D-Bus interface. Headsets can now discover and connect to Linux.
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
Enable the AG profile before the headset tries to connect. The SLC (Service Level Connection) negotiation happens automatically when a headset connects.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Connection Flow
|
||||||
|
|
||||||
|
When a headset connects, the following happens automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. HF connects RFCOMM → BlueZ calls NewConnection
|
||||||
|
2. AT+BRSF exchange → Both sides share feature flags
|
||||||
|
3. AT+CIND=? / AT+CIND? → Indicator mapping and values
|
||||||
|
4. AT+CMER=3,0,0,1 → Enable indicator reporting → SLC established
|
||||||
|
5. AT+BAC / +BCS → Codec negotiation (CVSD or mSBC)
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the connection status:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_hfp_ag_status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Simulate an Incoming Call
|
||||||
|
|
||||||
|
Once a headset is connected with SLC established:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_hfp_ag_simulate_call address="AA:BB:CC:DD:EE:FF" number="+15551234567"
|
||||||
|
```
|
||||||
|
|
||||||
|
The headset receives:
|
||||||
|
- **RING** alerts every 3 seconds
|
||||||
|
- **+CLIP** with caller ID information
|
||||||
|
|
||||||
|
The headset can:
|
||||||
|
- Answer with **ATA** (call becomes active)
|
||||||
|
- Reject with **AT+CHUP** (call is ended)
|
||||||
|
|
||||||
|
### End a Call
|
||||||
|
|
||||||
|
From the AG (Linux) side:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_hfp_ag_end_call address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
This terminates both ringing and active calls, updating the headset's indicators.
|
||||||
|
|
||||||
|
## Control Volume
|
||||||
|
|
||||||
|
HFP volume uses a 0-15 scale:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Set speaker volume
|
||||||
|
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="speaker" level=12
|
||||||
|
|
||||||
|
# Set microphone gain
|
||||||
|
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="microphone" level=10
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The headset can also change volume — it sends AT+VGS / AT+VGM commands which the AG acknowledges automatically.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Update Status Indicators
|
||||||
|
|
||||||
|
Simulate phone status changes shown on the headset display:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Signal strength (0-5)
|
||||||
|
bt_hfp_ag_set_signal address="AA:BB:CC:DD:EE:FF" level=4
|
||||||
|
|
||||||
|
# Battery level (0-5)
|
||||||
|
bt_hfp_ag_set_battery address="AA:BB:CC:DD:EE:FF" level=3
|
||||||
|
```
|
||||||
|
|
||||||
|
These send +CIEV indicator updates to the headset.
|
||||||
|
|
||||||
|
## HFP Indicators
|
||||||
|
|
||||||
|
The AG maintains 7 standard indicators, reported to the HF device:
|
||||||
|
|
||||||
|
| Index | Name | Range | Description |
|
||||||
|
|-------|------|-------|-------------|
|
||||||
|
| 1 | service | 0-1 | Network service available |
|
||||||
|
| 2 | call | 0-1 | Active call exists |
|
||||||
|
| 3 | callsetup | 0-3 | 0=none, 1=incoming, 2=outgoing, 3=alerting |
|
||||||
|
| 4 | callheld | 0-2 | 0=none, 1=held+active, 2=held only |
|
||||||
|
| 5 | signal | 0-5 | Signal strength |
|
||||||
|
| 6 | roam | 0-1 | Roaming active |
|
||||||
|
| 7 | battchg | 0-5 | Battery charge level |
|
||||||
|
|
||||||
|
## E2E Testing with ESP32
|
||||||
|
|
||||||
|
The HFP AG tools are designed for end-to-end testing with the [mcbluetooth-esp32](https://github.com/supported-systems/mcbluetooth-esp32) test harness, where the ESP32 acts as the HF (headset) device.
|
||||||
|
|
||||||
|
### Typical Test Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
# 1. Enable AG on Linux
|
||||||
|
bt_hfp_ag_enable
|
||||||
|
|
||||||
|
# 2. ESP32 connects as HF (from mcbluetooth-esp32)
|
||||||
|
# SLC auto-negotiated
|
||||||
|
|
||||||
|
# 3. Linux simulates incoming call
|
||||||
|
bt_hfp_ag_simulate_call address="<esp32>" number="5551234567"
|
||||||
|
|
||||||
|
# 4. ESP32 answers (sends ATA)
|
||||||
|
# Call becomes active
|
||||||
|
|
||||||
|
# 5. Linux ends call
|
||||||
|
bt_hfp_ag_end_call address="<esp32>"
|
||||||
|
|
||||||
|
# 6. Check status
|
||||||
|
bt_hfp_ag_status
|
||||||
|
```
|
||||||
|
|
||||||
|
## AT Commands Handled
|
||||||
|
|
||||||
|
The AG automatically handles these AT commands from the HF device:
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| AT+BRSF | Feature exchange |
|
||||||
|
| AT+CIND | Indicator mapping/values |
|
||||||
|
| AT+CMER | Enable indicator reporting |
|
||||||
|
| AT+CHLD | Call hold/multiparty support |
|
||||||
|
| AT+BAC / AT+BCS | Codec negotiation |
|
||||||
|
| ATA | Answer call |
|
||||||
|
| AT+CHUP | Hang up / reject |
|
||||||
|
| ATD | Outgoing call (from HF) |
|
||||||
|
| AT+VGS / AT+VGM | Volume control |
|
||||||
|
| AT+CLCC | List current calls |
|
||||||
|
| AT+COPS | Operator name query |
|
||||||
|
| AT+CNUM | Subscriber number |
|
||||||
|
| AT+BVRA | Voice recognition |
|
||||||
|
| AT+NREC | Noise reduction |
|
||||||
|
|
||||||
|
## Disable the AG Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_hfp_ag_disable
|
||||||
|
```
|
||||||
|
|
||||||
|
This unregisters the profile, disconnecting any active HFP sessions.
|
||||||
270
docs-site/src/content/docs/guides/obex.md
Normal file
270
docs-site/src/content/docs/guides/obex.md
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
title: OBEX File Transfer
|
||||||
|
description: Send files, browse phone storage, and download data using OBEX profiles
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
OBEX (Object Exchange) enables file transfer over Bluetooth. mcbluetooth supports four OBEX profiles:
|
||||||
|
|
||||||
|
| Profile | Code | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| **OPP** | Object Push | Simple file sending |
|
||||||
|
| **FTP** | File Transfer | Full file browsing |
|
||||||
|
| **PBAP** | Phonebook Access | Read contacts (see [Phonebook guide](/guides/phonebook-messages/)) |
|
||||||
|
| **MAP** | Message Access | Read SMS/MMS (see [Phonebook guide](/guides/phonebook-messages/)) |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
OBEX requires the `obexd` daemon:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Arch Linux">
|
||||||
|
```bash
|
||||||
|
sudo pacman -S bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Debian/Ubuntu">
|
||||||
|
```bash
|
||||||
|
sudo apt install bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
Verify installation:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
If obexd isn't running:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_start_daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
obexd runs on the session D-Bus and requires an active desktop session.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Quick File Send (OPP)
|
||||||
|
|
||||||
|
The simplest way to send a file:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_send_file address="AA:BB:CC:DD:EE:FF" file_path="~/Documents/report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Creates a temporary OPP session
|
||||||
|
- Sends the file (recipient sees accept prompt)
|
||||||
|
- Waits for completion
|
||||||
|
- Closes the session
|
||||||
|
|
||||||
|
### Non-blocking Send
|
||||||
|
|
||||||
|
For large files, start transfer without waiting:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_send_file address="..." file_path="large_video.mp4" wait=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a `transfer_path` to monitor progress:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Business Card (OPP)
|
||||||
|
|
||||||
|
Get the device's default vCard:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_get_vcard address="AA:BB:CC:DD:EE:FF" save_path="~/contact.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Browsing (FTP)
|
||||||
|
|
||||||
|
For full file system access, create an FTP session:
|
||||||
|
|
||||||
|
### Create Session
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_connect address="AA:BB:CC:DD:EE:FF" target="ftp"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"session_id": "ftp_AABBCCDDEEFF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"target": "ftp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browse Folders
|
||||||
|
|
||||||
|
```
|
||||||
|
# List root
|
||||||
|
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="/"
|
||||||
|
|
||||||
|
# Navigate to folder
|
||||||
|
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="DCIM"
|
||||||
|
|
||||||
|
# Go up
|
||||||
|
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path=".."
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{"name": "DCIM", "type": "folder"},
|
||||||
|
{"name": "Download", "type": "folder"},
|
||||||
|
{"name": "document.pdf", "type": "file", "size": 102400}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download Files
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_get session_id="ftp_..." remote_path="photo.jpg" local_path="~/Downloads/photo.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload Files
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_put session_id="ftp_..." local_path="~/document.pdf" remote_path="document.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Folder
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_mkdir session_id="ftp_..." folder_name="Backup"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Files
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_delete session_id="ftp_..." remote_path="old_file.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Close Session
|
||||||
|
|
||||||
|
Always close when done:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_disconnect session_id="ftp_AABBCCDDEEFF"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
### List Active Sessions
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check OBEX Status
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"obexd_installed": true,
|
||||||
|
"obexd_running": true,
|
||||||
|
"dbus_accessible": true,
|
||||||
|
"active_sessions": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transfer Monitoring
|
||||||
|
|
||||||
|
### Check Progress
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "active",
|
||||||
|
"size": 104857600,
|
||||||
|
"transferred": 52428800,
|
||||||
|
"progress_percent": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancel Transfer
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_transfer_cancel transfer_path="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Backup Phone Photos
|
||||||
|
|
||||||
|
```
|
||||||
|
# Connect FTP session
|
||||||
|
bt_obex_connect address="..." target="ftp"
|
||||||
|
|
||||||
|
# Navigate to photos
|
||||||
|
bt_obex_browse session_id="ftp_..." path="/"
|
||||||
|
bt_obex_browse session_id="ftp_..." path="DCIM"
|
||||||
|
bt_obex_browse session_id="ftp_..." path="Camera"
|
||||||
|
|
||||||
|
# Download each photo
|
||||||
|
bt_obex_get session_id="ftp_..." remote_path="IMG_001.jpg" local_path="~/backup/"
|
||||||
|
bt_obex_get session_id="ftp_..." remote_path="IMG_002.jpg" local_path="~/backup/"
|
||||||
|
|
||||||
|
# Close session
|
||||||
|
bt_obex_disconnect session_id="ftp_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Share Document
|
||||||
|
|
||||||
|
```
|
||||||
|
# Simple send
|
||||||
|
bt_obex_send_file address="..." file_path="~/report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Compatibility
|
||||||
|
|
||||||
|
| Device Type | OPP | FTP |
|
||||||
|
|-------------|-----|-----|
|
||||||
|
| Android phones | ✓ | Varies |
|
||||||
|
| iPhones | ✓ | ✗ |
|
||||||
|
| Feature phones | ✓ | ✓ |
|
||||||
|
| Windows PCs | ✓ | ✓ |
|
||||||
|
| macOS | ✓ | ✗ |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
FTP support varies by Android version and manufacturer. Some require enabling "File Transfer" mode in Bluetooth settings.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Device rejected connection"
|
||||||
|
|
||||||
|
- Ensure device is paired first
|
||||||
|
- Check device has OBEX enabled in Bluetooth settings
|
||||||
|
- Some devices require explicit file sharing permission
|
||||||
|
|
||||||
|
### "NotSupported"
|
||||||
|
|
||||||
|
The device doesn't support the requested profile. Try OPP instead of FTP.
|
||||||
|
|
||||||
|
### Transfer Stuck at 0%
|
||||||
|
|
||||||
|
The receiving device may be showing an accept prompt. Check its screen.
|
||||||
|
|
||||||
|
### Session Disappeared
|
||||||
|
|
||||||
|
Sessions are tied to obexd. If it restarts, create a new session.
|
||||||
237
docs-site/src/content/docs/guides/pairing.md
Normal file
237
docs-site/src/content/docs/guides/pairing.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
---
|
||||||
|
title: Device Pairing
|
||||||
|
description: Pair Bluetooth devices with automatic PIN and passkey handling
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Pairing establishes a trusted relationship between devices, enabling secure communication. mcbluetooth includes a smart pairing agent that handles all Bluetooth pairing methods.
|
||||||
|
|
||||||
|
## Scanning for Devices
|
||||||
|
|
||||||
|
Before pairing, discover available devices:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_scan adapter="hci0" timeout=10 mode="both"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scan modes:**
|
||||||
|
- `classic` — Traditional Bluetooth (BR/EDR)
|
||||||
|
- `ble` — Bluetooth Low Energy only
|
||||||
|
- `both` — Both types (default)
|
||||||
|
|
||||||
|
## Initiating Pairing
|
||||||
|
|
||||||
|
### Basic Pairing
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pairing Modes
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Interactive (default)">
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="..." pairing_mode="interactive"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns immediately with status. If confirmation needed, use `bt_pair_confirm` to respond.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Auto">
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="..." pairing_mode="auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
Automatically accepts all pairing requests. Use only in trusted environments.
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Elicit">
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="..." pairing_mode="elicit"
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses MCP elicitation to prompt the user directly (if the MCP client supports it).
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Pairing Methods
|
||||||
|
|
||||||
|
Bluetooth uses different pairing methods based on device capabilities:
|
||||||
|
|
||||||
|
| Method | Description | Agent Action |
|
||||||
|
|--------|-------------|--------------|
|
||||||
|
| **Just Works** | No user interaction | Auto-accepted |
|
||||||
|
| **Numeric Comparison** | Confirm 6-digit code matches | Show code, await confirmation |
|
||||||
|
| **Passkey Entry** | Enter code shown on other device | Prompt for passkey |
|
||||||
|
| **Legacy PIN** | Enter 4-6 digit PIN | Prompt for PIN |
|
||||||
|
|
||||||
|
### Handling Confirmation Requests
|
||||||
|
|
||||||
|
When pairing requires confirmation:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Check for pending requests
|
||||||
|
bt_pairing_status
|
||||||
|
|
||||||
|
# Confirm with passkey (if needed)
|
||||||
|
bt_pair_confirm adapter="hci0" address="..." passkey=123456 accept=true
|
||||||
|
|
||||||
|
# Or reject
|
||||||
|
bt_pair_confirm adapter="hci0" address="..." accept=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Managing Paired Devices
|
||||||
|
|
||||||
|
### List Paired Devices
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_list_devices adapter="hci0" filter="paired"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Pairing
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_unpair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes the device from known devices and deletes all pairing information.
|
||||||
|
|
||||||
|
## Connection Management
|
||||||
|
|
||||||
|
### Connect to Paired Device
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disconnect
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_disconnect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Disconnecting preserves the pairing. The device can reconnect later.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Trust and Security
|
||||||
|
|
||||||
|
### Trust a Device
|
||||||
|
|
||||||
|
Trusted devices can connect automatically without explicit authorization:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_trust adapter="hci0" address="..." trusted=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Untrust
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_trust adapter="hci0" address="..." trusted=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block a Device
|
||||||
|
|
||||||
|
Prevent a device from connecting:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_block adapter="hci0" address="..." blocked=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unblock
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_block adapter="hci0" address="..." blocked=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Information
|
||||||
|
|
||||||
|
### Get Device Details
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_device_info adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "Bose NCH700",
|
||||||
|
"alias": "My Headphones",
|
||||||
|
"paired": true,
|
||||||
|
"bonded": true,
|
||||||
|
"trusted": true,
|
||||||
|
"connected": true,
|
||||||
|
"uuids": ["0000110b-...", "0000110e-..."],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Device Name
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_device_set_alias adapter="hci0" address="..." alias="My Headphones"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Pair New Headphones
|
||||||
|
|
||||||
|
```
|
||||||
|
# Put headphones in pairing mode first!
|
||||||
|
|
||||||
|
# Scan for them
|
||||||
|
bt_scan adapter="hci0" timeout=15
|
||||||
|
|
||||||
|
# Pair
|
||||||
|
bt_pair adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
|
||||||
|
# Trust for auto-reconnect
|
||||||
|
bt_trust adapter="hci0" address="C8:7B:23:55:68:E8" trusted=true
|
||||||
|
|
||||||
|
# Connect
|
||||||
|
bt_connect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pair a Phone for OBEX
|
||||||
|
|
||||||
|
```
|
||||||
|
# Scan and pair
|
||||||
|
bt_scan adapter="hci0"
|
||||||
|
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Both devices may show confirmation prompts - accept on both
|
||||||
|
|
||||||
|
# After pairing, OBEX profiles become available
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Pairing Fails Immediately
|
||||||
|
|
||||||
|
- Ensure the device is in pairing mode
|
||||||
|
- Check device isn't already paired (try `bt_unpair` first)
|
||||||
|
- Verify adapter is powered and pairable
|
||||||
|
|
||||||
|
### "Authentication Rejected"
|
||||||
|
|
||||||
|
- Device may have reached its pairing limit
|
||||||
|
- Try resetting Bluetooth on the other device
|
||||||
|
- Delete the pairing on both sides and retry
|
||||||
|
|
||||||
|
### Pairing Stuck
|
||||||
|
|
||||||
|
```
|
||||||
|
# Check for pending requests
|
||||||
|
bt_pairing_status
|
||||||
|
|
||||||
|
# Cancel if stuck
|
||||||
|
bt_pair_confirm adapter="hci0" address="..." accept=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device Connects Then Disconnects
|
||||||
|
|
||||||
|
- Profile incompatibility (e.g., device doesn't support expected audio profile)
|
||||||
|
- Check device UUIDs with `bt_device_info`
|
||||||
|
- Try connecting specific profile with `bt_connect_profile`
|
||||||
265
docs-site/src/content/docs/guides/phonebook-messages.md
Normal file
265
docs-site/src/content/docs/guides/phonebook-messages.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
---
|
||||||
|
title: Phonebook & Messages
|
||||||
|
description: Access contacts and SMS using PBAP and MAP profiles
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
PBAP (Phonebook Access Profile) and MAP (Message Access Profile) let you read contacts and messages from paired phones.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Both profiles require the device to be paired first. See [Device Pairing](/guides/pairing/).
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
These profiles use OBEX. Ensure obexd is installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
If not ready:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Arch Linux">
|
||||||
|
```bash
|
||||||
|
sudo pacman -S bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Debian/Ubuntu">
|
||||||
|
```bash
|
||||||
|
sudo apt install bluez-obex
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Phonebook Access (PBAP)
|
||||||
|
|
||||||
|
### Download Entire Phonebook
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/contacts.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads all contacts as a single vCard file.
|
||||||
|
|
||||||
|
### List Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_list address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{"handle": "1.vcf", "name": "Alice Smith"},
|
||||||
|
{"handle": "2.vcf", "name": "Bob Jones"},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"count": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Contact
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_get address="..." handle="1.vcf" save_path="~/alice.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
# Search by name
|
||||||
|
bt_phonebook_search address="..." field="name" value="Smith"
|
||||||
|
|
||||||
|
# Search by phone number
|
||||||
|
bt_phonebook_search address="..." field="number" value="+1555"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Count Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_count address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns total number of contacts without downloading them.
|
||||||
|
|
||||||
|
### Phonebook Folders
|
||||||
|
|
||||||
|
PBAP provides access to different phonebook locations:
|
||||||
|
|
||||||
|
| Folder | Contents |
|
||||||
|
|--------|----------|
|
||||||
|
| `telecom/pb` | Main phonebook (default) |
|
||||||
|
| `telecom/ich` | Incoming call history |
|
||||||
|
| `telecom/och` | Outgoing call history |
|
||||||
|
| `telecom/mch` | Missed call history |
|
||||||
|
| `telecom/cch` | Combined call history |
|
||||||
|
| `SIM1/telecom/pb` | SIM card contacts |
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_list address="..." folder="telecom/ich"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Access (MAP)
|
||||||
|
|
||||||
|
### List Message Folders
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_messages_folders address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{"name": "inbox"},
|
||||||
|
{"name": "sent"},
|
||||||
|
{"name": "drafts"},
|
||||||
|
{"name": "outbox"},
|
||||||
|
{"name": "deleted"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Messages
|
||||||
|
|
||||||
|
```
|
||||||
|
# All inbox messages
|
||||||
|
bt_messages_list address="..." folder="inbox"
|
||||||
|
|
||||||
|
# Unread only
|
||||||
|
bt_messages_list address="..." folder="inbox" unread_only=true
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
bt_messages_list address="..." folder="inbox" max_count=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"handle": "msg001",
|
||||||
|
"subject": "Meeting tomorrow",
|
||||||
|
"sender": "+15551234567",
|
||||||
|
"timestamp": "2024-01-15T10:30:00",
|
||||||
|
"read": false,
|
||||||
|
"type": "SMS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download Message
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_messages_get address="..." handle="msg001" save_path="~/message.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send Message
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Message sending support varies by device. Many phones only allow read access.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_messages_send address="..." recipient="+15559876543" message="Hello from mcbluetooth!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Backup All Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
# Download as vCard (can import into any contact app)
|
||||||
|
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/phone_backup_$(date +%Y%m%d).vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Call History
|
||||||
|
|
||||||
|
```
|
||||||
|
# Get incoming calls
|
||||||
|
bt_phonebook_list address="..." folder="telecom/ich"
|
||||||
|
|
||||||
|
# Get missed calls
|
||||||
|
bt_phonebook_list address="..." folder="telecom/mch"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find Contact by Phone Number
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_search address="..." field="number" value="555-1234"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archive Text Messages
|
||||||
|
|
||||||
|
```
|
||||||
|
# List all sent messages
|
||||||
|
bt_messages_list address="..." folder="sent"
|
||||||
|
|
||||||
|
# Download specific message
|
||||||
|
bt_messages_get address="..." handle="msg042" save_path="~/messages/msg042.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Compatibility
|
||||||
|
|
||||||
|
### PBAP Support
|
||||||
|
|
||||||
|
| Device | Support |
|
||||||
|
|--------|---------|
|
||||||
|
| Android phones | ✓ Full |
|
||||||
|
| iPhones | ✓ Full (when paired) |
|
||||||
|
| Feature phones | ✓ Usually |
|
||||||
|
| Car systems | ✓ Often (receive only) |
|
||||||
|
|
||||||
|
### MAP Support
|
||||||
|
|
||||||
|
| Device | Read | Send |
|
||||||
|
|--------|------|------|
|
||||||
|
| Android | ✓ | Varies |
|
||||||
|
| iPhone | ✗ | ✗ |
|
||||||
|
| Feature phones | Varies | Varies |
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
iPhone doesn't support MAP. Use iCloud or other sync methods for messages.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "NotAuthorized" Error
|
||||||
|
|
||||||
|
The phone is blocking access. Check:
|
||||||
|
1. Phone screen for permission prompt
|
||||||
|
2. Bluetooth settings → paired device → enable phonebook/message access
|
||||||
|
|
||||||
|
### Empty Phonebook
|
||||||
|
|
||||||
|
Some phones require explicit permission:
|
||||||
|
- **Android**: Settings → Apps → Bluetooth → Permissions → Contacts
|
||||||
|
- **iPhone**: Settings → Bluetooth → [device] → Allow Contact Access
|
||||||
|
|
||||||
|
### MAP Connection Fails
|
||||||
|
|
||||||
|
1. Verify device supports MAP: `bt_device_info adapter="hci0" address="..."`
|
||||||
|
2. Check for MAP UUID in the UUIDs list
|
||||||
|
3. Some devices need MAP enabled in Bluetooth settings
|
||||||
|
|
||||||
|
### Slow Downloads
|
||||||
|
|
||||||
|
Large phonebooks (1000+ contacts) take time:
|
||||||
|
- PBAP downloads are sequential
|
||||||
|
- Consider using `bt_phonebook_count` first to estimate
|
||||||
|
- vCard format is verbose
|
||||||
|
|
||||||
|
## Privacy Considerations
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
These tools access personal data. Use responsibly and only on devices you own or have explicit permission to access.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
- Phonebook and message access requires active pairing
|
||||||
|
- Devices typically prompt for permission on first access
|
||||||
|
- Permission can be revoked on the phone at any time
|
||||||
105
docs-site/src/content/docs/index.mdx
Normal file
105
docs-site/src/content/docs/index.mdx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: mcbluetooth
|
||||||
|
description: Give LLMs control of your Linux Bluetooth stack
|
||||||
|
template: splash
|
||||||
|
hero:
|
||||||
|
tagline: A comprehensive MCP server that exposes the Linux Bluetooth stack to LLMs. Control adapters, pair devices, manage audio, read BLE sensors, and transfer files — all through natural language.
|
||||||
|
image:
|
||||||
|
file: ../../assets/logo-dark.svg
|
||||||
|
actions:
|
||||||
|
- text: Get Started
|
||||||
|
link: /getting-started/introduction/
|
||||||
|
icon: right-arrow
|
||||||
|
- text: View on GitHub
|
||||||
|
link: https://github.com/yourusername/mcbluetooth
|
||||||
|
icon: external
|
||||||
|
variant: minimal
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
<CardGrid stagger>
|
||||||
|
<Card title="Full BlueZ Integration" icon="bluetooth">
|
||||||
|
Adapters, devices, pairing, audio profiles, and BLE/GATT — the complete Linux Bluetooth stack at your fingertips.
|
||||||
|
</Card>
|
||||||
|
<Card title="Smart Pairing Agent" icon="shield">
|
||||||
|
Handles PIN codes, passkeys, and numeric comparison automatically. Supports interactive, auto, and elicitation modes.
|
||||||
|
</Card>
|
||||||
|
<Card title="Audio Control" icon="headphones">
|
||||||
|
PipeWire and PulseAudio integration for seamless Bluetooth audio management. Switch profiles, adjust volume, set defaults.
|
||||||
|
</Card>
|
||||||
|
<Card title="OBEX Profiles" icon="folder">
|
||||||
|
File transfer (OPP/FTP), phonebook access (PBAP), and message access (MAP) for connected phones and devices.
|
||||||
|
</Card>
|
||||||
|
<Card title="BLE & GATT" icon="cpu">
|
||||||
|
Read sensors, write commands, subscribe to notifications. Full support for Bluetooth Low Energy devices.
|
||||||
|
</Card>
|
||||||
|
<Card title="Protocol Analysis" icon="magnifier">
|
||||||
|
Capture and analyze Bluetooth traffic with btmon integration. Debug connections and reverse engineer protocols.
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
## Example Conversation
|
||||||
|
|
||||||
|
```
|
||||||
|
You: Scan for Bluetooth devices and connect to my Bose headphones
|
||||||
|
|
||||||
|
Claude: I'll scan for nearby Bluetooth devices.
|
||||||
|
|
||||||
|
[Calling bt_scan with adapter="hci0", timeout=10, mode="both"]
|
||||||
|
|
||||||
|
Found 5 devices:
|
||||||
|
• Bombay (C8:7B:23:55:68:E8) - Bose NCH700, paired
|
||||||
|
• DS18 BT-TWO (20:23:05:26:02:31) - Audio device
|
||||||
|
• iPhone (AA:BB:CC:DD:EE:FF) - Phone
|
||||||
|
...
|
||||||
|
|
||||||
|
I found your Bose headphones "Bombay". Connecting now...
|
||||||
|
|
||||||
|
[Calling bt_connect with adapter="hci0", address="C8:7B:23:55:68:E8"]
|
||||||
|
|
||||||
|
✓ Connected to Bombay! The headphones are ready. Would you like me
|
||||||
|
to set them as the default audio output?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="uvx (recommended)">
|
||||||
|
```bash
|
||||||
|
# Run directly
|
||||||
|
uvx mcbluetooth
|
||||||
|
|
||||||
|
# Or install globally
|
||||||
|
uv tool install mcbluetooth
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="From source">
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/mcbluetooth
|
||||||
|
cd mcbluetooth
|
||||||
|
uv sync
|
||||||
|
uv run mcbluetooth
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Add to Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add mcbluetooth -- uvx mcbluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Categories
|
||||||
|
|
||||||
|
| Category | Tools | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| **Adapter** | 6 | Power, discoverable, pairable, alias |
|
||||||
|
| **Device** | 12 | Scan, pair, connect, trust, block |
|
||||||
|
| **Audio** | 7 | Profiles, volume, mute, default sink |
|
||||||
|
| **BLE** | 7 | GATT services, characteristics, notify |
|
||||||
|
| **Monitor** | 6 | HCI capture, parse, analyze |
|
||||||
|
| **OBEX** | 23 | File transfer, phonebook, messages |
|
||||||
|
| **Total** | **61** | Complete Bluetooth control |
|
||||||
217
docs-site/src/content/docs/reference/adapter-tools.md
Normal file
217
docs-site/src/content/docs/reference/adapter-tools.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
---
|
||||||
|
title: Adapter Tools
|
||||||
|
description: Reference for Bluetooth adapter management tools
|
||||||
|
---
|
||||||
|
|
||||||
|
Tools for managing Bluetooth hardware adapters.
|
||||||
|
|
||||||
|
## bt_list_adapters
|
||||||
|
|
||||||
|
List all Bluetooth adapters on the system.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "hci0",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"alias": "My Laptop",
|
||||||
|
"powered": true,
|
||||||
|
"discoverable": false,
|
||||||
|
"discoverable_timeout": 180,
|
||||||
|
"pairable": true,
|
||||||
|
"pairable_timeout": 0,
|
||||||
|
"discovering": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_list_adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_adapter_info
|
||||||
|
|
||||||
|
Get detailed information about a specific adapter.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name (e.g., "hci0") |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hci0",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"alias": "My Laptop",
|
||||||
|
"class": 7995916,
|
||||||
|
"powered": true,
|
||||||
|
"discoverable": false,
|
||||||
|
"discoverable_timeout": 180,
|
||||||
|
"pairable": true,
|
||||||
|
"pairable_timeout": 0,
|
||||||
|
"discovering": false,
|
||||||
|
"uuids": [
|
||||||
|
"0000110a-0000-1000-8000-00805f9b34fb",
|
||||||
|
"0000110c-0000-1000-8000-00805f9b34fb"
|
||||||
|
],
|
||||||
|
"modalias": "usb:v1D6Bp0246d0540"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_adapter_info adapter="hci0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_adapter_power
|
||||||
|
|
||||||
|
Power an adapter on or off.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `on` | boolean | Yes | true to power on, false to power off |
|
||||||
|
|
||||||
|
**Returns:** Updated adapter info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_adapter_power adapter="hci0" on=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Powering off disconnects all devices
|
||||||
|
- Stops any ongoing discovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_adapter_discoverable
|
||||||
|
|
||||||
|
Set adapter discoverable (visible to other devices).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `on` | boolean | Yes | - | Enable/disable discoverable |
|
||||||
|
| `timeout` | integer | No | 180 | Seconds until auto-hidden (0 = forever) |
|
||||||
|
|
||||||
|
**Returns:** Updated adapter info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Discoverable for 5 minutes
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=true timeout=300
|
||||||
|
|
||||||
|
# Discoverable forever (use carefully)
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=true timeout=0
|
||||||
|
|
||||||
|
# Hide from other devices
|
||||||
|
bt_adapter_discoverable adapter="hci0" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_adapter_pairable
|
||||||
|
|
||||||
|
Set adapter pairable state.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `on` | boolean | Yes | - | Enable/disable pairing acceptance |
|
||||||
|
| `timeout` | integer | No | 0 | Seconds until auto-disable (0 = forever) |
|
||||||
|
|
||||||
|
**Returns:** Updated adapter info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Accept pairings for 5 minutes
|
||||||
|
bt_adapter_pairable adapter="hci0" on=true timeout=300
|
||||||
|
|
||||||
|
# Stop accepting pairings
|
||||||
|
bt_adapter_pairable adapter="hci0" on=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_adapter_set_alias
|
||||||
|
|
||||||
|
Set adapter friendly name (alias).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `alias` | string | Yes | New friendly name |
|
||||||
|
|
||||||
|
**Returns:** Updated adapter info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_adapter_set_alias adapter="hci0" alias="Ryan's Desktop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_scan
|
||||||
|
|
||||||
|
Scan for nearby Bluetooth devices.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `timeout` | integer | No | 10 | Scan duration in seconds |
|
||||||
|
| `mode` | string | No | "both" | Scan mode: "classic", "ble", or "both" |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "My Device",
|
||||||
|
"alias": "My Device",
|
||||||
|
"paired": false,
|
||||||
|
"connected": false,
|
||||||
|
"rssi": -65,
|
||||||
|
"uuids": ["0000110a-..."],
|
||||||
|
"manufacturer_data": {...},
|
||||||
|
"service_data": {...}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Standard scan
|
||||||
|
bt_scan adapter="hci0" timeout=10
|
||||||
|
|
||||||
|
# BLE only
|
||||||
|
bt_scan adapter="hci0" timeout=15 mode="ble"
|
||||||
|
|
||||||
|
# Classic only (faster for headphones, speakers)
|
||||||
|
bt_scan adapter="hci0" timeout=10 mode="classic"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Starts discovery, waits for timeout, then stops
|
||||||
|
- BLE devices must be advertising to be found
|
||||||
|
- Returns devices discovered during the scan period
|
||||||
256
docs-site/src/content/docs/reference/audio-tools.md
Normal file
256
docs-site/src/content/docs/reference/audio-tools.md
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
title: Audio Tools
|
||||||
|
description: Reference for Bluetooth audio management tools
|
||||||
|
---
|
||||||
|
|
||||||
|
Tools for managing Bluetooth audio with PipeWire/PulseAudio integration.
|
||||||
|
|
||||||
|
## bt_audio_list
|
||||||
|
|
||||||
|
List all audio devices including Bluetooth.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sinks": [
|
||||||
|
{
|
||||||
|
"name": "bluez_sink.C8_7B_23_55_68_E8.a2dp_sink",
|
||||||
|
"description": "Bose Headphones",
|
||||||
|
"bluetooth_address": "C8:7B:23:55:68:E8",
|
||||||
|
"volume": 65536,
|
||||||
|
"volume_percent": 75,
|
||||||
|
"muted": false,
|
||||||
|
"state": "running"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "bluez_source.C8_7B_23_55_68_E8.hfp_ag",
|
||||||
|
"description": "Bose Headphones (Microphone)",
|
||||||
|
"bluetooth_address": "C8:7B:23:55:68:E8",
|
||||||
|
"volume": 65536,
|
||||||
|
"volume_percent": 100,
|
||||||
|
"muted": false,
|
||||||
|
"state": "idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"name": "bluez_card.C8_7B_23_55_68_E8",
|
||||||
|
"bluetooth_address": "C8:7B:23:55:68:E8",
|
||||||
|
"active_profile": "a2dp-sink",
|
||||||
|
"profiles": ["a2dp-sink", "hfp-hf", "off"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_audio_list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_audio_connect
|
||||||
|
|
||||||
|
Connect audio profiles to a Bluetooth device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"profiles": ["a2dp-sink", "hfp-hf"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_audio_connect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Connects A2DP (high-quality audio) and/or HFP (hands-free) profiles
|
||||||
|
- Device must be paired first
|
||||||
|
- Automatically registers with PipeWire/PulseAudio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_audio_disconnect
|
||||||
|
|
||||||
|
Disconnect audio profiles from a Bluetooth device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "disconnected",
|
||||||
|
"address": "C8:7B:23:55:68:E8"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_audio_disconnect adapter="hci0" address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Disconnects audio profiles only
|
||||||
|
- Device remains connected for other services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_audio_set_profile
|
||||||
|
|
||||||
|
Switch audio profile for a Bluetooth device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `profile` | string | Yes | Profile: "a2dp", "hfp", or "off" |
|
||||||
|
|
||||||
|
**Profiles:**
|
||||||
|
| Profile | Quality | Microphone | Use Case |
|
||||||
|
|---------|---------|------------|----------|
|
||||||
|
| `a2dp` | High (stereo) | No | Music, videos |
|
||||||
|
| `hfp` | Low (mono) | Yes | Phone calls |
|
||||||
|
| `off` | - | - | Disable audio |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "profile_changed",
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"profile": "a2dp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# High-quality for music
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
|
||||||
|
|
||||||
|
# Enable microphone for calls
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="hfp"
|
||||||
|
|
||||||
|
# Disable audio (stay connected for other profiles)
|
||||||
|
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="off"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_audio_set_default
|
||||||
|
|
||||||
|
Set a Bluetooth device as the default audio output.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "default_set",
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"sink": "bluez_sink.C8_7B_23_55_68_E8.a2dp_sink"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_audio_set_default address="C8:7B:23:55:68:E8"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- All system audio routes to this device
|
||||||
|
- Persists until changed or device disconnects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_audio_volume
|
||||||
|
|
||||||
|
Set volume for a Bluetooth audio device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `volume` | integer | Yes | Volume level (0-100, can go up to 150) |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "volume_set",
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"volume": 75
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Normal volume
|
||||||
|
bt_audio_volume address="C8:7B:23:55:68:E8" volume=75
|
||||||
|
|
||||||
|
# Boost (may distort)
|
||||||
|
bt_audio_volume address="C8:7B:23:55:68:E8" volume=120
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- 0-100 is standard range
|
||||||
|
- 100-150 provides software amplification (may cause distortion)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_audio_mute
|
||||||
|
|
||||||
|
Mute or unmute a Bluetooth audio device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `muted` | boolean | Yes | true to mute, false to unmute |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "mute_changed",
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"muted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Mute
|
||||||
|
bt_audio_mute address="C8:7B:23:55:68:E8" muted=true
|
||||||
|
|
||||||
|
# Unmute
|
||||||
|
bt_audio_mute address="C8:7B:23:55:68:E8" muted=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Preserves volume level
|
||||||
|
- Faster than setting volume to 0
|
||||||
283
docs-site/src/content/docs/reference/ble-tools.md
Normal file
283
docs-site/src/content/docs/reference/ble-tools.md
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
title: BLE Tools
|
||||||
|
description: Reference for Bluetooth Low Energy and GATT tools
|
||||||
|
---
|
||||||
|
|
||||||
|
Tools for interacting with Bluetooth Low Energy devices — sensors, fitness trackers, IoT devices, and more.
|
||||||
|
|
||||||
|
## bt_ble_scan
|
||||||
|
|
||||||
|
Scan for BLE (Bluetooth Low Energy) devices.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `timeout` | integer | No | 10 | Scan duration in seconds |
|
||||||
|
| `name_filter` | string | No | - | Only devices with name containing this |
|
||||||
|
| `service_filter` | string | No | - | Only devices advertising this service UUID |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "Heart Rate Monitor",
|
||||||
|
"rssi": -65,
|
||||||
|
"uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
|
||||||
|
"manufacturer_data": {"76": "02150201..."},
|
||||||
|
"service_data": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Basic scan
|
||||||
|
bt_ble_scan adapter="hci0" timeout=10
|
||||||
|
|
||||||
|
# Filter by name
|
||||||
|
bt_ble_scan adapter="hci0" name_filter="Fitness"
|
||||||
|
|
||||||
|
# Filter by service (Heart Rate)
|
||||||
|
bt_ble_scan adapter="hci0" service_filter="0000180d-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_ble_services
|
||||||
|
|
||||||
|
List GATT services for a connected BLE device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "0000180f-0000-1000-8000-00805f9b34fb",
|
||||||
|
"primary": true,
|
||||||
|
"description": "Battery Service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "0000180d-0000-1000-8000-00805f9b34fb",
|
||||||
|
"primary": true,
|
||||||
|
"description": "Heart Rate Service"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_ble_services adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Device must be connected first
|
||||||
|
- Wait 2-3 seconds after connecting for service discovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_ble_characteristics
|
||||||
|
|
||||||
|
List GATT characteristics for a BLE device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `service_uuid` | string | No | - | Filter to this service only |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "00002a19-0000-1000-8000-00805f9b34fb",
|
||||||
|
"service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
|
||||||
|
"flags": ["read", "notify"],
|
||||||
|
"description": "Battery Level"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# All characteristics
|
||||||
|
bt_ble_characteristics adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
# Filter by service
|
||||||
|
bt_ble_characteristics adapter="hci0" address="..." service_uuid="0000180f-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_ble_read
|
||||||
|
|
||||||
|
Read a GATT characteristic value.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `char_uuid` | string | Yes | Characteristic UUID |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "00002a19-0000-1000-8000-00805f9b34fb",
|
||||||
|
"hex": "4b",
|
||||||
|
"decoded": 75,
|
||||||
|
"description": "Battery Level: 75%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_ble_read adapter="hci0" address="AA:BB:CC:DD:EE:FF" char_uuid="00002a19-0000-1000-8000-00805f9b34fb"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Characteristic must have `read` flag
|
||||||
|
- Device must be connected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_ble_write
|
||||||
|
|
||||||
|
Write a value to a GATT characteristic.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `char_uuid` | string | Yes | - | Characteristic UUID |
|
||||||
|
| `value` | string | Yes | - | Value to write |
|
||||||
|
| `value_type` | string | No | "hex" | How to interpret value: "hex", "string", "int" |
|
||||||
|
| `with_response` | boolean | No | true | Wait for write acknowledgment |
|
||||||
|
|
||||||
|
**Value Types:**
|
||||||
|
| Type | Example | Bytes Written |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| `hex` | "0102ff" | 0x01, 0x02, 0xFF |
|
||||||
|
| `string` | "hello" | UTF-8 encoded |
|
||||||
|
| `int` | "42" | Single byte (0-255) |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "written",
|
||||||
|
"uuid": "...",
|
||||||
|
"bytes_written": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Write hex bytes
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="0102ff" value_type="hex"
|
||||||
|
|
||||||
|
# Write string
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="hello" value_type="string"
|
||||||
|
|
||||||
|
# Write without waiting for response (faster)
|
||||||
|
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="01" with_response=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_ble_notify
|
||||||
|
|
||||||
|
Enable or disable notifications for a characteristic.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `char_uuid` | string | Yes | Characteristic UUID |
|
||||||
|
| `enable` | boolean | Yes | true to enable, false to disable |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "notifications_enabled",
|
||||||
|
"uuid": "00002a37-0000-1000-8000-00805f9b34fb"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Enable heart rate notifications
|
||||||
|
bt_ble_notify adapter="hci0" address="..." char_uuid="00002a37-0000-1000-8000-00805f9b34fb" enable=true
|
||||||
|
|
||||||
|
# Disable notifications
|
||||||
|
bt_ble_notify adapter="hci0" address="..." char_uuid="..." enable=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Characteristic must have `notify` flag
|
||||||
|
- Notifications are delivered via GATT protocol
|
||||||
|
- Use protocol capture to see notification data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_ble_battery
|
||||||
|
|
||||||
|
Read battery level from a BLE device (convenience function).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"battery_level": 75,
|
||||||
|
"unit": "percent"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_ble_battery adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Uses standard Battery Service (UUID 0x180F)
|
||||||
|
- Returns error if device doesn't support battery service
|
||||||
|
|
||||||
|
## Common UUIDs
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Service | Short UUID | Full UUID |
|
||||||
|
|---------|------------|-----------|
|
||||||
|
| Battery | 0x180F | 0000180f-0000-1000-8000-00805f9b34fb |
|
||||||
|
| Heart Rate | 0x180D | 0000180d-0000-1000-8000-00805f9b34fb |
|
||||||
|
| Device Info | 0x180A | 0000180a-0000-1000-8000-00805f9b34fb |
|
||||||
|
| Generic Access | 0x1800 | 00001800-0000-1000-8000-00805f9b34fb |
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
| Characteristic | Short UUID | Service |
|
||||||
|
|----------------|------------|---------|
|
||||||
|
| Battery Level | 0x2A19 | Battery |
|
||||||
|
| Heart Rate Measurement | 0x2A37 | Heart Rate |
|
||||||
|
| Manufacturer Name | 0x2A29 | Device Info |
|
||||||
|
| Model Number | 0x2A24 | Device Info |
|
||||||
345
docs-site/src/content/docs/reference/device-tools.md
Normal file
345
docs-site/src/content/docs/reference/device-tools.md
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
---
|
||||||
|
title: Device Tools
|
||||||
|
description: Reference for device discovery, pairing, and connection tools
|
||||||
|
---
|
||||||
|
|
||||||
|
Tools for managing Bluetooth device lifecycle — discovery, pairing, connection, and trust.
|
||||||
|
|
||||||
|
## bt_list_devices
|
||||||
|
|
||||||
|
List known Bluetooth devices.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `filter` | string | No | "all" | Filter: "all", "paired", "connected", "trusted" |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "My Headphones",
|
||||||
|
"alias": "Work Headphones",
|
||||||
|
"paired": true,
|
||||||
|
"connected": true,
|
||||||
|
"trusted": true,
|
||||||
|
"blocked": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_list_devices adapter="hci0" filter="paired"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_device_info
|
||||||
|
|
||||||
|
Get detailed information about a specific device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "Bose NCH700",
|
||||||
|
"alias": "My Headphones",
|
||||||
|
"class": 2360344,
|
||||||
|
"icon": "audio-headphones",
|
||||||
|
"paired": true,
|
||||||
|
"bonded": true,
|
||||||
|
"trusted": true,
|
||||||
|
"blocked": false,
|
||||||
|
"connected": true,
|
||||||
|
"legacy_pairing": false,
|
||||||
|
"rssi": -55,
|
||||||
|
"uuids": [
|
||||||
|
"0000110b-0000-1000-8000-00805f9b34fb",
|
||||||
|
"0000110e-0000-1000-8000-00805f9b34fb"
|
||||||
|
],
|
||||||
|
"modalias": "bluetooth:v009Ep4020d0134",
|
||||||
|
"adapter": "/org/bluez/hci0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_device_info adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_device_set_alias
|
||||||
|
|
||||||
|
Set a friendly name for a device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `alias` | string | Yes | New friendly name |
|
||||||
|
|
||||||
|
**Returns:** Updated device info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_device_set_alias adapter="hci0" address="AA:BB:CC:DD:EE:FF" alias="Work Headphones"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_pair
|
||||||
|
|
||||||
|
Initiate pairing with a device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `pairing_mode` | string | No | "interactive" | Mode: "interactive", "auto", "elicit" |
|
||||||
|
| `timeout` | integer | No | 60 | Pairing timeout in seconds |
|
||||||
|
|
||||||
|
**Pairing Modes:**
|
||||||
|
- `interactive` — Returns status; use `bt_pair_confirm` to respond to prompts
|
||||||
|
- `auto` — Auto-accepts all requests (trusted environments only)
|
||||||
|
- `elicit` — Uses MCP elicitation for PIN prompts (if supported)
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "paired",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "My Device"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if confirmation needed:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "awaiting_confirmation",
|
||||||
|
"method": "numeric_comparison",
|
||||||
|
"passkey": 123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF" pairing_mode="interactive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_pair_confirm
|
||||||
|
|
||||||
|
Confirm or reject a pairing request.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `adapter` | string | Yes | - | Adapter name |
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `accept` | boolean | No | true | Accept or reject pairing |
|
||||||
|
| `pin` | string | No | - | PIN code if required |
|
||||||
|
| `passkey` | integer | No | - | Numeric passkey (0-999999) |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "paired",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Accept with passkey
|
||||||
|
bt_pair_confirm adapter="hci0" address="AA:BB:CC:DD:EE:FF" passkey=123456 accept=true
|
||||||
|
|
||||||
|
# Reject pairing
|
||||||
|
bt_pair_confirm adapter="hci0" address="AA:BB:CC:DD:EE:FF" accept=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_pairing_status
|
||||||
|
|
||||||
|
Get status of pending pairing requests.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pending_requests": [
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"method": "passkey_entry",
|
||||||
|
"timestamp": "2024-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_pairing_status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_unpair
|
||||||
|
|
||||||
|
Remove pairing with a device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "unpaired",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_unpair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Removes device from known devices
|
||||||
|
- Deletes all stored pairing information
|
||||||
|
- Device must be re-paired to connect again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_connect
|
||||||
|
|
||||||
|
Connect to a paired device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:** Updated device info with `connected: true`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Device must be paired first
|
||||||
|
- For audio devices, also connects audio profiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_disconnect
|
||||||
|
|
||||||
|
Disconnect from a device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "disconnected",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_disconnect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Preserves pairing (device can reconnect)
|
||||||
|
- Use `bt_unpair` to fully remove device
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_trust
|
||||||
|
|
||||||
|
Set device trust status.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `trusted` | boolean | Yes | Trust state |
|
||||||
|
|
||||||
|
**Returns:** Updated device info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Trust device (auto-connect allowed)
|
||||||
|
bt_trust adapter="hci0" address="AA:BB:CC:DD:EE:FF" trusted=true
|
||||||
|
|
||||||
|
# Untrust device
|
||||||
|
bt_trust adapter="hci0" address="AA:BB:CC:DD:EE:FF" trusted=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Trusted devices can connect automatically
|
||||||
|
- Untrusted devices require explicit connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_block
|
||||||
|
|
||||||
|
Block or unblock a device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `adapter` | string | Yes | Adapter name |
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `blocked` | boolean | Yes | Block state |
|
||||||
|
|
||||||
|
**Returns:** Updated device info
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Block device
|
||||||
|
bt_block adapter="hci0" address="AA:BB:CC:DD:EE:FF" blocked=true
|
||||||
|
|
||||||
|
# Unblock device
|
||||||
|
bt_block adapter="hci0" address="AA:BB:CC:DD:EE:FF" blocked=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Blocked devices cannot connect
|
||||||
|
- Existing connection is terminated when blocked
|
||||||
251
docs-site/src/content/docs/reference/hfp-ag-tools.md
Normal file
251
docs-site/src/content/docs/reference/hfp-ag-tools.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
title: HFP Audio Gateway Tools
|
||||||
|
description: Reference for HFP Audio Gateway tools — act as a phone for headset testing
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Tools for the HFP Audio Gateway role, allowing Linux to simulate a phone for testing Bluetooth headsets and hands-free devices.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
These tools register a custom HFP AG profile with BlueZ via ProfileManager1. When a Hands-Free device connects, the SLC (Service Level Connection) is auto-negotiated — feature exchange, indicator setup, and codec selection happen automatically.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## bt_hfp_ag_enable
|
||||||
|
|
||||||
|
Enable HFP Audio Gateway mode on Linux.
|
||||||
|
|
||||||
|
Registers a custom HFP AG profile with BlueZ. After enabling, Bluetooth headsets (HF devices) can connect and Linux acts as the phone side.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"role": "audio_gateway",
|
||||||
|
"profile": "HFP AG 1.7"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_enable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Must be called before headsets can connect via HFP
|
||||||
|
- Registers on RFCOMM channel 13
|
||||||
|
- Supports HFP 1.7 features including codec negotiation (CVSD/mSBC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_disable
|
||||||
|
|
||||||
|
Disable HFP Audio Gateway mode.
|
||||||
|
|
||||||
|
Unregisters the AG profile and disconnects any active HFP sessions.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"disabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_disable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_status
|
||||||
|
|
||||||
|
Get HFP Audio Gateway status.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"registered": true,
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"slc_established": true,
|
||||||
|
"codec": "msbc",
|
||||||
|
"speaker_volume": 7,
|
||||||
|
"mic_volume": 7,
|
||||||
|
"calls": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indicators": {
|
||||||
|
"service": 1,
|
||||||
|
"call": 0,
|
||||||
|
"callsetup": 0,
|
||||||
|
"callheld": 0,
|
||||||
|
"signal": 5,
|
||||||
|
"roam": 0,
|
||||||
|
"battchg": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_simulate_call
|
||||||
|
|
||||||
|
Simulate an incoming call to a connected HF device.
|
||||||
|
|
||||||
|
Sends RING and +CLIP (caller ID) notifications to the headset. The HF device sees an incoming call and can answer (ATA) or reject (AT+CHUP). Ringing repeats every 3 seconds until answered or ended.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Bluetooth address of connected HF device |
|
||||||
|
| `number` | string | No | Caller phone number to display (default: "5551234567") |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"call_state": "ringing",
|
||||||
|
"number": "5551234567"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_simulate_call address="AA:BB:CC:DD:EE:FF" number="+15551234567"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Device must have an established SLC (Service Level Connection)
|
||||||
|
- The HF device will hear a ring tone (if in-band ring is supported)
|
||||||
|
- Use `bt_hfp_ag_end_call` to stop ringing or terminate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_end_call
|
||||||
|
|
||||||
|
End an active or ringing call from the AG side.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Bluetooth address of connected HF device |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"call_state": "ended"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_end_call address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_set_volume
|
||||||
|
|
||||||
|
Set speaker or microphone volume on the HF device.
|
||||||
|
|
||||||
|
Sends +VGS (speaker) or +VGM (microphone) command to change volume remotely.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Bluetooth address of connected HF device |
|
||||||
|
| `type` | string | Yes | `"speaker"` for output volume, `"microphone"` for input |
|
||||||
|
| `level` | integer | Yes | Volume level 0-15 (0 = muted, 15 = maximum) |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"type": "speaker",
|
||||||
|
"level": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Set speaker volume
|
||||||
|
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="speaker" level=12
|
||||||
|
|
||||||
|
# Set microphone volume
|
||||||
|
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="microphone" level=10
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
HFP uses 0-15 for volume levels, unlike PipeWire/PulseAudio (0-100). The HF device maps these to its own range.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_set_signal
|
||||||
|
|
||||||
|
Update the signal strength indicator shown on the HF device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Bluetooth address of connected HF device |
|
||||||
|
| `level` | integer | Yes | Signal strength 0-5 |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"signal_strength": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_set_signal address="AA:BB:CC:DD:EE:FF" level=4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_hfp_ag_set_battery
|
||||||
|
|
||||||
|
Update the battery level indicator shown on the HF device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Bluetooth address of connected HF device |
|
||||||
|
| `level` | integer | Yes | Battery level 0-5 |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"battery_level": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_hfp_ag_set_battery address="AA:BB:CC:DD:EE:FF" level=3
|
||||||
|
```
|
||||||
282
docs-site/src/content/docs/reference/monitor-tools.md
Normal file
282
docs-site/src/content/docs/reference/monitor-tools.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
---
|
||||||
|
title: Monitor Tools
|
||||||
|
description: Reference for Bluetooth protocol capture and analysis tools
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Tools for capturing and analyzing raw Bluetooth HCI (Host Controller Interface) traffic.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
Capture requires elevated privileges. See `bt_capture_start` for details.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## bt_capture_start
|
||||||
|
|
||||||
|
Start capturing Bluetooth HCI traffic.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `output_file` | string | Yes | - | Path for btsnoop capture file |
|
||||||
|
| `adapter` | string | No | - | Adapter index (e.g., "0" for hci0), or all if omitted |
|
||||||
|
| `include_sco` | boolean | No | false | Include SCO voice traffic |
|
||||||
|
| `include_a2dp` | boolean | No | false | Include A2DP audio streaming |
|
||||||
|
| `include_iso` | boolean | No | false | Include ISO (LE Audio) traffic |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"capture_id": "capture_abc123",
|
||||||
|
"output_file": "/tmp/bluetooth.btsnoop"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Basic capture
|
||||||
|
bt_capture_start output_file="/tmp/bluetooth.btsnoop"
|
||||||
|
|
||||||
|
# Capture from specific adapter
|
||||||
|
bt_capture_start output_file="/tmp/hci0.btsnoop" adapter="0"
|
||||||
|
|
||||||
|
# Include audio data (large files!)
|
||||||
|
bt_capture_start output_file="/tmp/audio.btsnoop" include_a2dp=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Requires root or `CAP_NET_RAW` capability on btmon
|
||||||
|
- Capture runs in background until stopped
|
||||||
|
- Audio flags generate large files quickly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_capture_stop
|
||||||
|
|
||||||
|
Stop a running Bluetooth capture.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `capture_id` | string | Yes | Capture ID from bt_capture_start |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "stopped",
|
||||||
|
"output_file": "/tmp/bluetooth.btsnoop",
|
||||||
|
"packets_captured": 1542,
|
||||||
|
"file_size": 245760
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_capture_stop capture_id="capture_abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Always stop captures to ensure files are properly closed
|
||||||
|
- Capture ID is returned by bt_capture_start
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_capture_list_active
|
||||||
|
|
||||||
|
List all active Bluetooth captures.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"captures": [
|
||||||
|
{
|
||||||
|
"capture_id": "capture_abc123",
|
||||||
|
"output_file": "/tmp/bluetooth.btsnoop",
|
||||||
|
"started": "2024-01-15T10:30:00",
|
||||||
|
"adapter": "all"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_capture_list_active
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_capture_parse
|
||||||
|
|
||||||
|
Parse a btsnoop capture file and return packet summaries.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `filepath` | string | Yes | - | Path to btsnoop file |
|
||||||
|
| `max_packets` | integer | No | 100 | Maximum packets to return (0 for all) |
|
||||||
|
| `packet_type_filter` | string | No | - | Filter by type: HCI_CMD, ACL_DATA, HCI_EVENT, SCO_DATA |
|
||||||
|
| `direction_filter` | string | No | - | Filter by direction: TX or RX |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_packets": 1542,
|
||||||
|
"packet_types": {
|
||||||
|
"HCI_CMD": 234,
|
||||||
|
"HCI_EVENT": 456,
|
||||||
|
"ACL_DATA": 852
|
||||||
|
},
|
||||||
|
"packets": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"timestamp": "2024-01-15T10:30:00.123456",
|
||||||
|
"type": "HCI_CMD",
|
||||||
|
"direction": "TX",
|
||||||
|
"length": 7,
|
||||||
|
"summary": "Inquiry"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# Basic parse
|
||||||
|
bt_capture_parse filepath="/tmp/bluetooth.btsnoop"
|
||||||
|
|
||||||
|
# Only HCI commands
|
||||||
|
bt_capture_parse filepath="..." packet_type_filter="HCI_CMD"
|
||||||
|
|
||||||
|
# Only received packets
|
||||||
|
bt_capture_parse filepath="..." direction_filter="RX"
|
||||||
|
|
||||||
|
# First 50 packets
|
||||||
|
bt_capture_parse filepath="..." max_packets=50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_capture_analyze
|
||||||
|
|
||||||
|
Analyze a btsnoop capture file with high-level statistics.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `filepath` | string | Yes | Path to btsnoop file |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"duration_seconds": 45.2,
|
||||||
|
"total_packets": 1542,
|
||||||
|
"protocols": {
|
||||||
|
"L2CAP": 423,
|
||||||
|
"RFCOMM": 156,
|
||||||
|
"SDP": 34,
|
||||||
|
"ATT": 289
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "My Device",
|
||||||
|
"packets": 892,
|
||||||
|
"bytes": 45678
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_capture_analyze filepath="/tmp/bluetooth.btsnoop"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Provides higher-level view than bt_capture_parse
|
||||||
|
- Useful for understanding overall traffic patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bt_capture_read_raw
|
||||||
|
|
||||||
|
Read raw packet data with btmon-style decoding.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `filepath` | string | Yes | - | Path to btsnoop file |
|
||||||
|
| `offset` | integer | No | 0 | Packets to skip from beginning |
|
||||||
|
| `count` | integer | No | 50 | Number of packets to display |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"output": "@ MGMT Command: Read Management... (0x0001) plen 0\n..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
# First 50 packets
|
||||||
|
bt_capture_read_raw filepath="/tmp/bluetooth.btsnoop"
|
||||||
|
|
||||||
|
# Skip first 100, read next 20
|
||||||
|
bt_capture_read_raw filepath="..." offset=100 count=20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Returns btmon's human-readable packet decoding
|
||||||
|
- Useful for detailed protocol analysis
|
||||||
|
- Output can be large for many packets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
Captures use **btsnoop** format, compatible with:
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Wireshark | Full GUI protocol analyzer |
|
||||||
|
| btmon | BlueZ command-line decoder |
|
||||||
|
| hcidump | Legacy packet dumper |
|
||||||
|
|
||||||
|
### Opening in Wireshark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wireshark /tmp/bluetooth.btsnoop
|
||||||
|
```
|
||||||
|
|
||||||
|
Wireshark provides:
|
||||||
|
- Full protocol dissection
|
||||||
|
- Conversation tracking
|
||||||
|
- Expert analysis
|
||||||
|
- Multiple export formats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Capture requires access to the Bluetooth monitor socket:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Run as root
|
||||||
|
sudo btmon
|
||||||
|
|
||||||
|
# Option 2: Add capability (recommended)
|
||||||
|
sudo setcap cap_net_raw+ep /usr/bin/btmon
|
||||||
|
|
||||||
|
# Verify capability
|
||||||
|
getcap /usr/bin/btmon
|
||||||
|
```
|
||||||
582
docs-site/src/content/docs/reference/obex-tools.md
Normal file
582
docs-site/src/content/docs/reference/obex-tools.md
Normal file
@ -0,0 +1,582 @@
|
|||||||
|
---
|
||||||
|
title: OBEX Tools
|
||||||
|
description: Reference for OBEX file transfer, phonebook, and message tools
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Tools for OBEX profiles — file transfer (OPP/FTP), phonebook access (PBAP), and message access (MAP).
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
OBEX tools require the `obexd` daemon. Use `bt_obex_status` to check availability.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Setup Tools
|
||||||
|
|
||||||
|
### bt_obex_status
|
||||||
|
|
||||||
|
Check OBEX subsystem status.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"obexd_installed": true,
|
||||||
|
"obexd_running": true,
|
||||||
|
"dbus_accessible": true,
|
||||||
|
"active_sessions": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_start_daemon
|
||||||
|
|
||||||
|
Start the obexd daemon if not running.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_start_daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
### bt_obex_connect
|
||||||
|
|
||||||
|
Create an OBEX session to a device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `target` | string | Yes | Profile: "opp", "ftp", "pbap", "map" |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"session_id": "ftp_AABBCCDDEEFF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"target": "ftp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_connect address="AA:BB:CC:DD:EE:FF" target="ftp"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_disconnect
|
||||||
|
|
||||||
|
Close an OBEX session.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `session_id` | string | Yes | Session ID from bt_obex_connect |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "disconnected",
|
||||||
|
"session_id": "ftp_AABBCCDDEEFF"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_disconnect session_id="ftp_AABBCCDDEEFF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_sessions
|
||||||
|
|
||||||
|
List active OBEX sessions.
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "ftp_AABBCCDDEEFF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"target": "ftp",
|
||||||
|
"created": "2024-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OPP Tools (Object Push)
|
||||||
|
|
||||||
|
### bt_obex_send_file
|
||||||
|
|
||||||
|
Send a file via Object Push Profile.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `file_path` | string | Yes | - | Local file to send |
|
||||||
|
| `wait` | boolean | No | true | Wait for transfer completion |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"size": 102400,
|
||||||
|
"transferred": 102400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if `wait=false`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"transfer_path": "/org/bluez/obex/client/session0/transfer0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_send_file address="AA:BB:CC:DD:EE:FF" file_path="~/Documents/report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_get_vcard
|
||||||
|
|
||||||
|
Pull business card (vCard) from device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `save_path` | string | Yes | Where to save the vCard |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"save_path": "/home/user/contact.vcf",
|
||||||
|
"size": 512
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_get_vcard address="AA:BB:CC:DD:EE:FF" save_path="~/contact.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FTP Tools (File Transfer)
|
||||||
|
|
||||||
|
### bt_obex_browse
|
||||||
|
|
||||||
|
List files and folders at a path.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `session_id` | string | Yes | - | FTP session ID |
|
||||||
|
| `path` | string | No | "/" | Path to list ("/" for root, ".." to go up) |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "/DCIM",
|
||||||
|
"entries": [
|
||||||
|
{"name": "Camera", "type": "folder"},
|
||||||
|
{"name": "photo.jpg", "type": "file", "size": 2048576}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_browse session_id="ftp_..." path="/DCIM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_get
|
||||||
|
|
||||||
|
Download a file from the device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `session_id` | string | Yes | FTP session ID |
|
||||||
|
| `remote_path` | string | Yes | File path on device |
|
||||||
|
| `local_path` | string | Yes | Where to save locally |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"remote_path": "photo.jpg",
|
||||||
|
"local_path": "/home/user/Downloads/photo.jpg",
|
||||||
|
"size": 2048576
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_get session_id="ftp_..." remote_path="photo.jpg" local_path="~/Downloads/photo.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_put
|
||||||
|
|
||||||
|
Upload a file to the device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `session_id` | string | Yes | FTP session ID |
|
||||||
|
| `local_path` | string | Yes | Local file to upload |
|
||||||
|
| `remote_path` | string | Yes | Destination path on device |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"local_path": "/home/user/document.pdf",
|
||||||
|
"remote_path": "document.pdf",
|
||||||
|
"size": 102400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_put session_id="ftp_..." local_path="~/document.pdf" remote_path="document.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_delete
|
||||||
|
|
||||||
|
Delete a file or folder on the device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `session_id` | string | Yes | FTP session ID |
|
||||||
|
| `remote_path` | string | Yes | File/folder to delete |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "deleted",
|
||||||
|
"path": "old_file.txt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_delete session_id="ftp_..." remote_path="old_file.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_mkdir
|
||||||
|
|
||||||
|
Create a folder on the device.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `session_id` | string | Yes | FTP session ID |
|
||||||
|
| `folder_name` | string | Yes | Name of folder to create |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "created",
|
||||||
|
"folder": "Backup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_mkdir session_id="ftp_..." folder_name="Backup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Monitoring
|
||||||
|
|
||||||
|
### bt_obex_transfer_status
|
||||||
|
|
||||||
|
Check progress of a transfer.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `transfer_path` | string | Yes | Transfer path from send/get operations |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "active",
|
||||||
|
"filename": "large_file.mp4",
|
||||||
|
"size": 104857600,
|
||||||
|
"transferred": 52428800,
|
||||||
|
"progress_percent": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_obex_transfer_cancel
|
||||||
|
|
||||||
|
Cancel an in-progress transfer.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `transfer_path` | string | Yes | Transfer path to cancel |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "cancelled",
|
||||||
|
"transfer_path": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBAP Tools (Phonebook)
|
||||||
|
|
||||||
|
### bt_phonebook_pull
|
||||||
|
|
||||||
|
Download entire phonebook as vCard file.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `save_path` | string | Yes | Where to save |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"save_path": "/home/user/contacts.vcf",
|
||||||
|
"count": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_phonebook_list
|
||||||
|
|
||||||
|
List phonebook entries with handles.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `folder` | string | No | "telecom/pb" | Phonebook folder |
|
||||||
|
|
||||||
|
**Folders:**
|
||||||
|
- `telecom/pb` — Main phonebook
|
||||||
|
- `telecom/ich` — Incoming call history
|
||||||
|
- `telecom/och` — Outgoing call history
|
||||||
|
- `telecom/mch` — Missed call history
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{"handle": "1.vcf", "name": "Alice Smith"},
|
||||||
|
{"handle": "2.vcf", "name": "Bob Jones"}
|
||||||
|
],
|
||||||
|
"count": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_phonebook_get
|
||||||
|
|
||||||
|
Download single contact by handle.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `handle` | string | Yes | Contact handle from list |
|
||||||
|
| `save_path` | string | Yes | Where to save vCard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_phonebook_search
|
||||||
|
|
||||||
|
Search phonebook by field.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `field` | string | Yes | Field to search: "name", "number", "email" |
|
||||||
|
| `value` | string | Yes | Search value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_phonebook_count
|
||||||
|
|
||||||
|
Get total number of contacts.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MAP Tools (Messages)
|
||||||
|
|
||||||
|
### bt_messages_folders
|
||||||
|
|
||||||
|
List message folders.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{"name": "inbox"},
|
||||||
|
{"name": "sent"},
|
||||||
|
{"name": "drafts"},
|
||||||
|
{"name": "outbox"},
|
||||||
|
{"name": "deleted"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_messages_list
|
||||||
|
|
||||||
|
List messages in a folder.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|------|------|----------|---------|-------------|
|
||||||
|
| `address` | string | Yes | - | Device MAC address |
|
||||||
|
| `folder` | string | No | "inbox" | Folder to list |
|
||||||
|
| `unread_only` | boolean | No | false | Only unread messages |
|
||||||
|
| `max_count` | integer | No | 100 | Maximum messages to return |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"handle": "msg001",
|
||||||
|
"subject": "Meeting tomorrow",
|
||||||
|
"sender": "+15551234567",
|
||||||
|
"timestamp": "2024-01-15T10:30:00",
|
||||||
|
"read": false,
|
||||||
|
"type": "SMS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_messages_get
|
||||||
|
|
||||||
|
Download message content.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `handle` | string | Yes | Message handle from list |
|
||||||
|
| `save_path` | string | Yes | Where to save message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bt_messages_send
|
||||||
|
|
||||||
|
Send a message via MAP.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `address` | string | Yes | Device MAC address |
|
||||||
|
| `recipient` | string | Yes | Phone number to send to |
|
||||||
|
| `message` | string | Yes | Message text |
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Message sending support varies by device. Many phones only allow read access.
|
||||||
|
</Aside>
|
||||||
197
docs-site/src/content/docs/reference/resources.md
Normal file
197
docs-site/src/content/docs/reference/resources.md
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
---
|
||||||
|
title: MCP Resources
|
||||||
|
description: Reference for mcbluetooth MCP resource URIs
|
||||||
|
---
|
||||||
|
|
||||||
|
mcbluetooth exposes live Bluetooth state through MCP resources. These provide real-time queries without tool calls.
|
||||||
|
|
||||||
|
## Resource URIs
|
||||||
|
|
||||||
|
| URI | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `bluetooth://adapters` | All Bluetooth adapters |
|
||||||
|
| `bluetooth://paired` | Paired devices |
|
||||||
|
| `bluetooth://connected` | Connected devices |
|
||||||
|
| `bluetooth://visible` | All known/discovered devices |
|
||||||
|
| `bluetooth://trusted` | Trusted devices |
|
||||||
|
| `bluetooth://adapter/{name}` | Specific adapter details |
|
||||||
|
| `bluetooth://device/{address}` | Specific device details |
|
||||||
|
|
||||||
|
## Adapter Resources
|
||||||
|
|
||||||
|
### bluetooth://adapters
|
||||||
|
|
||||||
|
Lists all Bluetooth adapters on the system.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "hci0",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"alias": "My Laptop",
|
||||||
|
"powered": true,
|
||||||
|
"discoverable": false,
|
||||||
|
"pairable": true,
|
||||||
|
"discovering": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### bluetooth://adapter/{name}
|
||||||
|
|
||||||
|
Get details for a specific adapter.
|
||||||
|
|
||||||
|
**URI:** `bluetooth://adapter/hci0`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hci0",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"alias": "My Laptop",
|
||||||
|
"class": 7995916,
|
||||||
|
"powered": true,
|
||||||
|
"discoverable": false,
|
||||||
|
"discoverable_timeout": 180,
|
||||||
|
"pairable": true,
|
||||||
|
"pairable_timeout": 0,
|
||||||
|
"discovering": false,
|
||||||
|
"uuids": ["0000110a-...", "0000110c-..."],
|
||||||
|
"modalias": "usb:v1D6Bp0246d0540"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Resources
|
||||||
|
|
||||||
|
### bluetooth://paired
|
||||||
|
|
||||||
|
Lists all paired devices across all adapters.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"name": "Bose NCH700",
|
||||||
|
"alias": "My Headphones",
|
||||||
|
"paired": true,
|
||||||
|
"connected": true,
|
||||||
|
"trusted": true,
|
||||||
|
"adapter": "hci0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### bluetooth://connected
|
||||||
|
|
||||||
|
Lists currently connected devices.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"name": "Bose NCH700",
|
||||||
|
"connected": true,
|
||||||
|
"services": ["audio", "hfp"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### bluetooth://visible
|
||||||
|
|
||||||
|
Lists all known devices (discovered or previously seen).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "Unknown Device",
|
||||||
|
"paired": false,
|
||||||
|
"connected": false,
|
||||||
|
"rssi": -75,
|
||||||
|
"last_seen": "2024-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### bluetooth://trusted
|
||||||
|
|
||||||
|
Lists devices marked as trusted (auto-connect allowed).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"name": "Bose NCH700",
|
||||||
|
"trusted": true,
|
||||||
|
"paired": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### bluetooth://device/{address}
|
||||||
|
|
||||||
|
Get details for a specific device.
|
||||||
|
|
||||||
|
**URI:** `bluetooth://device/C8:7B:23:55:68:E8`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"address": "C8:7B:23:55:68:E8",
|
||||||
|
"name": "Bose NCH700",
|
||||||
|
"alias": "My Headphones",
|
||||||
|
"class": 2360344,
|
||||||
|
"icon": "audio-headphones",
|
||||||
|
"paired": true,
|
||||||
|
"bonded": true,
|
||||||
|
"trusted": true,
|
||||||
|
"blocked": false,
|
||||||
|
"connected": true,
|
||||||
|
"legacy_pairing": false,
|
||||||
|
"rssi": -55,
|
||||||
|
"uuids": [
|
||||||
|
"0000110b-0000-1000-8000-00805f9b34fb",
|
||||||
|
"0000110e-0000-1000-8000-00805f9b34fb"
|
||||||
|
],
|
||||||
|
"modalias": "bluetooth:v009Ep4020d0134",
|
||||||
|
"adapter": "hci0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in MCP Clients
|
||||||
|
|
||||||
|
Resources can be read using the standard MCP resource protocol:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "resources/read",
|
||||||
|
"params": {
|
||||||
|
"uri": "bluetooth://paired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison: Resources vs Tools
|
||||||
|
|
||||||
|
| Use Case | Resource | Tool |
|
||||||
|
|----------|----------|------|
|
||||||
|
| Check current state | ✓ Resources | |
|
||||||
|
| Modify state | | ✓ Tools |
|
||||||
|
| Real-time queries | ✓ Resources | |
|
||||||
|
| Complex operations | | ✓ Tools |
|
||||||
|
|
||||||
|
**Resources** are read-only and ideal for:
|
||||||
|
- Dashboard displays
|
||||||
|
- State monitoring
|
||||||
|
- Quick queries
|
||||||
|
|
||||||
|
**Tools** are for actions:
|
||||||
|
- Pairing devices
|
||||||
|
- Connecting/disconnecting
|
||||||
|
- Sending files
|
||||||
|
- Changing settings
|
||||||
161
docs-site/src/content/docs/reference/tools.md
Normal file
161
docs-site/src/content/docs/reference/tools.md
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
title: Tools Overview
|
||||||
|
description: Complete reference for all mcbluetooth MCP tools
|
||||||
|
---
|
||||||
|
|
||||||
|
mcbluetooth provides **69 MCP tools** organized into functional categories.
|
||||||
|
|
||||||
|
## Tool Categories
|
||||||
|
|
||||||
|
| Category | Tools | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| [Adapter](/reference/adapter-tools/) | 7 | Hardware adapter management |
|
||||||
|
| [Device](/reference/device-tools/) | 12 | Device discovery, pairing, connection |
|
||||||
|
| [Audio](/reference/audio-tools/) | 7 | PipeWire/PulseAudio integration |
|
||||||
|
| [HFP Audio Gateway](/reference/hfp-ag-tools/) | 8 | Phone role for headset testing |
|
||||||
|
| [BLE](/reference/ble-tools/) | 8 | Bluetooth Low Energy & GATT |
|
||||||
|
| [OBEX](/reference/obex-tools/) | 21 | File transfer, phonebook, messages |
|
||||||
|
| [Monitor](/reference/monitor-tools/) | 6 | Protocol capture & analysis |
|
||||||
|
|
||||||
|
## Tool Naming Convention
|
||||||
|
|
||||||
|
All tools follow the pattern `bt_<category>_<action>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_adapter_power # Adapter category, power action
|
||||||
|
bt_ble_read # BLE category, read action
|
||||||
|
bt_obex_connect # OBEX category, connect action
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Parameters
|
||||||
|
|
||||||
|
### `adapter`
|
||||||
|
|
||||||
|
Most tools require an adapter name (e.g., `"hci0"`):
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_scan adapter="hci0" timeout=10
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `bt_list_adapters` to discover available adapters.
|
||||||
|
|
||||||
|
### `address`
|
||||||
|
|
||||||
|
Device Bluetooth MAC address in colon-separated format:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Return Values
|
||||||
|
|
||||||
|
Tools return JSON objects with:
|
||||||
|
|
||||||
|
- **Success**: Operation-specific data
|
||||||
|
- **Errors**: `{"error": "description", "code": "ERROR_CODE"}`
|
||||||
|
|
||||||
|
## Quick Reference Table
|
||||||
|
|
||||||
|
### Adapter Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_list_adapters` | List all Bluetooth adapters |
|
||||||
|
| `bt_adapter_info` | Get adapter details |
|
||||||
|
| `bt_adapter_power` | Turn adapter on/off |
|
||||||
|
| `bt_adapter_discoverable` | Set discoverable mode |
|
||||||
|
| `bt_adapter_pairable` | Set pairable mode |
|
||||||
|
| `bt_adapter_set_alias` | Set adapter name |
|
||||||
|
| `bt_scan` | Scan for devices |
|
||||||
|
|
||||||
|
### Device Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_list_devices` | List known devices |
|
||||||
|
| `bt_device_info` | Get device details |
|
||||||
|
| `bt_device_set_alias` | Set device name |
|
||||||
|
| `bt_pair` | Initiate pairing |
|
||||||
|
| `bt_pair_confirm` | Confirm/reject pairing |
|
||||||
|
| `bt_pairing_status` | Check pending pairings |
|
||||||
|
| `bt_unpair` | Remove pairing |
|
||||||
|
| `bt_connect` | Connect to device |
|
||||||
|
| `bt_disconnect` | Disconnect device |
|
||||||
|
| `bt_trust` | Set trust status |
|
||||||
|
| `bt_block` | Block/unblock device |
|
||||||
|
|
||||||
|
### Audio Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_audio_list` | List audio devices |
|
||||||
|
| `bt_audio_connect` | Connect audio profiles |
|
||||||
|
| `bt_audio_disconnect` | Disconnect audio |
|
||||||
|
| `bt_audio_set_profile` | Switch A2DP/HFP |
|
||||||
|
| `bt_audio_set_default` | Set default output |
|
||||||
|
| `bt_audio_volume` | Adjust volume |
|
||||||
|
| `bt_audio_mute` | Mute/unmute |
|
||||||
|
|
||||||
|
### HFP Audio Gateway Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_hfp_ag_enable` | Register AG profile with BlueZ |
|
||||||
|
| `bt_hfp_ag_disable` | Unregister AG profile |
|
||||||
|
| `bt_hfp_ag_status` | Get connections and indicators |
|
||||||
|
| `bt_hfp_ag_simulate_call` | Simulate incoming call |
|
||||||
|
| `bt_hfp_ag_end_call` | End active/ringing call |
|
||||||
|
| `bt_hfp_ag_set_volume` | Set speaker/mic volume |
|
||||||
|
| `bt_hfp_ag_set_signal` | Update signal indicator |
|
||||||
|
| `bt_hfp_ag_set_battery` | Update battery indicator |
|
||||||
|
|
||||||
|
### BLE Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_ble_scan` | Scan for BLE devices |
|
||||||
|
| `bt_ble_services` | List GATT services |
|
||||||
|
| `bt_ble_characteristics` | List characteristics |
|
||||||
|
| `bt_ble_read` | Read characteristic |
|
||||||
|
| `bt_ble_write` | Write characteristic |
|
||||||
|
| `bt_ble_notify` | Enable/disable notifications |
|
||||||
|
| `bt_ble_battery` | Read battery level |
|
||||||
|
|
||||||
|
### OBEX Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_obex_status` | Check obexd status |
|
||||||
|
| `bt_obex_start_daemon` | Start obexd |
|
||||||
|
| `bt_obex_connect` | Create OBEX session |
|
||||||
|
| `bt_obex_disconnect` | Close session |
|
||||||
|
| `bt_obex_sessions` | List active sessions |
|
||||||
|
| `bt_obex_send_file` | Send file (OPP) |
|
||||||
|
| `bt_obex_get_vcard` | Get business card |
|
||||||
|
| `bt_obex_browse` | List files/folders |
|
||||||
|
| `bt_obex_get` | Download file |
|
||||||
|
| `bt_obex_put` | Upload file |
|
||||||
|
| `bt_obex_delete` | Delete file/folder |
|
||||||
|
| `bt_obex_mkdir` | Create folder |
|
||||||
|
| `bt_obex_transfer_status` | Check transfer progress |
|
||||||
|
| `bt_obex_transfer_cancel` | Cancel transfer |
|
||||||
|
| `bt_phonebook_pull` | Download phonebook |
|
||||||
|
| `bt_phonebook_list` | List contacts |
|
||||||
|
| `bt_phonebook_get` | Get single contact |
|
||||||
|
| `bt_phonebook_search` | Search contacts |
|
||||||
|
| `bt_phonebook_count` | Count contacts |
|
||||||
|
| `bt_messages_folders` | List message folders |
|
||||||
|
| `bt_messages_list` | List messages |
|
||||||
|
| `bt_messages_get` | Download message |
|
||||||
|
| `bt_messages_send` | Send message |
|
||||||
|
|
||||||
|
### Monitor Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_capture_start` | Start HCI capture |
|
||||||
|
| `bt_capture_stop` | Stop capture |
|
||||||
|
| `bt_capture_list_active` | List active captures |
|
||||||
|
| `bt_capture_parse` | Parse capture file |
|
||||||
|
| `bt_capture_analyze` | Analyze capture |
|
||||||
|
| `bt_capture_read_raw` | Read raw packets |
|
||||||
130
docs-site/src/styles/custom.css
Normal file
130
docs-site/src/styles/custom.css
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/* mcbluetooth Starlight Custom Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Bluetooth-inspired color palette */
|
||||||
|
--sl-color-accent-low: #1a3a5c;
|
||||||
|
--sl-color-accent: #0066cc;
|
||||||
|
--sl-color-accent-high: #3399ff;
|
||||||
|
--sl-color-white: #ffffff;
|
||||||
|
--sl-color-gray-1: #f5f6f8;
|
||||||
|
--sl-color-gray-2: #e8eaed;
|
||||||
|
--sl-color-gray-3: #c0c4cc;
|
||||||
|
--sl-color-gray-4: #888d96;
|
||||||
|
--sl-color-gray-5: #545a64;
|
||||||
|
--sl-color-gray-6: #2d3139;
|
||||||
|
--sl-color-black: #181a1f;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--sl-font-system: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
--sl-font-system-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode accent adjustments */
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--sl-color-accent-low: #0d2847;
|
||||||
|
--sl-color-accent: #3399ff;
|
||||||
|
--sl-color-accent-high: #66b3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.expressive-code {
|
||||||
|
--ec-brdRad: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero section on splash pages */
|
||||||
|
.hero {
|
||||||
|
padding-block: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card styling */
|
||||||
|
.sl-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar styling */
|
||||||
|
.sidebar-content {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool reference tables */
|
||||||
|
table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table code {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code styling */
|
||||||
|
:not(pre) > code {
|
||||||
|
background: var(--sl-color-gray-6);
|
||||||
|
color: var(--sl-color-accent-high);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] :not(pre) > code {
|
||||||
|
background: var(--sl-color-gray-2);
|
||||||
|
color: var(--sl-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Callouts / Asides */
|
||||||
|
.starlight-aside {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--sl-color-gray-5);
|
||||||
|
padding-block: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ASCII art banner (if used) */
|
||||||
|
.ascii-banner {
|
||||||
|
font-family: var(--sl-font-system-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--sl-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool cards grid */
|
||||||
|
.tool-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile badges */
|
||||||
|
.profile-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-badge.opp { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.profile-badge.ftp { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.profile-badge.pbap { background: #fff3e0; color: #e65100; }
|
||||||
|
.profile-badge.map { background: #fce4ec; color: #c2185b; }
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .profile-badge.opp { background: #1565c0; color: #e3f2fd; }
|
||||||
|
:root[data-theme='dark'] .profile-badge.ftp { background: #2e7d32; color: #e8f5e9; }
|
||||||
|
:root[data-theme='dark'] .profile-badge.pbap { background: #e65100; color: #fff3e0; }
|
||||||
|
:root[data-theme='dark'] .profile-badge.map { background: #c2185b; color: #fce4ec; }
|
||||||
5
docs-site/tsconfig.json
Normal file
5
docs-site/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
368
docs/obex.md
Normal file
368
docs/obex.md
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
# OBEX Profile Support
|
||||||
|
|
||||||
|
OBEX (Object Exchange) enables file transfer and data synchronization over Bluetooth. mcbluetooth provides full support for the four major OBEX profiles through the `obexd` daemon.
|
||||||
|
|
||||||
|
## Profiles Overview
|
||||||
|
|
||||||
|
| Profile | UUID | Purpose | Typical Use |
|
||||||
|
|---------|------|---------|-------------|
|
||||||
|
| **OPP** (Object Push) | 0x1105 | Simple file sending | "Send this photo to my laptop" |
|
||||||
|
| **FTP** (File Transfer) | 0x1106 | Full file browsing | "Browse phone storage, download DCIM" |
|
||||||
|
| **PBAP** (Phonebook Access) | 0x112F | Contact/call history | "Download my contacts" |
|
||||||
|
| **MAP** (Message Access) | 0x1132 | SMS/MMS/email | "Show unread messages" |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
OBEX requires the `obexd` daemon, which is separate from the main `bluetoothd`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S bluez-obex
|
||||||
|
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt install bluez-obex
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install bluez-obex
|
||||||
|
```
|
||||||
|
|
||||||
|
Check your setup with:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_status
|
||||||
|
```
|
||||||
|
|
||||||
|
If obexd isn't running, start it:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_start_daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: obexd runs as a user service on the session D-Bus, not the system bus. It requires a desktop session to be active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Send a File (OPP)
|
||||||
|
|
||||||
|
The simplest OBEX operation — push a file to any device:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_obex_send_file address="C8:7B:23:55:68:E8" file_path="~/Documents/report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
The receiving device will show an accept/reject prompt. The tool waits for completion and reports success/failure.
|
||||||
|
|
||||||
|
### Download Contacts (PBAP)
|
||||||
|
|
||||||
|
Pull all contacts from a paired phone:
|
||||||
|
|
||||||
|
```
|
||||||
|
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/contacts.vcf"
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a vCard file with all contacts. You can import it into any contacts app.
|
||||||
|
|
||||||
|
### Browse Phone Storage (FTP)
|
||||||
|
|
||||||
|
For full file system access, create an FTP session:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Connect
|
||||||
|
bt_obex_connect address="AA:BB:CC:DD:EE:FF" target="ftp"
|
||||||
|
# Returns: session_id="ftp_AABBCCDDEEFF"
|
||||||
|
|
||||||
|
# Browse root
|
||||||
|
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="/"
|
||||||
|
|
||||||
|
# Navigate to DCIM
|
||||||
|
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="DCIM"
|
||||||
|
|
||||||
|
# Download a photo
|
||||||
|
bt_obex_get session_id="ftp_AABBCCDDEEFF" remote_path="photo.jpg" local_path="~/Downloads/"
|
||||||
|
|
||||||
|
# Disconnect when done
|
||||||
|
bt_obex_disconnect session_id="ftp_AABBCCDDEEFF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How-To Guides
|
||||||
|
|
||||||
|
### How to Transfer Files Between Devices
|
||||||
|
|
||||||
|
**Scenario**: You want to send multiple files to your phone.
|
||||||
|
|
||||||
|
1. **For single files**, use OPP (no session needed):
|
||||||
|
```
|
||||||
|
bt_obex_send_file address="..." file_path="file1.pdf"
|
||||||
|
bt_obex_send_file address="..." file_path="file2.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **For multiple files or browsing**, use FTP:
|
||||||
|
```
|
||||||
|
bt_obex_connect address="..." target="ftp"
|
||||||
|
bt_obex_browse session_id="..." path="/"
|
||||||
|
bt_obex_put session_id="..." local_path="file1.pdf" remote_path="file1.pdf"
|
||||||
|
bt_obex_put session_id="..." local_path="file2.jpg" remote_path="file2.jpg"
|
||||||
|
bt_obex_disconnect session_id="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Backup Phone Contacts
|
||||||
|
|
||||||
|
**Scenario**: Create a backup of all contacts from your phone.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Pull main phonebook
|
||||||
|
bt_phonebook_pull address="..." save_path="~/backup/contacts.vcf"
|
||||||
|
|
||||||
|
# Also backup call history
|
||||||
|
bt_phonebook_pull address="..." save_path="~/backup/calls_incoming.vcf" phonebook="ich"
|
||||||
|
bt_phonebook_pull address="..." save_path="~/backup/calls_outgoing.vcf" phonebook="och"
|
||||||
|
bt_phonebook_pull address="..." save_path="~/backup/calls_missed.vcf" phonebook="mch"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phonebook types**:
|
||||||
|
- `pb` — Main contacts (default)
|
||||||
|
- `ich` — Incoming call history
|
||||||
|
- `och` — Outgoing call history
|
||||||
|
- `mch` — Missed call history
|
||||||
|
- `cch` — Combined call history
|
||||||
|
|
||||||
|
### How to Search Contacts
|
||||||
|
|
||||||
|
**Scenario**: Find a contact by name or phone number.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Search by name
|
||||||
|
bt_phonebook_search address="..." field="name" value="John"
|
||||||
|
|
||||||
|
# Search by phone number
|
||||||
|
bt_phonebook_search address="..." field="number" value="555"
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Read Text Messages
|
||||||
|
|
||||||
|
**Scenario**: Check unread SMS messages on your phone.
|
||||||
|
|
||||||
|
```
|
||||||
|
# List unread messages
|
||||||
|
bt_messages_list address="..." folder="inbox" unread_only=true
|
||||||
|
|
||||||
|
# Get full message content
|
||||||
|
bt_messages_get address="..." handle="msg001" save_path="~/message.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Manage FTP Sessions
|
||||||
|
|
||||||
|
**Scenario**: You have a long-running file transfer workflow.
|
||||||
|
|
||||||
|
```
|
||||||
|
# List active sessions
|
||||||
|
bt_obex_sessions
|
||||||
|
|
||||||
|
# Check session details
|
||||||
|
bt_obex_connect address="..." target="ftp"
|
||||||
|
|
||||||
|
# Navigate folder structure
|
||||||
|
bt_obex_browse session_id="..." path="/" # Root
|
||||||
|
bt_obex_browse session_id="..." path="DCIM" # Enter folder
|
||||||
|
bt_obex_browse session_id="..." path=".." # Go up
|
||||||
|
|
||||||
|
# Create/delete
|
||||||
|
bt_obex_mkdir session_id="..." folder_name="Backup"
|
||||||
|
bt_obex_delete session_id="..." remote_path="old_file.txt"
|
||||||
|
|
||||||
|
# Always disconnect when done
|
||||||
|
bt_obex_disconnect session_id="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Monitor Transfer Progress
|
||||||
|
|
||||||
|
**Scenario**: You're transferring a large file and want to check progress.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Start transfer without waiting
|
||||||
|
bt_obex_send_file address="..." file_path="large_video.mp4" wait=false
|
||||||
|
# Returns: transfer_path="/org/bluez/obex/client/session0/transfer0"
|
||||||
|
|
||||||
|
# Check progress
|
||||||
|
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
|
||||||
|
# Returns: status="active", transferred=52428800, size=104857600, progress_percent=50
|
||||||
|
|
||||||
|
# Cancel if needed
|
||||||
|
bt_obex_transfer_cancel transfer_path="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Reference
|
||||||
|
|
||||||
|
### Setup & Status
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `bt_obex_status` | Check obexd installation and D-Bus connectivity |
|
||||||
|
| `bt_obex_start_daemon` | Start obexd if not running |
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
| Tool | Parameters | Description |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `bt_obex_connect` | `address`, `target` (opp/ftp/pbap/map) | Create OBEX session |
|
||||||
|
| `bt_obex_disconnect` | `session_id` | Close session |
|
||||||
|
| `bt_obex_sessions` | — | List active sessions |
|
||||||
|
|
||||||
|
### Object Push (OPP)
|
||||||
|
|
||||||
|
| Tool | Parameters | Description |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `bt_obex_send_file` | `address`, `file_path`, `wait` | Send file to device |
|
||||||
|
| `bt_obex_get_vcard` | `address`, `save_path` | Pull device's business card |
|
||||||
|
|
||||||
|
### File Transfer (FTP)
|
||||||
|
|
||||||
|
| Tool | Parameters | Description |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `bt_obex_browse` | `session_id`, `path` | List folder contents |
|
||||||
|
| `bt_obex_get` | `session_id`, `remote_path`, `local_path` | Download file |
|
||||||
|
| `bt_obex_put` | `session_id`, `local_path`, `remote_path` | Upload file |
|
||||||
|
| `bt_obex_delete` | `session_id`, `remote_path` | Delete file/folder |
|
||||||
|
| `bt_obex_mkdir` | `session_id`, `folder_name` | Create folder |
|
||||||
|
|
||||||
|
### Phonebook Access (PBAP)
|
||||||
|
|
||||||
|
| Tool | Parameters | Description |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `bt_phonebook_pull` | `address`, `save_path`, `location`, `phonebook` | Download full phonebook |
|
||||||
|
| `bt_phonebook_list` | `address`, `location`, `phonebook` | List entries with handles |
|
||||||
|
| `bt_phonebook_get` | `address`, `handle`, `save_path` | Download single entry |
|
||||||
|
| `bt_phonebook_search` | `address`, `field`, `value` | Search contacts |
|
||||||
|
| `bt_phonebook_count` | `address`, `location`, `phonebook` | Count entries |
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `location`: `"int"` (internal memory) or `"sim"` (SIM card)
|
||||||
|
- `phonebook`: `"pb"`, `"ich"`, `"och"`, `"mch"`, `"cch"`
|
||||||
|
- `field`: `"name"`, `"number"`, `"sound"`
|
||||||
|
|
||||||
|
### Message Access (MAP)
|
||||||
|
|
||||||
|
| Tool | Parameters | Description |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `bt_messages_folders` | `address` | List message folders |
|
||||||
|
| `bt_messages_list` | `address`, `folder`, `unread_only`, `max_count` | List messages |
|
||||||
|
| `bt_messages_get` | `address`, `handle`, `save_path` | Download message |
|
||||||
|
| `bt_messages_send` | `address`, `recipient`, `message` | Send SMS |
|
||||||
|
|
||||||
|
### Transfer Management
|
||||||
|
|
||||||
|
| Tool | Parameters | Description |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `bt_obex_transfer_status` | `transfer_path` | Check transfer progress |
|
||||||
|
| `bt_obex_transfer_cancel` | `transfer_path` | Cancel active transfer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "obexd not found"
|
||||||
|
|
||||||
|
Install the bluez-obex package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arch
|
||||||
|
sudo pacman -S bluez-obex
|
||||||
|
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt install bluez-obex
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Cannot connect to obexd D-Bus"
|
||||||
|
|
||||||
|
obexd runs on the **session bus**, which requires:
|
||||||
|
1. A graphical desktop session (X11 or Wayland)
|
||||||
|
2. D-Bus session daemon running
|
||||||
|
|
||||||
|
If running headless:
|
||||||
|
```bash
|
||||||
|
# Start a D-Bus session
|
||||||
|
eval $(dbus-launch --sh-syntax)
|
||||||
|
export DBUS_SESSION_BUS_ADDRESS
|
||||||
|
|
||||||
|
# Then start obexd
|
||||||
|
/usr/lib/bluetooth/obexd &
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Device rejected connection" / "NotAuthorized"
|
||||||
|
|
||||||
|
The target device must:
|
||||||
|
1. Be **paired** first (use `bt_pair`)
|
||||||
|
2. Have the profile **enabled** (check device settings)
|
||||||
|
3. Be **connected** for some profiles
|
||||||
|
|
||||||
|
Some devices require explicit authorization for OBEX access in their Bluetooth settings.
|
||||||
|
|
||||||
|
### "Profile not supported"
|
||||||
|
|
||||||
|
Not all devices support all profiles:
|
||||||
|
|
||||||
|
| Device Type | OPP | FTP | PBAP | MAP |
|
||||||
|
|-------------|-----|-----|------|-----|
|
||||||
|
| Android phones | ✓ | varies | ✓ | varies |
|
||||||
|
| iPhones | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
| Feature phones | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Bluetooth speakers | ✗ | ✗ | ✗ | ✗ |
|
||||||
|
| Cars (infotainment) | ✓ | ✗ | ✓ | ✓ |
|
||||||
|
|
||||||
|
### "Transfer stuck at 0%"
|
||||||
|
|
||||||
|
The receiving device may be showing an accept prompt. Check the device screen.
|
||||||
|
|
||||||
|
### Session disappeared
|
||||||
|
|
||||||
|
Sessions are tied to the obexd process. If obexd restarts, sessions are lost. Create a new session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ MCP Tools (tools/obex.py) │
|
||||||
|
│ bt_obex_*, bt_phonebook_*, bt_messages_* │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ObexClient (obex_client.py) │
|
||||||
|
│ Session management, transfer monitoring, profile methods │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ dbus-fast (async) │
|
||||||
|
│ SESSION D-Bus │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ obexd │
|
||||||
|
│ /org/bluez/obex/client/sessionN/transferM │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ BlueZ │
|
||||||
|
│ Bluetooth Stack │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### D-Bus Object Paths
|
||||||
|
|
||||||
|
- **Client**: `/org/bluez/obex`
|
||||||
|
- **Session**: `/org/bluez/obex/client/session0`
|
||||||
|
- **Transfer**: `/org/bluez/obex/client/session0/transfer0`
|
||||||
|
|
||||||
|
### Session Lifecycle
|
||||||
|
|
||||||
|
1. `CreateSession(address, {Target: "ftp"})` → session path
|
||||||
|
2. Use profile interface (FileTransfer1, PhonebookAccess1, etc.)
|
||||||
|
3. Transfers create temporary objects with Status property
|
||||||
|
4. `RemoveSession(session_path)` when done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [BlueZ OBEX D-Bus API](https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/obex-api.txt)
|
||||||
|
- [OBEX Specification](https://www.bluetooth.com/specifications/specs/object-exchange-obex/)
|
||||||
|
- [vCard Format](https://en.wikipedia.org/wiki/VCard)
|
||||||
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mcbluetooth"
|
name = "mcbluetooth"
|
||||||
version = "2026.02.02"
|
version = "2026.02.04"
|
||||||
description = "Comprehensive BlueZ MCP server - expose the full Linux Bluetooth stack to LLMs"
|
description = "Comprehensive BlueZ MCP server - expose the full Linux Bluetooth stack to LLMs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@ -56,6 +56,10 @@ target-version = "py311"
|
|||||||
select = ["E", "F", "W", "I", "B", "UP"]
|
select = ["E", "F", "W", "I", "B", "UP"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# dbus-fast uses D-Bus type signatures ("o", "h", "a{sv}") as annotations
|
||||||
|
"src/mcbluetooth/hfp_ag.py" = ["F821", "F722"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
388
src/mcbluetooth/agent.py
Normal file
388
src/mcbluetooth/agent.py
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
"""BlueZ pairing agent implementation.
|
||||||
|
|
||||||
|
This module implements the org.bluez.Agent1 D-Bus interface for handling
|
||||||
|
Bluetooth pairing operations. The agent supports multiple pairing modes:
|
||||||
|
|
||||||
|
- elicit: Use MCP elicitation to request PIN/confirmation from user (preferred)
|
||||||
|
- interactive: Return pending status, wait for bt_pair_confirm tool call
|
||||||
|
- auto: Auto-accept all pairings (use only in trusted environments)
|
||||||
|
|
||||||
|
The agent is registered with BlueZ's AgentManager1 and handles callbacks
|
||||||
|
for PIN codes, passkeys, and confirmations during the pairing process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from dbus_fast import BusType, DBusError
|
||||||
|
from dbus_fast.aio import MessageBus
|
||||||
|
from dbus_fast.service import ServiceInterface, method
|
||||||
|
|
||||||
|
# Agent constants
|
||||||
|
AGENT_PATH = "/mcbluetooth/agent"
|
||||||
|
AGENT_CAPABILITY = "KeyboardDisplay" # Can display and enter PINs
|
||||||
|
|
||||||
|
BLUEZ_SERVICE = "org.bluez"
|
||||||
|
AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
|
||||||
|
|
||||||
|
|
||||||
|
class PairingRequestType(Enum):
|
||||||
|
"""Types of pairing requests the agent can receive."""
|
||||||
|
|
||||||
|
PIN_CODE = "pin_code"
|
||||||
|
PASSKEY = "passkey"
|
||||||
|
CONFIRMATION = "confirmation"
|
||||||
|
AUTHORIZATION = "authorization"
|
||||||
|
SERVICE_AUTH = "service_authorization"
|
||||||
|
|
||||||
|
|
||||||
|
class PairingMode(Enum):
|
||||||
|
"""Pairing behavior modes."""
|
||||||
|
|
||||||
|
ELICIT = "elicit" # Use MCP elicitation
|
||||||
|
INTERACTIVE = "interactive" # Wait for bt_pair_confirm
|
||||||
|
AUTO = "auto" # Auto-accept everything
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PairingRequest:
|
||||||
|
"""A pending pairing request from BlueZ."""
|
||||||
|
|
||||||
|
request_type: PairingRequestType
|
||||||
|
device_path: str
|
||||||
|
device_address: str
|
||||||
|
passkey: int | None = None # For confirmation requests
|
||||||
|
uuid: str | None = None # For service authorization
|
||||||
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
response_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
response_value: str | int | bool | None = None
|
||||||
|
response_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BlueZAgentError(DBusError):
|
||||||
|
"""Base class for agent D-Bus errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Rejected(BlueZAgentError):
|
||||||
|
"""Pairing was rejected by user."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("org.bluez.Error.Rejected", "Pairing rejected")
|
||||||
|
|
||||||
|
|
||||||
|
class Canceled(BlueZAgentError):
|
||||||
|
"""Pairing was canceled."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("org.bluez.Error.Canceled", "Pairing canceled")
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_address(device_path: str) -> str:
|
||||||
|
"""Extract Bluetooth address from D-Bus object path."""
|
||||||
|
# /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX -> XX:XX:XX:XX:XX:XX
|
||||||
|
parts = device_path.split("/")
|
||||||
|
if len(parts) >= 5 and parts[-1].startswith("dev_"):
|
||||||
|
return parts[-1][4:].replace("_", ":")
|
||||||
|
return device_path
|
||||||
|
|
||||||
|
|
||||||
|
class BlueZAgent(ServiceInterface):
|
||||||
|
"""D-Bus service implementing org.bluez.Agent1 interface.
|
||||||
|
|
||||||
|
This agent handles pairing requests from BlueZ and coordinates
|
||||||
|
with the MCP tools for user interaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, mode: PairingMode = PairingMode.INTERACTIVE):
|
||||||
|
super().__init__("org.bluez.Agent1")
|
||||||
|
self.mode = mode
|
||||||
|
self.pending_requests: dict[str, PairingRequest] = {} # keyed by device_path
|
||||||
|
self._timeout = 60.0 # Seconds to wait for user response
|
||||||
|
|
||||||
|
def set_mode(self, mode: PairingMode) -> None:
|
||||||
|
"""Change the pairing mode."""
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def get_pending_request(self, device_address: str) -> PairingRequest | None:
|
||||||
|
"""Get a pending request by device address."""
|
||||||
|
for req in self.pending_requests.values():
|
||||||
|
if req.device_address.upper() == device_address.upper():
|
||||||
|
return req
|
||||||
|
return None
|
||||||
|
|
||||||
|
def respond_to_request(
|
||||||
|
self,
|
||||||
|
device_address: str,
|
||||||
|
accept: bool,
|
||||||
|
pin_or_passkey: str | int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Respond to a pending pairing request.
|
||||||
|
|
||||||
|
Returns True if a request was found and responded to.
|
||||||
|
"""
|
||||||
|
request = self.get_pending_request(device_address)
|
||||||
|
if not request:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if accept:
|
||||||
|
request.response_value = pin_or_passkey
|
||||||
|
request.response_error = None
|
||||||
|
else:
|
||||||
|
request.response_error = "rejected"
|
||||||
|
|
||||||
|
request.response_event.set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _wait_for_response(self, request: PairingRequest) -> Any:
|
||||||
|
"""Wait for user response to a pairing request."""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
request.response_event.wait(), timeout=self._timeout
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
request.response_error = "timeout"
|
||||||
|
raise Canceled() from None
|
||||||
|
finally:
|
||||||
|
# Clean up
|
||||||
|
if request.device_path in self.pending_requests:
|
||||||
|
del self.pending_requests[request.device_path]
|
||||||
|
|
||||||
|
if request.response_error:
|
||||||
|
raise Rejected()
|
||||||
|
|
||||||
|
return request.response_value
|
||||||
|
|
||||||
|
def _create_request(
|
||||||
|
self,
|
||||||
|
request_type: PairingRequestType,
|
||||||
|
device_path: str,
|
||||||
|
passkey: int | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
) -> PairingRequest:
|
||||||
|
"""Create and store a new pairing request."""
|
||||||
|
address = path_to_address(device_path)
|
||||||
|
request = PairingRequest(
|
||||||
|
request_type=request_type,
|
||||||
|
device_path=device_path,
|
||||||
|
device_address=address,
|
||||||
|
passkey=passkey,
|
||||||
|
uuid=uuid,
|
||||||
|
)
|
||||||
|
self.pending_requests[device_path] = request
|
||||||
|
return request
|
||||||
|
|
||||||
|
# ==================== Agent1 Interface Methods ====================
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Release(self) -> None:
|
||||||
|
"""Called when the agent is unregistered."""
|
||||||
|
# Clear any pending requests
|
||||||
|
for req in self.pending_requests.values():
|
||||||
|
req.response_error = "released"
|
||||||
|
req.response_event.set()
|
||||||
|
self.pending_requests.clear()
|
||||||
|
|
||||||
|
@method()
|
||||||
|
async def RequestPinCode(self, device: "o") -> "s": # noqa: F821
|
||||||
|
"""Request PIN code for pairing.
|
||||||
|
|
||||||
|
Legacy pairing method - returns a string PIN (usually 4-6 digits).
|
||||||
|
"""
|
||||||
|
if self.mode == PairingMode.AUTO:
|
||||||
|
return "0000" # Default PIN for auto mode
|
||||||
|
|
||||||
|
request = self._create_request(PairingRequestType.PIN_CODE, device)
|
||||||
|
|
||||||
|
# For interactive/elicit, wait for response
|
||||||
|
response = await self._wait_for_response(request)
|
||||||
|
return str(response) if response else "0000"
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def DisplayPinCode(self, device: "o", pincode: "s") -> None: # noqa: F821
|
||||||
|
"""Display PIN code to the user.
|
||||||
|
|
||||||
|
Called when the remote device generated the PIN.
|
||||||
|
"""
|
||||||
|
# Store as info - the MCP client can retrieve this
|
||||||
|
self._create_request(PairingRequestType.PIN_CODE, device)
|
||||||
|
self.pending_requests[device].response_value = pincode
|
||||||
|
# For display, we don't wait - just inform
|
||||||
|
# The MCP logging will show this
|
||||||
|
|
||||||
|
@method()
|
||||||
|
async def RequestPasskey(self, device: "o") -> "u": # noqa: F821
|
||||||
|
"""Request numeric passkey for pairing.
|
||||||
|
|
||||||
|
Returns a 6-digit numeric passkey (0-999999).
|
||||||
|
"""
|
||||||
|
if self.mode == PairingMode.AUTO:
|
||||||
|
return 0 # Accept any passkey in auto mode
|
||||||
|
|
||||||
|
request = self._create_request(PairingRequestType.PASSKEY, device)
|
||||||
|
|
||||||
|
response = await self._wait_for_response(request)
|
||||||
|
return int(response) if response else 0
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def DisplayPasskey(self, device: "o", passkey: "u", entered: "q") -> None: # noqa: F821
|
||||||
|
"""Display passkey with progress indicator.
|
||||||
|
|
||||||
|
Called to show the passkey being entered on the remote device.
|
||||||
|
"""
|
||||||
|
# Store for display purposes
|
||||||
|
if device not in self.pending_requests:
|
||||||
|
self._create_request(PairingRequestType.PASSKEY, device, passkey=passkey)
|
||||||
|
self.pending_requests[device].passkey = passkey
|
||||||
|
# entered shows how many digits have been entered
|
||||||
|
|
||||||
|
@method()
|
||||||
|
async def RequestConfirmation(self, device: "o", passkey: "u") -> None: # noqa: F821
|
||||||
|
"""Request confirmation of a passkey.
|
||||||
|
|
||||||
|
User should confirm the displayed passkey matches the remote device.
|
||||||
|
Raise Rejected() to reject, return normally to accept.
|
||||||
|
"""
|
||||||
|
if self.mode == PairingMode.AUTO:
|
||||||
|
return # Auto-accept
|
||||||
|
|
||||||
|
request = self._create_request(
|
||||||
|
PairingRequestType.CONFIRMATION, device, passkey=passkey
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._wait_for_response(request)
|
||||||
|
# If we get here without exception, confirmation accepted
|
||||||
|
|
||||||
|
@method()
|
||||||
|
async def RequestAuthorization(self, device: "o") -> None: # noqa: F821
|
||||||
|
"""Request authorization for pairing.
|
||||||
|
|
||||||
|
Called when the remote device wants to pair without PIN.
|
||||||
|
"""
|
||||||
|
if self.mode == PairingMode.AUTO:
|
||||||
|
return # Auto-accept
|
||||||
|
|
||||||
|
request = self._create_request(PairingRequestType.AUTHORIZATION, device)
|
||||||
|
await self._wait_for_response(request)
|
||||||
|
|
||||||
|
@method()
|
||||||
|
async def AuthorizeService(self, device: "o", uuid: "s") -> None: # noqa: F821
|
||||||
|
"""Authorize a service connection.
|
||||||
|
|
||||||
|
Called when a device wants to connect to a specific service.
|
||||||
|
"""
|
||||||
|
if self.mode == PairingMode.AUTO:
|
||||||
|
return # Auto-accept
|
||||||
|
|
||||||
|
request = self._create_request(
|
||||||
|
PairingRequestType.SERVICE_AUTH, device, uuid=uuid
|
||||||
|
)
|
||||||
|
await self._wait_for_response(request)
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Cancel(self) -> None:
|
||||||
|
"""Cancel any ongoing pairing operation."""
|
||||||
|
# Cancel all pending requests
|
||||||
|
for req in self.pending_requests.values():
|
||||||
|
req.response_error = "canceled"
|
||||||
|
req.response_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
# Global agent instance and bus
|
||||||
|
_agent: BlueZAgent | None = None
|
||||||
|
_agent_bus: MessageBus | None = None
|
||||||
|
_agent_registered: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agent() -> BlueZAgent:
|
||||||
|
"""Get or create the global agent instance."""
|
||||||
|
global _agent, _agent_bus, _agent_registered
|
||||||
|
|
||||||
|
if _agent is None:
|
||||||
|
_agent = BlueZAgent(PairingMode.INTERACTIVE)
|
||||||
|
|
||||||
|
if _agent_bus is None:
|
||||||
|
_agent_bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
|
||||||
|
# Export the agent interface
|
||||||
|
_agent_bus.export(AGENT_PATH, _agent)
|
||||||
|
|
||||||
|
if not _agent_registered:
|
||||||
|
try:
|
||||||
|
# Get AgentManager1 interface
|
||||||
|
introspection = await _agent_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
|
||||||
|
proxy = _agent_bus.get_proxy_object(
|
||||||
|
BLUEZ_SERVICE, "/org/bluez", introspection
|
||||||
|
)
|
||||||
|
agent_manager = proxy.get_interface(AGENT_MANAGER_IFACE)
|
||||||
|
|
||||||
|
# Register our agent
|
||||||
|
await agent_manager.call_register_agent(AGENT_PATH, AGENT_CAPABILITY)
|
||||||
|
await agent_manager.call_request_default_agent(AGENT_PATH)
|
||||||
|
_agent_registered = True
|
||||||
|
except Exception as e:
|
||||||
|
# Agent might already be registered or other error
|
||||||
|
# Log but don't fail - pairing might still work with default agent
|
||||||
|
print(f"Warning: Could not register agent: {e}")
|
||||||
|
|
||||||
|
return _agent
|
||||||
|
|
||||||
|
|
||||||
|
async def unregister_agent() -> None:
|
||||||
|
"""Unregister the agent from BlueZ."""
|
||||||
|
global _agent, _agent_bus, _agent_registered
|
||||||
|
|
||||||
|
if _agent_bus and _agent_registered:
|
||||||
|
try:
|
||||||
|
introspection = await _agent_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
|
||||||
|
proxy = _agent_bus.get_proxy_object(
|
||||||
|
BLUEZ_SERVICE, "/org/bluez", introspection
|
||||||
|
)
|
||||||
|
agent_manager = proxy.get_interface(AGENT_MANAGER_IFACE)
|
||||||
|
await agent_manager.call_unregister_agent(AGENT_PATH)
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore errors during cleanup
|
||||||
|
_agent_registered = False
|
||||||
|
|
||||||
|
if _agent_bus:
|
||||||
|
_agent_bus.disconnect()
|
||||||
|
_agent_bus = None
|
||||||
|
|
||||||
|
_agent = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_requests() -> list[dict]:
|
||||||
|
"""Get all pending pairing requests as dicts for MCP response."""
|
||||||
|
if _agent is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"device_address": req.device_address,
|
||||||
|
"request_type": req.request_type.value,
|
||||||
|
"passkey": req.passkey,
|
||||||
|
"uuid": req.uuid,
|
||||||
|
"timestamp": req.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
for req in _agent.pending_requests.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_pairing(
|
||||||
|
device_address: str,
|
||||||
|
accept: bool,
|
||||||
|
pin_or_passkey: str | int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Respond to a pending pairing request."""
|
||||||
|
if _agent is None:
|
||||||
|
return False
|
||||||
|
return _agent.respond_to_request(device_address, accept, pin_or_passkey)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_pairing_mode(mode: str) -> None:
|
||||||
|
"""Set the pairing mode for the agent."""
|
||||||
|
agent = await get_agent()
|
||||||
|
agent.set_mode(PairingMode(mode))
|
||||||
@ -569,7 +569,7 @@ class BlueZClient:
|
|||||||
"""
|
"""
|
||||||
iface = await self._get_interface(char_path, BLUEZ_GATT_CHAR_IFACE)
|
iface = await self._get_interface(char_path, BLUEZ_GATT_CHAR_IFACE)
|
||||||
options = {"type": Variant("s", write_type)}
|
options = {"type": Variant("s", write_type)}
|
||||||
await iface.call_write_value(list(value), options)
|
await iface.call_write_value(bytearray(value), options)
|
||||||
|
|
||||||
async def start_notify(self, char_path: str) -> None:
|
async def start_notify(self, char_path: str) -> None:
|
||||||
"""Start notifications for a characteristic."""
|
"""Start notifications for a characteristic."""
|
||||||
|
|||||||
892
src/mcbluetooth/hfp_ag.py
Normal file
892
src/mcbluetooth/hfp_ag.py
Normal file
@ -0,0 +1,892 @@
|
|||||||
|
"""HFP Audio Gateway implementation for BlueZ.
|
||||||
|
|
||||||
|
Registers as an HFP AG (phone role) via BlueZ ProfileManager1, then speaks
|
||||||
|
the HFP AT command protocol over the RFCOMM socket handed to us by BlueZ
|
||||||
|
when an HF device (headset) connects.
|
||||||
|
|
||||||
|
This allows Linux to simulate a phone for E2E testing of Bluetooth headsets
|
||||||
|
(like our ESP32 test harness acting as a Hands-Free Unit).
|
||||||
|
|
||||||
|
HFP AT command flow (simplified):
|
||||||
|
1. HF connects → BlueZ calls NewConnection with RFCOMM fd
|
||||||
|
2. SLC negotiation: BRSF features, CIND indicators, CMER enable
|
||||||
|
3. AG can then simulate: RING (incoming call), +CLIP (caller ID)
|
||||||
|
4. HF responds: ATA (answer), AT+CHUP (hangup), AT+VGS (volume)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from dbus_fast import BusType, Variant
|
||||||
|
from dbus_fast.aio import MessageBus
|
||||||
|
from dbus_fast.service import ServiceInterface, method
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Bluetooth socket constants — use Python's if available, else Linux values.
|
||||||
|
# Python is often compiled without bluetooth.h, so these may not exist.
|
||||||
|
_AF_BLUETOOTH = getattr(socket, "AF_BLUETOOTH", 31)
|
||||||
|
_BTPROTO_RFCOMM = getattr(socket, "BTPROTO_RFCOMM", 3)
|
||||||
|
|
||||||
|
# D-Bus constants
|
||||||
|
BLUEZ_SERVICE = "org.bluez"
|
||||||
|
PROFILE_MANAGER_IFACE = "org.bluez.ProfileManager1"
|
||||||
|
HFP_AG_UUID = "0000111f-0000-1000-8000-00805f9b34fb"
|
||||||
|
HFP_AG_PROFILE_PATH = "/mcbluetooth/hfp_ag"
|
||||||
|
|
||||||
|
# HFP 1.7 AG feature flags
|
||||||
|
HFP_AG_FEATURES = (
|
||||||
|
(1 << 0) # Three-way calling
|
||||||
|
| (1 << 1) # EC/NR function
|
||||||
|
| (1 << 2) # Voice recognition
|
||||||
|
| (1 << 3) # In-band ring tone
|
||||||
|
| (1 << 4) # Voice tag
|
||||||
|
| (1 << 5) # Reject call
|
||||||
|
| (1 << 6) # Enhanced call status
|
||||||
|
| (1 << 7) # Enhanced call control
|
||||||
|
| (1 << 8) # Extended error result codes
|
||||||
|
| (1 << 9) # Codec negotiation
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CallState(Enum):
|
||||||
|
IDLE = "idle"
|
||||||
|
INCOMING = "incoming" # ringing
|
||||||
|
OUTGOING = "outgoing" # dialing
|
||||||
|
ALERTING = "alerting" # remote ringing
|
||||||
|
ACTIVE = "active" # in call
|
||||||
|
HELD = "held"
|
||||||
|
|
||||||
|
|
||||||
|
class CallDirection(Enum):
|
||||||
|
OUTGOING = 0
|
||||||
|
INCOMING = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CallInfo:
|
||||||
|
index: int
|
||||||
|
direction: CallDirection
|
||||||
|
state: CallState
|
||||||
|
number: str = ""
|
||||||
|
number_type: int = 129 # 129=unknown, 145=international
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HFPConnection:
|
||||||
|
"""State for a single HFP connection to an HF device."""
|
||||||
|
device_path: str
|
||||||
|
address: str
|
||||||
|
fd: int
|
||||||
|
sock: socket.socket | None = None
|
||||||
|
reader: asyncio.StreamReader | None = None
|
||||||
|
writer: asyncio.StreamWriter | None = None
|
||||||
|
slc_established: bool = False
|
||||||
|
codec: str = "cvsd" # cvsd or msbc
|
||||||
|
hf_features: int = 0
|
||||||
|
speaker_volume: int = 7
|
||||||
|
mic_volume: int = 7
|
||||||
|
calls: list[CallInfo] = field(default_factory=list)
|
||||||
|
_hf_supports_msbc: bool = False
|
||||||
|
_clip_enabled: bool = False # Caller line ID presentation
|
||||||
|
_ccwa_enabled: bool = False # Call waiting notification
|
||||||
|
_indicator_active: list[bool] = field(default_factory=list) # per-indicator activation (AT+BIA)
|
||||||
|
_read_task: asyncio.Task | None = field(default=None, repr=False)
|
||||||
|
_ring_task: asyncio.Task | None = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
# HFP indicator definitions (order matters — index referenced by +CIEV)
|
||||||
|
# (name, range, initial_value)
|
||||||
|
INDICATORS = [
|
||||||
|
("service", (0, 1), 1), # 1: network available
|
||||||
|
("call", (0, 1), 0), # 2: active call
|
||||||
|
("callsetup", (0, 3), 0), # 3: 0=none, 1=incoming, 2=outgoing, 3=alerting
|
||||||
|
("callheld", (0, 2), 0), # 4: 0=none, 1=held+active, 2=held only
|
||||||
|
("signal", (0, 5), 5), # 5: signal strength
|
||||||
|
("roam", (0, 1), 0), # 6: roaming
|
||||||
|
("battchg", (0, 5), 5), # 7: battery level
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _path_to_address(device_path: str) -> str:
|
||||||
|
parts = device_path.split("/")
|
||||||
|
if len(parts) >= 5 and parts[-1].startswith("dev_"):
|
||||||
|
return parts[-1][4:].replace("_", ":")
|
||||||
|
return device_path
|
||||||
|
|
||||||
|
|
||||||
|
class HFPAudioGatewayProfile(ServiceInterface):
|
||||||
|
"""D-Bus Profile1 service for HFP Audio Gateway role."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("org.bluez.Profile1")
|
||||||
|
self.connections: dict[str, HFPConnection] = {} # keyed by device_path
|
||||||
|
self.indicator_values = [ind[2] for ind in INDICATORS]
|
||||||
|
self._event_callbacks: list[Any] = []
|
||||||
|
|
||||||
|
def on_event(self, callback):
|
||||||
|
"""Register a callback for HFP events: callback(event_type, data)."""
|
||||||
|
self._event_callbacks.append(callback)
|
||||||
|
|
||||||
|
def _emit(self, event_type: str, data: dict[str, Any]) -> None:
|
||||||
|
for cb in self._event_callbacks:
|
||||||
|
try:
|
||||||
|
cb(event_type, data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Release(self) -> None:
|
||||||
|
log.info("HFP AG profile released")
|
||||||
|
for conn in list(self.connections.values()):
|
||||||
|
self._cleanup_connection(conn)
|
||||||
|
self.connections.clear()
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def NewConnection(self, device: "o", fd: "h", properties: "a{sv}") -> None:
|
||||||
|
address = _path_to_address(device)
|
||||||
|
log.debug("NewConnection called: device=%s fd=%r type=%s props=%s",
|
||||||
|
device, fd, type(fd).__name__, properties)
|
||||||
|
|
||||||
|
if fd is None or (isinstance(fd, int) and fd < 0):
|
||||||
|
log.error("INVALID fd received: %r — D-Bus FD negotiation may have failed", fd)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate the fd is actually usable
|
||||||
|
try:
|
||||||
|
stat = os.fstat(fd)
|
||||||
|
log.debug("fd %d fstat: mode=0o%o", fd, stat.st_mode)
|
||||||
|
except OSError as e:
|
||||||
|
log.error("fd %d fstat failed: %s", fd, e)
|
||||||
|
|
||||||
|
log.info("HFP AG: NewConnection from %s (fd=%d)", address, fd)
|
||||||
|
|
||||||
|
# Duplicate the fd so we own it independent of dbus-fast
|
||||||
|
try:
|
||||||
|
new_fd = os.dup(fd)
|
||||||
|
log.debug("os.dup(%d) → %d", fd, new_fd)
|
||||||
|
except OSError:
|
||||||
|
log.exception("os.dup(%d) FAILED", fd)
|
||||||
|
log.exception("HFP AG: os.dup(%d) failed for %s", fd, address)
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = HFPConnection(
|
||||||
|
device_path=device,
|
||||||
|
address=address,
|
||||||
|
fd=new_fd,
|
||||||
|
)
|
||||||
|
self.connections[device] = conn
|
||||||
|
|
||||||
|
# Start async AT command handler
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
conn._read_task = loop.create_task(self._handle_connection(conn))
|
||||||
|
|
||||||
|
self._emit("hfp_ag_connect", {"address": address})
|
||||||
|
log.debug("NewConnection done, task created for %s", address)
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def RequestDisconnection(self, device: "o") -> None:
|
||||||
|
address = _path_to_address(device)
|
||||||
|
log.info("HFP AG: disconnect requested for %s", address)
|
||||||
|
conn = self.connections.pop(device, None)
|
||||||
|
if conn:
|
||||||
|
self._cleanup_connection(conn)
|
||||||
|
self._emit("hfp_ag_disconnect", {"address": address})
|
||||||
|
|
||||||
|
def _cleanup_connection(self, conn: HFPConnection) -> None:
|
||||||
|
if conn._ring_task and not conn._ring_task.done():
|
||||||
|
conn._ring_task.cancel()
|
||||||
|
if conn._read_task and not conn._read_task.done():
|
||||||
|
conn._read_task.cancel()
|
||||||
|
if conn.writer:
|
||||||
|
try:
|
||||||
|
conn.writer.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if conn.sock:
|
||||||
|
try:
|
||||||
|
conn.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif conn.fd >= 0:
|
||||||
|
try:
|
||||||
|
os.close(conn.fd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ==================== AT Command Protocol ====================
|
||||||
|
|
||||||
|
async def _handle_connection(self, conn: HFPConnection) -> None:
|
||||||
|
"""Read loop for AT commands from the HF device."""
|
||||||
|
try:
|
||||||
|
log.debug("_handle_connection start: addr=%s fd=%d", conn.address, conn.fd)
|
||||||
|
|
||||||
|
# socket.fromfd() dups the fd internally — close our intermediate copy
|
||||||
|
conn.sock = socket.fromfd(
|
||||||
|
conn.fd, _AF_BLUETOOTH, socket.SOCK_STREAM,
|
||||||
|
_BTPROTO_RFCOMM,
|
||||||
|
)
|
||||||
|
log.debug("socket.fromfd OK: fileno=%d family=%s type=%s",
|
||||||
|
conn.sock.fileno(), conn.sock.family, conn.sock.type)
|
||||||
|
try:
|
||||||
|
os.close(conn.fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
conn.fd = -1 # transferred to socket
|
||||||
|
|
||||||
|
conn.sock.setblocking(False)
|
||||||
|
|
||||||
|
conn.reader, conn.writer = await asyncio.open_connection(sock=conn.sock)
|
||||||
|
log.debug("asyncio streams ready for %s, entering read loop", conn.address)
|
||||||
|
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
data = await conn.reader.read(1024)
|
||||||
|
if not data:
|
||||||
|
log.debug("EOF from %s (clean disconnect)", conn.address)
|
||||||
|
break
|
||||||
|
log.debug("recv %d bytes from %s: %r", len(data), conn.address, data[:80])
|
||||||
|
buf += data
|
||||||
|
|
||||||
|
while b"\r" in buf:
|
||||||
|
line, buf = buf.split(b"\r", 1)
|
||||||
|
# Strip leading \n if present
|
||||||
|
line = line.lstrip(b"\n").strip()
|
||||||
|
if line:
|
||||||
|
await self._process_at_command(conn, line.decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
except (ConnectionResetError, BrokenPipeError, OSError) as e:
|
||||||
|
log.debug("connection error for %s: %s: %s", conn.address, type(e).__name__, e)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
log.debug("task cancelled for %s", conn.address)
|
||||||
|
except Exception:
|
||||||
|
log.exception("UNEXPECTED error for %s", conn.address)
|
||||||
|
finally:
|
||||||
|
log.debug("cleanup for %s", conn.address)
|
||||||
|
self.connections.pop(conn.device_path, None)
|
||||||
|
self._cleanup_connection(conn)
|
||||||
|
self._emit("hfp_ag_disconnect", {"address": conn.address})
|
||||||
|
|
||||||
|
async def _send(self, conn: HFPConnection, response: str) -> None:
|
||||||
|
"""Send an AT response to the HF device."""
|
||||||
|
if conn.writer and not conn.writer.is_closing():
|
||||||
|
data = f"\r\n{response}\r\n".encode()
|
||||||
|
log.debug("send %d bytes to %s: %r", len(data), conn.address, data)
|
||||||
|
conn.writer.write(data)
|
||||||
|
await conn.writer.drain()
|
||||||
|
log.debug("HFP AG → %s: %s", conn.address, response)
|
||||||
|
|
||||||
|
async def _send_ok(self, conn: HFPConnection) -> None:
|
||||||
|
await self._send(conn, "OK")
|
||||||
|
|
||||||
|
async def _send_error(self, conn: HFPConnection) -> None:
|
||||||
|
await self._send(conn, "ERROR")
|
||||||
|
|
||||||
|
async def _process_at_command(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""Parse and handle an AT command from the HF."""
|
||||||
|
log.debug("HFP AG ← %s: %s", conn.address, line)
|
||||||
|
cmd = line.strip().upper()
|
||||||
|
|
||||||
|
self._emit("hfp_ag_at_recv", {"address": conn.address, "command": line.strip()})
|
||||||
|
|
||||||
|
# SLC establishment commands
|
||||||
|
if cmd.startswith("AT+BRSF="):
|
||||||
|
await self._handle_brsf(conn, line)
|
||||||
|
elif cmd == "AT+CIND=?":
|
||||||
|
await self._handle_cind_test(conn)
|
||||||
|
elif cmd == "AT+CIND?":
|
||||||
|
await self._handle_cind_read(conn)
|
||||||
|
elif cmd.startswith("AT+CMER="):
|
||||||
|
await self._handle_cmer(conn, line)
|
||||||
|
elif cmd.startswith("AT+CHLD=?"):
|
||||||
|
await self._handle_chld_test(conn)
|
||||||
|
|
||||||
|
# Codec negotiation
|
||||||
|
elif cmd.startswith("AT+BAC="):
|
||||||
|
await self._handle_bac(conn, line)
|
||||||
|
elif cmd.startswith("AT+BCS="):
|
||||||
|
await self._handle_bcs(conn, line)
|
||||||
|
|
||||||
|
# Call control
|
||||||
|
elif cmd == "ATA":
|
||||||
|
await self._handle_answer(conn)
|
||||||
|
elif cmd == "AT+CHUP":
|
||||||
|
await self._handle_hangup(conn)
|
||||||
|
elif cmd.startswith("ATD"):
|
||||||
|
await self._handle_dial(conn, line)
|
||||||
|
elif cmd.startswith("AT+DTMF="):
|
||||||
|
await self._handle_dtmf(conn, line)
|
||||||
|
elif cmd.startswith("AT+VTS="):
|
||||||
|
await self._handle_dtmf(conn, line)
|
||||||
|
|
||||||
|
# Volume
|
||||||
|
elif cmd.startswith("AT+VGS="):
|
||||||
|
await self._handle_vgs(conn, line)
|
||||||
|
elif cmd.startswith("AT+VGM="):
|
||||||
|
await self._handle_vgm(conn, line)
|
||||||
|
|
||||||
|
# Status queries
|
||||||
|
elif cmd.startswith("AT+CLCC"):
|
||||||
|
await self._handle_clcc(conn)
|
||||||
|
elif cmd.startswith("AT+COPS"):
|
||||||
|
await self._handle_cops(conn, line)
|
||||||
|
elif cmd.startswith("AT+CNUM"):
|
||||||
|
await self._handle_cnum(conn)
|
||||||
|
|
||||||
|
# Voice recognition
|
||||||
|
elif cmd.startswith("AT+BVRA="):
|
||||||
|
await self._handle_bvra(conn, line)
|
||||||
|
|
||||||
|
# Post-SLC configuration
|
||||||
|
elif cmd.startswith("AT+BIA="):
|
||||||
|
await self._handle_bia(conn, line)
|
||||||
|
elif cmd.startswith("AT+CCWA="):
|
||||||
|
await self._handle_ccwa(conn, line)
|
||||||
|
elif cmd.startswith("AT+CLIP="):
|
||||||
|
await self._handle_clip(conn, line)
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
elif cmd == "AT+CMEE=1":
|
||||||
|
await self._send_ok(conn)
|
||||||
|
elif cmd.startswith("AT+NREC="):
|
||||||
|
await self._send_ok(conn) # Noise reduction - acknowledge
|
||||||
|
elif cmd.startswith("AT+BTRH"):
|
||||||
|
await self._send_ok(conn)
|
||||||
|
elif cmd.startswith("AT+BIND"):
|
||||||
|
await self._handle_bind(conn, line)
|
||||||
|
elif cmd.startswith("AT+XAPL="):
|
||||||
|
await self._handle_xapl(conn, line)
|
||||||
|
elif cmd == "AT":
|
||||||
|
await self._send_ok(conn)
|
||||||
|
else:
|
||||||
|
log.warning("HFP AG: unknown command from %s: %s", conn.address, line)
|
||||||
|
await self._send_error(conn)
|
||||||
|
|
||||||
|
# ---- SLC negotiation handlers ----
|
||||||
|
|
||||||
|
async def _handle_brsf(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+BRSF=<features> — exchange supported features."""
|
||||||
|
try:
|
||||||
|
conn.hf_features = int(line.split("=")[1].strip())
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
conn.hf_features = 0
|
||||||
|
await self._send(conn, f"+BRSF: {HFP_AG_FEATURES}")
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_cind_test(self, conn: HFPConnection) -> None:
|
||||||
|
"""AT+CIND=? — report indicator mapping."""
|
||||||
|
parts = []
|
||||||
|
for name, (lo, hi), _ in INDICATORS:
|
||||||
|
parts.append(f'("{name}",({lo},{hi}))')
|
||||||
|
await self._send(conn, "+CIND: " + ",".join(parts))
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_cind_read(self, conn: HFPConnection) -> None:
|
||||||
|
"""AT+CIND? — report current indicator values."""
|
||||||
|
vals = ",".join(str(v) for v in self.indicator_values)
|
||||||
|
await self._send(conn, f"+CIND: {vals}")
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_cmer(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+CMER=3,0,0,1 — enable indicator status reporting. SLC done."""
|
||||||
|
conn.slc_established = True
|
||||||
|
await self._send_ok(conn)
|
||||||
|
log.info("HFP AG: SLC established with %s", conn.address)
|
||||||
|
self._emit("hfp_ag_slc_established", {
|
||||||
|
"address": conn.address,
|
||||||
|
"hf_features": conn.hf_features,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_chld_test(self, conn: HFPConnection) -> None:
|
||||||
|
"""AT+CHLD=? — report call hold/multiparty capabilities."""
|
||||||
|
await self._send(conn, "+CHLD: (0,1,2,3)")
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_bac(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+BAC=<codec_ids> — available codecs from HF.
|
||||||
|
|
||||||
|
Just acknowledge and store codec availability. Codec selection (+BCS)
|
||||||
|
happens after SLC is established, not during setup — sending it here
|
||||||
|
confuses some Bluedroid implementations.
|
||||||
|
"""
|
||||||
|
# 1=CVSD, 2=mSBC — store for later codec negotiation
|
||||||
|
conn._hf_supports_msbc = "2" in line
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_bcs(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+BCS=<codec_id> — codec confirmation from HF."""
|
||||||
|
try:
|
||||||
|
codec_id = int(line.split("=")[1].strip())
|
||||||
|
conn.codec = "msbc" if codec_id == 2 else "cvsd"
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
conn.codec = "cvsd"
|
||||||
|
await self._send_ok(conn)
|
||||||
|
log.info("HFP AG: codec selected: %s with %s", conn.codec, conn.address)
|
||||||
|
|
||||||
|
# ---- Call control handlers ----
|
||||||
|
|
||||||
|
async def _handle_answer(self, conn: HFPConnection) -> None:
|
||||||
|
"""ATA — HF answers incoming call."""
|
||||||
|
incoming = [c for c in conn.calls if c.state == CallState.INCOMING]
|
||||||
|
if incoming:
|
||||||
|
call = incoming[0]
|
||||||
|
call.state = CallState.ACTIVE
|
||||||
|
await self._send_ok(conn)
|
||||||
|
# Update indicators: call=1, callsetup=0
|
||||||
|
await self._update_indicator(conn, 2, 1) # call active
|
||||||
|
await self._update_indicator(conn, 3, 0) # callsetup none
|
||||||
|
self._emit("hfp_ag_call_answered", {
|
||||||
|
"address": conn.address,
|
||||||
|
"number": call.number,
|
||||||
|
})
|
||||||
|
# Stop ring task
|
||||||
|
if conn._ring_task and not conn._ring_task.done():
|
||||||
|
conn._ring_task.cancel()
|
||||||
|
conn._ring_task = None
|
||||||
|
else:
|
||||||
|
await self._send_error(conn)
|
||||||
|
|
||||||
|
async def _handle_hangup(self, conn: HFPConnection) -> None:
|
||||||
|
"""AT+CHUP — HF hangs up / rejects call."""
|
||||||
|
active = [c for c in conn.calls if c.state in (CallState.ACTIVE, CallState.INCOMING, CallState.OUTGOING, CallState.ALERTING)]
|
||||||
|
if active:
|
||||||
|
call = active[0]
|
||||||
|
old_state = call.state
|
||||||
|
conn.calls.remove(call)
|
||||||
|
await self._send_ok(conn)
|
||||||
|
# Update indicators
|
||||||
|
if old_state == CallState.ACTIVE:
|
||||||
|
await self._update_indicator(conn, 2, 0) # call inactive
|
||||||
|
elif old_state == CallState.INCOMING:
|
||||||
|
await self._update_indicator(conn, 3, 0) # callsetup none
|
||||||
|
self._emit("hfp_ag_call_ended", {
|
||||||
|
"address": conn.address,
|
||||||
|
"number": call.number,
|
||||||
|
"reason": "hangup",
|
||||||
|
})
|
||||||
|
# Stop ring task
|
||||||
|
if conn._ring_task and not conn._ring_task.done():
|
||||||
|
conn._ring_task.cancel()
|
||||||
|
conn._ring_task = None
|
||||||
|
else:
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_dial(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""ATD<number>; — HF initiates outgoing call."""
|
||||||
|
number = line[3:].rstrip(";").strip()
|
||||||
|
call = CallInfo(
|
||||||
|
index=len(conn.calls) + 1,
|
||||||
|
direction=CallDirection.OUTGOING,
|
||||||
|
state=CallState.OUTGOING,
|
||||||
|
number=number,
|
||||||
|
)
|
||||||
|
conn.calls.append(call)
|
||||||
|
await self._send_ok(conn)
|
||||||
|
await self._update_indicator(conn, 3, 2) # callsetup=outgoing
|
||||||
|
self._emit("hfp_ag_outgoing_call", {
|
||||||
|
"address": conn.address,
|
||||||
|
"number": number,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_dtmf(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+VTS=<code> / AT+DTMF=<code> — HF sends DTMF tone."""
|
||||||
|
try:
|
||||||
|
code = line.split("=")[1].strip()
|
||||||
|
except IndexError:
|
||||||
|
code = ""
|
||||||
|
await self._send_ok(conn)
|
||||||
|
self._emit("hfp_ag_dtmf", {"address": conn.address, "code": code})
|
||||||
|
|
||||||
|
# ---- Volume handlers ----
|
||||||
|
|
||||||
|
async def _handle_vgs(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+VGS=<level> — HF reports speaker volume."""
|
||||||
|
try:
|
||||||
|
conn.speaker_volume = int(line.split("=")[1].strip())
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
await self._send_ok(conn)
|
||||||
|
self._emit("hfp_ag_volume", {
|
||||||
|
"address": conn.address,
|
||||||
|
"type": "speaker",
|
||||||
|
"level": conn.speaker_volume,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_vgm(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+VGM=<level> — HF reports microphone volume."""
|
||||||
|
try:
|
||||||
|
conn.mic_volume = int(line.split("=")[1].strip())
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
await self._send_ok(conn)
|
||||||
|
self._emit("hfp_ag_volume", {
|
||||||
|
"address": conn.address,
|
||||||
|
"type": "microphone",
|
||||||
|
"level": conn.mic_volume,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Status queries ----
|
||||||
|
|
||||||
|
async def _handle_clcc(self, conn: HFPConnection) -> None:
|
||||||
|
"""AT+CLCC — list current calls."""
|
||||||
|
for call in conn.calls:
|
||||||
|
state_map = {
|
||||||
|
CallState.ACTIVE: 0,
|
||||||
|
CallState.HELD: 1,
|
||||||
|
CallState.OUTGOING: 2,
|
||||||
|
CallState.ALERTING: 3,
|
||||||
|
CallState.INCOMING: 4,
|
||||||
|
}
|
||||||
|
stat = state_map.get(call.state, 0)
|
||||||
|
await self._send(
|
||||||
|
conn,
|
||||||
|
f"+CLCC: {call.index},{call.direction.value},{stat},0,0"
|
||||||
|
+ (f',"{call.number}",{call.number_type}' if call.number else ""),
|
||||||
|
)
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_cops(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+COPS — operator name query."""
|
||||||
|
if "?" in line:
|
||||||
|
await self._send(conn, '+COPS: 0,0,"mcbluetooth"')
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_cnum(self, conn: HFPConnection) -> None:
|
||||||
|
"""AT+CNUM — subscriber number."""
|
||||||
|
await self._send(conn, '+CNUM: ,"5551234567",129,,4')
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_bvra(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+BVRA=<state> — voice recognition."""
|
||||||
|
try:
|
||||||
|
state = int(line.split("=")[1].strip())
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
state = 0
|
||||||
|
await self._send_ok(conn)
|
||||||
|
self._emit("hfp_ag_voice_recognition", {
|
||||||
|
"address": conn.address,
|
||||||
|
"active": bool(state),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_bind(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+BIND — HF indicators."""
|
||||||
|
if "=?" in line:
|
||||||
|
await self._send(conn, "+BIND: (1,2)") # enhanced safety, battery
|
||||||
|
await self._send_ok(conn)
|
||||||
|
elif "?" in line:
|
||||||
|
await self._send(conn, "+BIND: 1,1")
|
||||||
|
await self._send(conn, "+BIND: 2,1")
|
||||||
|
await self._send_ok(conn)
|
||||||
|
elif "=" in line:
|
||||||
|
await self._send_ok(conn)
|
||||||
|
else:
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_xapl(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+XAPL= — Apple-specific extension. Acknowledge."""
|
||||||
|
await self._send(conn, "+XAPL=iPhone,7")
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
# ---- Post-SLC configuration handlers ----
|
||||||
|
|
||||||
|
async def _handle_bia(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+BIA=<indrep1>,<indrep2>,... — Bluetooth Indicators Activation.
|
||||||
|
|
||||||
|
Each parameter is 0 (deactivate) or 1 (activate) for the corresponding
|
||||||
|
CIND indicator by position. Controls which +CIEV updates the HF wants.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = line.split("=", 1)[1].strip()
|
||||||
|
flags = [v.strip() == "1" for v in params.split(",")]
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
flags = []
|
||||||
|
conn._indicator_active = flags
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_ccwa(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+CCWA=<n> — Call Waiting Notification enable/disable."""
|
||||||
|
try:
|
||||||
|
conn._ccwa_enabled = line.split("=")[1].strip() == "1"
|
||||||
|
except IndexError:
|
||||||
|
conn._ccwa_enabled = False
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
async def _handle_clip(self, conn: HFPConnection, line: str) -> None:
|
||||||
|
"""AT+CLIP=<n> — Calling Line Identification Presentation enable/disable."""
|
||||||
|
try:
|
||||||
|
conn._clip_enabled = line.split("=")[1].strip() == "1"
|
||||||
|
except IndexError:
|
||||||
|
conn._clip_enabled = False
|
||||||
|
await self._send_ok(conn)
|
||||||
|
|
||||||
|
# ==================== AG-initiated actions ====================
|
||||||
|
|
||||||
|
async def _update_indicator(self, conn: HFPConnection, index: int, value: int) -> None:
|
||||||
|
"""Send +CIEV indicator update to HF (1-based index).
|
||||||
|
|
||||||
|
Respects AT+BIA activation flags — if the HF deactivated this indicator,
|
||||||
|
we still store the value but don't send +CIEV over the air.
|
||||||
|
"""
|
||||||
|
if 1 <= index <= len(self.indicator_values):
|
||||||
|
self.indicator_values[index - 1] = value
|
||||||
|
if conn.slc_established:
|
||||||
|
# Check AT+BIA flags (0-indexed); if no flags set, send all
|
||||||
|
bia_idx = index - 1
|
||||||
|
if conn._indicator_active and bia_idx < len(conn._indicator_active):
|
||||||
|
if not conn._indicator_active[bia_idx]:
|
||||||
|
return # HF doesn't want this indicator
|
||||||
|
await self._send(conn, f"+CIEV: {index},{value}")
|
||||||
|
|
||||||
|
async def simulate_incoming_call(
|
||||||
|
self, address: str, number: str = "5551234567", number_type: int = 129
|
||||||
|
) -> bool:
|
||||||
|
"""Simulate an incoming call from the AG to the HF device.
|
||||||
|
|
||||||
|
Returns True if the call was initiated successfully.
|
||||||
|
"""
|
||||||
|
conn = self._get_connection(address)
|
||||||
|
if not conn or not conn.slc_established:
|
||||||
|
return False
|
||||||
|
|
||||||
|
call = CallInfo(
|
||||||
|
index=len(conn.calls) + 1,
|
||||||
|
direction=CallDirection.INCOMING,
|
||||||
|
state=CallState.INCOMING,
|
||||||
|
number=number,
|
||||||
|
number_type=number_type,
|
||||||
|
)
|
||||||
|
conn.calls.append(call)
|
||||||
|
|
||||||
|
# Update callsetup indicator: 1 = incoming
|
||||||
|
await self._update_indicator(conn, 3, 1)
|
||||||
|
|
||||||
|
# Start ringing
|
||||||
|
async def ring_loop():
|
||||||
|
try:
|
||||||
|
while call.state == CallState.INCOMING:
|
||||||
|
await self._send(conn, "RING")
|
||||||
|
if conn._clip_enabled:
|
||||||
|
await self._send(conn, f'+CLIP: "{number}",{number_type}')
|
||||||
|
await asyncio.sleep(3.0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn._ring_task = asyncio.get_event_loop().create_task(ring_loop())
|
||||||
|
self._emit("hfp_ag_incoming_call", {
|
||||||
|
"address": address,
|
||||||
|
"number": number,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def simulate_call_end(self, address: str) -> bool:
|
||||||
|
"""End any active, ringing, or outgoing call from AG side."""
|
||||||
|
conn = self._get_connection(address)
|
||||||
|
if not conn:
|
||||||
|
return False
|
||||||
|
|
||||||
|
active = [c for c in conn.calls if c.state in (
|
||||||
|
CallState.ACTIVE, CallState.INCOMING,
|
||||||
|
CallState.OUTGOING, CallState.ALERTING,
|
||||||
|
)]
|
||||||
|
if not active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
call = active[0]
|
||||||
|
was_active = call.state == CallState.ACTIVE
|
||||||
|
conn.calls.remove(call)
|
||||||
|
|
||||||
|
if conn._ring_task and not conn._ring_task.done():
|
||||||
|
conn._ring_task.cancel()
|
||||||
|
conn._ring_task = None
|
||||||
|
|
||||||
|
if was_active:
|
||||||
|
await self._update_indicator(conn, 2, 0) # call inactive
|
||||||
|
await self._update_indicator(conn, 3, 0) # callsetup none
|
||||||
|
|
||||||
|
self._emit("hfp_ag_call_ended", {
|
||||||
|
"address": address,
|
||||||
|
"number": call.number,
|
||||||
|
"reason": "ag_hangup",
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_signal_strength(self, address: str, level: int) -> bool:
|
||||||
|
"""Update signal strength indicator (0-5)."""
|
||||||
|
conn = self._get_connection(address)
|
||||||
|
if not conn:
|
||||||
|
return False
|
||||||
|
level = max(0, min(5, level))
|
||||||
|
await self._update_indicator(conn, 5, level)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_battery_level(self, address: str, level: int) -> bool:
|
||||||
|
"""Update battery level indicator (0-5)."""
|
||||||
|
conn = self._get_connection(address)
|
||||||
|
if not conn:
|
||||||
|
return False
|
||||||
|
level = max(0, min(5, level))
|
||||||
|
await self._update_indicator(conn, 7, level)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_speaker_volume(self, address: str, level: int) -> bool:
|
||||||
|
"""Send volume change to HF device (0-15)."""
|
||||||
|
conn = self._get_connection(address)
|
||||||
|
if not conn or not conn.slc_established:
|
||||||
|
return False
|
||||||
|
level = max(0, min(15, level))
|
||||||
|
conn.speaker_volume = level
|
||||||
|
await self._send(conn, f"+VGS: {level}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_mic_volume(self, address: str, level: int) -> bool:
|
||||||
|
"""Send mic volume change to HF device (0-15)."""
|
||||||
|
conn = self._get_connection(address)
|
||||||
|
if not conn or not conn.slc_established:
|
||||||
|
return False
|
||||||
|
level = max(0, min(15, level))
|
||||||
|
conn.mic_volume = level
|
||||||
|
await self._send(conn, f"+VGM: {level}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_connection(self, address: str) -> HFPConnection | None:
|
||||||
|
"""Get connection by address (public API)."""
|
||||||
|
return self._get_connection(address)
|
||||||
|
|
||||||
|
def _get_connection(self, address: str) -> HFPConnection | None:
|
||||||
|
for conn in self.connections.values():
|
||||||
|
if conn.address.upper() == address.upper():
|
||||||
|
return conn
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
"""Get overall HFP AG status."""
|
||||||
|
conns = []
|
||||||
|
for conn in self.connections.values():
|
||||||
|
calls = []
|
||||||
|
for call in conn.calls:
|
||||||
|
calls.append({
|
||||||
|
"index": call.index,
|
||||||
|
"direction": call.direction.name.lower(),
|
||||||
|
"state": call.state.value,
|
||||||
|
"number": call.number,
|
||||||
|
})
|
||||||
|
conns.append({
|
||||||
|
"address": conn.address,
|
||||||
|
"slc_established": conn.slc_established,
|
||||||
|
"codec": conn.codec,
|
||||||
|
"speaker_volume": conn.speaker_volume,
|
||||||
|
"mic_volume": conn.mic_volume,
|
||||||
|
"calls": calls,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"registered": _profile_registered,
|
||||||
|
"connections": conns,
|
||||||
|
"indicators": {
|
||||||
|
ind[0]: self.indicator_values[i]
|
||||||
|
for i, ind in enumerate(INDICATORS)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Module-level lifecycle ====================
|
||||||
|
|
||||||
|
_profile: HFPAudioGatewayProfile | None = None
|
||||||
|
_profile_bus: MessageBus | None = None
|
||||||
|
_profile_registered: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
async def enable_hfp_ag() -> HFPAudioGatewayProfile:
|
||||||
|
"""Register the HFP AG profile with BlueZ."""
|
||||||
|
global _profile, _profile_bus, _profile_registered
|
||||||
|
|
||||||
|
if _profile_registered and _profile:
|
||||||
|
return _profile
|
||||||
|
|
||||||
|
if _profile is None:
|
||||||
|
_profile = HFPAudioGatewayProfile()
|
||||||
|
|
||||||
|
if _profile_bus is None:
|
||||||
|
_profile_bus = await MessageBus(
|
||||||
|
bus_type=BusType.SYSTEM,
|
||||||
|
negotiate_unix_fd=True, # Required: BlueZ passes RFCOMM fd via D-Bus
|
||||||
|
).connect()
|
||||||
|
log.debug("D-Bus connected: negotiate_unix_fd=%s unique_name=%s",
|
||||||
|
_profile_bus._negotiate_unix_fd, _profile_bus.unique_name)
|
||||||
|
_profile_bus.export(HFP_AG_PROFILE_PATH, _profile)
|
||||||
|
|
||||||
|
# Register with ProfileManager1
|
||||||
|
introspection = await _profile_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
|
||||||
|
proxy = _profile_bus.get_proxy_object(BLUEZ_SERVICE, "/org/bluez", introspection)
|
||||||
|
profile_mgr = proxy.get_interface(PROFILE_MANAGER_IFACE)
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"Name": Variant("s", "mcbluetooth HFP AG"),
|
||||||
|
"Role": Variant("s", "server"),
|
||||||
|
"Channel": Variant("q", 13), # RFCOMM channel for HFP
|
||||||
|
"Features": Variant("q", HFP_AG_FEATURES & 0xFFFF),
|
||||||
|
"Version": Variant("q", 0x0107), # HFP 1.7
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await profile_mgr.call_register_profile(
|
||||||
|
HFP_AG_PROFILE_PATH,
|
||||||
|
HFP_AG_UUID,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
_profile_registered = True
|
||||||
|
log.info("HFP AG profile registered with BlueZ")
|
||||||
|
except Exception as e:
|
||||||
|
if "Already Exists" in str(e):
|
||||||
|
# Stale registration from a previous process that didn't clean up.
|
||||||
|
# Unregister the orphaned profile, then re-register with our bus.
|
||||||
|
log.info("HFP AG profile stale — unregistering and re-registering")
|
||||||
|
try:
|
||||||
|
await profile_mgr.call_unregister_profile(HFP_AG_PROFILE_PATH)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await profile_mgr.call_register_profile(
|
||||||
|
HFP_AG_PROFILE_PATH,
|
||||||
|
HFP_AG_UUID,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
_profile_registered = True
|
||||||
|
log.info("HFP AG profile re-registered with BlueZ")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _profile
|
||||||
|
|
||||||
|
|
||||||
|
async def disable_hfp_ag() -> None:
|
||||||
|
"""Unregister the HFP AG profile."""
|
||||||
|
global _profile, _profile_bus, _profile_registered
|
||||||
|
|
||||||
|
if _profile_bus and _profile_registered:
|
||||||
|
try:
|
||||||
|
introspection = await _profile_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
|
||||||
|
proxy = _profile_bus.get_proxy_object(BLUEZ_SERVICE, "/org/bluez", introspection)
|
||||||
|
profile_mgr = proxy.get_interface(PROFILE_MANAGER_IFACE)
|
||||||
|
await profile_mgr.call_unregister_profile(HFP_AG_PROFILE_PATH)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_profile_registered = False
|
||||||
|
|
||||||
|
if _profile:
|
||||||
|
_profile.Release()
|
||||||
|
_profile = None
|
||||||
|
|
||||||
|
if _profile_bus:
|
||||||
|
_profile_bus.disconnect()
|
||||||
|
_profile_bus = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hfp_ag() -> HFPAudioGatewayProfile | None:
|
||||||
|
"""Get the current HFP AG profile instance (None if not enabled)."""
|
||||||
|
return _profile
|
||||||
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, hfp, 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
|
||||||
@ -42,8 +43,10 @@ resources.register_resources(mcp)
|
|||||||
adapter.register_tools(mcp)
|
adapter.register_tools(mcp)
|
||||||
device.register_tools(mcp)
|
device.register_tools(mcp)
|
||||||
audio.register_tools(mcp)
|
audio.register_tools(mcp)
|
||||||
|
hfp.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"]
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from fastmcp import Context, FastMCP
|
from fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from mcbluetooth import agent
|
||||||
|
from mcbluetooth.agent import PairingMode as AgentPairingMode
|
||||||
from mcbluetooth.dbus_client import get_client
|
from mcbluetooth.dbus_client import get_client
|
||||||
|
|
||||||
# Type aliases for MCP tool parameters
|
# Type aliases for MCP tool parameters
|
||||||
@ -130,6 +132,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|||||||
adapter: str,
|
adapter: str,
|
||||||
address: str,
|
address: str,
|
||||||
pairing_mode: PairingMode = "interactive",
|
pairing_mode: PairingMode = "interactive",
|
||||||
|
timeout: int = 60,
|
||||||
ctx: Context | None = None,
|
ctx: Context | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Initiate pairing with a device.
|
"""Initiate pairing with a device.
|
||||||
@ -144,6 +147,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|||||||
- "elicit": Use MCP elicitation to request PIN from user (if supported)
|
- "elicit": Use MCP elicitation to request PIN from user (if supported)
|
||||||
- "interactive": Return status, then call bt_pair_confirm with PIN
|
- "interactive": Return status, then call bt_pair_confirm with PIN
|
||||||
- "auto": Auto-accept pairings (for trusted environments)
|
- "auto": Auto-accept pairings (for trusted environments)
|
||||||
|
timeout: Pairing timeout in seconds (default 60)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Pairing status including whether confirmation is needed
|
Pairing status including whether confirmation is needed
|
||||||
@ -160,56 +164,107 @@ def register_tools(mcp: FastMCP) -> None:
|
|||||||
await ctx.info(f"Device {address} is already paired")
|
await ctx.info(f"Device {address} is already paired")
|
||||||
return {"status": "already_paired", "device": asdict(device)}
|
return {"status": "already_paired", "device": asdict(device)}
|
||||||
|
|
||||||
|
# Initialize and configure the pairing agent
|
||||||
|
pairing_agent = await agent.get_agent()
|
||||||
|
pairing_agent.set_mode(AgentPairingMode(pairing_mode))
|
||||||
|
pairing_agent._timeout = float(timeout)
|
||||||
|
|
||||||
|
if ctx:
|
||||||
|
await ctx.debug(f"Agent registered with mode: {pairing_mode}")
|
||||||
|
|
||||||
|
# Start pairing
|
||||||
|
async def do_pair():
|
||||||
|
try:
|
||||||
|
await client.pair_device(adapter, address)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
if pairing_mode == "auto":
|
if pairing_mode == "auto":
|
||||||
# Direct pairing without agent - may fail if PIN required
|
# For auto mode, just wait for pairing to complete
|
||||||
try:
|
result = await do_pair()
|
||||||
await client.pair_device(adapter, address)
|
if result["success"]:
|
||||||
device = await client.get_device(adapter, address)
|
device = await client.get_device(adapter, address)
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.info(f"Successfully paired with {address}")
|
await ctx.info(f"Successfully paired with {address}")
|
||||||
return {"status": "paired", "device": asdict(device) if device else None}
|
return {"status": "paired", "device": asdict(device) if device else None}
|
||||||
except Exception as e:
|
else:
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.error(f"Pairing failed: {e}")
|
await ctx.error(f"Pairing failed: {result['error']}")
|
||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": result["error"]}
|
||||||
else:
|
else:
|
||||||
# For interactive/elicit modes, we need an agent
|
# For interactive/elicit modes, start pairing in background
|
||||||
|
# and return immediately if a request is pending
|
||||||
|
pair_task = asyncio.create_task(do_pair())
|
||||||
|
|
||||||
|
# Wait briefly to see if pairing completes quickly or needs input
|
||||||
try:
|
try:
|
||||||
await client.pair_device(adapter, address)
|
result = await asyncio.wait_for(asyncio.shield(pair_task), timeout=3.0)
|
||||||
device = await client.get_device(adapter, address)
|
if result["success"]:
|
||||||
if ctx:
|
device = await client.get_device(adapter, address)
|
||||||
await ctx.info(f"Successfully paired with {address}")
|
|
||||||
return {"status": "paired", "device": asdict(device) if device else None}
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
if "AuthenticationFailed" in error_msg or "AuthenticationCanceled" in error_msg:
|
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.warning("Pairing requires user confirmation or PIN")
|
await ctx.info(f"Successfully paired with {address}")
|
||||||
|
return {"status": "paired", "device": asdict(device) if device else None}
|
||||||
|
else:
|
||||||
|
# Check if there's a pending request
|
||||||
|
pending = pairing_agent.get_pending_request(address)
|
||||||
|
if pending:
|
||||||
|
if ctx:
|
||||||
|
await ctx.warning(
|
||||||
|
f"Pairing requires {pending.request_type.value}: "
|
||||||
|
f"passkey={pending.passkey}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "awaiting_confirmation",
|
||||||
|
"request_type": pending.request_type.value,
|
||||||
|
"passkey": pending.passkey,
|
||||||
|
"message": "Use bt_pair_confirm to accept or reject",
|
||||||
|
}
|
||||||
|
if ctx:
|
||||||
|
await ctx.error(f"Pairing failed: {result['error']}")
|
||||||
|
return {"status": "error", "error": result["error"]}
|
||||||
|
except TimeoutError:
|
||||||
|
# Pairing is still in progress - check for pending requests
|
||||||
|
pending = pairing_agent.get_pending_request(address)
|
||||||
|
if pending:
|
||||||
|
if ctx:
|
||||||
|
await ctx.warning(
|
||||||
|
f"Pairing requires {pending.request_type.value}: "
|
||||||
|
f"passkey={pending.passkey}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "awaiting_confirmation",
|
"status": "awaiting_confirmation",
|
||||||
"message": "Pairing requires user confirmation or PIN entry",
|
"request_type": pending.request_type.value,
|
||||||
"pairing_mode": pairing_mode,
|
"passkey": pending.passkey,
|
||||||
|
"message": "Use bt_pair_confirm to accept or reject",
|
||||||
}
|
}
|
||||||
|
# No request yet, pairing still in progress
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.error(f"Pairing failed: {error_msg}")
|
await ctx.info("Pairing in progress, waiting for device...")
|
||||||
return {"status": "error", "error": error_msg}
|
return {
|
||||||
|
"status": "pairing_in_progress",
|
||||||
|
"message": "Check bt_pairing_status or wait for device response",
|
||||||
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def bt_pair_confirm(
|
async def bt_pair_confirm(
|
||||||
adapter: str,
|
adapter: str,
|
||||||
address: str,
|
address: str,
|
||||||
pin: str | None = None,
|
pin: str | None = None,
|
||||||
|
passkey: int | None = None,
|
||||||
accept: bool = True,
|
accept: bool = True,
|
||||||
ctx: Context | None = None,
|
ctx: Context | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Confirm or reject a pairing request.
|
"""Confirm or reject a pairing request.
|
||||||
|
|
||||||
Use this after bt_pair returns "awaiting_confirmation" status.
|
Use this after bt_pair returns "awaiting_confirmation" status.
|
||||||
|
The agent will respond to BlueZ's pairing request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
adapter: Adapter name
|
adapter: Adapter name
|
||||||
address: Device Bluetooth address
|
address: Device Bluetooth address
|
||||||
pin: PIN code if required (usually 4-6 digits)
|
pin: PIN code if required (string, usually 4-6 digits)
|
||||||
|
passkey: Numeric passkey if required (0-999999)
|
||||||
accept: True to accept pairing, False to reject
|
accept: True to accept pairing, False to reject
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -217,30 +272,69 @@ def register_tools(mcp: FastMCP) -> None:
|
|||||||
"""
|
"""
|
||||||
client = await get_client()
|
client = await get_client()
|
||||||
|
|
||||||
|
# Respond to the pending agent request
|
||||||
|
pin_or_passkey = passkey if passkey is not None else pin
|
||||||
|
responded = await agent.respond_to_pairing(address, accept, pin_or_passkey)
|
||||||
|
|
||||||
|
if not responded:
|
||||||
|
# No pending request from agent - might be using default agent
|
||||||
|
if ctx:
|
||||||
|
await ctx.warning(f"No pending pairing request for {address}")
|
||||||
|
|
||||||
if not accept:
|
if not accept:
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.info(f"Rejecting pairing with {address}")
|
await ctx.info(f"Rejecting pairing with {address}")
|
||||||
try:
|
try:
|
||||||
await client.cancel_pairing(adapter, address)
|
await client.cancel_pairing(adapter, address)
|
||||||
return {"status": "pairing_cancelled"}
|
return {"status": "pairing_rejected"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# May already be cancelled
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.error(f"Failed to cancel pairing: {e}")
|
await ctx.debug(f"Cancel pairing: {e}")
|
||||||
return {"status": "error", "error": str(e)}
|
return {"status": "pairing_rejected"}
|
||||||
|
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.info(f"Confirming pairing with {address}")
|
await ctx.info(f"Confirming pairing with {address}")
|
||||||
|
|
||||||
try:
|
# Wait a moment for the agent to process and BlueZ to complete
|
||||||
await client.pair_device(adapter, address)
|
await asyncio.sleep(1.0)
|
||||||
device = await client.get_device(adapter, address)
|
|
||||||
|
# Check if pairing succeeded
|
||||||
|
device = await client.get_device(adapter, address)
|
||||||
|
if device and device.paired:
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.info(f"Pairing confirmed with {address}")
|
await ctx.info(f"Pairing confirmed with {address}")
|
||||||
return {"status": "paired", "device": asdict(device) if device else None}
|
return {"status": "paired", "device": asdict(device)}
|
||||||
except Exception as e:
|
else:
|
||||||
|
# Pairing might still be in progress
|
||||||
if ctx:
|
if ctx:
|
||||||
await ctx.error(f"Pairing confirmation failed: {e}")
|
await ctx.info("Pairing in progress...")
|
||||||
return {"status": "error", "error": str(e)}
|
return {
|
||||||
|
"status": "confirmation_sent",
|
||||||
|
"message": "Confirmation sent to agent. Check device status.",
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_pairing_status(
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get the status of pending pairing requests.
|
||||||
|
|
||||||
|
Shows any pairing requests waiting for confirmation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of pending pairing requests
|
||||||
|
"""
|
||||||
|
pending = agent.get_pending_requests()
|
||||||
|
if ctx:
|
||||||
|
if pending:
|
||||||
|
await ctx.info(f"Found {len(pending)} pending pairing request(s)")
|
||||||
|
else:
|
||||||
|
await ctx.info("No pending pairing requests")
|
||||||
|
return {
|
||||||
|
"pending_requests": pending,
|
||||||
|
"count": len(pending),
|
||||||
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def bt_unpair(
|
async def bt_unpair(
|
||||||
|
|||||||
212
src/mcbluetooth/tools/hfp.py
Normal file
212
src/mcbluetooth/tools/hfp.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
"""HFP Audio Gateway tools for Bluetooth MCP server.
|
||||||
|
|
||||||
|
These tools let Linux act as a phone (Audio Gateway) for testing Bluetooth
|
||||||
|
headsets and hands-free devices. The ESP32 test harness connects as the
|
||||||
|
Hands-Free Unit (headset), and these tools simulate call control from the
|
||||||
|
phone side.
|
||||||
|
|
||||||
|
Typical E2E test flow:
|
||||||
|
1. bt_hfp_ag_enable() → Register AG profile with BlueZ
|
||||||
|
2. ESP32 connects as HF → SLC auto-negotiated
|
||||||
|
3. bt_hfp_ag_simulate_call() → Send RING + CLIP to ESP32
|
||||||
|
4. ESP32 answers (ATA) → Call becomes active
|
||||||
|
5. bt_hfp_ag_end_call() → Terminate call
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcbluetooth.hfp_ag import (
|
||||||
|
disable_hfp_ag,
|
||||||
|
enable_hfp_ag,
|
||||||
|
get_hfp_ag,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_tools(mcp: FastMCP) -> None:
|
||||||
|
"""Register HFP AG tools with the MCP server."""
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_enable() -> dict[str, Any]:
|
||||||
|
"""Enable HFP Audio Gateway mode on Linux.
|
||||||
|
|
||||||
|
Registers a custom HFP AG profile with BlueZ via ProfileManager1.
|
||||||
|
After enabling, Bluetooth headsets (HF devices) can connect and
|
||||||
|
Linux will act as the phone side.
|
||||||
|
|
||||||
|
The SLC (Service Level Connection) is auto-negotiated when an HF
|
||||||
|
device connects — feature exchange, indicator setup, and codec
|
||||||
|
selection happen automatically.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status confirming AG profile registration.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await enable_hfp_ag()
|
||||||
|
return {"status": "ok", "role": "audio_gateway", "profile": "HFP AG 1.7"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_disable() -> dict[str, Any]:
|
||||||
|
"""Disable HFP Audio Gateway mode.
|
||||||
|
|
||||||
|
Unregisters the AG profile, disconnecting any active HFP sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status confirming AG profile removal.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await disable_hfp_ag()
|
||||||
|
return {"status": "ok", "disabled": True}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_status() -> dict[str, Any]:
|
||||||
|
"""Get HFP Audio Gateway status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- registered: Whether the AG profile is active
|
||||||
|
- connections: List of connected HF devices with SLC state,
|
||||||
|
codec, volumes, and active calls
|
||||||
|
- indicators: Current indicator values (service, call, signal, etc.)
|
||||||
|
"""
|
||||||
|
profile = await get_hfp_ag()
|
||||||
|
if not profile:
|
||||||
|
return {"status": "ok", "registered": False, "connections": []}
|
||||||
|
return {"status": "ok", **profile.get_status()}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_simulate_call(
|
||||||
|
address: str,
|
||||||
|
number: str = "5551234567",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Simulate an incoming call to a connected HF device.
|
||||||
|
|
||||||
|
Sends RING and +CLIP (caller ID) notifications to the headset.
|
||||||
|
The HF device will see an incoming call and can answer (ATA) or
|
||||||
|
reject (AT+CHUP).
|
||||||
|
|
||||||
|
Ringing repeats every 3 seconds until answered or ended.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bluetooth address of the connected HF device.
|
||||||
|
number: Caller phone number to display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status of call initiation.
|
||||||
|
"""
|
||||||
|
profile = await get_hfp_ag()
|
||||||
|
if not profile:
|
||||||
|
return {"status": "error", "error": "HFP AG not enabled"}
|
||||||
|
|
||||||
|
ok = await profile.simulate_incoming_call(address, number=number)
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok", "call_state": "ringing", "number": number}
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "No SLC connection to device (connect HF first)",
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_end_call(address: str) -> dict[str, Any]:
|
||||||
|
"""End an active or ringing call from the AG side.
|
||||||
|
|
||||||
|
Terminates the current call and sends indicator updates to the
|
||||||
|
HF device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bluetooth address of the connected HF device.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status of call termination.
|
||||||
|
"""
|
||||||
|
profile = await get_hfp_ag()
|
||||||
|
if not profile:
|
||||||
|
return {"status": "error", "error": "HFP AG not enabled"}
|
||||||
|
|
||||||
|
ok = await profile.simulate_call_end(address)
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok", "call_state": "ended"}
|
||||||
|
return {"status": "error", "error": "No active call to end"}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_set_volume(
|
||||||
|
address: str,
|
||||||
|
type: Literal["speaker", "microphone"],
|
||||||
|
level: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Set speaker or microphone volume on the HF device.
|
||||||
|
|
||||||
|
Sends +VGS (speaker) or +VGM (mic) to change volume remotely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bluetooth address of the connected HF device.
|
||||||
|
type: "speaker" for output volume, "microphone" for input.
|
||||||
|
level: Volume level 0-15 (0 = muted, 15 = maximum).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status with applied volume level.
|
||||||
|
"""
|
||||||
|
profile = await get_hfp_ag()
|
||||||
|
if not profile:
|
||||||
|
return {"status": "error", "error": "HFP AG not enabled"}
|
||||||
|
|
||||||
|
if type == "speaker":
|
||||||
|
ok = await profile.set_speaker_volume(address, level)
|
||||||
|
else:
|
||||||
|
ok = await profile.set_mic_volume(address, level)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok", "type": type, "level": level}
|
||||||
|
return {"status": "error", "error": "No SLC connection to device"}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_set_signal(
|
||||||
|
address: str,
|
||||||
|
level: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update the signal strength indicator shown on the HF device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bluetooth address of the connected HF device.
|
||||||
|
level: Signal strength 0-5.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status with applied signal level.
|
||||||
|
"""
|
||||||
|
profile = await get_hfp_ag()
|
||||||
|
if not profile:
|
||||||
|
return {"status": "error", "error": "HFP AG not enabled"}
|
||||||
|
|
||||||
|
ok = await profile.set_signal_strength(address, level)
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok", "signal_strength": level}
|
||||||
|
return {"status": "error", "error": "No SLC connection to device"}
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def bt_hfp_ag_set_battery(
|
||||||
|
address: str,
|
||||||
|
level: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update the battery level indicator shown on the HF device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Bluetooth address of the connected HF device.
|
||||||
|
level: Battery level 0-5.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status with applied battery level.
|
||||||
|
"""
|
||||||
|
profile = await get_hfp_ag()
|
||||||
|
if not profile:
|
||||||
|
return {"status": "error", "error": "HFP AG not enabled"}
|
||||||
|
|
||||||
|
ok = await profile.set_battery_level(address, level)
|
||||||
|
if ok:
|
||||||
|
return {"status": "ok", "battery_level": level}
|
||||||
|
return {"status": "error", "error": "No SLC connection to device"}
|
||||||
@ -9,8 +9,6 @@ These tools provide MCP integration for:
|
|||||||
Note: Live capture requires elevated privileges (CAP_NET_RAW or sudo).
|
Note: Live capture requires elevated privileges (CAP_NET_RAW or sudo).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
|
|||||||
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