Compare commits
No commits in common. "41306bb36f724ef185c66e5e5c220e9d270e581d" and "cd03fa9253e672a8fe5c6121c56a60322a0ee2ac" have entirely different histories.
41306bb36f
...
cd03fa9253
619
README.md
619
README.md
@ -1,501 +1,180 @@
|
||||
<p align="center">
|
||||
<pre>
|
||||
╔╗ ╔╗ ╔╗ ╔╗ ╔╗
|
||||
║║ ║║ ║║ ╔╗ ║║ ║║
|
||||
╔╗╔╗╔══╗╔══╗ ║╚═╗║║ ╔╗ ╔╗╔══╝║╔══╗╔═╝║╔══╗╔══╝║╔═╝║╔══╗
|
||||
║╚╝║║╔═╝║╔╗║ ║╔╗║║║ ║║ ║║║╔══╝║╔╗║║╔╗║║╔╗║║╔╗║║╔╗║║╔╗║
|
||||
║║║║║╚═╗║╚╝╚╗ ║╚╝║║╚╗║╚═╝║║╚══╗║╚╝║║╚╝║║╚╝║║╚╝║║╚╝║║║║║
|
||||
╚╩╩╝╚══╝╚═══╝ ╚══╝╚═╝╚═══╝╚═══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚╝╚╝
|
||||
</pre>
|
||||
</p>
|
||||
# mcbluetooth
|
||||
|
||||
<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.
|
||||
A comprehensive MCP server exposing the Linux Bluetooth stack (BlueZ) to LLMs.
|
||||
|
||||
## Features
|
||||
|
||||
### Adapter Management
|
||||
Control your Bluetooth hardware — power, discovery, pairing acceptance.
|
||||
- List, power, and configure Bluetooth adapters
|
||||
- Control discoverable and pairable states
|
||||
- Set adapter aliases
|
||||
|
||||
```
|
||||
"Turn on Bluetooth"
|
||||
"Make my computer discoverable for 2 minutes"
|
||||
"List all Bluetooth adapters"
|
||||
### Device Management
|
||||
- Classic Bluetooth and BLE scanning with filters
|
||||
- Pairing with multi-modal support (elicit, interactive, auto)
|
||||
- Connect/disconnect/trust/block devices
|
||||
- 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 .
|
||||
```
|
||||
|
||||
### Device Discovery & Pairing
|
||||
Scan for devices with Classic Bluetooth or BLE filters. Smart pairing agent handles PIN codes, numeric comparison, and passkey entry automatically.
|
||||
## Usage with Claude Code
|
||||
|
||||
```
|
||||
"Scan for nearby Bluetooth devices"
|
||||
"Pair with the Sony headphones"
|
||||
"Show me all paired devices"
|
||||
"Remove the old keyboard from paired devices"
|
||||
```bash
|
||||
# Add to Claude Code (from source)
|
||||
claude mcp add mcbluetooth-local -- uv run --directory /path/to/mcbluetooth mcbluetooth
|
||||
|
||||
# Or if published to PyPI
|
||||
claude mcp add mcbluetooth -- uvx mcbluetooth
|
||||
```
|
||||
|
||||
### Audio Control
|
||||
Full PipeWire/PulseAudio integration for Bluetooth audio devices.
|
||||
## Requirements
|
||||
|
||||
- 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
|
||||
|
||||
Live state queries without tool calls:
|
||||
The server exposes dynamic resources for live state queries:
|
||||
|
||||
| Resource URI | Description |
|
||||
|--------------|-------------|
|
||||
| `bluetooth://adapters` | All Bluetooth adapters |
|
||||
| `bluetooth://paired` | Paired devices |
|
||||
| `bluetooth://connected` | Currently connected devices |
|
||||
| `bluetooth://visible` | All discovered devices |
|
||||
| `bluetooth://connected` | Connected devices |
|
||||
| `bluetooth://visible` | All known devices |
|
||||
| `bluetooth://trusted` | Trusted devices |
|
||||
| `bluetooth://adapter/{name}` | Specific adapter (e.g., `hci0`) |
|
||||
| `bluetooth://adapter/{name}` | Specific adapter 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
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Claude / LLM │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MCP Protocol (stdio) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────── FastMCP Server ──────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐│
|
||||
│ │ │ Adapter │ │ Device │ │Audio/HFP│ │ BLE │ │ OBEX ││
|
||||
│ │ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │ Tools ││
|
||||
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘│
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ┌────┴───────────┴───────────┴───────────┴────┐ │ │
|
||||
│ │ │ 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) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ FastMCP Server │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Tool Categories │
|
||||
│ ┌─────────┬─────────┬─────────┬─────────┬────────┐ │
|
||||
│ │ Adapter │ Device │ Audio │ BLE │Monitor │ │
|
||||
│ │ Tools │ Tools │ Tools │ Tools │ Tools │ │
|
||||
│ └─────────┴─────────┴─────────┴─────────┴────────┘ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ BlueZ D-Bus Client │ btmon (HCI capture) │
|
||||
│ (dbus-fast) │ (btsnoop format) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ PipeWire/PulseAudio Integration │
|
||||
│ (pulsectl-asyncio) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
MIT License — See [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Built with FastMCP • Powered by BlueZ • Made for LLMs
|
||||
</p>
|
||||
MIT
|
||||
|
||||
21
docs-site/.gitignore
vendored
21
docs-site/.gitignore
vendored
@ -1,21 +0,0 @@
|
||||
# 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
|
||||
@ -1,49 +0,0 @@
|
||||
# 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).
|
||||
@ -1,87 +0,0 @@
|
||||
// @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
6988
docs-site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 329 B |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 329 B |
@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 329 B |
@ -1,7 +0,0 @@
|
||||
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() }),
|
||||
};
|
||||
@ -1,278 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@ -1,353 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,276 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@ -1,191 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,105 +0,0 @@
|
||||
---
|
||||
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/)
|
||||
@ -1,150 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,201 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@ -1,199 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@ -1,240 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,278 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@ -1,174 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@ -1,270 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@ -1,237 +0,0 @@
|
||||
---
|
||||
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`
|
||||
@ -1,265 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,105 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
@ -1,217 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,256 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,283 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
@ -1,345 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,251 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@ -1,282 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@ -1,582 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@ -1,197 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,161 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
@ -1,130 +0,0 @@
|
||||
/* 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; }
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
368
docs/obex.md
368
docs/obex.md
@ -1,368 +0,0 @@
|
||||
# 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]
|
||||
name = "mcbluetooth"
|
||||
version = "2026.02.04"
|
||||
version = "2026.02.02"
|
||||
description = "Comprehensive BlueZ MCP server - expose the full Linux Bluetooth stack to LLMs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@ -56,10 +56,6 @@ target-version = "py311"
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
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]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
@ -1,388 +0,0 @@
|
||||
"""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)
|
||||
options = {"type": Variant("s", write_type)}
|
||||
await iface.call_write_value(bytearray(value), options)
|
||||
await iface.call_write_value(list(value), options)
|
||||
|
||||
async def start_notify(self, char_path: str) -> None:
|
||||
"""Start notifications for a characteristic."""
|
||||
|
||||
@ -1,892 +0,0 @@
|
||||
"""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
|
||||
@ -1,887 +0,0 @@
|
||||
"""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 mcbluetooth import resources
|
||||
from mcbluetooth.tools import adapter, audio, ble, device, hfp, monitor, obex
|
||||
from mcbluetooth.tools import adapter, audio, ble, device, monitor
|
||||
|
||||
mcp = FastMCP(
|
||||
name="mcbluetooth",
|
||||
@ -14,7 +14,6 @@ This server provides comprehensive control over the Linux Bluetooth stack:
|
||||
- Device discovery and management (scan, pair, connect)
|
||||
- Audio profiles (A2DP, HFP) with PipeWire/PulseAudio integration
|
||||
- BLE/GATT services (read/write characteristics, notifications)
|
||||
- OBEX profiles (file transfer, phonebook access, messages)
|
||||
|
||||
## Resources (live state queries)
|
||||
- bluetooth://adapters - All Bluetooth adapters
|
||||
@ -43,10 +42,8 @@ resources.register_resources(mcp)
|
||||
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)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""MCP tool modules for Bluetooth management."""
|
||||
|
||||
from mcbluetooth.tools import adapter, audio, ble, device, monitor, obex
|
||||
from mcbluetooth.tools import adapter, audio, ble, device, monitor
|
||||
|
||||
__all__ = ["adapter", "device", "audio", "ble", "monitor", "obex"]
|
||||
__all__ = ["adapter", "device", "audio", "ble", "monitor"]
|
||||
|
||||
@ -6,8 +6,6 @@ from typing import Any, Literal
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from mcbluetooth import agent
|
||||
from mcbluetooth.agent import PairingMode as AgentPairingMode
|
||||
from mcbluetooth.dbus_client import get_client
|
||||
|
||||
# Type aliases for MCP tool parameters
|
||||
@ -132,7 +130,6 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
adapter: str,
|
||||
address: str,
|
||||
pairing_mode: PairingMode = "interactive",
|
||||
timeout: int = 60,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Initiate pairing with a device.
|
||||
@ -147,7 +144,6 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
- "elicit": Use MCP elicitation to request PIN from user (if supported)
|
||||
- "interactive": Return status, then call bt_pair_confirm with PIN
|
||||
- "auto": Auto-accept pairings (for trusted environments)
|
||||
timeout: Pairing timeout in seconds (default 60)
|
||||
|
||||
Returns:
|
||||
Pairing status including whether confirmation is needed
|
||||
@ -164,107 +160,56 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
await ctx.info(f"Device {address} is already paired")
|
||||
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():
|
||||
if pairing_mode == "auto":
|
||||
# Direct pairing without agent - may fail if PIN required
|
||||
try:
|
||||
await client.pair_device(adapter, address)
|
||||
return {"success": True}
|
||||
device = await client.get_device(adapter, address)
|
||||
if ctx:
|
||||
await ctx.info(f"Successfully paired with {address}")
|
||||
return {"status": "paired", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
if pairing_mode == "auto":
|
||||
# For auto mode, just wait for pairing to complete
|
||||
result = await do_pair()
|
||||
if result["success"]:
|
||||
device = await client.get_device(adapter, address)
|
||||
if ctx:
|
||||
await ctx.info(f"Successfully paired with {address}")
|
||||
return {"status": "paired", "device": asdict(device) if device else None}
|
||||
await ctx.error(f"Pairing failed: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
else:
|
||||
if ctx:
|
||||
await ctx.error(f"Pairing failed: {result['error']}")
|
||||
return {"status": "error", "error": result["error"]}
|
||||
else:
|
||||
# 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
|
||||
# For interactive/elicit modes, we need an agent
|
||||
try:
|
||||
result = await asyncio.wait_for(asyncio.shield(pair_task), timeout=3.0)
|
||||
if result["success"]:
|
||||
await client.pair_device(adapter, address)
|
||||
device = await client.get_device(adapter, address)
|
||||
if ctx:
|
||||
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:
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "AuthenticationFailed" in error_msg or "AuthenticationCanceled" in error_msg:
|
||||
if ctx:
|
||||
await ctx.warning(
|
||||
f"Pairing requires {pending.request_type.value}: "
|
||||
f"passkey={pending.passkey}"
|
||||
)
|
||||
await ctx.warning("Pairing requires user confirmation or PIN")
|
||||
return {
|
||||
"status": "awaiting_confirmation",
|
||||
"request_type": pending.request_type.value,
|
||||
"passkey": pending.passkey,
|
||||
"message": "Use bt_pair_confirm to accept or reject",
|
||||
"message": "Pairing requires user confirmation or PIN entry",
|
||||
"pairing_mode": pairing_mode,
|
||||
}
|
||||
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 {
|
||||
"status": "awaiting_confirmation",
|
||||
"request_type": pending.request_type.value,
|
||||
"passkey": pending.passkey,
|
||||
"message": "Use bt_pair_confirm to accept or reject",
|
||||
}
|
||||
# No request yet, pairing still in progress
|
||||
if ctx:
|
||||
await ctx.info("Pairing in progress, waiting for device...")
|
||||
return {
|
||||
"status": "pairing_in_progress",
|
||||
"message": "Check bt_pairing_status or wait for device response",
|
||||
}
|
||||
await ctx.error(f"Pairing failed: {error_msg}")
|
||||
return {"status": "error", "error": error_msg}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_pair_confirm(
|
||||
adapter: str,
|
||||
address: str,
|
||||
pin: str | None = None,
|
||||
passkey: int | None = None,
|
||||
accept: bool = True,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm or reject a pairing request.
|
||||
|
||||
Use this after bt_pair returns "awaiting_confirmation" status.
|
||||
The agent will respond to BlueZ's pairing request.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name
|
||||
address: Device Bluetooth address
|
||||
pin: PIN code if required (string, usually 4-6 digits)
|
||||
passkey: Numeric passkey if required (0-999999)
|
||||
pin: PIN code if required (usually 4-6 digits)
|
||||
accept: True to accept pairing, False to reject
|
||||
|
||||
Returns:
|
||||
@ -272,69 +217,30 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
"""
|
||||
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 ctx:
|
||||
await ctx.info(f"Rejecting pairing with {address}")
|
||||
try:
|
||||
await client.cancel_pairing(adapter, address)
|
||||
return {"status": "pairing_rejected"}
|
||||
return {"status": "pairing_cancelled"}
|
||||
except Exception as e:
|
||||
# May already be cancelled
|
||||
if ctx:
|
||||
await ctx.debug(f"Cancel pairing: {e}")
|
||||
return {"status": "pairing_rejected"}
|
||||
await ctx.error(f"Failed to cancel pairing: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
if ctx:
|
||||
await ctx.info(f"Confirming pairing with {address}")
|
||||
|
||||
# Wait a moment for the agent to process and BlueZ to complete
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Check if pairing succeeded
|
||||
try:
|
||||
await client.pair_device(adapter, address)
|
||||
device = await client.get_device(adapter, address)
|
||||
if device and device.paired:
|
||||
if ctx:
|
||||
await ctx.info(f"Pairing confirmed with {address}")
|
||||
return {"status": "paired", "device": asdict(device)}
|
||||
else:
|
||||
# Pairing might still be in progress
|
||||
return {"status": "paired", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
if ctx:
|
||||
await ctx.info("Pairing in progress...")
|
||||
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),
|
||||
}
|
||||
await ctx.error(f"Pairing confirmation failed: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_unpair(
|
||||
|
||||
@ -1,212 +0,0 @@
|
||||
"""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,6 +9,8 @@ These tools provide MCP integration for:
|
||||
Note: Live capture requires elevated privileges (CAP_NET_RAW or sudo).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user