Compare commits

...

10 Commits

Author SHA1 Message Date
41306bb36f Bump version to 2026.02.04 2026-02-04 07:36:57 -07:00
0799067a1a Fix HFP AG E2E: post-SLC handlers, fd management, DTMF routing
Multiple bugs preventing stable HFP connections with real hardware
(ESP32 Bluedroid HFU):

- Add AF_BLUETOOTH/BTPROTO_RFCOMM fallback constants for Python builds
  compiled without bluetooth.h
- Fix NewConnection fd handling: validate, os.dup, transfer to socket
  via socket.fromfd with proper protocol param, close intermediate fd
- Remove premature +BCS codec selection from AT+BAC handler — sending
  +BCS during SLC setup confuses Bluedroid's state machine
- Add post-SLC command handlers: AT+BIA (indicator activation),
  AT+CCWA (call waiting), AT+CLIP (caller line ID) — without these
  the HF drops the RFCOMM connection after ~22 seconds
- Route AT+VTS= to DTMF handler (standard HFP command, alongside
  the non-standard AT+DTMF=)
- Fix simulate_call_end to handle OUTGOING/ALERTING call states
- Respect AT+BIA flags in _update_indicator
- Only send +CLIP during RING when HF has enabled it
- Clean up debug logging: remove file-based logger, use log.debug
- Add ruff per-file-ignores for dbus-fast D-Bus type annotations

Validated: 85/86 E2E tests PASS with ESP32 HFP Hands-Free Unit
2026-02-03 18:38:34 -07:00
2597c8b8b4 Document HFP Audio Gateway across README and docs site
- README: Add HFP AG to features, tools table (8 tools), architecture
  diagram, project structure, and roadmap. Update total from 61 to 69.
- Docs site: Create reference page (hfp-ag-tools.md) and practical
  guide (hfp-ag.md) covering AG enable, call simulation, volume,
  indicators, AT commands, and E2E test flow.
- Update tools overview, architecture, introduction, and sidebar
  to include HFP AG category.
2026-02-03 15:23:43 -07:00
1bfb7b57ee Add HFP Audio Gateway profile for E2E headset testing
Implements the AG (phone) role via BlueZ ProfileManager1, allowing
Linux to simulate a phone for testing HF devices like our ESP32 harness.

Core module (hfp_ag.py):
- Profile1 D-Bus service with RFCOMM socket handling
- Full AT command protocol: SLC negotiation (BRSF, CIND, CMER, CHLD),
  call control, codec negotiation (CVSD/mSBC), volume, voice recognition
- AG-initiated actions: simulate incoming calls, end calls, set indicators

MCP tools (8): enable/disable, status, simulate_call, end_call,
set_volume, set_signal, set_battery

Avoids `from __future__ import annotations` which breaks dbus-fast's
@method() decorator annotation parsing.
2026-02-03 15:14:54 -07:00
31b911febd Add mcbluetooth-esp32 to Related Projects section 2026-02-03 12:12:01 -07:00
f6b2ac40fb Fix bt_ble_write: use bytearray instead of list for D-Bus WriteValue
dbus_fast maps BlueZ's WriteValue 'ay' parameter to bytearray internally.
Passing list(value) caused "can't concat list to bytearray" when the
library tried to serialize the argument.
2026-02-02 20:48:19 -07:00
9d1c0f3e0f Add Astro/Starlight documentation site
23-page docs site following diataxis principles with guides,
reference, and explanation sections covering all 61 MCP tools.
Bluetooth-themed design with Pagefind search.
2026-02-02 14:36:07 -07:00
e9f06173c5 Add OBEX profile support (OPP, FTP, PBAP, MAP)
23 new tools for file transfer, phonebook access, and messaging
via obexd D-Bus API. Includes session management, transfer
monitoring, and daemon lifecycle tools.
2026-02-02 14:36:02 -07:00
8cbbcfa286 Overhaul README with comprehensive documentation
- Add ASCII art banner and badges
- Add example conversation showing real usage
- Reorganize features with natural language examples
- Complete tools reference with counts (38 total)
- Detailed architecture diagram showing all layers
- Pairing agent documentation with SSP methods
- Requirements table with tested configurations
- Development guide with project structure
- Roadmap showing completed and planned features
2026-02-02 12:36:17 -07:00
7f3b096c83 Add BlueZ Agent1 implementation for pairing
Implements the org.bluez.Agent1 D-Bus interface to handle Bluetooth
pairing operations with three modes:
- elicit: MCP elicitation for PIN/confirmation (if client supports)
- interactive: Returns pending status for bt_pair_confirm calls
- auto: Auto-accepts pairings (for trusted environments)

Changes:
- New agent.py with BlueZAgent ServiceInterface
- Updated bt_pair to use agent with configurable timeout
- Updated bt_pair_confirm to respond to pending agent requests
- Added bt_pairing_status tool to check pending requests
- Removed PEP 563 future import from monitor.py for FastMCP compat
2026-02-02 11:57:58 -07:00
49 changed files with 17966 additions and 187 deletions

619
README.md
View File

@ -1,180 +1,501 @@
# mcbluetooth
<p align="center">
<pre>
╔╗ ╔╗ ╔╗ ╔╗ ╔╗
║║ ║║ ║║ ╔╗ ║║ ║║
╔╗╔╗╔══╗╔══╗ ║╚═╗║║ ╔╗ ╔╗╔══╝║╔══╗╔═╝║╔══╗╔══╝║╔═╝║╔══╗
║╚╝║║╔═╝║╔╗║ ║╔╗║║║ ║║ ║║║╔══╝║╔╗║║╔╗║║╔╗║║╔╗║║╔╗║║╔╗║
║║║║║╚═╗║╚╝╚╗ ║╚╝║║╚╗║╚═╝║║╚══╗║╚╝║║╚╝║║╚╝║║╚╝║║╚╝║║║║║
╚╩╩╝╚══╝╚═══╝ ╚══╝╚═╝╚═══╝╚═══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚╝╚╝
</pre>
</p>
A comprehensive MCP server exposing the Linux Bluetooth stack (BlueZ) to LLMs.
<p align="center">
<strong>Give LLMs control of your Linux Bluetooth stack</strong>
</p>
<p align="center">
<a href="#installation">Installation</a>
<a href="#quick-start">Quick Start</a>
<a href="#features">Features</a>
<a href="#tools-reference">Tools</a>
<a href="#architecture">Architecture</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/python-3.11+-blue.svg" alt="Python 3.11+">
<img src="https://img.shields.io/badge/BlueZ-5.x-blue.svg" alt="BlueZ 5.x">
<img src="https://img.shields.io/badge/MCP-FastMCP-green.svg" alt="FastMCP">
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
</p>
---
**mcbluetooth** is a comprehensive [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes the Linux Bluetooth stack to LLMs. Ask Claude to scan for devices, pair your headphones, adjust volume, read BLE sensors, or capture Bluetooth traffic — all through natural language.
## Why mcbluetooth?
- **Natural Language Control** — "Connect to my headphones and set volume to 80%"
- **Full BlueZ Integration** — Adapters, devices, pairing, audio profiles, BLE/GATT
- **Smart Pairing Agent** — Handles PIN codes, passkeys, and confirmations automatically
- **Protocol Analysis** — Capture and analyze Bluetooth traffic with btmon integration
- **Audio Intelligence** — PipeWire/PulseAudio integration for seamless audio control
- **HFP Audio Gateway** — Act as a phone for headset testing and call simulation
- **OBEX Profiles** — File transfer, phonebook access, and message access
## Example Conversation
```
You: Scan for Bluetooth devices and connect to my Bose headphones
Claude: I'll scan for nearby Bluetooth devices.
[Calling bt_scan with adapter="hci0", timeout=10, mode="both"]
Found 5 devices:
• Bombay (C8:7B:23:55:68:E8) - Bose NCH700, paired
• DS18 BT-TWO (20:23:05:26:02:31) - Audio device
• iPhone (AA:BB:CC:DD:EE:FF) - Phone
...
I found your Bose headphones "Bombay". Connecting now...
[Calling bt_connect with adapter="hci0", address="C8:7B:23:55:68:E8"]
✓ Connected to Bombay! The headphones are ready. Would you like me
to set them as the default audio output?
```
## Installation
### Using uvx (recommended)
```bash
# Run directly
uvx mcbluetooth
# Or install globally
uv tool install mcbluetooth
```
### From Source
```bash
git clone https://github.com/yourusername/mcbluetooth
cd mcbluetooth
uv sync
uv run mcbluetooth
```
## Quick Start
### Add to Claude Code
```bash
# From PyPI
claude mcp add mcbluetooth -- uvx mcbluetooth
# From source
claude mcp add mcbluetooth -- uv run --directory /path/to/mcbluetooth mcbluetooth
```
### Permissions Setup
mcbluetooth needs access to the BlueZ D-Bus interface:
```bash
# Option 1: Add user to bluetooth group (requires re-login)
sudo usermod -aG bluetooth $USER
# Option 2: Run with current permissions (if polkit is configured)
# Most desktop Linux distributions allow this by default
```
For HCI packet capture (btmon), additional permissions are needed:
```bash
# Allow btmon without sudo
sudo setcap cap_net_raw+ep /usr/bin/btmon
```
### OBEX Setup (for file transfer, phonebook, messages)
OBEX features require the `obexd` daemon:
```bash
# Install obexd (if not included with BlueZ)
# Arch Linux
sudo pacman -S bluez-obex
# Debian/Ubuntu
sudo apt install bluez-obex
# Check status
bt_obex_status # Use this tool to verify setup
```
The `bt_obex_status` tool will check if obexd is installed and running, and provide guidance if setup is needed.
## Features
### Adapter Management
- List, power, and configure Bluetooth adapters
- Control discoverable and pairable states
- Set adapter aliases
Control your Bluetooth hardware — power, discovery, pairing acceptance.
### 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 .
```
"Turn on Bluetooth"
"Make my computer discoverable for 2 minutes"
"List all Bluetooth adapters"
```
## Usage with Claude Code
### Device Discovery & Pairing
Scan for devices with Classic Bluetooth or BLE filters. Smart pairing agent handles PIN codes, numeric comparison, and passkey entry automatically.
```bash
# Add to Claude Code (from source)
claude mcp add mcbluetooth-local -- uv run --directory /path/to/mcbluetooth mcbluetooth
# Or if published to PyPI
claude mcp add mcbluetooth -- uvx mcbluetooth
```
"Scan for nearby Bluetooth devices"
"Pair with the Sony headphones"
"Show me all paired devices"
"Remove the old keyboard from paired devices"
```
## Requirements
### Audio Control
Full PipeWire/PulseAudio integration for Bluetooth audio devices.
- Linux with BlueZ 5.x
- Python 3.11+
- PipeWire or PulseAudio (for audio features)
- User must be in `bluetooth` group or have polkit permissions
### Permissions
```bash
# Add user to bluetooth group
sudo usermod -aG bluetooth $USER
# Or configure polkit for BlueZ D-Bus access
```
"Connect my headphones and set them as default output"
"Switch to HFP mode for a phone call"
"Set headphone volume to 70%"
"Mute Bluetooth audio"
```
### BLE / GATT
Interact with Bluetooth Low Energy devices — read sensors, write commands, subscribe to notifications.
```
"Connect to my fitness tracker and read the battery level"
"List all GATT services on the heart rate monitor"
"Read the temperature from the BLE thermometer"
"Subscribe to heart rate notifications"
```
### HFP Audio Gateway
Act as a phone (Audio Gateway) for testing Bluetooth headsets. Simulate calls, control volume, and update status indicators over the HFP AT command protocol.
```
"Enable HFP Audio Gateway mode"
"Simulate an incoming call to the connected headset"
"Set the headset speaker volume to 12"
"Update the battery indicator to level 3"
```
### Protocol Analysis
Capture and analyze Bluetooth traffic for debugging and reverse engineering.
```
"Start capturing Bluetooth traffic"
"Show me the last 50 HCI packets"
"Analyze the capture file for errors"
```
### OBEX File Transfer
Send files, browse phone storage, and download photos using OBEX profiles.
```
"Send this PDF to my phone"
"Browse the files on my phone's storage"
"Download all photos from DCIM folder"
```
### Phonebook & Messages
Access contacts and SMS messages from connected phones via PBAP and MAP profiles.
```
"Download my phone's contacts"
"Search for John in the phonebook"
"List unread text messages"
```
## Tools Reference
### Adapter Tools (6)
| Tool | Description |
|------|-------------|
| `bt_list_adapters` | List all Bluetooth adapters with status |
| `bt_adapter_info` | Get detailed adapter properties |
| `bt_adapter_power` | Power adapter on/off |
| `bt_adapter_discoverable` | Set discoverable state and timeout |
| `bt_adapter_pairable` | Enable/disable pairing acceptance |
| `bt_adapter_set_alias` | Set adapter friendly name |
### Device Tools (12)
| Tool | Description |
|------|-------------|
| `bt_scan` | Scan for devices (classic/BLE/both) |
| `bt_list_devices` | List known devices with filters |
| `bt_device_info` | Get device properties and services |
| `bt_device_set_alias` | Set device friendly name |
| `bt_pair` | Initiate pairing with agent support |
| `bt_pair_confirm` | Confirm/reject pending pairing request |
| `bt_pairing_status` | Check for pending pairing requests |
| `bt_unpair` | Remove device and pairing info |
| `bt_connect` | Connect to paired device |
| `bt_disconnect` | Disconnect active connection |
| `bt_trust` | Set device trust status |
| `bt_block` | Block/unblock device |
### Audio Tools (7)
| Tool | Description |
|------|-------------|
| `bt_audio_list` | List audio devices with profiles |
| `bt_audio_connect` | Connect audio profiles |
| `bt_audio_disconnect` | Disconnect audio |
| `bt_audio_set_profile` | Switch A2DP/HFP/HSP/off |
| `bt_audio_set_default` | Set as default audio sink |
| `bt_audio_volume` | Set volume (0-150%) |
| `bt_audio_mute` | Mute/unmute audio |
### HFP Audio Gateway Tools (8)
| Tool | Description |
|------|-------------|
| `bt_hfp_ag_enable` | Register HFP AG profile with BlueZ |
| `bt_hfp_ag_disable` | Unregister HFP AG profile |
| `bt_hfp_ag_status` | Get AG connections, SLC state, indicators |
| `bt_hfp_ag_simulate_call` | Simulate incoming call (RING + CLIP) |
| `bt_hfp_ag_end_call` | Terminate call from AG side |
| `bt_hfp_ag_set_volume` | Set speaker or microphone volume (0-15) |
| `bt_hfp_ag_set_signal` | Update signal strength indicator (0-5) |
| `bt_hfp_ag_set_battery` | Update battery level indicator (0-5) |
### BLE Tools (7)
| Tool | Description |
|------|-------------|
| `bt_ble_scan` | BLE-specific scan with filters |
| `bt_ble_services` | List GATT services |
| `bt_ble_characteristics` | List service characteristics |
| `bt_ble_read` | Read characteristic value |
| `bt_ble_write` | Write characteristic value |
| `bt_ble_notify` | Enable/disable notifications |
| `bt_ble_battery` | Read standard battery level |
### Monitor Tools (6)
| Tool | Description |
|------|-------------|
| `bt_capture_start` | Start HCI traffic capture |
| `bt_capture_stop` | Stop running capture |
| `bt_capture_list_active` | List active captures |
| `bt_capture_parse` | Parse btsnoop to structured data |
| `bt_capture_analyze` | Statistical analysis of capture |
| `bt_capture_read_raw` | Human-readable packet decode |
### OBEX Tools (23)
#### Setup & Session Management
| Tool | Description |
|------|-------------|
| `bt_obex_status` | Check obexd daemon status and requirements |
| `bt_obex_start_daemon` | Start the obexd daemon |
| `bt_obex_connect` | Create OBEX session (OPP/FTP/PBAP/MAP) |
| `bt_obex_disconnect` | Close OBEX session |
| `bt_obex_sessions` | List active OBEX sessions |
#### Object Push (OPP)
| Tool | Description |
|------|-------------|
| `bt_obex_send_file` | Send file to device |
| `bt_obex_get_vcard` | Pull business card from device |
#### File Transfer (FTP)
| Tool | Description |
|------|-------------|
| `bt_obex_browse` | List files/folders on device |
| `bt_obex_get` | Download file from device |
| `bt_obex_put` | Upload file to device |
| `bt_obex_delete` | Delete file/folder on device |
| `bt_obex_mkdir` | Create folder on device |
#### Phonebook Access (PBAP)
| Tool | Description |
|------|-------------|
| `bt_phonebook_pull` | Download entire phonebook |
| `bt_phonebook_list` | List phonebook entries |
| `bt_phonebook_get` | Download single contact |
| `bt_phonebook_search` | Search phonebook |
| `bt_phonebook_count` | Count phonebook entries |
#### Message Access (MAP)
| Tool | Description |
|------|-------------|
| `bt_messages_folders` | List message folders |
| `bt_messages_list` | List messages in folder |
| `bt_messages_get` | Download message content |
| `bt_messages_send` | Send SMS via MAP |
#### Transfer Management
| Tool | Description |
|------|-------------|
| `bt_obex_transfer_status` | Check transfer progress |
| `bt_obex_transfer_cancel` | Cancel active transfer |
**Total: 69 tools**
## MCP Resources
The server exposes dynamic resources for live state queries:
Live state queries without tool calls:
| Resource URI | Description |
|--------------|-------------|
| `bluetooth://adapters` | All Bluetooth adapters |
| `bluetooth://paired` | Paired devices |
| `bluetooth://connected` | Connected devices |
| `bluetooth://visible` | All known devices |
| `bluetooth://connected` | Currently connected devices |
| `bluetooth://visible` | All discovered devices |
| `bluetooth://trusted` | Trusted devices |
| `bluetooth://adapter/{name}` | Specific adapter details |
| `bluetooth://adapter/{name}` | Specific adapter (e.g., `hci0`) |
| `bluetooth://device/{address}` | Specific device details |
## 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
```
┌─────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────────────┘
```
## 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
MIT License — See [LICENSE](LICENSE) for details.
---
<p align="center">
Built with FastMCP • Powered by BlueZ • Made for LLMs
</p>

21
docs-site/.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

49
docs-site/README.md Normal file
View File

@ -0,0 +1,49 @@
# Starlight Starter Kit: Basics
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](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 [Starlights docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).

View File

@ -0,0 +1,87 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import icon from 'astro-icon';
// https://astro.build/config
export default defineConfig({
// Site URL for sitemap generation
site: 'https://mcbluetooth.example.com',
// Disable telemetry
telemetry: false,
// Disable devToolbar
devToolbar: { enabled: false },
integrations: [
icon(),
starlight({
title: 'mcbluetooth',
description: 'Give LLMs control of your Linux Bluetooth stack',
logo: {
light: './src/assets/logo-light.svg',
dark: './src/assets/logo-dark.svg',
replacesTitle: false,
},
social: [
{ icon: 'github', label: 'GitHub', href: 'https://github.com/yourusername/mcbluetooth' },
],
customCss: [
'./src/styles/custom.css',
],
head: [
// Favicon
{ tag: 'link', attrs: { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' } },
],
sidebar: [
{
label: 'Getting Started',
items: [
{ label: 'Introduction', slug: 'getting-started/introduction' },
{ label: 'Installation', slug: 'getting-started/installation' },
{ label: 'Quick Start', slug: 'getting-started/quick-start' },
],
},
{
label: 'Guides',
items: [
{ label: 'Adapter Management', slug: 'guides/adapters' },
{ label: 'Device Pairing', slug: 'guides/pairing' },
{ label: 'Audio Control', slug: 'guides/audio' },
{ label: 'HFP Audio Gateway', slug: 'guides/hfp-ag' },
{ label: 'BLE & GATT', slug: 'guides/ble' },
{ label: 'OBEX File Transfer', slug: 'guides/obex' },
{ label: 'Phonebook & Messages', slug: 'guides/phonebook-messages' },
{ label: 'Protocol Capture', slug: 'guides/capture' },
],
},
{
label: 'Reference',
items: [
{ label: 'All Tools', slug: 'reference/tools' },
{ label: 'Adapter Tools', slug: 'reference/adapter-tools' },
{ label: 'Device Tools', slug: 'reference/device-tools' },
{ label: 'Audio Tools', slug: 'reference/audio-tools' },
{ label: 'HFP AG Tools', slug: 'reference/hfp-ag-tools' },
{ label: 'BLE Tools', slug: 'reference/ble-tools' },
{ label: 'OBEX Tools', slug: 'reference/obex-tools' },
{ label: 'Monitor Tools', slug: 'reference/monitor-tools' },
{ label: 'MCP Resources', slug: 'reference/resources' },
],
},
{
label: 'Explanation',
items: [
{ label: 'Architecture', slug: 'explanation/architecture' },
{ label: 'Pairing Agent', slug: 'explanation/pairing-agent' },
{ label: 'OBEX Profiles', slug: 'explanation/obex-profiles' },
],
},
],
editLink: {
baseUrl: 'https://github.com/yourusername/mcbluetooth/edit/main/docs-site/',
},
}),
],
});

6988
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
docs-site/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "docs-site",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/starlight": "^0.37.6",
"@iconify-json/lucide": "^1.2.87",
"astro": "^5.6.1",
"astro-icon": "^1.1.5",
"sharp": "^0.34.2"
}
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<circle cx="16" cy="16" r="14" fill="#0066cc"/>
<path d="M16 6 L16 26 M16 6 L22 12 L16 16 L22 22 L16 26 M16 16 L10 10 M16 16 L10 22"
stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<circle cx="16" cy="16" r="14" fill="#3399ff"/>
<path d="M16 6 L16 26 M16 6 L22 12 L16 16 L22 22 L16 26 M16 16 L10 10 M16 16 L10 22"
stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<circle cx="16" cy="16" r="14" fill="#0066cc"/>
<path d="M16 6 L16 26 M16 6 L22 12 L16 16 L22 22 L16 26 M16 16 L10 10 M16 16 L10 22"
stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,278 @@
---
title: Architecture
description: How mcbluetooth integrates with BlueZ, D-Bus, and the Linux Bluetooth stack
---
import { Aside } from '@astrojs/starlight/components';
Understanding mcbluetooth's architecture helps you troubleshoot issues and extend functionality.
## System Overview
```
┌─────────────────────────────────────────────────────────────┐
│ MCP Client (Claude, etc.) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ mcbluetooth │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ MCP Tools │ │ MCP Resources│ │ Pairing Agent │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ D-Bus Clients (async) ││
│ │ ┌──────────────┐ ┌──────────────────────┐ ││
│ │ │ BlueZClient │ │ ObexClient │ ││
│ │ │ (system bus) │ │ (session bus) │ ││
│ │ └──────────────┘ └──────────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ BlueZ │ │ obexd │ │ PipeWire/ │
│ (bluetoothd) │ │ (session) │ │ PulseAudio │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────┼───────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Bluetooth │ │ btusb │ │ HCI driver │ │
│ │ subsystem │ │ module │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Bluetooth Hardware │
│ (USB dongle, built-in) │
└─────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### mcbluetooth (MCP Server)
The MCP server layer that exposes Bluetooth functionality:
| Component | Purpose |
|-----------|---------|
| **MCP Tools** | 69 tools for Bluetooth operations |
| **MCP Resources** | Live state queries via URIs |
| **Pairing Agent** | Handles PIN/passkey negotiation |
| **D-Bus Clients** | Async communication with BlueZ/obexd |
### BlueZ (bluetoothd)
The official Linux Bluetooth stack daemon:
- Manages adapter hardware
- Handles device discovery and pairing
- Implements Bluetooth profiles (A2DP, HFP, etc.)
- Manages custom profile registrations (HFP AG via ProfileManager1)
- Exposes D-Bus API on **system bus**
### obexd
OBEX protocol daemon for file transfer:
- Runs per-user (session daemon)
- Implements OPP, FTP, PBAP, MAP profiles
- Exposes D-Bus API on **session bus**
<Aside type="note">
obexd requires an active desktop session because it runs on the session D-Bus.
</Aside>
### PipeWire/PulseAudio
Audio server integration:
- Receives audio streams from BlueZ
- Provides volume, mute, routing controls
- Handles codec negotiation
## D-Bus Architecture
mcbluetooth communicates with two D-Bus buses:
### System Bus (BlueZ)
```
Service: org.bluez
Paths: /org/bluez
/org/bluez/hci0
/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
Interfaces:
org.bluez.Adapter1 - Adapter control
org.bluez.Device1 - Device management
org.bluez.GattService1 - BLE services
org.bluez.GattCharacteristic1 - BLE characteristics
org.bluez.AgentManager1 - Pairing agent registration
org.bluez.ProfileManager1 - Custom profile registration (HFP AG)
```
### Session Bus (OBEX)
```
Service: org.bluez.obex
Paths: /org/bluez/obex
/org/bluez/obex/client/session0
/org/bluez/obex/client/session0/transfer0
Interfaces:
org.bluez.obex.Client1 - Session management
org.bluez.obex.Session1 - Session properties
org.bluez.obex.ObjectPush1 - OPP file sending
org.bluez.obex.FileTransfer1 - FTP operations
org.bluez.obex.PhonebookAccess1 - PBAP
org.bluez.obex.MessageAccess1 - MAP
```
## Async Design
mcbluetooth uses **dbus-fast** for non-blocking D-Bus communication:
```python
# Singleton pattern ensures one connection
class BlueZClient:
_instance = None
@classmethod
async def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
await cls._instance._connect()
return cls._instance
```
Benefits:
- Multiple concurrent operations
- No blocking the MCP event loop
- Efficient resource usage
## Tool Registration
Tools are organized by functional area:
```python
# server.py
from mcbluetooth.tools import adapter, audio, ble, device, hfp, monitor, obex
def create_server():
mcp = FastMCP("mcbluetooth")
adapter.register_tools(mcp)
device.register_tools(mcp)
audio.register_tools(mcp)
hfp.register_tools(mcp)
ble.register_tools(mcp)
monitor.register_tools(mcp)
obex.register_tools(mcp)
return mcp
```
Each module follows the pattern:
```python
# tools/device.py
def register_tools(mcp: FastMCP) -> None:
@mcp.tool()
async def bt_connect(adapter: str, address: str) -> dict:
"""Connect to a paired device."""
client = await BlueZClient.get_instance()
return await client.connect_device(adapter, address)
```
## Error Handling
D-Bus errors are translated to user-friendly messages:
| D-Bus Error | mcbluetooth Response |
|-------------|---------------------|
| `org.bluez.Error.Failed` | `{"error": "Operation failed", ...}` |
| `org.bluez.Error.NotReady` | `{"error": "Device not ready", ...}` |
| `org.bluez.Error.AuthenticationFailed` | `{"error": "Authentication failed", ...}` |
| `org.freedesktop.DBus.Error.ServiceUnknown` | `{"error": "BlueZ not running", ...}` |
## State Management
### BlueZ State
mcbluetooth doesn't cache BlueZ state — each query goes to D-Bus:
- Ensures fresh data
- Avoids stale state issues
- BlueZ handles the caching
### OBEX Sessions
OBEX sessions are tracked locally:
```python
_active_sessions: dict[str, dict] = {}
# session_id -> {path, address, target, created}
def generate_session_id(address: str, target: str) -> str:
"""Generate friendly ID: ftp_C87B235568E8"""
clean_addr = address.replace(":", "")
return f"{target}_{clean_addr}"
```
### Pairing Agent
The pairing agent registers with BlueZ and handles callbacks:
```
1. Agent registers with AgentManager1
2. BlueZ calls agent methods for pairing events
3. Agent handles PIN/passkey based on pairing_mode
4. Results returned to BlueZ
```
## Performance Considerations
### D-Bus Call Overhead
Each tool call involves:
1. MCP request parsing
2. D-Bus method call (async)
3. Response serialization
Typical latency: 1-10ms per call.
### Scanning
Discovery is resource-intensive:
- `bt_scan` starts/stops discovery explicitly
- Avoids continuous scanning
- Timeout prevents runaway scans
### Large Transfers
OBEX transfers use polling for progress:
```python
while True:
status = await get_transfer_status(transfer_path)
if status["status"] in ("complete", "error"):
return status
await asyncio.sleep(0.5)
```
## Security Model
| Layer | Security |
|-------|----------|
| MCP | Trust boundary at MCP client |
| D-Bus | PolicyKit for privileged ops |
| BlueZ | Pairing provides encryption |
| OBEX | Session-based access |
<Aside type="caution">
mcbluetooth inherits the permissions of its running user. System-level operations require appropriate D-Bus policies.
</Aside>

View File

@ -0,0 +1,353 @@
---
title: OBEX Profiles
description: Understanding OPP, FTP, PBAP, and MAP — the OBEX family of Bluetooth profiles
---
import { Aside } from '@astrojs/starlight/components';
OBEX (Object Exchange) is a protocol for transferring objects over Bluetooth. Four profiles build on OBEX for specific use cases.
## What is OBEX?
OBEX is a binary protocol originally designed for infrared (IrDA) that was adapted for Bluetooth. It provides:
- Session-based connections
- Request/response pattern
- Headers for metadata (name, type, length)
- Support for large object transfers
Think of it as "HTTP for Bluetooth" — a simple way to push and pull objects between devices.
## The Four OBEX Profiles
```
┌──────────────────────────────────────────────────────────────┐
│ OBEX Protocol │
├──────────────────────────────────────────────────────────────┤
│ OPP │ FTP │ PBAP │ MAP │
│ (Push) │ (Files) │ (Contacts) │ (Messages) │
└──────────────────────────────────────────────────────────────┘
```
## OPP — Object Push Profile
**Purpose:** Simple file sending, like handing someone a file.
### Characteristics
- No session management needed
- One-shot transfers
- Receiver can accept or reject
- Minimal device interaction required
### Operations
| Operation | Description |
|-----------|-------------|
| Push | Send a file to device |
| Pull | Get device's business card |
### Use Cases
- Send a photo to a friend
- Share a document
- Exchange contact cards
### mcbluetooth Tools
```
bt_obex_send_file address="..." file_path="~/photo.jpg"
bt_obex_get_vcard address="..." save_path="~/contact.vcf"
```
### Compatibility
Almost universal — OPP is the most widely supported OBEX profile.
| Device | Support |
|--------|---------|
| Android | ✓ |
| iPhone | ✓ |
| Feature phones | ✓ |
| Windows | ✓ |
| macOS | ✓ |
## FTP — File Transfer Profile
**Purpose:** Browse and manage remote file systems.
### Characteristics
- Session-based (connect → browse → disconnect)
- Full file system operations
- Folder navigation
- Two-way transfers
### Operations
| Operation | Description |
|-----------|-------------|
| Connect | Create FTP session |
| List | Get folder contents |
| Get | Download file |
| Put | Upload file |
| Delete | Remove file/folder |
| Mkdir | Create folder |
| Setpath | Navigate folders |
### Use Cases
- Backup phone photos
- Browse device storage
- Manage files on remote device
### mcbluetooth Tools
```
bt_obex_connect address="..." target="ftp"
bt_obex_browse session_id="ftp_..."
bt_obex_get session_id="..." remote_path="photo.jpg" local_path="~/photo.jpg"
bt_obex_put session_id="..." local_path="~/doc.pdf" remote_path="doc.pdf"
bt_obex_disconnect session_id="ftp_..."
```
### Compatibility
More limited than OPP:
| Device | Support |
|--------|---------|
| Android | Varies by version/manufacturer |
| iPhone | ✗ |
| Feature phones | ✓ Usually |
| Windows | ✓ |
| macOS | ✗ |
<Aside type="note">
Android FTP support varies. Some versions require enabling "File Transfer" mode when connected via Bluetooth.
</Aside>
## PBAP — Phonebook Access Profile
**Purpose:** Read contacts from a phone.
### Characteristics
- Read-only access (mostly)
- Standardized folder structure
- vCard format output
- Search capabilities
### Folder Structure
```
telecom/
├── pb/ ← Main phonebook
├── ich/ ← Incoming call history
├── och/ ← Outgoing call history
├── mch/ ← Missed call history
└── cch/ ← Combined call history
SIM1/
└── telecom/
└── pb/ ← SIM card contacts
```
### Operations
| Operation | Description |
|-----------|-------------|
| PullAll | Download entire phonebook |
| List | Get contact handles |
| Pull | Get specific contact |
| Search | Find contacts by field |
### Use Cases
- Sync contacts to car system
- Backup phone contacts
- Contact management applications
### mcbluetooth Tools
```
bt_phonebook_pull address="..." save_path="~/contacts.vcf"
bt_phonebook_list address="..." folder="telecom/pb"
bt_phonebook_search address="..." field="name" value="Smith"
bt_phonebook_count address="..."
```
### Compatibility
| Device | Support |
|--------|---------|
| Android | ✓ Full |
| iPhone | ✓ (when properly paired) |
| Feature phones | ✓ Usually |
| Car systems | ✓ Often |
<Aside type="tip">
PBAP is widely supported because car infotainment systems rely on it for contact syncing.
</Aside>
## MAP — Message Access Profile
**Purpose:** Access SMS/MMS messages.
### Characteristics
- Read messages from phone
- Some devices support sending
- Folder-based organization
- Notification of new messages
### Folder Structure
```
telecom/
└── msg/
├── inbox/
├── outbox/
├── sent/
├── drafts/
└── deleted/
```
### Operations
| Operation | Description |
|-----------|-------------|
| GetFolderListing | List message folders |
| GetMessagesListing | List messages in folder |
| GetMessage | Download message content |
| PushMessage | Send message (if supported) |
| SetMessageStatus | Mark read/unread |
### Use Cases
- Read texts through car system
- Message backup
- SMS integration applications
### mcbluetooth Tools
```
bt_messages_folders address="..."
bt_messages_list address="..." folder="inbox" unread_only=true
bt_messages_get address="..." handle="msg001" save_path="~/message.txt"
bt_messages_send address="..." recipient="+1555..." message="Hello"
```
### Compatibility
Most limited of the OBEX profiles:
| Device | Read | Send |
|--------|------|------|
| Android | ✓ | Varies |
| iPhone | ✗ | ✗ |
| Car systems | ✓ (receive) | ✗ |
<Aside type="caution">
iPhone does not support MAP. Apple uses proprietary protocols for message sync.
</Aside>
## Session Architecture
### OPP (Stateless)
```
Client Server
│ │
│────── CONNECT ─────────→│
│←───── SUCCESS ──────────│
│────── PUT file ────────→│
│←───── SUCCESS ──────────│
│────── DISCONNECT ──────→│
│ │
```
OPP creates temporary sessions for each transfer.
### FTP/PBAP/MAP (Session-Based)
```
Client Server
│ │
│────── CONNECT ─────────→│ ← Session created
│←───── SUCCESS ──────────│
│ │
│────── LIST ────────────→│
│←───── folder contents ──│
│ │
│────── GET file ────────→│
│←───── file data ────────│
│ │
│────── DISCONNECT ──────→│ ← Session closed
│ │
```
Sessions persist for multiple operations.
## obexd Architecture
mcbluetooth uses BlueZ's **obexd** daemon:
```
┌─────────────────────────────────────────────────────┐
│ mcbluetooth │
│ │ │
│ D-Bus (session) │
│ │ │
└────────────────────────┼────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ obexd │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ OPP │ │ FTP │ │ PBAP │ │ MAP │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │ │
│ RFCOMM/L2CAP │
└────────────────────────┼────────────────────────────┘
Bluetooth Stack
```
obexd runs on the **session D-Bus** (not system bus), which means:
- Requires active desktop session
- Per-user isolation
- Integrates with user's file permissions
## Transfer Progress
OBEX transfers report progress via D-Bus properties:
```json
{
"Status": "active",
"Transferred": 52428800,
"Size": 104857600,
"Filename": "large_file.mp4"
}
```
mcbluetooth polls these properties to provide progress updates.
## Error Handling
Common OBEX errors:
| Error | Meaning |
|-------|---------|
| `NotSupported` | Profile not available on device |
| `NotAuthorized` | Permission denied |
| `NotFound` | File/folder doesn't exist |
| `Forbidden` | Operation not allowed |
| `Failed` | Generic failure |
## Security Notes
- OBEX uses Bluetooth encryption (if paired)
- Phone typically prompts for permission on first access
- Permission can be revoked in phone settings
- Sessions are tied to pairing relationship

View File

@ -0,0 +1,276 @@
---
title: Pairing Agent
description: How mcbluetooth handles Bluetooth pairing with its smart agent
---
import { Aside } from '@astrojs/starlight/components';
Bluetooth pairing establishes trust between devices. mcbluetooth includes a pairing agent that handles all pairing methods automatically.
## What is a Pairing Agent?
In BlueZ, a **pairing agent** is a program that:
1. Registers with BlueZ's AgentManager
2. Receives callbacks during pairing
3. Responds to PIN/passkey requests
4. Confirms or rejects pairings
Without an agent, pairing requests fail because there's no program to handle the negotiation.
## Secure Simple Pairing (SSP)
Modern Bluetooth uses SSP with four association models:
### Just Works
**No user interaction required.**
- Used when one device has no display/keyboard
- Provides encryption but no MITM protection
- mcbluetooth auto-accepts these
**Example devices:** Simple speakers, mice, keyboards
### Numeric Comparison
**Both devices show 6-digit code; user confirms they match.**
```
Your device: 123456
Their device: 123456
Do they match? [Y/N]
```
- Both devices have displays
- User verifies the numbers match
- Protects against MITM attacks
**Example devices:** Phones, computers, smart watches
### Passkey Entry
**One device displays code; user enters it on the other.**
```
Their device shows: 123456
Enter on your device: [______]
```
- One device has display, other has keyboard
- Code entry proves physical access
**Example devices:** Phone pairing with keyboard
### Legacy PIN
**Old-style 4-6 digit PIN entry.**
```
Enter PIN: [____]
```
- Pre-SSP devices (Bluetooth 2.0 and earlier)
- Often uses "0000" or "1234"
**Example devices:** Older headsets, car systems
## mcbluetooth Pairing Modes
The `bt_pair` tool supports three modes:
### Interactive Mode (Default)
```
bt_pair adapter="hci0" address="..." pairing_mode="interactive"
```
1. mcbluetooth initiates pairing
2. Returns immediately with status
3. If confirmation needed, returns passkey/info
4. Use `bt_pair_confirm` to respond
**Flow:**
```
bt_pair → {"status": "awaiting_confirmation", "passkey": 123456}
bt_pair_confirm passkey=123456 accept=true → {"status": "paired"}
```
Best for: LLM-driven pairing where you want control over each step.
### Auto Mode
```
bt_pair adapter="hci0" address="..." pairing_mode="auto"
```
- Automatically accepts all pairing requests
- No user confirmation required
- Just Works and auto-confirm numeric comparison
<Aside type="caution">
Auto mode should only be used in trusted environments. It accepts pairings without verification.
</Aside>
Best for: Automated testing, trusted lab environments.
### Elicit Mode
```
bt_pair adapter="hci0" address="..." pairing_mode="elicit"
```
- Uses MCP elicitation to prompt user directly
- If MCP client supports elicitation, user sees prompt
- Falls back to interactive if not supported
Best for: Human-in-the-loop pairing with MCP clients that support elicitation.
## Agent Implementation
### Registration
mcbluetooth registers its agent at startup:
```python
# Register with BlueZ
agent_manager = await bus.get_proxy_object(
"org.bluez",
"/org/bluez"
).get_interface("org.bluez.AgentManager1")
await agent_manager.call_register_agent(
agent_path,
"KeyboardDisplay" # Capability
)
await agent_manager.call_request_default_agent(agent_path)
```
### Capability Declaration
The agent declares "KeyboardDisplay" capability:
- Can display codes (for numeric comparison)
- Can accept input (for passkey entry)
- Maximizes pairing compatibility
### Callback Methods
The agent implements these D-Bus methods:
| Method | Called When |
|--------|-------------|
| `RequestPinCode` | Legacy PIN needed |
| `RequestPasskey` | Passkey entry needed |
| `DisplayPasskey` | Show passkey to user |
| `DisplayPinCode` | Show PIN to user |
| `RequestConfirmation` | Numeric comparison |
| `RequestAuthorization` | Just Works confirmation |
| `AuthorizeService` | Service-level auth |
| `Cancel` | Pairing cancelled |
| `Release` | Agent released |
## Handling Pending Requests
When pairing requires confirmation:
```python
# Store pending request
_pending_pairings[address] = {
"method": "numeric_comparison",
"passkey": 123456,
"timestamp": datetime.now()
}
# Return to bt_pair caller
return {
"status": "awaiting_confirmation",
"method": "numeric_comparison",
"passkey": 123456
}
```
Later, `bt_pair_confirm` resolves it:
```python
@mcp.tool()
async def bt_pair_confirm(adapter: str, address: str, accept: bool, passkey: int = None):
pending = _pending_pairings.get(address)
if pending:
if accept:
pending["resolve"](passkey)
else:
pending["reject"]()
```
## Troubleshooting Pairing
### "No agent registered"
mcbluetooth's agent didn't register properly:
1. Check mcbluetooth is running
2. Verify D-Bus connectivity
3. Check for existing agents (some DEs register their own)
### "Authentication rejected"
The other device rejected pairing:
1. Device may have reached pairing limit
2. Try removing existing pairing on both sides
3. Restart Bluetooth on the other device
### Pairing timeout
No response within timeout period:
1. Extend timeout: `bt_pair ... timeout=120`
2. Check `bt_pairing_status` for pending requests
3. Other device may need user interaction
### Wrong passkey
For Passkey Entry mode:
1. Ensure you're entering the exact code shown
2. Some devices show passkey only briefly
3. Use `bt_pairing_status` to see expected passkey
## Security Considerations
### MITM Protection
| Mode | MITM Protected |
|------|----------------|
| Just Works | ✗ No |
| Numeric Comparison | ✓ Yes |
| Passkey Entry | ✓ Yes |
| Legacy PIN | Partial |
<Aside type="note">
Just Works provides encryption but doesn't verify you're pairing with the intended device. An attacker could intercept the pairing (MITM).
</Aside>
### Best Practices
1. **Prefer Numeric Comparison** - Verify codes match on both devices
2. **Avoid auto mode in production** - Human verification is important
3. **Check device identity** - Verify name/address before confirming
4. **Remove unused pairings** - Reduce attack surface
## Agent Conflicts
Desktop environments often register their own agents:
| Environment | Agent |
|-------------|-------|
| GNOME | gnome-shell |
| KDE | bluedevil |
| XFCE | blueman |
mcbluetooth requests to be the **default agent**, which usually works. If you have issues:
1. Temporarily stop the DE's Bluetooth applet
2. Or use mcbluetooth's agent alongside (may cause double prompts)

View File

@ -0,0 +1,191 @@
---
title: Installation
description: Install mcbluetooth and configure permissions
---
import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
## Install mcbluetooth
<Tabs>
<TabItem label="uvx (recommended)">
```bash
# Run directly without installing
uvx mcbluetooth
# Or install globally
uv tool install mcbluetooth
```
</TabItem>
<TabItem label="pip">
```bash
pip install mcbluetooth
```
</TabItem>
<TabItem label="From source">
```bash
git clone https://github.com/yourusername/mcbluetooth
cd mcbluetooth
uv sync
uv run mcbluetooth
```
</TabItem>
</Tabs>
## Add to Claude Code
```bash
# Using uvx
claude mcp add mcbluetooth -- uvx mcbluetooth
# From source
claude mcp add mcbluetooth -- uv run --directory /path/to/mcbluetooth mcbluetooth
```
## Configure Permissions
### BlueZ Access
mcbluetooth needs access to the BlueZ D-Bus interface. Most desktop Linux distributions allow this by default.
```bash
# Option 1: Add user to bluetooth group (requires re-login)
sudo usermod -aG bluetooth $USER
# Option 2: Verify polkit allows access (check for errors when running)
uvx mcbluetooth
```
### HCI Packet Capture (Optional)
For `bt_capture_*` tools (btmon integration):
```bash
# Allow btmon without sudo
sudo setcap cap_net_raw+ep /usr/bin/btmon
```
### OBEX Profiles (Optional)
For file transfer, phonebook, and message access:
<Tabs>
<TabItem label="Arch Linux">
```bash
sudo pacman -S bluez-obex
```
</TabItem>
<TabItem label="Debian/Ubuntu">
```bash
sudo apt install bluez-obex
```
</TabItem>
<TabItem label="Fedora">
```bash
sudo dnf install bluez-obex
```
</TabItem>
</Tabs>
After installing, verify with:
```
bt_obex_status
```
<Aside type="note">
obexd runs as a user service on the session D-Bus. It requires an active desktop session (X11 or Wayland).
</Aside>
## Verify Installation
### Check the Server Starts
```bash
# Should show startup banner and wait for MCP connections
uvx mcbluetooth
```
Expected output:
```
🔵 mcbluetooth v0.1.0
BlueZ MCP Server - Bluetooth management for LLMs
```
### Test with Claude Code
```bash
# In Claude Code, try:
bt_list_adapters
```
Should return your Bluetooth adapter(s):
```json
[
{
"name": "hci0",
"address": "AA:BB:CC:DD:EE:FF",
"powered": true,
"discoverable": false,
"discovering": false
}
]
```
## Troubleshooting
### "org.bluez was not provided"
BlueZ daemon isn't running:
```bash
# Check status
systemctl status bluetooth
# Start if stopped
sudo systemctl start bluetooth
sudo systemctl enable bluetooth
```
### "Permission denied" on D-Bus
Add user to bluetooth group:
```bash
sudo usermod -aG bluetooth $USER
# Log out and back in
```
### No Bluetooth adapter found
Check if adapter is detected by the kernel:
```bash
# List USB devices
lsusb | grep -i bluetooth
# Check kernel module
lsmod | grep btusb
# Check dmesg
dmesg | grep -i bluetooth
```
### obexd not found
Install the bluez-obex package for your distribution (see OBEX section above).
## Tested Configurations
| Distribution | BlueZ | Audio | Status |
|--------------|-------|-------|--------|
| Arch Linux | 5.85 | PipeWire 1.4 | ✓ Full support |
| Ubuntu 22.04 | 5.64 | PulseAudio | ✓ Full support |
| Ubuntu 24.04 | 5.72 | PipeWire | ✓ Full support |
| Fedora 39 | 5.70 | PipeWire | ✓ Full support |
| Debian 12 | 5.66 | PipeWire | ✓ Full support |
## Next Steps
- [Quick Start Guide](/getting-started/quick-start/) — Try your first commands
- [Adapter Management](/guides/adapters/) — Control your Bluetooth hardware

View File

@ -0,0 +1,105 @@
---
title: Introduction
description: What is mcbluetooth and why use it?
---
**mcbluetooth** is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that gives LLMs full control over the Linux Bluetooth stack via BlueZ.
## What Can You Do?
Ask Claude (or any MCP-compatible LLM) to:
- **"Turn on Bluetooth and make my computer discoverable"**
- **"Scan for devices and pair with my Sony headphones"**
- **"Connect my headphones and set volume to 80%"**
- **"Read the battery level from my fitness tracker"**
- **"Send this PDF to my phone via Bluetooth"**
- **"Download my phone's contacts"**
- **"Enable HFP Audio Gateway and simulate an incoming call"**
- **"Start capturing Bluetooth traffic for debugging"**
## Why mcbluetooth?
### Natural Language Control
Instead of memorizing `bluetoothctl` commands or navigating GUI menus, just describe what you want:
```
"My headphones disconnected, reconnect them and set them as the default audio output"
```
### Complete BlueZ Coverage
mcbluetooth exposes the full power of BlueZ:
- **Adapters** — Power, discovery, pairing acceptance
- **Devices** — Scanning, pairing, connection management
- **Audio** — A2DP/HFP profiles, volume, routing
- **HFP Audio Gateway** — Phone simulation, call control, indicators
- **BLE/GATT** — Services, characteristics, notifications
- **OBEX** — File transfer, phonebook, messages
- **Monitoring** — HCI packet capture and analysis
### Smart Pairing
The built-in pairing agent handles all Bluetooth pairing methods:
| Method | Description | Agent Response |
|--------|-------------|----------------|
| Just Works | No user interaction | Auto-accept |
| Numeric Comparison | Confirm 6-digit code | Interactive or auto |
| Passkey Entry | Enter code from device | PIN prompt |
| Legacy PIN | 4-6 digit PIN | PIN prompt |
### MCP Resources
Live state queries without tool calls:
```
bluetooth://adapters → All Bluetooth adapters
bluetooth://paired → Paired devices
bluetooth://connected → Connected devices
bluetooth://device/AA:BB:CC:DD:EE:FF → Specific device
```
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Claude / LLM │
├─────────────────────────────────────────────────────────────┤
│ MCP Protocol (stdio) │
├─────────────────────────────────────────────────────────────┤
│ mcbluetooth (FastMCP) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Adapter │ │ Device │ │ Audio │ │ OBEX │ ... │
│ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ └──────────┬┴──────────┬┴───────────┘ │
│ ┌────────┴────────┐ │ │
│ │ BlueZ D-Bus │ │ PipeWire/Pulse │
│ │ (dbus-fast) │ │ (pulsectl-asyncio) │
│ └────────┬────────┘ └──────────────────────────────┤
├──────────────────┼──────────────────────────────────────────┤
│ │ │
│ BlueZ (bluetoothd) obexd │
│ │ │
│ Linux Kernel (Bluetooth subsystem) │
└─────────────────────────────────────────────────────────────┘
```
## Requirements
| Requirement | Version | Notes |
|-------------|---------|-------|
| Linux | Any | BlueZ is Linux-only |
| Python | 3.11+ | Async/await, type hints |
| BlueZ | 5.x | Bluetooth daemon |
| PipeWire or PulseAudio | Any | For audio features |
| bluez-obex | Any | For OBEX features (optional) |
## Next Steps
1. [Install mcbluetooth](/getting-started/installation/)
2. [Try the quick start guide](/getting-started/quick-start/)
3. [Explore the tool reference](/reference/tools/)

View File

@ -0,0 +1,150 @@
---
title: Quick Start
description: Get up and running with mcbluetooth in 5 minutes
---
import { Steps, Aside } from '@astrojs/starlight/components';
This guide walks you through basic Bluetooth operations with mcbluetooth.
## Prerequisites
- mcbluetooth installed and added to Claude Code
- A Bluetooth adapter (built-in or USB dongle)
- A Bluetooth device to test with (headphones, phone, etc.)
## Basic Operations
<Steps>
1. **Check your adapter**
```
bt_list_adapters
```
You should see your Bluetooth adapter:
```json
[{"name": "hci0", "address": "...", "powered": true, ...}]
```
2. **Power on Bluetooth (if needed)**
```
bt_adapter_power adapter="hci0" on=true
```
3. **Scan for devices**
```
bt_scan adapter="hci0" timeout=10 mode="both"
```
This scans for 10 seconds and returns all discovered devices.
4. **Pair with a device**
Put your device in pairing mode, then:
```
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
The agent handles PIN/passkey automatically for most devices.
5. **Connect to the paired device**
```
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
</Steps>
## Common Workflows
### Connect Headphones
```
# Scan for devices
bt_scan adapter="hci0"
# Pair (first time only)
bt_pair adapter="hci0" address="C8:7B:23:55:68:E8"
# Connect
bt_connect adapter="hci0" address="C8:7B:23:55:68:E8"
# Set as default audio and adjust volume
bt_audio_set_default address="C8:7B:23:55:68:E8"
bt_audio_volume address="C8:7B:23:55:68:E8" volume=75
```
### Read a BLE Sensor
```
# Scan for BLE devices
bt_ble_scan adapter="hci0" timeout=10
# Connect and discover services
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
bt_ble_services adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Read battery level (standard BLE service)
bt_ble_battery adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Read a specific characteristic
bt_ble_read adapter="hci0" address="AA:BB:CC:DD:EE:FF" char_uuid="00002a19-0000-1000-8000-00805f9b34fb"
```
### Send a File to Your Phone
```
# Check OBEX is ready
bt_obex_status
# Send file (phone will show accept prompt)
bt_obex_send_file address="AA:BB:CC:DD:EE:FF" file_path="~/Documents/report.pdf"
```
### Download Contacts from Phone
```
# Pull entire phonebook
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/contacts.vcf"
# Or search for specific contact
bt_phonebook_search address="AA:BB:CC:DD:EE:FF" field="name" value="John"
```
## Using MCP Resources
Resources provide live state without tool calls:
```
# In your MCP client, read these URIs:
bluetooth://adapters # All adapters
bluetooth://paired # Paired devices
bluetooth://connected # Connected devices
bluetooth://device/AA:BB:... # Specific device details
```
## Tips
<Aside type="tip">
**Use device aliases**: Set friendly names with `bt_device_set_alias` so you don't have to remember MAC addresses.
</Aside>
<Aside type="tip">
**Trust devices**: Use `bt_trust` to allow devices to auto-connect without explicit authorization.
</Aside>
<Aside type="tip">
**Check OBEX first**: Before using file transfer or phonebook tools, run `bt_obex_status` to verify obexd is running.
</Aside>
## Next Steps
- [Adapter Management](/guides/adapters/) — Control Bluetooth hardware
- [Device Pairing](/guides/pairing/) — Deep dive into pairing modes
- [Audio Control](/guides/audio/) — Master Bluetooth audio
- [Tool Reference](/reference/tools/) — Complete tool documentation

View File

@ -0,0 +1,201 @@
---
title: Adapter Management
description: Control your Bluetooth hardware — power, discovery, and pairing modes
---
import { Aside } from '@astrojs/starlight/components';
Bluetooth adapters are the hardware interfaces that enable wireless communication. mcbluetooth provides full control over adapter configuration.
## List Adapters
```
bt_list_adapters
```
Returns all Bluetooth adapters with their current state:
```json
[
{
"name": "hci0",
"address": "AA:BB:CC:DD:EE:FF",
"alias": "My Laptop",
"powered": true,
"discoverable": false,
"discoverable_timeout": 180,
"pairable": true,
"pairable_timeout": 0,
"discovering": false
}
]
```
## Get Adapter Details
```
bt_adapter_info adapter="hci0"
```
Returns detailed information including supported features and UUIDs.
## Power Control
### Turn Bluetooth On
```
bt_adapter_power adapter="hci0" on=true
```
### Turn Bluetooth Off
```
bt_adapter_power adapter="hci0" on=false
```
<Aside type="note">
Powering off disconnects all devices and stops any ongoing discovery.
</Aside>
## Discovery Settings
### Make Discoverable
Allow other devices to see your computer:
```
bt_adapter_discoverable adapter="hci0" on=true timeout=180
```
- `timeout=0` means discoverable forever (use carefully)
- `timeout=180` means 3 minutes (default)
### Stop Being Discoverable
```
bt_adapter_discoverable adapter="hci0" on=false
```
## Pairing Acceptance
### Enable Pairing
Allow devices to pair with your computer:
```
bt_adapter_pairable adapter="hci0" on=true timeout=0
```
### Disable Pairing
```
bt_adapter_pairable adapter="hci0" on=false
```
<Aside type="caution">
Leaving pairable enabled indefinitely (`timeout=0`) may be a security risk in public environments.
</Aside>
## Set Adapter Name
Change the friendly name other devices see:
```
bt_adapter_set_alias adapter="hci0" alias="Ryan's Desktop"
```
## Common Workflows
### Prepare for Incoming Connection
When you want another device to find and connect to your computer:
```
# Make discoverable and pairable
bt_adapter_discoverable adapter="hci0" on=true timeout=300
bt_adapter_pairable adapter="hci0" on=true timeout=300
```
This opens a 5-minute window for pairing.
### Secure Configuration
For everyday use with known devices:
```
# Hidden but accepting connections from paired devices
bt_adapter_discoverable adapter="hci0" on=false
bt_adapter_pairable adapter="hci0" on=false
```
## Adapter Properties Reference
| Property | Description |
|----------|-------------|
| `name` | System name (e.g., `hci0`) |
| `address` | MAC address |
| `alias` | Friendly name |
| `powered` | On/off state |
| `discoverable` | Visible to other devices |
| `discoverable_timeout` | Seconds until auto-hidden (0=forever) |
| `pairable` | Accepting new pairings |
| `pairable_timeout` | Seconds until auto-disable (0=forever) |
| `discovering` | Currently scanning |
| `uuids` | Supported Bluetooth profiles |
| `modalias` | Hardware identifier |
## Multiple Adapters
If you have multiple Bluetooth adapters (e.g., built-in + USB dongle):
```
# List all adapters
bt_list_adapters
# Configure each separately
bt_adapter_power adapter="hci0" on=true
bt_adapter_power adapter="hci1" on=false
```
<Aside type="tip">
Use different adapters for different purposes — one for audio, one for BLE sensors.
</Aside>
## Troubleshooting
### Adapter Not Found
Check if the kernel sees your hardware:
```bash
# List USB Bluetooth devices
lsusb | grep -i bluetooth
# Check kernel messages
dmesg | grep -i bluetooth
# Verify btusb module is loaded
lsmod | grep btusb
```
### Adapter Won't Power On
BlueZ might be blocked by rfkill:
```bash
# Check rfkill status
rfkill list bluetooth
# Unblock if blocked
rfkill unblock bluetooth
```
### Discovery Stops Unexpectedly
Discovery has a default timeout. For continuous scanning:
```
bt_scan adapter="hci0" timeout=60 mode="both"
```
The `bt_scan` tool handles starting and stopping discovery automatically.

View File

@ -0,0 +1,199 @@
---
title: Audio Control
description: Manage Bluetooth audio devices with PipeWire/PulseAudio integration
---
import { Aside } from '@astrojs/starlight/components';
mcbluetooth integrates with PipeWire and PulseAudio to provide seamless Bluetooth audio control.
## List Audio Devices
```
bt_audio_list
```
Returns all audio devices including Bluetooth:
```json
{
"sinks": [
{
"name": "bluez_sink.C8_7B_23_55_68_E8.a2dp_sink",
"description": "Bombay",
"bluetooth_address": "C8:7B:23:55:68:E8",
"volume": 65536,
"volume_percent": 75,
"muted": false,
"state": "running"
}
],
"sources": [...],
"cards": [...]
}
```
## Connect Audio
After pairing a device, connect its audio profiles:
```
bt_audio_connect adapter="hci0" address="C8:7B:23:55:68:E8"
```
This connects A2DP (high-quality audio) and/or HFP (hands-free) profiles.
## Audio Profiles
Bluetooth audio devices support different profiles:
| Profile | Quality | Microphone | Use Case |
|---------|---------|------------|----------|
| **A2DP** | High (stereo) | No | Music, videos |
| **HFP** | Low (mono) | Yes | Phone calls |
| **HSP** | Low (mono) | Yes | Legacy headsets |
### Switch Profiles
```
# High-quality stereo for music
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
# Hands-free for calls (enables microphone)
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="hfp"
# Disable audio (but stay connected for other profiles)
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="off"
```
<Aside type="tip">
Switch to HFP before a video call to enable the headset microphone, then back to A2DP for music.
</Aside>
## Set Default Output
Make a Bluetooth device the default audio output:
```
bt_audio_set_default address="C8:7B:23:55:68:E8"
```
## Volume Control
### Set Volume
```
# Set to 75%
bt_audio_volume address="C8:7B:23:55:68:E8" volume=75
# Boost to 120% (use carefully)
bt_audio_volume address="C8:7B:23:55:68:E8" volume=120
```
<Aside type="caution">
Volume above 100% may cause distortion or hearing damage.
</Aside>
### Mute/Unmute
```
# Mute
bt_audio_mute address="C8:7B:23:55:68:E8" muted=true
# Unmute
bt_audio_mute address="C8:7B:23:55:68:E8" muted=false
```
## Disconnect Audio
```
bt_audio_disconnect adapter="hci0" address="C8:7B:23:55:68:E8"
```
This disconnects audio profiles but keeps the device connected for other services.
## Common Workflows
### Connect Headphones for Music
```
# Connect and set up for music
bt_connect adapter="hci0" address="C8:7B:23:55:68:E8"
bt_audio_connect adapter="hci0" address="C8:7B:23:55:68:E8"
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
bt_audio_set_default address="C8:7B:23:55:68:E8"
bt_audio_volume address="C8:7B:23:55:68:E8" volume=70
```
### Prepare for Video Call
```
# Switch to HFP for microphone support
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="hfp"
```
### Return to Music After Call
```
# Switch back to A2DP for high-quality audio
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
```
## Troubleshooting
### No Sound from Device
1. Check the device is connected: `bt_device_info adapter="hci0" address="..."`
2. Verify audio profile is connected: `bt_audio_list`
3. Ensure it's set as default: `bt_audio_set_default address="..."`
4. Check volume isn't zero: `bt_audio_volume address="..." volume=70`
### Audio Cuts Out
- **Interference**: Move away from WiFi routers, microwaves
- **Distance**: Stay within 10 meters of the adapter
- **Battery**: Low battery can cause audio issues
- **Profile**: Try switching profiles and back
### Poor Audio Quality
```
# Ensure A2DP (not HFP) is active
bt_audio_set_profile address="..." profile="a2dp"
```
HFP is mono and lower quality — it's for calls, not music.
### Microphone Not Working
```
# Switch to HFP or HSP
bt_audio_set_profile address="..." profile="hfp"
```
A2DP doesn't support microphone input.
### Device Shows "Off" Profile
The device may need reconnection:
```
bt_audio_disconnect adapter="hci0" address="..."
bt_audio_connect adapter="hci0" address="..."
```
## Audio Codecs
Audio quality depends on the codec negotiated between devices:
| Codec | Quality | Latency | Notes |
|-------|---------|---------|-------|
| SBC | Good | Medium | Universal, default |
| AAC | Better | Medium | Apple devices |
| aptX | Better | Low | Qualcomm devices |
| aptX HD | Best | Low | High-res audio |
| LDAC | Best | Medium | Sony devices |
<Aside type="note">
Codec selection is automatic based on device capabilities. mcbluetooth doesn't directly control codec selection — this is handled by BlueZ and PipeWire.
</Aside>

View File

@ -0,0 +1,240 @@
---
title: BLE & GATT
description: Interact with Bluetooth Low Energy devices — sensors, fitness trackers, and IoT
---
import { Aside } from '@astrojs/starlight/components';
Bluetooth Low Energy (BLE) devices use GATT (Generic Attribute Profile) to expose services and characteristics. mcbluetooth provides tools to discover, read, write, and subscribe to BLE data.
## BLE Scanning
### Basic Scan
```
bt_ble_scan adapter="hci0" timeout=10
```
### Filtered Scan
```
# Filter by name
bt_ble_scan adapter="hci0" name_filter="Fitness"
# Filter by service UUID
bt_ble_scan adapter="hci0" service_filter="0000180d-0000-1000-8000-00805f9b34fb"
```
## GATT Structure
BLE devices organize data hierarchically:
```
Device
└── Service (UUID: 0000180d-...) ← Heart Rate Service
├── Characteristic (UUID: 00002a37-...) ← Heart Rate Measurement
│ └── Descriptor ← Client Configuration
└── Characteristic (UUID: 00002a38-...) ← Body Sensor Location
```
## Discover Services
After connecting:
```
bt_ble_services adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
Returns:
```json
[
{
"uuid": "0000180f-0000-1000-8000-00805f9b34fb",
"primary": true,
"description": "Battery Service"
},
{
"uuid": "0000180d-0000-1000-8000-00805f9b34fb",
"primary": true,
"description": "Heart Rate Service"
}
]
```
## List Characteristics
```
# All characteristics
bt_ble_characteristics adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Filter by service
bt_ble_characteristics adapter="hci0" address="..." service_uuid="0000180f-0000-1000-8000-00805f9b34fb"
```
Returns:
```json
[
{
"uuid": "00002a19-0000-1000-8000-00805f9b34fb",
"flags": ["read", "notify"],
"description": "Battery Level"
}
]
```
## Read Values
### Read Battery Level (Shortcut)
```
bt_ble_battery adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
Returns battery percentage (0-100).
### Read Any Characteristic
```
bt_ble_read adapter="hci0" address="..." char_uuid="00002a19-0000-1000-8000-00805f9b34fb"
```
Returns:
```json
{
"hex": "4b",
"decoded": 75,
"description": "Battery Level: 75%"
}
```
## Write Values
```
# Write hex bytes
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="0102ff" value_type="hex"
# Write string
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="hello" value_type="string"
# Write integer
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="42" value_type="int"
```
### Write with/without Response
```
# With response (default) - waits for acknowledgment
bt_ble_write ... with_response=true
# Without response (faster, less reliable)
bt_ble_write ... with_response=false
```
## Notifications
Subscribe to value changes:
### Enable Notifications
```
bt_ble_notify adapter="hci0" address="..." char_uuid="00002a37-..." enable=true
```
<Aside type="note">
After enabling notifications, the device will send updates when values change. Currently, mcbluetooth enables the notification mode but doesn't provide a callback mechanism — use protocol capture to see the actual notifications.
</Aside>
### Disable Notifications
```
bt_ble_notify adapter="hci0" address="..." char_uuid="..." enable=false
```
## Common UUIDs
### Standard Services
| Service | UUID | Description |
|---------|------|-------------|
| Generic Access | `0x1800` | Device name, appearance |
| Generic Attribute | `0x1801` | Service change indication |
| Battery | `0x180F` | Battery level |
| Device Information | `0x180A` | Manufacturer, model, etc. |
| Heart Rate | `0x180D` | Heart rate measurement |
| Health Thermometer | `0x1809` | Temperature |
| Blood Pressure | `0x1810` | Blood pressure |
### Standard Characteristics
| Characteristic | UUID | Service |
|----------------|------|---------|
| Battery Level | `0x2A19` | Battery |
| Heart Rate Measurement | `0x2A37` | Heart Rate |
| Temperature Measurement | `0x2A1C` | Health Thermometer |
| Manufacturer Name | `0x2A29` | Device Information |
| Model Number | `0x2A24` | Device Information |
<Aside type="tip">
Full UUIDs are `0000XXXX-0000-1000-8000-00805f9b34fb` where `XXXX` is the short UUID.
</Aside>
## Example: Heart Rate Monitor
```
# Scan for heart rate monitors
bt_ble_scan adapter="hci0" service_filter="0000180d-0000-1000-8000-00805f9b34fb"
# Connect
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# List services
bt_ble_services adapter="hci0" address="..."
# Enable heart rate notifications
bt_ble_notify adapter="hci0" address="..." char_uuid="00002a37-0000-1000-8000-00805f9b34fb" enable=true
# Read body sensor location
bt_ble_read adapter="hci0" address="..." char_uuid="00002a38-0000-1000-8000-00805f9b34fb"
```
## Example: Smart Light Bulb
```
# Connect to bulb
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Find the control characteristic (vendor-specific)
bt_ble_characteristics adapter="hci0" address="..."
# Write command to turn on (example - actual commands vary by device)
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="01" value_type="hex"
# Set color (RGB example)
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="ff0000" value_type="hex"
```
## Troubleshooting
### "ServicesResolved: false"
Services aren't discovered yet. Wait a moment after connecting:
```
bt_connect adapter="hci0" address="..."
# Wait 2-3 seconds
bt_ble_services adapter="hci0" address="..."
```
### Can't Read Characteristic
Check the characteristic flags:
- `read` must be present for reading
- `write` or `write-without-response` for writing
- `notify` for notifications
### Connection Drops Frequently
BLE has limited connection capacity. Try:
- Disconnecting other BLE devices
- Moving closer to the adapter
- Checking device battery level

View File

@ -0,0 +1,278 @@
---
title: Protocol Capture
description: Capture and analyze Bluetooth HCI traffic for debugging
---
import { Aside } from '@astrojs/starlight/components';
mcbluetooth can capture raw Bluetooth HCI (Host Controller Interface) traffic for protocol analysis and debugging.
## Overview
HCI captures record all communication between the Bluetooth stack and hardware adapter. This includes:
- Device discovery packets
- Pairing and authentication
- Connection management
- Audio streaming data
- BLE GATT operations
## Starting a Capture
```
bt_capture_start output_file="/tmp/bluetooth.btsnoop"
```
Returns:
```json
{
"status": "started",
"capture_id": "capture_abc123",
"output_file": "/tmp/bluetooth.btsnoop"
}
```
### Capture Options
```
# Capture from specific adapter
bt_capture_start output_file="/tmp/hci0.btsnoop" adapter="0"
# Include voice data (SCO)
bt_capture_start output_file="/tmp/calls.btsnoop" include_sco=true
# Include audio streaming (A2DP)
bt_capture_start output_file="/tmp/audio.btsnoop" include_a2dp=true
# Include LE Audio (ISO)
bt_capture_start output_file="/tmp/le_audio.btsnoop" include_iso=true
```
<Aside type="note">
Audio captures (`include_sco`, `include_a2dp`, `include_iso`) generate large files quickly.
</Aside>
## Stopping a Capture
```
bt_capture_stop capture_id="capture_abc123"
```
Returns:
```json
{
"status": "stopped",
"output_file": "/tmp/bluetooth.btsnoop",
"packets_captured": 1542,
"file_size": 245760
}
```
## Listing Active Captures
```
bt_capture_list_active
```
## Analyzing Captures
### Quick Parse
```
bt_capture_parse filepath="/tmp/bluetooth.btsnoop"
```
Returns packet summaries:
```json
{
"total_packets": 1542,
"packet_types": {
"HCI_CMD": 234,
"HCI_EVENT": 456,
"ACL_DATA": 852
},
"packets": [
{
"index": 0,
"timestamp": "2024-01-15T10:30:00.123456",
"type": "HCI_CMD",
"direction": "TX",
"summary": "Inquiry"
}
]
}
```
### Filtered Parse
```
# Only HCI commands
bt_capture_parse filepath="..." packet_type_filter="HCI_CMD"
# Only received packets
bt_capture_parse filepath="..." direction_filter="RX"
# Limit results
bt_capture_parse filepath="..." max_packets=100
```
### Detailed Analysis
```
bt_capture_analyze filepath="/tmp/bluetooth.btsnoop"
```
Returns high-level statistics:
```json
{
"duration_seconds": 45.2,
"total_packets": 1542,
"protocols": {
"L2CAP": 423,
"RFCOMM": 156,
"SDP": 34,
"ATT": 289
},
"connections": [
{
"address": "AA:BB:CC:DD:EE:FF",
"packets": 892,
"bytes": 45678
}
]
}
```
### Raw Packet Decoding
```
bt_capture_read_raw filepath="/tmp/bluetooth.btsnoop" offset=0 count=50
```
Returns btmon-style decoded output for detailed inspection.
## Common Workflows
### Debug Pairing Issues
```
# Start capture
bt_capture_start output_file="/tmp/pairing_debug.btsnoop"
# Attempt pairing
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Stop and analyze
bt_capture_stop capture_id="..."
bt_capture_parse filepath="/tmp/pairing_debug.btsnoop" packet_type_filter="HCI_EVENT"
```
### Investigate Audio Glitches
```
# Capture with A2DP data
bt_capture_start output_file="/tmp/audio_debug.btsnoop" include_a2dp=true
# Play audio for 30 seconds
# Stop and check for errors
bt_capture_stop capture_id="..."
bt_capture_analyze filepath="/tmp/audio_debug.btsnoop"
```
### Monitor BLE Sensor
```
# Start capture
bt_capture_start output_file="/tmp/ble_sensor.btsnoop"
# Connect and interact with sensor
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
bt_ble_read adapter="hci0" address="..." char_uuid="..."
# Analyze ATT/GATT traffic
bt_capture_stop capture_id="..."
bt_capture_parse filepath="/tmp/ble_sensor.btsnoop"
```
## File Format
Captures are saved in **btsnoop** format, compatible with:
| Tool | Usage |
|------|-------|
| **Wireshark** | Full GUI analysis |
| **btmon** | Command-line decode |
| **hcidump** | Legacy analysis |
### Open in Wireshark
```bash
wireshark /tmp/bluetooth.btsnoop
```
Wireshark provides:
- Protocol dissection
- Conversation tracking
- Expert analysis
- Export options
## Requirements
<Aside type="caution">
Capture requires elevated privileges (root or `CAP_NET_RAW`).
</Aside>
The capture functionality uses `btmon` which needs access to the Bluetooth monitor socket:
```bash
# Option 1: Run as root
sudo btmon
# Option 2: Add capability (one-time setup)
sudo setcap cap_net_raw+ep /usr/bin/btmon
```
## Troubleshooting
### "Permission Denied"
```bash
# Check btmon capability
getcap /usr/bin/btmon
# Add if missing
sudo setcap cap_net_raw+ep /usr/bin/btmon
```
### Capture File Empty
- Ensure adapter is active: `bt_adapter_power adapter="hci0" on=true`
- Verify Bluetooth activity is occurring
- Check btmon is running: `bt_capture_list_active`
### Very Large Files
Audio data generates significant traffic:
| Content | Approximate Size |
|---------|------------------|
| Discovery/pairing | ~10 KB/min |
| BLE sensors | ~50 KB/min |
| A2DP audio | ~2 MB/min |
| HFP calls | ~500 KB/min |
Use without audio flags for general debugging:
```
bt_capture_start output_file="..." include_sco=false include_a2dp=false
```
### Can't Open in Wireshark
Verify file format:
```bash
file /tmp/capture.btsnoop
# Should show: BTSnoop version 1, HCI UART (H4)
```
If corrupted, the capture may have been interrupted. Always use `bt_capture_stop` to properly close files.

View File

@ -0,0 +1,174 @@
---
title: HFP Audio Gateway
description: Use Linux as a phone to test Bluetooth headsets with call simulation
---
import { Aside } from '@astrojs/starlight/components';
mcbluetooth can act as an HFP Audio Gateway (the "phone" role), allowing you to test Bluetooth headsets and hands-free devices by simulating calls and controlling audio indicators.
## HFP Roles
HFP (Hands-Free Profile) defines two roles:
| Role | Description | Example Device |
|------|-------------|----------------|
| **AG** (Audio Gateway) | The phone side — originates calls, sends ring signals | Phone, Linux (with mcbluetooth) |
| **HF** (Hands-Free) | The headset side — answers calls, controls volume | Headset, car kit, ESP32 test device |
mcbluetooth implements the AG role, so headsets connect to Linux as if it were a phone.
## Enable the AG Profile
```
bt_hfp_ag_enable
```
This registers a custom HFP AG profile with BlueZ via the ProfileManager1 D-Bus interface. Headsets can now discover and connect to Linux.
<Aside type="tip">
Enable the AG profile before the headset tries to connect. The SLC (Service Level Connection) negotiation happens automatically when a headset connects.
</Aside>
## Connection Flow
When a headset connects, the following happens automatically:
```
1. HF connects RFCOMM → BlueZ calls NewConnection
2. AT+BRSF exchange → Both sides share feature flags
3. AT+CIND=? / AT+CIND? → Indicator mapping and values
4. AT+CMER=3,0,0,1 → Enable indicator reporting → SLC established
5. AT+BAC / +BCS → Codec negotiation (CVSD or mSBC)
```
Check the connection status:
```
bt_hfp_ag_status
```
## Simulate an Incoming Call
Once a headset is connected with SLC established:
```
bt_hfp_ag_simulate_call address="AA:BB:CC:DD:EE:FF" number="+15551234567"
```
The headset receives:
- **RING** alerts every 3 seconds
- **+CLIP** with caller ID information
The headset can:
- Answer with **ATA** (call becomes active)
- Reject with **AT+CHUP** (call is ended)
### End a Call
From the AG (Linux) side:
```
bt_hfp_ag_end_call address="AA:BB:CC:DD:EE:FF"
```
This terminates both ringing and active calls, updating the headset's indicators.
## Control Volume
HFP volume uses a 0-15 scale:
```
# Set speaker volume
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="speaker" level=12
# Set microphone gain
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="microphone" level=10
```
<Aside type="note">
The headset can also change volume — it sends AT+VGS / AT+VGM commands which the AG acknowledges automatically.
</Aside>
## Update Status Indicators
Simulate phone status changes shown on the headset display:
```
# Signal strength (0-5)
bt_hfp_ag_set_signal address="AA:BB:CC:DD:EE:FF" level=4
# Battery level (0-5)
bt_hfp_ag_set_battery address="AA:BB:CC:DD:EE:FF" level=3
```
These send +CIEV indicator updates to the headset.
## HFP Indicators
The AG maintains 7 standard indicators, reported to the HF device:
| Index | Name | Range | Description |
|-------|------|-------|-------------|
| 1 | service | 0-1 | Network service available |
| 2 | call | 0-1 | Active call exists |
| 3 | callsetup | 0-3 | 0=none, 1=incoming, 2=outgoing, 3=alerting |
| 4 | callheld | 0-2 | 0=none, 1=held+active, 2=held only |
| 5 | signal | 0-5 | Signal strength |
| 6 | roam | 0-1 | Roaming active |
| 7 | battchg | 0-5 | Battery charge level |
## E2E Testing with ESP32
The HFP AG tools are designed for end-to-end testing with the [mcbluetooth-esp32](https://github.com/supported-systems/mcbluetooth-esp32) test harness, where the ESP32 acts as the HF (headset) device.
### Typical Test Flow
```
# 1. Enable AG on Linux
bt_hfp_ag_enable
# 2. ESP32 connects as HF (from mcbluetooth-esp32)
# SLC auto-negotiated
# 3. Linux simulates incoming call
bt_hfp_ag_simulate_call address="<esp32>" number="5551234567"
# 4. ESP32 answers (sends ATA)
# Call becomes active
# 5. Linux ends call
bt_hfp_ag_end_call address="<esp32>"
# 6. Check status
bt_hfp_ag_status
```
## AT Commands Handled
The AG automatically handles these AT commands from the HF device:
| Command | Purpose |
|---------|---------|
| AT+BRSF | Feature exchange |
| AT+CIND | Indicator mapping/values |
| AT+CMER | Enable indicator reporting |
| AT+CHLD | Call hold/multiparty support |
| AT+BAC / AT+BCS | Codec negotiation |
| ATA | Answer call |
| AT+CHUP | Hang up / reject |
| ATD | Outgoing call (from HF) |
| AT+VGS / AT+VGM | Volume control |
| AT+CLCC | List current calls |
| AT+COPS | Operator name query |
| AT+CNUM | Subscriber number |
| AT+BVRA | Voice recognition |
| AT+NREC | Noise reduction |
## Disable the AG Profile
```
bt_hfp_ag_disable
```
This unregisters the profile, disconnecting any active HFP sessions.

View File

@ -0,0 +1,270 @@
---
title: OBEX File Transfer
description: Send files, browse phone storage, and download data using OBEX profiles
---
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
OBEX (Object Exchange) enables file transfer over Bluetooth. mcbluetooth supports four OBEX profiles:
| Profile | Code | Purpose |
|---------|------|---------|
| **OPP** | Object Push | Simple file sending |
| **FTP** | File Transfer | Full file browsing |
| **PBAP** | Phonebook Access | Read contacts (see [Phonebook guide](/guides/phonebook-messages/)) |
| **MAP** | Message Access | Read SMS/MMS (see [Phonebook guide](/guides/phonebook-messages/)) |
## Prerequisites
OBEX requires the `obexd` daemon:
<Tabs>
<TabItem label="Arch Linux">
```bash
sudo pacman -S bluez-obex
```
</TabItem>
<TabItem label="Debian/Ubuntu">
```bash
sudo apt install bluez-obex
```
</TabItem>
</Tabs>
Verify installation:
```
bt_obex_status
```
If obexd isn't running:
```
bt_obex_start_daemon
```
<Aside type="note">
obexd runs on the session D-Bus and requires an active desktop session.
</Aside>
## Quick File Send (OPP)
The simplest way to send a file:
```
bt_obex_send_file address="AA:BB:CC:DD:EE:FF" file_path="~/Documents/report.pdf"
```
- Creates a temporary OPP session
- Sends the file (recipient sees accept prompt)
- Waits for completion
- Closes the session
### Non-blocking Send
For large files, start transfer without waiting:
```
bt_obex_send_file address="..." file_path="large_video.mp4" wait=false
```
Returns a `transfer_path` to monitor progress:
```
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
```
## Pull Business Card (OPP)
Get the device's default vCard:
```
bt_obex_get_vcard address="AA:BB:CC:DD:EE:FF" save_path="~/contact.vcf"
```
## File Browsing (FTP)
For full file system access, create an FTP session:
### Create Session
```
bt_obex_connect address="AA:BB:CC:DD:EE:FF" target="ftp"
```
Returns:
```json
{
"success": true,
"session_id": "ftp_AABBCCDDEEFF",
"address": "AA:BB:CC:DD:EE:FF",
"target": "ftp"
}
```
### Browse Folders
```
# List root
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="/"
# Navigate to folder
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="DCIM"
# Go up
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path=".."
```
Returns:
```json
{
"entries": [
{"name": "DCIM", "type": "folder"},
{"name": "Download", "type": "folder"},
{"name": "document.pdf", "type": "file", "size": 102400}
]
}
```
### Download Files
```
bt_obex_get session_id="ftp_..." remote_path="photo.jpg" local_path="~/Downloads/photo.jpg"
```
### Upload Files
```
bt_obex_put session_id="ftp_..." local_path="~/document.pdf" remote_path="document.pdf"
```
### Create Folder
```
bt_obex_mkdir session_id="ftp_..." folder_name="Backup"
```
### Delete Files
```
bt_obex_delete session_id="ftp_..." remote_path="old_file.txt"
```
### Close Session
Always close when done:
```
bt_obex_disconnect session_id="ftp_AABBCCDDEEFF"
```
## Session Management
### List Active Sessions
```
bt_obex_sessions
```
### Check OBEX Status
```
bt_obex_status
```
Returns:
```json
{
"status": "ready",
"obexd_installed": true,
"obexd_running": true,
"dbus_accessible": true,
"active_sessions": [...]
}
```
## Transfer Monitoring
### Check Progress
```
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
```
Returns:
```json
{
"status": "active",
"size": 104857600,
"transferred": 52428800,
"progress_percent": 50
}
```
### Cancel Transfer
```
bt_obex_transfer_cancel transfer_path="..."
```
## Common Workflows
### Backup Phone Photos
```
# Connect FTP session
bt_obex_connect address="..." target="ftp"
# Navigate to photos
bt_obex_browse session_id="ftp_..." path="/"
bt_obex_browse session_id="ftp_..." path="DCIM"
bt_obex_browse session_id="ftp_..." path="Camera"
# Download each photo
bt_obex_get session_id="ftp_..." remote_path="IMG_001.jpg" local_path="~/backup/"
bt_obex_get session_id="ftp_..." remote_path="IMG_002.jpg" local_path="~/backup/"
# Close session
bt_obex_disconnect session_id="ftp_..."
```
### Share Document
```
# Simple send
bt_obex_send_file address="..." file_path="~/report.pdf"
```
## Device Compatibility
| Device Type | OPP | FTP |
|-------------|-----|-----|
| Android phones | ✓ | Varies |
| iPhones | ✓ | ✗ |
| Feature phones | ✓ | ✓ |
| Windows PCs | ✓ | ✓ |
| macOS | ✓ | ✗ |
<Aside type="note">
FTP support varies by Android version and manufacturer. Some require enabling "File Transfer" mode in Bluetooth settings.
</Aside>
## Troubleshooting
### "Device rejected connection"
- Ensure device is paired first
- Check device has OBEX enabled in Bluetooth settings
- Some devices require explicit file sharing permission
### "NotSupported"
The device doesn't support the requested profile. Try OPP instead of FTP.
### Transfer Stuck at 0%
The receiving device may be showing an accept prompt. Check its screen.
### Session Disappeared
Sessions are tied to obexd. If it restarts, create a new session.

View File

@ -0,0 +1,237 @@
---
title: Device Pairing
description: Pair Bluetooth devices with automatic PIN and passkey handling
---
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
Pairing establishes a trusted relationship between devices, enabling secure communication. mcbluetooth includes a smart pairing agent that handles all Bluetooth pairing methods.
## Scanning for Devices
Before pairing, discover available devices:
```
bt_scan adapter="hci0" timeout=10 mode="both"
```
**Scan modes:**
- `classic` — Traditional Bluetooth (BR/EDR)
- `ble` — Bluetooth Low Energy only
- `both` — Both types (default)
## Initiating Pairing
### Basic Pairing
```
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
### Pairing Modes
<Tabs>
<TabItem label="Interactive (default)">
```
bt_pair adapter="hci0" address="..." pairing_mode="interactive"
```
Returns immediately with status. If confirmation needed, use `bt_pair_confirm` to respond.
</TabItem>
<TabItem label="Auto">
```
bt_pair adapter="hci0" address="..." pairing_mode="auto"
```
Automatically accepts all pairing requests. Use only in trusted environments.
</TabItem>
<TabItem label="Elicit">
```
bt_pair adapter="hci0" address="..." pairing_mode="elicit"
```
Uses MCP elicitation to prompt the user directly (if the MCP client supports it).
</TabItem>
</Tabs>
## Pairing Methods
Bluetooth uses different pairing methods based on device capabilities:
| Method | Description | Agent Action |
|--------|-------------|--------------|
| **Just Works** | No user interaction | Auto-accepted |
| **Numeric Comparison** | Confirm 6-digit code matches | Show code, await confirmation |
| **Passkey Entry** | Enter code shown on other device | Prompt for passkey |
| **Legacy PIN** | Enter 4-6 digit PIN | Prompt for PIN |
### Handling Confirmation Requests
When pairing requires confirmation:
```
# Check for pending requests
bt_pairing_status
# Confirm with passkey (if needed)
bt_pair_confirm adapter="hci0" address="..." passkey=123456 accept=true
# Or reject
bt_pair_confirm adapter="hci0" address="..." accept=false
```
## Managing Paired Devices
### List Paired Devices
```
bt_list_devices adapter="hci0" filter="paired"
```
### Remove Pairing
```
bt_unpair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
This removes the device from known devices and deletes all pairing information.
## Connection Management
### Connect to Paired Device
```
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
### Disconnect
```
bt_disconnect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
<Aside type="note">
Disconnecting preserves the pairing. The device can reconnect later.
</Aside>
## Trust and Security
### Trust a Device
Trusted devices can connect automatically without explicit authorization:
```
bt_trust adapter="hci0" address="..." trusted=true
```
### Untrust
```
bt_trust adapter="hci0" address="..." trusted=false
```
### Block a Device
Prevent a device from connecting:
```
bt_block adapter="hci0" address="..." blocked=true
```
### Unblock
```
bt_block adapter="hci0" address="..." blocked=false
```
## Device Information
### Get Device Details
```
bt_device_info adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
Returns:
```json
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "Bose NCH700",
"alias": "My Headphones",
"paired": true,
"bonded": true,
"trusted": true,
"connected": true,
"uuids": ["0000110b-...", "0000110e-..."],
...
}
```
### Set Device Name
```
bt_device_set_alias adapter="hci0" address="..." alias="My Headphones"
```
## Common Workflows
### Pair New Headphones
```
# Put headphones in pairing mode first!
# Scan for them
bt_scan adapter="hci0" timeout=15
# Pair
bt_pair adapter="hci0" address="C8:7B:23:55:68:E8"
# Trust for auto-reconnect
bt_trust adapter="hci0" address="C8:7B:23:55:68:E8" trusted=true
# Connect
bt_connect adapter="hci0" address="C8:7B:23:55:68:E8"
```
### Pair a Phone for OBEX
```
# Scan and pair
bt_scan adapter="hci0"
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Both devices may show confirmation prompts - accept on both
# After pairing, OBEX profiles become available
bt_obex_status
```
## Troubleshooting
### Pairing Fails Immediately
- Ensure the device is in pairing mode
- Check device isn't already paired (try `bt_unpair` first)
- Verify adapter is powered and pairable
### "Authentication Rejected"
- Device may have reached its pairing limit
- Try resetting Bluetooth on the other device
- Delete the pairing on both sides and retry
### Pairing Stuck
```
# Check for pending requests
bt_pairing_status
# Cancel if stuck
bt_pair_confirm adapter="hci0" address="..." accept=false
```
### Device Connects Then Disconnects
- Profile incompatibility (e.g., device doesn't support expected audio profile)
- Check device UUIDs with `bt_device_info`
- Try connecting specific profile with `bt_connect_profile`

View File

@ -0,0 +1,265 @@
---
title: Phonebook & Messages
description: Access contacts and SMS using PBAP and MAP profiles
---
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
PBAP (Phonebook Access Profile) and MAP (Message Access Profile) let you read contacts and messages from paired phones.
<Aside type="note">
Both profiles require the device to be paired first. See [Device Pairing](/guides/pairing/).
</Aside>
## Prerequisites
These profiles use OBEX. Ensure obexd is installed:
```
bt_obex_status
```
If not ready:
<Tabs>
<TabItem label="Arch Linux">
```bash
sudo pacman -S bluez-obex
```
</TabItem>
<TabItem label="Debian/Ubuntu">
```bash
sudo apt install bluez-obex
```
</TabItem>
</Tabs>
## Phonebook Access (PBAP)
### Download Entire Phonebook
```
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/contacts.vcf"
```
This downloads all contacts as a single vCard file.
### List Contacts
```
bt_phonebook_list address="AA:BB:CC:DD:EE:FF"
```
Returns:
```json
{
"entries": [
{"handle": "1.vcf", "name": "Alice Smith"},
{"handle": "2.vcf", "name": "Bob Jones"},
...
],
"count": 150
}
```
### Get Single Contact
```
bt_phonebook_get address="..." handle="1.vcf" save_path="~/alice.vcf"
```
### Search Contacts
```
# Search by name
bt_phonebook_search address="..." field="name" value="Smith"
# Search by phone number
bt_phonebook_search address="..." field="number" value="+1555"
```
### Count Contacts
```
bt_phonebook_count address="AA:BB:CC:DD:EE:FF"
```
Returns total number of contacts without downloading them.
### Phonebook Folders
PBAP provides access to different phonebook locations:
| Folder | Contents |
|--------|----------|
| `telecom/pb` | Main phonebook (default) |
| `telecom/ich` | Incoming call history |
| `telecom/och` | Outgoing call history |
| `telecom/mch` | Missed call history |
| `telecom/cch` | Combined call history |
| `SIM1/telecom/pb` | SIM card contacts |
```
bt_phonebook_list address="..." folder="telecom/ich"
```
## Message Access (MAP)
### List Message Folders
```
bt_messages_folders address="AA:BB:CC:DD:EE:FF"
```
Returns:
```json
{
"folders": [
{"name": "inbox"},
{"name": "sent"},
{"name": "drafts"},
{"name": "outbox"},
{"name": "deleted"}
]
}
```
### List Messages
```
# All inbox messages
bt_messages_list address="..." folder="inbox"
# Unread only
bt_messages_list address="..." folder="inbox" unread_only=true
# Limit results
bt_messages_list address="..." folder="inbox" max_count=50
```
Returns:
```json
{
"messages": [
{
"handle": "msg001",
"subject": "Meeting tomorrow",
"sender": "+15551234567",
"timestamp": "2024-01-15T10:30:00",
"read": false,
"type": "SMS"
}
]
}
```
### Download Message
```
bt_messages_get address="..." handle="msg001" save_path="~/message.txt"
```
### Send Message
<Aside type="caution">
Message sending support varies by device. Many phones only allow read access.
</Aside>
```
bt_messages_send address="..." recipient="+15559876543" message="Hello from mcbluetooth!"
```
## Common Workflows
### Backup All Contacts
```
# Download as vCard (can import into any contact app)
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/phone_backup_$(date +%Y%m%d).vcf"
```
### Export Call History
```
# Get incoming calls
bt_phonebook_list address="..." folder="telecom/ich"
# Get missed calls
bt_phonebook_list address="..." folder="telecom/mch"
```
### Find Contact by Phone Number
```
bt_phonebook_search address="..." field="number" value="555-1234"
```
### Archive Text Messages
```
# List all sent messages
bt_messages_list address="..." folder="sent"
# Download specific message
bt_messages_get address="..." handle="msg042" save_path="~/messages/msg042.txt"
```
## Device Compatibility
### PBAP Support
| Device | Support |
|--------|---------|
| Android phones | ✓ Full |
| iPhones | ✓ Full (when paired) |
| Feature phones | ✓ Usually |
| Car systems | ✓ Often (receive only) |
### MAP Support
| Device | Read | Send |
|--------|------|------|
| Android | ✓ | Varies |
| iPhone | ✗ | ✗ |
| Feature phones | Varies | Varies |
<Aside type="note">
iPhone doesn't support MAP. Use iCloud or other sync methods for messages.
</Aside>
## Troubleshooting
### "NotAuthorized" Error
The phone is blocking access. Check:
1. Phone screen for permission prompt
2. Bluetooth settings → paired device → enable phonebook/message access
### Empty Phonebook
Some phones require explicit permission:
- **Android**: Settings → Apps → Bluetooth → Permissions → Contacts
- **iPhone**: Settings → Bluetooth → [device] → Allow Contact Access
### MAP Connection Fails
1. Verify device supports MAP: `bt_device_info adapter="hci0" address="..."`
2. Check for MAP UUID in the UUIDs list
3. Some devices need MAP enabled in Bluetooth settings
### Slow Downloads
Large phonebooks (1000+ contacts) take time:
- PBAP downloads are sequential
- Consider using `bt_phonebook_count` first to estimate
- vCard format is verbose
## Privacy Considerations
<Aside type="caution">
These tools access personal data. Use responsibly and only on devices you own or have explicit permission to access.
</Aside>
- Phonebook and message access requires active pairing
- Devices typically prompt for permission on first access
- Permission can be revoked on the phone at any time

View File

@ -0,0 +1,105 @@
---
title: mcbluetooth
description: Give LLMs control of your Linux Bluetooth stack
template: splash
hero:
tagline: A comprehensive MCP server that exposes the Linux Bluetooth stack to LLMs. Control adapters, pair devices, manage audio, read BLE sensors, and transfer files — all through natural language.
image:
file: ../../assets/logo-dark.svg
actions:
- text: Get Started
link: /getting-started/introduction/
icon: right-arrow
- text: View on GitHub
link: https://github.com/yourusername/mcbluetooth
icon: external
variant: minimal
---
import { Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components';
## Features
<CardGrid stagger>
<Card title="Full BlueZ Integration" icon="bluetooth">
Adapters, devices, pairing, audio profiles, and BLE/GATT — the complete Linux Bluetooth stack at your fingertips.
</Card>
<Card title="Smart Pairing Agent" icon="shield">
Handles PIN codes, passkeys, and numeric comparison automatically. Supports interactive, auto, and elicitation modes.
</Card>
<Card title="Audio Control" icon="headphones">
PipeWire and PulseAudio integration for seamless Bluetooth audio management. Switch profiles, adjust volume, set defaults.
</Card>
<Card title="OBEX Profiles" icon="folder">
File transfer (OPP/FTP), phonebook access (PBAP), and message access (MAP) for connected phones and devices.
</Card>
<Card title="BLE & GATT" icon="cpu">
Read sensors, write commands, subscribe to notifications. Full support for Bluetooth Low Energy devices.
</Card>
<Card title="Protocol Analysis" icon="magnifier">
Capture and analyze Bluetooth traffic with btmon integration. Debug connections and reverse engineer protocols.
</Card>
</CardGrid>
## Example Conversation
```
You: Scan for Bluetooth devices and connect to my Bose headphones
Claude: I'll scan for nearby Bluetooth devices.
[Calling bt_scan with adapter="hci0", timeout=10, mode="both"]
Found 5 devices:
• Bombay (C8:7B:23:55:68:E8) - Bose NCH700, paired
• DS18 BT-TWO (20:23:05:26:02:31) - Audio device
• iPhone (AA:BB:CC:DD:EE:FF) - Phone
...
I found your Bose headphones "Bombay". Connecting now...
[Calling bt_connect with adapter="hci0", address="C8:7B:23:55:68:E8"]
✓ Connected to Bombay! The headphones are ready. Would you like me
to set them as the default audio output?
```
## Quick Install
<Tabs>
<TabItem label="uvx (recommended)">
```bash
# Run directly
uvx mcbluetooth
# Or install globally
uv tool install mcbluetooth
```
</TabItem>
<TabItem label="From source">
```bash
git clone https://github.com/yourusername/mcbluetooth
cd mcbluetooth
uv sync
uv run mcbluetooth
```
</TabItem>
</Tabs>
## Add to Claude Code
```bash
claude mcp add mcbluetooth -- uvx mcbluetooth
```
## Tool Categories
| Category | Tools | Description |
|----------|-------|-------------|
| **Adapter** | 6 | Power, discoverable, pairable, alias |
| **Device** | 12 | Scan, pair, connect, trust, block |
| **Audio** | 7 | Profiles, volume, mute, default sink |
| **BLE** | 7 | GATT services, characteristics, notify |
| **Monitor** | 6 | HCI capture, parse, analyze |
| **OBEX** | 23 | File transfer, phonebook, messages |
| **Total** | **61** | Complete Bluetooth control |

View File

@ -0,0 +1,217 @@
---
title: Adapter Tools
description: Reference for Bluetooth adapter management tools
---
Tools for managing Bluetooth hardware adapters.
## bt_list_adapters
List all Bluetooth adapters on the system.
**Parameters:** None
**Returns:**
```json
[
{
"name": "hci0",
"address": "AA:BB:CC:DD:EE:FF",
"alias": "My Laptop",
"powered": true,
"discoverable": false,
"discoverable_timeout": 180,
"pairable": true,
"pairable_timeout": 0,
"discovering": false
}
]
```
**Example:**
```
bt_list_adapters
```
---
## bt_adapter_info
Get detailed information about a specific adapter.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name (e.g., "hci0") |
**Returns:**
```json
{
"name": "hci0",
"address": "AA:BB:CC:DD:EE:FF",
"alias": "My Laptop",
"class": 7995916,
"powered": true,
"discoverable": false,
"discoverable_timeout": 180,
"pairable": true,
"pairable_timeout": 0,
"discovering": false,
"uuids": [
"0000110a-0000-1000-8000-00805f9b34fb",
"0000110c-0000-1000-8000-00805f9b34fb"
],
"modalias": "usb:v1D6Bp0246d0540"
}
```
**Example:**
```
bt_adapter_info adapter="hci0"
```
---
## bt_adapter_power
Power an adapter on or off.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `on` | boolean | Yes | true to power on, false to power off |
**Returns:** Updated adapter info
**Example:**
```
bt_adapter_power adapter="hci0" on=true
```
**Notes:**
- Powering off disconnects all devices
- Stops any ongoing discovery
---
## bt_adapter_discoverable
Set adapter discoverable (visible to other devices).
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `on` | boolean | Yes | - | Enable/disable discoverable |
| `timeout` | integer | No | 180 | Seconds until auto-hidden (0 = forever) |
**Returns:** Updated adapter info
**Example:**
```
# Discoverable for 5 minutes
bt_adapter_discoverable adapter="hci0" on=true timeout=300
# Discoverable forever (use carefully)
bt_adapter_discoverable adapter="hci0" on=true timeout=0
# Hide from other devices
bt_adapter_discoverable adapter="hci0" on=false
```
---
## bt_adapter_pairable
Set adapter pairable state.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `on` | boolean | Yes | - | Enable/disable pairing acceptance |
| `timeout` | integer | No | 0 | Seconds until auto-disable (0 = forever) |
**Returns:** Updated adapter info
**Example:**
```
# Accept pairings for 5 minutes
bt_adapter_pairable adapter="hci0" on=true timeout=300
# Stop accepting pairings
bt_adapter_pairable adapter="hci0" on=false
```
---
## bt_adapter_set_alias
Set adapter friendly name (alias).
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `alias` | string | Yes | New friendly name |
**Returns:** Updated adapter info
**Example:**
```
bt_adapter_set_alias adapter="hci0" alias="Ryan's Desktop"
```
---
## bt_scan
Scan for nearby Bluetooth devices.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `timeout` | integer | No | 10 | Scan duration in seconds |
| `mode` | string | No | "both" | Scan mode: "classic", "ble", or "both" |
**Returns:**
```json
[
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "My Device",
"alias": "My Device",
"paired": false,
"connected": false,
"rssi": -65,
"uuids": ["0000110a-..."],
"manufacturer_data": {...},
"service_data": {...}
}
]
```
**Example:**
```
# Standard scan
bt_scan adapter="hci0" timeout=10
# BLE only
bt_scan adapter="hci0" timeout=15 mode="ble"
# Classic only (faster for headphones, speakers)
bt_scan adapter="hci0" timeout=10 mode="classic"
```
**Notes:**
- Starts discovery, waits for timeout, then stops
- BLE devices must be advertising to be found
- Returns devices discovered during the scan period

View File

@ -0,0 +1,256 @@
---
title: Audio Tools
description: Reference for Bluetooth audio management tools
---
Tools for managing Bluetooth audio with PipeWire/PulseAudio integration.
## bt_audio_list
List all audio devices including Bluetooth.
**Parameters:** None
**Returns:**
```json
{
"sinks": [
{
"name": "bluez_sink.C8_7B_23_55_68_E8.a2dp_sink",
"description": "Bose Headphones",
"bluetooth_address": "C8:7B:23:55:68:E8",
"volume": 65536,
"volume_percent": 75,
"muted": false,
"state": "running"
}
],
"sources": [
{
"name": "bluez_source.C8_7B_23_55_68_E8.hfp_ag",
"description": "Bose Headphones (Microphone)",
"bluetooth_address": "C8:7B:23:55:68:E8",
"volume": 65536,
"volume_percent": 100,
"muted": false,
"state": "idle"
}
],
"cards": [
{
"name": "bluez_card.C8_7B_23_55_68_E8",
"bluetooth_address": "C8:7B:23:55:68:E8",
"active_profile": "a2dp-sink",
"profiles": ["a2dp-sink", "hfp-hf", "off"]
}
]
}
```
**Example:**
```
bt_audio_list
```
---
## bt_audio_connect
Connect audio profiles to a Bluetooth device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"status": "connected",
"address": "C8:7B:23:55:68:E8",
"profiles": ["a2dp-sink", "hfp-hf"]
}
```
**Example:**
```
bt_audio_connect adapter="hci0" address="C8:7B:23:55:68:E8"
```
**Notes:**
- Connects A2DP (high-quality audio) and/or HFP (hands-free) profiles
- Device must be paired first
- Automatically registers with PipeWire/PulseAudio
---
## bt_audio_disconnect
Disconnect audio profiles from a Bluetooth device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"status": "disconnected",
"address": "C8:7B:23:55:68:E8"
}
```
**Example:**
```
bt_audio_disconnect adapter="hci0" address="C8:7B:23:55:68:E8"
```
**Notes:**
- Disconnects audio profiles only
- Device remains connected for other services
---
## bt_audio_set_profile
Switch audio profile for a Bluetooth device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `profile` | string | Yes | Profile: "a2dp", "hfp", or "off" |
**Profiles:**
| Profile | Quality | Microphone | Use Case |
|---------|---------|------------|----------|
| `a2dp` | High (stereo) | No | Music, videos |
| `hfp` | Low (mono) | Yes | Phone calls |
| `off` | - | - | Disable audio |
**Returns:**
```json
{
"status": "profile_changed",
"address": "C8:7B:23:55:68:E8",
"profile": "a2dp"
}
```
**Example:**
```
# High-quality for music
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="a2dp"
# Enable microphone for calls
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="hfp"
# Disable audio (stay connected for other profiles)
bt_audio_set_profile address="C8:7B:23:55:68:E8" profile="off"
```
---
## bt_audio_set_default
Set a Bluetooth device as the default audio output.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"status": "default_set",
"address": "C8:7B:23:55:68:E8",
"sink": "bluez_sink.C8_7B_23_55_68_E8.a2dp_sink"
}
```
**Example:**
```
bt_audio_set_default address="C8:7B:23:55:68:E8"
```
**Notes:**
- All system audio routes to this device
- Persists until changed or device disconnects
---
## bt_audio_volume
Set volume for a Bluetooth audio device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `volume` | integer | Yes | Volume level (0-100, can go up to 150) |
**Returns:**
```json
{
"status": "volume_set",
"address": "C8:7B:23:55:68:E8",
"volume": 75
}
```
**Example:**
```
# Normal volume
bt_audio_volume address="C8:7B:23:55:68:E8" volume=75
# Boost (may distort)
bt_audio_volume address="C8:7B:23:55:68:E8" volume=120
```
**Notes:**
- 0-100 is standard range
- 100-150 provides software amplification (may cause distortion)
---
## bt_audio_mute
Mute or unmute a Bluetooth audio device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `muted` | boolean | Yes | true to mute, false to unmute |
**Returns:**
```json
{
"status": "mute_changed",
"address": "C8:7B:23:55:68:E8",
"muted": true
}
```
**Example:**
```
# Mute
bt_audio_mute address="C8:7B:23:55:68:E8" muted=true
# Unmute
bt_audio_mute address="C8:7B:23:55:68:E8" muted=false
```
**Notes:**
- Preserves volume level
- Faster than setting volume to 0

View File

@ -0,0 +1,283 @@
---
title: BLE Tools
description: Reference for Bluetooth Low Energy and GATT tools
---
Tools for interacting with Bluetooth Low Energy devices — sensors, fitness trackers, IoT devices, and more.
## bt_ble_scan
Scan for BLE (Bluetooth Low Energy) devices.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `timeout` | integer | No | 10 | Scan duration in seconds |
| `name_filter` | string | No | - | Only devices with name containing this |
| `service_filter` | string | No | - | Only devices advertising this service UUID |
**Returns:**
```json
[
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "Heart Rate Monitor",
"rssi": -65,
"uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"manufacturer_data": {"76": "02150201..."},
"service_data": {}
}
]
```
**Example:**
```
# Basic scan
bt_ble_scan adapter="hci0" timeout=10
# Filter by name
bt_ble_scan adapter="hci0" name_filter="Fitness"
# Filter by service (Heart Rate)
bt_ble_scan adapter="hci0" service_filter="0000180d-0000-1000-8000-00805f9b34fb"
```
---
## bt_ble_services
List GATT services for a connected BLE device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
[
{
"uuid": "0000180f-0000-1000-8000-00805f9b34fb",
"primary": true,
"description": "Battery Service"
},
{
"uuid": "0000180d-0000-1000-8000-00805f9b34fb",
"primary": true,
"description": "Heart Rate Service"
}
]
```
**Example:**
```
bt_ble_services adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
**Notes:**
- Device must be connected first
- Wait 2-3 seconds after connecting for service discovery
---
## bt_ble_characteristics
List GATT characteristics for a BLE device.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `address` | string | Yes | - | Device MAC address |
| `service_uuid` | string | No | - | Filter to this service only |
**Returns:**
```json
[
{
"uuid": "00002a19-0000-1000-8000-00805f9b34fb",
"service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
"flags": ["read", "notify"],
"description": "Battery Level"
}
]
```
**Example:**
```
# All characteristics
bt_ble_characteristics adapter="hci0" address="AA:BB:CC:DD:EE:FF"
# Filter by service
bt_ble_characteristics adapter="hci0" address="..." service_uuid="0000180f-0000-1000-8000-00805f9b34fb"
```
---
## bt_ble_read
Read a GATT characteristic value.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
| `char_uuid` | string | Yes | Characteristic UUID |
**Returns:**
```json
{
"uuid": "00002a19-0000-1000-8000-00805f9b34fb",
"hex": "4b",
"decoded": 75,
"description": "Battery Level: 75%"
}
```
**Example:**
```
bt_ble_read adapter="hci0" address="AA:BB:CC:DD:EE:FF" char_uuid="00002a19-0000-1000-8000-00805f9b34fb"
```
**Notes:**
- Characteristic must have `read` flag
- Device must be connected
---
## bt_ble_write
Write a value to a GATT characteristic.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `address` | string | Yes | - | Device MAC address |
| `char_uuid` | string | Yes | - | Characteristic UUID |
| `value` | string | Yes | - | Value to write |
| `value_type` | string | No | "hex" | How to interpret value: "hex", "string", "int" |
| `with_response` | boolean | No | true | Wait for write acknowledgment |
**Value Types:**
| Type | Example | Bytes Written |
|------|---------|---------------|
| `hex` | "0102ff" | 0x01, 0x02, 0xFF |
| `string` | "hello" | UTF-8 encoded |
| `int` | "42" | Single byte (0-255) |
**Returns:**
```json
{
"status": "written",
"uuid": "...",
"bytes_written": 3
}
```
**Example:**
```
# Write hex bytes
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="0102ff" value_type="hex"
# Write string
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="hello" value_type="string"
# Write without waiting for response (faster)
bt_ble_write adapter="hci0" address="..." char_uuid="..." value="01" with_response=false
```
---
## bt_ble_notify
Enable or disable notifications for a characteristic.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
| `char_uuid` | string | Yes | Characteristic UUID |
| `enable` | boolean | Yes | true to enable, false to disable |
**Returns:**
```json
{
"status": "notifications_enabled",
"uuid": "00002a37-0000-1000-8000-00805f9b34fb"
}
```
**Example:**
```
# Enable heart rate notifications
bt_ble_notify adapter="hci0" address="..." char_uuid="00002a37-0000-1000-8000-00805f9b34fb" enable=true
# Disable notifications
bt_ble_notify adapter="hci0" address="..." char_uuid="..." enable=false
```
**Notes:**
- Characteristic must have `notify` flag
- Notifications are delivered via GATT protocol
- Use protocol capture to see notification data
---
## bt_ble_battery
Read battery level from a BLE device (convenience function).
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"battery_level": 75,
"unit": "percent"
}
```
**Example:**
```
bt_ble_battery adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
**Notes:**
- Uses standard Battery Service (UUID 0x180F)
- Returns error if device doesn't support battery service
## Common UUIDs
### Services
| Service | Short UUID | Full UUID |
|---------|------------|-----------|
| Battery | 0x180F | 0000180f-0000-1000-8000-00805f9b34fb |
| Heart Rate | 0x180D | 0000180d-0000-1000-8000-00805f9b34fb |
| Device Info | 0x180A | 0000180a-0000-1000-8000-00805f9b34fb |
| Generic Access | 0x1800 | 00001800-0000-1000-8000-00805f9b34fb |
### Characteristics
| Characteristic | Short UUID | Service |
|----------------|------------|---------|
| Battery Level | 0x2A19 | Battery |
| Heart Rate Measurement | 0x2A37 | Heart Rate |
| Manufacturer Name | 0x2A29 | Device Info |
| Model Number | 0x2A24 | Device Info |

View File

@ -0,0 +1,345 @@
---
title: Device Tools
description: Reference for device discovery, pairing, and connection tools
---
Tools for managing Bluetooth device lifecycle — discovery, pairing, connection, and trust.
## bt_list_devices
List known Bluetooth devices.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `filter` | string | No | "all" | Filter: "all", "paired", "connected", "trusted" |
**Returns:**
```json
[
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "My Headphones",
"alias": "Work Headphones",
"paired": true,
"connected": true,
"trusted": true,
"blocked": false
}
]
```
**Example:**
```
bt_list_devices adapter="hci0" filter="paired"
```
---
## bt_device_info
Get detailed information about a specific device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "Bose NCH700",
"alias": "My Headphones",
"class": 2360344,
"icon": "audio-headphones",
"paired": true,
"bonded": true,
"trusted": true,
"blocked": false,
"connected": true,
"legacy_pairing": false,
"rssi": -55,
"uuids": [
"0000110b-0000-1000-8000-00805f9b34fb",
"0000110e-0000-1000-8000-00805f9b34fb"
],
"modalias": "bluetooth:v009Ep4020d0134",
"adapter": "/org/bluez/hci0"
}
```
**Example:**
```
bt_device_info adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
---
## bt_device_set_alias
Set a friendly name for a device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
| `alias` | string | Yes | New friendly name |
**Returns:** Updated device info
**Example:**
```
bt_device_set_alias adapter="hci0" address="AA:BB:CC:DD:EE:FF" alias="Work Headphones"
```
---
## bt_pair
Initiate pairing with a device.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `address` | string | Yes | - | Device MAC address |
| `pairing_mode` | string | No | "interactive" | Mode: "interactive", "auto", "elicit" |
| `timeout` | integer | No | 60 | Pairing timeout in seconds |
**Pairing Modes:**
- `interactive` — Returns status; use `bt_pair_confirm` to respond to prompts
- `auto` — Auto-accepts all requests (trusted environments only)
- `elicit` — Uses MCP elicitation for PIN prompts (if supported)
**Returns:**
```json
{
"status": "paired",
"address": "AA:BB:CC:DD:EE:FF",
"name": "My Device"
}
```
Or if confirmation needed:
```json
{
"status": "awaiting_confirmation",
"method": "numeric_comparison",
"passkey": 123456
}
```
**Example:**
```
bt_pair adapter="hci0" address="AA:BB:CC:DD:EE:FF" pairing_mode="interactive"
```
---
## bt_pair_confirm
Confirm or reject a pairing request.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `adapter` | string | Yes | - | Adapter name |
| `address` | string | Yes | - | Device MAC address |
| `accept` | boolean | No | true | Accept or reject pairing |
| `pin` | string | No | - | PIN code if required |
| `passkey` | integer | No | - | Numeric passkey (0-999999) |
**Returns:**
```json
{
"status": "paired",
"address": "AA:BB:CC:DD:EE:FF"
}
```
**Example:**
```
# Accept with passkey
bt_pair_confirm adapter="hci0" address="AA:BB:CC:DD:EE:FF" passkey=123456 accept=true
# Reject pairing
bt_pair_confirm adapter="hci0" address="AA:BB:CC:DD:EE:FF" accept=false
```
---
## bt_pairing_status
Get status of pending pairing requests.
**Parameters:** None
**Returns:**
```json
{
"pending_requests": [
{
"address": "AA:BB:CC:DD:EE:FF",
"method": "passkey_entry",
"timestamp": "2024-01-15T10:30:00"
}
]
}
```
**Example:**
```
bt_pairing_status
```
---
## bt_unpair
Remove pairing with a device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"status": "unpaired",
"address": "AA:BB:CC:DD:EE:FF"
}
```
**Example:**
```
bt_unpair adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
**Notes:**
- Removes device from known devices
- Deletes all stored pairing information
- Device must be re-paired to connect again
---
## bt_connect
Connect to a paired device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:** Updated device info with `connected: true`
**Example:**
```
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
**Notes:**
- Device must be paired first
- For audio devices, also connects audio profiles
---
## bt_disconnect
Disconnect from a device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"status": "disconnected",
"address": "AA:BB:CC:DD:EE:FF"
}
```
**Example:**
```
bt_disconnect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
**Notes:**
- Preserves pairing (device can reconnect)
- Use `bt_unpair` to fully remove device
---
## bt_trust
Set device trust status.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
| `trusted` | boolean | Yes | Trust state |
**Returns:** Updated device info
**Example:**
```
# Trust device (auto-connect allowed)
bt_trust adapter="hci0" address="AA:BB:CC:DD:EE:FF" trusted=true
# Untrust device
bt_trust adapter="hci0" address="AA:BB:CC:DD:EE:FF" trusted=false
```
**Notes:**
- Trusted devices can connect automatically
- Untrusted devices require explicit connection
---
## bt_block
Block or unblock a device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `adapter` | string | Yes | Adapter name |
| `address` | string | Yes | Device MAC address |
| `blocked` | boolean | Yes | Block state |
**Returns:** Updated device info
**Example:**
```
# Block device
bt_block adapter="hci0" address="AA:BB:CC:DD:EE:FF" blocked=true
# Unblock device
bt_block adapter="hci0" address="AA:BB:CC:DD:EE:FF" blocked=false
```
**Notes:**
- Blocked devices cannot connect
- Existing connection is terminated when blocked

View File

@ -0,0 +1,251 @@
---
title: HFP Audio Gateway Tools
description: Reference for HFP Audio Gateway tools — act as a phone for headset testing
---
import { Aside } from '@astrojs/starlight/components';
Tools for the HFP Audio Gateway role, allowing Linux to simulate a phone for testing Bluetooth headsets and hands-free devices.
<Aside type="note">
These tools register a custom HFP AG profile with BlueZ via ProfileManager1. When a Hands-Free device connects, the SLC (Service Level Connection) is auto-negotiated — feature exchange, indicator setup, and codec selection happen automatically.
</Aside>
## bt_hfp_ag_enable
Enable HFP Audio Gateway mode on Linux.
Registers a custom HFP AG profile with BlueZ. After enabling, Bluetooth headsets (HF devices) can connect and Linux acts as the phone side.
**Parameters:** None
**Returns:**
```json
{
"status": "ok",
"role": "audio_gateway",
"profile": "HFP AG 1.7"
}
```
**Example:**
```
bt_hfp_ag_enable
```
**Notes:**
- Must be called before headsets can connect via HFP
- Registers on RFCOMM channel 13
- Supports HFP 1.7 features including codec negotiation (CVSD/mSBC)
---
## bt_hfp_ag_disable
Disable HFP Audio Gateway mode.
Unregisters the AG profile and disconnects any active HFP sessions.
**Parameters:** None
**Returns:**
```json
{
"status": "ok",
"disabled": true
}
```
**Example:**
```
bt_hfp_ag_disable
```
---
## bt_hfp_ag_status
Get HFP Audio Gateway status.
**Parameters:** None
**Returns:**
```json
{
"status": "ok",
"registered": true,
"connections": [
{
"address": "AA:BB:CC:DD:EE:FF",
"slc_established": true,
"codec": "msbc",
"speaker_volume": 7,
"mic_volume": 7,
"calls": []
}
],
"indicators": {
"service": 1,
"call": 0,
"callsetup": 0,
"callheld": 0,
"signal": 5,
"roam": 0,
"battchg": 5
}
}
```
**Example:**
```
bt_hfp_ag_status
```
---
## bt_hfp_ag_simulate_call
Simulate an incoming call to a connected HF device.
Sends RING and +CLIP (caller ID) notifications to the headset. The HF device sees an incoming call and can answer (ATA) or reject (AT+CHUP). Ringing repeats every 3 seconds until answered or ended.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Bluetooth address of connected HF device |
| `number` | string | No | Caller phone number to display (default: "5551234567") |
**Returns:**
```json
{
"status": "ok",
"call_state": "ringing",
"number": "5551234567"
}
```
**Example:**
```
bt_hfp_ag_simulate_call address="AA:BB:CC:DD:EE:FF" number="+15551234567"
```
**Notes:**
- Device must have an established SLC (Service Level Connection)
- The HF device will hear a ring tone (if in-band ring is supported)
- Use `bt_hfp_ag_end_call` to stop ringing or terminate
---
## bt_hfp_ag_end_call
End an active or ringing call from the AG side.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Bluetooth address of connected HF device |
**Returns:**
```json
{
"status": "ok",
"call_state": "ended"
}
```
**Example:**
```
bt_hfp_ag_end_call address="AA:BB:CC:DD:EE:FF"
```
---
## bt_hfp_ag_set_volume
Set speaker or microphone volume on the HF device.
Sends +VGS (speaker) or +VGM (microphone) command to change volume remotely.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Bluetooth address of connected HF device |
| `type` | string | Yes | `"speaker"` for output volume, `"microphone"` for input |
| `level` | integer | Yes | Volume level 0-15 (0 = muted, 15 = maximum) |
**Returns:**
```json
{
"status": "ok",
"type": "speaker",
"level": 12
}
```
**Example:**
```
# Set speaker volume
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="speaker" level=12
# Set microphone volume
bt_hfp_ag_set_volume address="AA:BB:CC:DD:EE:FF" type="microphone" level=10
```
<Aside type="note">
HFP uses 0-15 for volume levels, unlike PipeWire/PulseAudio (0-100). The HF device maps these to its own range.
</Aside>
---
## bt_hfp_ag_set_signal
Update the signal strength indicator shown on the HF device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Bluetooth address of connected HF device |
| `level` | integer | Yes | Signal strength 0-5 |
**Returns:**
```json
{
"status": "ok",
"signal_strength": 4
}
```
**Example:**
```
bt_hfp_ag_set_signal address="AA:BB:CC:DD:EE:FF" level=4
```
---
## bt_hfp_ag_set_battery
Update the battery level indicator shown on the HF device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Bluetooth address of connected HF device |
| `level` | integer | Yes | Battery level 0-5 |
**Returns:**
```json
{
"status": "ok",
"battery_level": 3
}
```
**Example:**
```
bt_hfp_ag_set_battery address="AA:BB:CC:DD:EE:FF" level=3
```

View File

@ -0,0 +1,282 @@
---
title: Monitor Tools
description: Reference for Bluetooth protocol capture and analysis tools
---
import { Aside } from '@astrojs/starlight/components';
Tools for capturing and analyzing raw Bluetooth HCI (Host Controller Interface) traffic.
<Aside type="note">
Capture requires elevated privileges. See `bt_capture_start` for details.
</Aside>
## bt_capture_start
Start capturing Bluetooth HCI traffic.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `output_file` | string | Yes | - | Path for btsnoop capture file |
| `adapter` | string | No | - | Adapter index (e.g., "0" for hci0), or all if omitted |
| `include_sco` | boolean | No | false | Include SCO voice traffic |
| `include_a2dp` | boolean | No | false | Include A2DP audio streaming |
| `include_iso` | boolean | No | false | Include ISO (LE Audio) traffic |
**Returns:**
```json
{
"status": "started",
"capture_id": "capture_abc123",
"output_file": "/tmp/bluetooth.btsnoop"
}
```
**Example:**
```
# Basic capture
bt_capture_start output_file="/tmp/bluetooth.btsnoop"
# Capture from specific adapter
bt_capture_start output_file="/tmp/hci0.btsnoop" adapter="0"
# Include audio data (large files!)
bt_capture_start output_file="/tmp/audio.btsnoop" include_a2dp=true
```
**Notes:**
- Requires root or `CAP_NET_RAW` capability on btmon
- Capture runs in background until stopped
- Audio flags generate large files quickly
---
## bt_capture_stop
Stop a running Bluetooth capture.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `capture_id` | string | Yes | Capture ID from bt_capture_start |
**Returns:**
```json
{
"status": "stopped",
"output_file": "/tmp/bluetooth.btsnoop",
"packets_captured": 1542,
"file_size": 245760
}
```
**Example:**
```
bt_capture_stop capture_id="capture_abc123"
```
**Notes:**
- Always stop captures to ensure files are properly closed
- Capture ID is returned by bt_capture_start
---
## bt_capture_list_active
List all active Bluetooth captures.
**Parameters:** None
**Returns:**
```json
{
"captures": [
{
"capture_id": "capture_abc123",
"output_file": "/tmp/bluetooth.btsnoop",
"started": "2024-01-15T10:30:00",
"adapter": "all"
}
]
}
```
**Example:**
```
bt_capture_list_active
```
---
## bt_capture_parse
Parse a btsnoop capture file and return packet summaries.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `filepath` | string | Yes | - | Path to btsnoop file |
| `max_packets` | integer | No | 100 | Maximum packets to return (0 for all) |
| `packet_type_filter` | string | No | - | Filter by type: HCI_CMD, ACL_DATA, HCI_EVENT, SCO_DATA |
| `direction_filter` | string | No | - | Filter by direction: TX or RX |
**Returns:**
```json
{
"total_packets": 1542,
"packet_types": {
"HCI_CMD": 234,
"HCI_EVENT": 456,
"ACL_DATA": 852
},
"packets": [
{
"index": 0,
"timestamp": "2024-01-15T10:30:00.123456",
"type": "HCI_CMD",
"direction": "TX",
"length": 7,
"summary": "Inquiry"
}
]
}
```
**Example:**
```
# Basic parse
bt_capture_parse filepath="/tmp/bluetooth.btsnoop"
# Only HCI commands
bt_capture_parse filepath="..." packet_type_filter="HCI_CMD"
# Only received packets
bt_capture_parse filepath="..." direction_filter="RX"
# First 50 packets
bt_capture_parse filepath="..." max_packets=50
```
---
## bt_capture_analyze
Analyze a btsnoop capture file with high-level statistics.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `filepath` | string | Yes | Path to btsnoop file |
**Returns:**
```json
{
"duration_seconds": 45.2,
"total_packets": 1542,
"protocols": {
"L2CAP": 423,
"RFCOMM": 156,
"SDP": 34,
"ATT": 289
},
"connections": [
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "My Device",
"packets": 892,
"bytes": 45678
}
],
"errors": 0
}
```
**Example:**
```
bt_capture_analyze filepath="/tmp/bluetooth.btsnoop"
```
**Notes:**
- Provides higher-level view than bt_capture_parse
- Useful for understanding overall traffic patterns
---
## bt_capture_read_raw
Read raw packet data with btmon-style decoding.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `filepath` | string | Yes | - | Path to btsnoop file |
| `offset` | integer | No | 0 | Packets to skip from beginning |
| `count` | integer | No | 50 | Number of packets to display |
**Returns:**
```json
{
"output": "@ MGMT Command: Read Management... (0x0001) plen 0\n..."
}
```
**Example:**
```
# First 50 packets
bt_capture_read_raw filepath="/tmp/bluetooth.btsnoop"
# Skip first 100, read next 20
bt_capture_read_raw filepath="..." offset=100 count=20
```
**Notes:**
- Returns btmon's human-readable packet decoding
- Useful for detailed protocol analysis
- Output can be large for many packets
---
## File Format
Captures use **btsnoop** format, compatible with:
| Tool | Description |
|------|-------------|
| Wireshark | Full GUI protocol analyzer |
| btmon | BlueZ command-line decoder |
| hcidump | Legacy packet dumper |
### Opening in Wireshark
```bash
wireshark /tmp/bluetooth.btsnoop
```
Wireshark provides:
- Full protocol dissection
- Conversation tracking
- Expert analysis
- Multiple export formats
---
## Permissions
Capture requires access to the Bluetooth monitor socket:
```bash
# Option 1: Run as root
sudo btmon
# Option 2: Add capability (recommended)
sudo setcap cap_net_raw+ep /usr/bin/btmon
# Verify capability
getcap /usr/bin/btmon
```

View File

@ -0,0 +1,582 @@
---
title: OBEX Tools
description: Reference for OBEX file transfer, phonebook, and message tools
---
import { Aside } from '@astrojs/starlight/components';
Tools for OBEX profiles — file transfer (OPP/FTP), phonebook access (PBAP), and message access (MAP).
<Aside type="note">
OBEX tools require the `obexd` daemon. Use `bt_obex_status` to check availability.
</Aside>
## Setup Tools
### bt_obex_status
Check OBEX subsystem status.
**Parameters:** None
**Returns:**
```json
{
"status": "ready",
"obexd_installed": true,
"obexd_running": true,
"dbus_accessible": true,
"active_sessions": []
}
```
**Example:**
```
bt_obex_status
```
---
### bt_obex_start_daemon
Start the obexd daemon if not running.
**Parameters:** None
**Returns:**
```json
{
"status": "started",
"pid": 12345
}
```
**Example:**
```
bt_obex_start_daemon
```
---
## Session Management
### bt_obex_connect
Create an OBEX session to a device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `target` | string | Yes | Profile: "opp", "ftp", "pbap", "map" |
**Returns:**
```json
{
"success": true,
"session_id": "ftp_AABBCCDDEEFF",
"address": "AA:BB:CC:DD:EE:FF",
"target": "ftp"
}
```
**Example:**
```
bt_obex_connect address="AA:BB:CC:DD:EE:FF" target="ftp"
```
---
### bt_obex_disconnect
Close an OBEX session.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `session_id` | string | Yes | Session ID from bt_obex_connect |
**Returns:**
```json
{
"status": "disconnected",
"session_id": "ftp_AABBCCDDEEFF"
}
```
**Example:**
```
bt_obex_disconnect session_id="ftp_AABBCCDDEEFF"
```
---
### bt_obex_sessions
List active OBEX sessions.
**Parameters:** None
**Returns:**
```json
{
"sessions": [
{
"session_id": "ftp_AABBCCDDEEFF",
"address": "AA:BB:CC:DD:EE:FF",
"target": "ftp",
"created": "2024-01-15T10:30:00"
}
]
}
```
---
## OPP Tools (Object Push)
### bt_obex_send_file
Send a file via Object Push Profile.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `address` | string | Yes | - | Device MAC address |
| `file_path` | string | Yes | - | Local file to send |
| `wait` | boolean | No | true | Wait for transfer completion |
**Returns:**
```json
{
"status": "complete",
"filename": "report.pdf",
"size": 102400,
"transferred": 102400
}
```
Or if `wait=false`:
```json
{
"status": "started",
"transfer_path": "/org/bluez/obex/client/session0/transfer0"
}
```
**Example:**
```
bt_obex_send_file address="AA:BB:CC:DD:EE:FF" file_path="~/Documents/report.pdf"
```
---
### bt_obex_get_vcard
Pull business card (vCard) from device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `save_path` | string | Yes | Where to save the vCard |
**Returns:**
```json
{
"status": "complete",
"save_path": "/home/user/contact.vcf",
"size": 512
}
```
**Example:**
```
bt_obex_get_vcard address="AA:BB:CC:DD:EE:FF" save_path="~/contact.vcf"
```
---
## FTP Tools (File Transfer)
### bt_obex_browse
List files and folders at a path.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `session_id` | string | Yes | - | FTP session ID |
| `path` | string | No | "/" | Path to list ("/" for root, ".." to go up) |
**Returns:**
```json
{
"path": "/DCIM",
"entries": [
{"name": "Camera", "type": "folder"},
{"name": "photo.jpg", "type": "file", "size": 2048576}
]
}
```
**Example:**
```
bt_obex_browse session_id="ftp_..." path="/DCIM"
```
---
### bt_obex_get
Download a file from the device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `session_id` | string | Yes | FTP session ID |
| `remote_path` | string | Yes | File path on device |
| `local_path` | string | Yes | Where to save locally |
**Returns:**
```json
{
"status": "complete",
"remote_path": "photo.jpg",
"local_path": "/home/user/Downloads/photo.jpg",
"size": 2048576
}
```
**Example:**
```
bt_obex_get session_id="ftp_..." remote_path="photo.jpg" local_path="~/Downloads/photo.jpg"
```
---
### bt_obex_put
Upload a file to the device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `session_id` | string | Yes | FTP session ID |
| `local_path` | string | Yes | Local file to upload |
| `remote_path` | string | Yes | Destination path on device |
**Returns:**
```json
{
"status": "complete",
"local_path": "/home/user/document.pdf",
"remote_path": "document.pdf",
"size": 102400
}
```
**Example:**
```
bt_obex_put session_id="ftp_..." local_path="~/document.pdf" remote_path="document.pdf"
```
---
### bt_obex_delete
Delete a file or folder on the device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `session_id` | string | Yes | FTP session ID |
| `remote_path` | string | Yes | File/folder to delete |
**Returns:**
```json
{
"status": "deleted",
"path": "old_file.txt"
}
```
**Example:**
```
bt_obex_delete session_id="ftp_..." remote_path="old_file.txt"
```
---
### bt_obex_mkdir
Create a folder on the device.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `session_id` | string | Yes | FTP session ID |
| `folder_name` | string | Yes | Name of folder to create |
**Returns:**
```json
{
"status": "created",
"folder": "Backup"
}
```
**Example:**
```
bt_obex_mkdir session_id="ftp_..." folder_name="Backup"
```
---
## Transfer Monitoring
### bt_obex_transfer_status
Check progress of a transfer.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `transfer_path` | string | Yes | Transfer path from send/get operations |
**Returns:**
```json
{
"status": "active",
"filename": "large_file.mp4",
"size": 104857600,
"transferred": 52428800,
"progress_percent": 50
}
```
**Example:**
```
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
```
---
### bt_obex_transfer_cancel
Cancel an in-progress transfer.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `transfer_path` | string | Yes | Transfer path to cancel |
**Returns:**
```json
{
"status": "cancelled",
"transfer_path": "..."
}
```
---
## PBAP Tools (Phonebook)
### bt_phonebook_pull
Download entire phonebook as vCard file.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `save_path` | string | Yes | Where to save |
**Returns:**
```json
{
"status": "complete",
"save_path": "/home/user/contacts.vcf",
"count": 150
}
```
---
### bt_phonebook_list
List phonebook entries with handles.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `address` | string | Yes | - | Device MAC address |
| `folder` | string | No | "telecom/pb" | Phonebook folder |
**Folders:**
- `telecom/pb` — Main phonebook
- `telecom/ich` — Incoming call history
- `telecom/och` — Outgoing call history
- `telecom/mch` — Missed call history
**Returns:**
```json
{
"entries": [
{"handle": "1.vcf", "name": "Alice Smith"},
{"handle": "2.vcf", "name": "Bob Jones"}
],
"count": 150
}
```
---
### bt_phonebook_get
Download single contact by handle.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `handle` | string | Yes | Contact handle from list |
| `save_path` | string | Yes | Where to save vCard |
---
### bt_phonebook_search
Search phonebook by field.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `field` | string | Yes | Field to search: "name", "number", "email" |
| `value` | string | Yes | Search value |
---
### bt_phonebook_count
Get total number of contacts.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"count": 150
}
```
---
## MAP Tools (Messages)
### bt_messages_folders
List message folders.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
**Returns:**
```json
{
"folders": [
{"name": "inbox"},
{"name": "sent"},
{"name": "drafts"},
{"name": "outbox"},
{"name": "deleted"}
]
}
```
---
### bt_messages_list
List messages in a folder.
**Parameters:**
| Name | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `address` | string | Yes | - | Device MAC address |
| `folder` | string | No | "inbox" | Folder to list |
| `unread_only` | boolean | No | false | Only unread messages |
| `max_count` | integer | No | 100 | Maximum messages to return |
**Returns:**
```json
{
"messages": [
{
"handle": "msg001",
"subject": "Meeting tomorrow",
"sender": "+15551234567",
"timestamp": "2024-01-15T10:30:00",
"read": false,
"type": "SMS"
}
]
}
```
---
### bt_messages_get
Download message content.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `handle` | string | Yes | Message handle from list |
| `save_path` | string | Yes | Where to save message |
---
### bt_messages_send
Send a message via MAP.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `address` | string | Yes | Device MAC address |
| `recipient` | string | Yes | Phone number to send to |
| `message` | string | Yes | Message text |
<Aside type="caution">
Message sending support varies by device. Many phones only allow read access.
</Aside>

View File

@ -0,0 +1,197 @@
---
title: MCP Resources
description: Reference for mcbluetooth MCP resource URIs
---
mcbluetooth exposes live Bluetooth state through MCP resources. These provide real-time queries without tool calls.
## Resource URIs
| URI | Description |
|-----|-------------|
| `bluetooth://adapters` | All Bluetooth adapters |
| `bluetooth://paired` | Paired devices |
| `bluetooth://connected` | Connected devices |
| `bluetooth://visible` | All known/discovered devices |
| `bluetooth://trusted` | Trusted devices |
| `bluetooth://adapter/{name}` | Specific adapter details |
| `bluetooth://device/{address}` | Specific device details |
## Adapter Resources
### bluetooth://adapters
Lists all Bluetooth adapters on the system.
**Response:**
```json
[
{
"name": "hci0",
"address": "AA:BB:CC:DD:EE:FF",
"alias": "My Laptop",
"powered": true,
"discoverable": false,
"pairable": true,
"discovering": false
}
]
```
### bluetooth://adapter/{name}
Get details for a specific adapter.
**URI:** `bluetooth://adapter/hci0`
**Response:**
```json
{
"name": "hci0",
"address": "AA:BB:CC:DD:EE:FF",
"alias": "My Laptop",
"class": 7995916,
"powered": true,
"discoverable": false,
"discoverable_timeout": 180,
"pairable": true,
"pairable_timeout": 0,
"discovering": false,
"uuids": ["0000110a-...", "0000110c-..."],
"modalias": "usb:v1D6Bp0246d0540"
}
```
## Device Resources
### bluetooth://paired
Lists all paired devices across all adapters.
**Response:**
```json
[
{
"address": "C8:7B:23:55:68:E8",
"name": "Bose NCH700",
"alias": "My Headphones",
"paired": true,
"connected": true,
"trusted": true,
"adapter": "hci0"
}
]
```
### bluetooth://connected
Lists currently connected devices.
**Response:**
```json
[
{
"address": "C8:7B:23:55:68:E8",
"name": "Bose NCH700",
"connected": true,
"services": ["audio", "hfp"]
}
]
```
### bluetooth://visible
Lists all known devices (discovered or previously seen).
**Response:**
```json
[
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "Unknown Device",
"paired": false,
"connected": false,
"rssi": -75,
"last_seen": "2024-01-15T10:30:00"
}
]
```
### bluetooth://trusted
Lists devices marked as trusted (auto-connect allowed).
**Response:**
```json
[
{
"address": "C8:7B:23:55:68:E8",
"name": "Bose NCH700",
"trusted": true,
"paired": true
}
]
```
### bluetooth://device/{address}
Get details for a specific device.
**URI:** `bluetooth://device/C8:7B:23:55:68:E8`
**Response:**
```json
{
"address": "C8:7B:23:55:68:E8",
"name": "Bose NCH700",
"alias": "My Headphones",
"class": 2360344,
"icon": "audio-headphones",
"paired": true,
"bonded": true,
"trusted": true,
"blocked": false,
"connected": true,
"legacy_pairing": false,
"rssi": -55,
"uuids": [
"0000110b-0000-1000-8000-00805f9b34fb",
"0000110e-0000-1000-8000-00805f9b34fb"
],
"modalias": "bluetooth:v009Ep4020d0134",
"adapter": "hci0"
}
```
## Usage in MCP Clients
Resources can be read using the standard MCP resource protocol:
```json
{
"method": "resources/read",
"params": {
"uri": "bluetooth://paired"
}
}
```
## Comparison: Resources vs Tools
| Use Case | Resource | Tool |
|----------|----------|------|
| Check current state | ✓ Resources | |
| Modify state | | ✓ Tools |
| Real-time queries | ✓ Resources | |
| Complex operations | | ✓ Tools |
**Resources** are read-only and ideal for:
- Dashboard displays
- State monitoring
- Quick queries
**Tools** are for actions:
- Pairing devices
- Connecting/disconnecting
- Sending files
- Changing settings

View File

@ -0,0 +1,161 @@
---
title: Tools Overview
description: Complete reference for all mcbluetooth MCP tools
---
mcbluetooth provides **69 MCP tools** organized into functional categories.
## Tool Categories
| Category | Tools | Description |
|----------|-------|-------------|
| [Adapter](/reference/adapter-tools/) | 7 | Hardware adapter management |
| [Device](/reference/device-tools/) | 12 | Device discovery, pairing, connection |
| [Audio](/reference/audio-tools/) | 7 | PipeWire/PulseAudio integration |
| [HFP Audio Gateway](/reference/hfp-ag-tools/) | 8 | Phone role for headset testing |
| [BLE](/reference/ble-tools/) | 8 | Bluetooth Low Energy & GATT |
| [OBEX](/reference/obex-tools/) | 21 | File transfer, phonebook, messages |
| [Monitor](/reference/monitor-tools/) | 6 | Protocol capture & analysis |
## Tool Naming Convention
All tools follow the pattern `bt_<category>_<action>`:
```
bt_adapter_power # Adapter category, power action
bt_ble_read # BLE category, read action
bt_obex_connect # OBEX category, connect action
```
## Common Parameters
### `adapter`
Most tools require an adapter name (e.g., `"hci0"`):
```
bt_scan adapter="hci0" timeout=10
```
Use `bt_list_adapters` to discover available adapters.
### `address`
Device Bluetooth MAC address in colon-separated format:
```
bt_connect adapter="hci0" address="AA:BB:CC:DD:EE:FF"
```
## Return Values
Tools return JSON objects with:
- **Success**: Operation-specific data
- **Errors**: `{"error": "description", "code": "ERROR_CODE"}`
## Quick Reference Table
### Adapter Tools
| Tool | Description |
|------|-------------|
| `bt_list_adapters` | List all Bluetooth adapters |
| `bt_adapter_info` | Get adapter details |
| `bt_adapter_power` | Turn adapter on/off |
| `bt_adapter_discoverable` | Set discoverable mode |
| `bt_adapter_pairable` | Set pairable mode |
| `bt_adapter_set_alias` | Set adapter name |
| `bt_scan` | Scan for devices |
### Device Tools
| Tool | Description |
|------|-------------|
| `bt_list_devices` | List known devices |
| `bt_device_info` | Get device details |
| `bt_device_set_alias` | Set device name |
| `bt_pair` | Initiate pairing |
| `bt_pair_confirm` | Confirm/reject pairing |
| `bt_pairing_status` | Check pending pairings |
| `bt_unpair` | Remove pairing |
| `bt_connect` | Connect to device |
| `bt_disconnect` | Disconnect device |
| `bt_trust` | Set trust status |
| `bt_block` | Block/unblock device |
### Audio Tools
| Tool | Description |
|------|-------------|
| `bt_audio_list` | List audio devices |
| `bt_audio_connect` | Connect audio profiles |
| `bt_audio_disconnect` | Disconnect audio |
| `bt_audio_set_profile` | Switch A2DP/HFP |
| `bt_audio_set_default` | Set default output |
| `bt_audio_volume` | Adjust volume |
| `bt_audio_mute` | Mute/unmute |
### HFP Audio Gateway Tools
| Tool | Description |
|------|-------------|
| `bt_hfp_ag_enable` | Register AG profile with BlueZ |
| `bt_hfp_ag_disable` | Unregister AG profile |
| `bt_hfp_ag_status` | Get connections and indicators |
| `bt_hfp_ag_simulate_call` | Simulate incoming call |
| `bt_hfp_ag_end_call` | End active/ringing call |
| `bt_hfp_ag_set_volume` | Set speaker/mic volume |
| `bt_hfp_ag_set_signal` | Update signal indicator |
| `bt_hfp_ag_set_battery` | Update battery indicator |
### BLE Tools
| Tool | Description |
|------|-------------|
| `bt_ble_scan` | Scan for BLE devices |
| `bt_ble_services` | List GATT services |
| `bt_ble_characteristics` | List characteristics |
| `bt_ble_read` | Read characteristic |
| `bt_ble_write` | Write characteristic |
| `bt_ble_notify` | Enable/disable notifications |
| `bt_ble_battery` | Read battery level |
### OBEX Tools
| Tool | Description |
|------|-------------|
| `bt_obex_status` | Check obexd status |
| `bt_obex_start_daemon` | Start obexd |
| `bt_obex_connect` | Create OBEX session |
| `bt_obex_disconnect` | Close session |
| `bt_obex_sessions` | List active sessions |
| `bt_obex_send_file` | Send file (OPP) |
| `bt_obex_get_vcard` | Get business card |
| `bt_obex_browse` | List files/folders |
| `bt_obex_get` | Download file |
| `bt_obex_put` | Upload file |
| `bt_obex_delete` | Delete file/folder |
| `bt_obex_mkdir` | Create folder |
| `bt_obex_transfer_status` | Check transfer progress |
| `bt_obex_transfer_cancel` | Cancel transfer |
| `bt_phonebook_pull` | Download phonebook |
| `bt_phonebook_list` | List contacts |
| `bt_phonebook_get` | Get single contact |
| `bt_phonebook_search` | Search contacts |
| `bt_phonebook_count` | Count contacts |
| `bt_messages_folders` | List message folders |
| `bt_messages_list` | List messages |
| `bt_messages_get` | Download message |
| `bt_messages_send` | Send message |
### Monitor Tools
| Tool | Description |
|------|-------------|
| `bt_capture_start` | Start HCI capture |
| `bt_capture_stop` | Stop capture |
| `bt_capture_list_active` | List active captures |
| `bt_capture_parse` | Parse capture file |
| `bt_capture_analyze` | Analyze capture |
| `bt_capture_read_raw` | Read raw packets |

View File

@ -0,0 +1,130 @@
/* mcbluetooth Starlight Custom Styles */
:root {
/* Bluetooth-inspired color palette */
--sl-color-accent-low: #1a3a5c;
--sl-color-accent: #0066cc;
--sl-color-accent-high: #3399ff;
--sl-color-white: #ffffff;
--sl-color-gray-1: #f5f6f8;
--sl-color-gray-2: #e8eaed;
--sl-color-gray-3: #c0c4cc;
--sl-color-gray-4: #888d96;
--sl-color-gray-5: #545a64;
--sl-color-gray-6: #2d3139;
--sl-color-black: #181a1f;
/* Typography */
--sl-font-system: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--sl-font-system-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Dark mode accent adjustments */
:root[data-theme='dark'] {
--sl-color-accent-low: #0d2847;
--sl-color-accent: #3399ff;
--sl-color-accent-high: #66b3ff;
}
/* Code blocks */
.expressive-code {
--ec-brdRad: 8px;
}
/* Hero section on splash pages */
.hero {
padding-block: 4rem;
}
/* Card styling */
.sl-card {
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.sl-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15);
}
/* Sidebar styling */
.sidebar-content {
padding-top: 1rem;
}
/* Tool reference tables */
table {
font-size: 0.9rem;
}
table code {
font-size: 0.85em;
padding: 0.15em 0.35em;
}
/* Inline code styling */
:not(pre) > code {
background: var(--sl-color-gray-6);
color: var(--sl-color-accent-high);
border-radius: 4px;
padding: 0.15em 0.35em;
}
:root[data-theme='light'] :not(pre) > code {
background: var(--sl-color-gray-2);
color: var(--sl-color-accent);
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
}
/* Callouts / Asides */
.starlight-aside {
border-radius: 8px;
}
/* Footer */
.site-footer {
border-top: 1px solid var(--sl-color-gray-5);
padding-block: 1.5rem;
}
/* ASCII art banner (if used) */
.ascii-banner {
font-family: var(--sl-font-system-mono);
font-size: 0.65rem;
line-height: 1.2;
white-space: pre;
overflow-x: auto;
color: var(--sl-color-accent);
}
/* Tool cards grid */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* Profile badges */
.profile-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2em 0.5em;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.profile-badge.opp { background: #e3f2fd; color: #1565c0; }
.profile-badge.ftp { background: #e8f5e9; color: #2e7d32; }
.profile-badge.pbap { background: #fff3e0; color: #e65100; }
.profile-badge.map { background: #fce4ec; color: #c2185b; }
:root[data-theme='dark'] .profile-badge.opp { background: #1565c0; color: #e3f2fd; }
:root[data-theme='dark'] .profile-badge.ftp { background: #2e7d32; color: #e8f5e9; }
:root[data-theme='dark'] .profile-badge.pbap { background: #e65100; color: #fff3e0; }
:root[data-theme='dark'] .profile-badge.map { background: #c2185b; color: #fce4ec; }

5
docs-site/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

368
docs/obex.md Normal file
View File

@ -0,0 +1,368 @@
# OBEX Profile Support
OBEX (Object Exchange) enables file transfer and data synchronization over Bluetooth. mcbluetooth provides full support for the four major OBEX profiles through the `obexd` daemon.
## Profiles Overview
| Profile | UUID | Purpose | Typical Use |
|---------|------|---------|-------------|
| **OPP** (Object Push) | 0x1105 | Simple file sending | "Send this photo to my laptop" |
| **FTP** (File Transfer) | 0x1106 | Full file browsing | "Browse phone storage, download DCIM" |
| **PBAP** (Phonebook Access) | 0x112F | Contact/call history | "Download my contacts" |
| **MAP** (Message Access) | 0x1132 | SMS/MMS/email | "Show unread messages" |
## Prerequisites
OBEX requires the `obexd` daemon, which is separate from the main `bluetoothd`:
```bash
# Arch Linux
sudo pacman -S bluez-obex
# Debian/Ubuntu
sudo apt install bluez-obex
# Fedora
sudo dnf install bluez-obex
```
Check your setup with:
```
bt_obex_status
```
If obexd isn't running, start it:
```
bt_obex_start_daemon
```
> **Note**: obexd runs as a user service on the session D-Bus, not the system bus. It requires a desktop session to be active.
---
## Quick Start
### Send a File (OPP)
The simplest OBEX operation — push a file to any device:
```
bt_obex_send_file address="C8:7B:23:55:68:E8" file_path="~/Documents/report.pdf"
```
The receiving device will show an accept/reject prompt. The tool waits for completion and reports success/failure.
### Download Contacts (PBAP)
Pull all contacts from a paired phone:
```
bt_phonebook_pull address="AA:BB:CC:DD:EE:FF" save_path="~/contacts.vcf"
```
This creates a vCard file with all contacts. You can import it into any contacts app.
### Browse Phone Storage (FTP)
For full file system access, create an FTP session:
```
# Connect
bt_obex_connect address="AA:BB:CC:DD:EE:FF" target="ftp"
# Returns: session_id="ftp_AABBCCDDEEFF"
# Browse root
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="/"
# Navigate to DCIM
bt_obex_browse session_id="ftp_AABBCCDDEEFF" path="DCIM"
# Download a photo
bt_obex_get session_id="ftp_AABBCCDDEEFF" remote_path="photo.jpg" local_path="~/Downloads/"
# Disconnect when done
bt_obex_disconnect session_id="ftp_AABBCCDDEEFF"
```
---
## How-To Guides
### How to Transfer Files Between Devices
**Scenario**: You want to send multiple files to your phone.
1. **For single files**, use OPP (no session needed):
```
bt_obex_send_file address="..." file_path="file1.pdf"
bt_obex_send_file address="..." file_path="file2.jpg"
```
2. **For multiple files or browsing**, use FTP:
```
bt_obex_connect address="..." target="ftp"
bt_obex_browse session_id="..." path="/"
bt_obex_put session_id="..." local_path="file1.pdf" remote_path="file1.pdf"
bt_obex_put session_id="..." local_path="file2.jpg" remote_path="file2.jpg"
bt_obex_disconnect session_id="..."
```
### How to Backup Phone Contacts
**Scenario**: Create a backup of all contacts from your phone.
```
# Pull main phonebook
bt_phonebook_pull address="..." save_path="~/backup/contacts.vcf"
# Also backup call history
bt_phonebook_pull address="..." save_path="~/backup/calls_incoming.vcf" phonebook="ich"
bt_phonebook_pull address="..." save_path="~/backup/calls_outgoing.vcf" phonebook="och"
bt_phonebook_pull address="..." save_path="~/backup/calls_missed.vcf" phonebook="mch"
```
**Phonebook types**:
- `pb` — Main contacts (default)
- `ich` — Incoming call history
- `och` — Outgoing call history
- `mch` — Missed call history
- `cch` — Combined call history
### How to Search Contacts
**Scenario**: Find a contact by name or phone number.
```
# Search by name
bt_phonebook_search address="..." field="name" value="John"
# Search by phone number
bt_phonebook_search address="..." field="number" value="555"
```
### How to Read Text Messages
**Scenario**: Check unread SMS messages on your phone.
```
# List unread messages
bt_messages_list address="..." folder="inbox" unread_only=true
# Get full message content
bt_messages_get address="..." handle="msg001" save_path="~/message.txt"
```
### How to Manage FTP Sessions
**Scenario**: You have a long-running file transfer workflow.
```
# List active sessions
bt_obex_sessions
# Check session details
bt_obex_connect address="..." target="ftp"
# Navigate folder structure
bt_obex_browse session_id="..." path="/" # Root
bt_obex_browse session_id="..." path="DCIM" # Enter folder
bt_obex_browse session_id="..." path=".." # Go up
# Create/delete
bt_obex_mkdir session_id="..." folder_name="Backup"
bt_obex_delete session_id="..." remote_path="old_file.txt"
# Always disconnect when done
bt_obex_disconnect session_id="..."
```
### How to Monitor Transfer Progress
**Scenario**: You're transferring a large file and want to check progress.
```
# Start transfer without waiting
bt_obex_send_file address="..." file_path="large_video.mp4" wait=false
# Returns: transfer_path="/org/bluez/obex/client/session0/transfer0"
# Check progress
bt_obex_transfer_status transfer_path="/org/bluez/obex/client/session0/transfer0"
# Returns: status="active", transferred=52428800, size=104857600, progress_percent=50
# Cancel if needed
bt_obex_transfer_cancel transfer_path="..."
```
---
## Tool Reference
### Setup & Status
| Tool | Description |
|------|-------------|
| `bt_obex_status` | Check obexd installation and D-Bus connectivity |
| `bt_obex_start_daemon` | Start obexd if not running |
### Session Management
| Tool | Parameters | Description |
|------|------------|-------------|
| `bt_obex_connect` | `address`, `target` (opp/ftp/pbap/map) | Create OBEX session |
| `bt_obex_disconnect` | `session_id` | Close session |
| `bt_obex_sessions` | — | List active sessions |
### Object Push (OPP)
| Tool | Parameters | Description |
|------|------------|-------------|
| `bt_obex_send_file` | `address`, `file_path`, `wait` | Send file to device |
| `bt_obex_get_vcard` | `address`, `save_path` | Pull device's business card |
### File Transfer (FTP)
| Tool | Parameters | Description |
|------|------------|-------------|
| `bt_obex_browse` | `session_id`, `path` | List folder contents |
| `bt_obex_get` | `session_id`, `remote_path`, `local_path` | Download file |
| `bt_obex_put` | `session_id`, `local_path`, `remote_path` | Upload file |
| `bt_obex_delete` | `session_id`, `remote_path` | Delete file/folder |
| `bt_obex_mkdir` | `session_id`, `folder_name` | Create folder |
### Phonebook Access (PBAP)
| Tool | Parameters | Description |
|------|------------|-------------|
| `bt_phonebook_pull` | `address`, `save_path`, `location`, `phonebook` | Download full phonebook |
| `bt_phonebook_list` | `address`, `location`, `phonebook` | List entries with handles |
| `bt_phonebook_get` | `address`, `handle`, `save_path` | Download single entry |
| `bt_phonebook_search` | `address`, `field`, `value` | Search contacts |
| `bt_phonebook_count` | `address`, `location`, `phonebook` | Count entries |
**Parameters**:
- `location`: `"int"` (internal memory) or `"sim"` (SIM card)
- `phonebook`: `"pb"`, `"ich"`, `"och"`, `"mch"`, `"cch"`
- `field`: `"name"`, `"number"`, `"sound"`
### Message Access (MAP)
| Tool | Parameters | Description |
|------|------------|-------------|
| `bt_messages_folders` | `address` | List message folders |
| `bt_messages_list` | `address`, `folder`, `unread_only`, `max_count` | List messages |
| `bt_messages_get` | `address`, `handle`, `save_path` | Download message |
| `bt_messages_send` | `address`, `recipient`, `message` | Send SMS |
### Transfer Management
| Tool | Parameters | Description |
|------|------------|-------------|
| `bt_obex_transfer_status` | `transfer_path` | Check transfer progress |
| `bt_obex_transfer_cancel` | `transfer_path` | Cancel active transfer |
---
## Troubleshooting
### "obexd not found"
Install the bluez-obex package:
```bash
# Arch
sudo pacman -S bluez-obex
# Debian/Ubuntu
sudo apt install bluez-obex
```
### "Cannot connect to obexd D-Bus"
obexd runs on the **session bus**, which requires:
1. A graphical desktop session (X11 or Wayland)
2. D-Bus session daemon running
If running headless:
```bash
# Start a D-Bus session
eval $(dbus-launch --sh-syntax)
export DBUS_SESSION_BUS_ADDRESS
# Then start obexd
/usr/lib/bluetooth/obexd &
```
### "Device rejected connection" / "NotAuthorized"
The target device must:
1. Be **paired** first (use `bt_pair`)
2. Have the profile **enabled** (check device settings)
3. Be **connected** for some profiles
Some devices require explicit authorization for OBEX access in their Bluetooth settings.
### "Profile not supported"
Not all devices support all profiles:
| Device Type | OPP | FTP | PBAP | MAP |
|-------------|-----|-----|------|-----|
| Android phones | ✓ | varies | ✓ | varies |
| iPhones | ✓ | ✗ | ✗ | ✗ |
| Feature phones | ✓ | ✓ | ✓ | ✓ |
| Bluetooth speakers | ✗ | ✗ | ✗ | ✗ |
| Cars (infotainment) | ✓ | ✗ | ✓ | ✓ |
### "Transfer stuck at 0%"
The receiving device may be showing an accept prompt. Check the device screen.
### Session disappeared
Sessions are tied to the obexd process. If obexd restarts, sessions are lost. Create a new session.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ MCP Tools (tools/obex.py) │
│ bt_obex_*, bt_phonebook_*, bt_messages_* │
├─────────────────────────────────────────────────────────────┤
│ ObexClient (obex_client.py) │
│ Session management, transfer monitoring, profile methods │
├─────────────────────────────────────────────────────────────┤
│ dbus-fast (async) │
│ SESSION D-Bus │
├─────────────────────────────────────────────────────────────┤
│ obexd │
│ /org/bluez/obex/client/sessionN/transferM │
├─────────────────────────────────────────────────────────────┤
│ BlueZ │
│ Bluetooth Stack │
└─────────────────────────────────────────────────────────────┘
```
### D-Bus Object Paths
- **Client**: `/org/bluez/obex`
- **Session**: `/org/bluez/obex/client/session0`
- **Transfer**: `/org/bluez/obex/client/session0/transfer0`
### Session Lifecycle
1. `CreateSession(address, {Target: "ftp"})` → session path
2. Use profile interface (FileTransfer1, PhonebookAccess1, etc.)
3. Transfers create temporary objects with Status property
4. `RemoveSession(session_path)` when done
---
## See Also
- [BlueZ OBEX D-Bus API](https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/obex-api.txt)
- [OBEX Specification](https://www.bluetooth.com/specifications/specs/object-exchange-obex/)
- [vCard Format](https://en.wikipedia.org/wiki/VCard)

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mcbluetooth"
version = "2026.02.02"
version = "2026.02.04"
description = "Comprehensive BlueZ MCP server - expose the full Linux Bluetooth stack to LLMs"
readme = "README.md"
requires-python = ">=3.11"
@ -56,6 +56,10 @@ 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"]

388
src/mcbluetooth/agent.py Normal file
View File

@ -0,0 +1,388 @@
"""BlueZ pairing agent implementation.
This module implements the org.bluez.Agent1 D-Bus interface for handling
Bluetooth pairing operations. The agent supports multiple pairing modes:
- elicit: Use MCP elicitation to request PIN/confirmation from user (preferred)
- interactive: Return pending status, wait for bt_pair_confirm tool call
- auto: Auto-accept all pairings (use only in trusted environments)
The agent is registered with BlueZ's AgentManager1 and handles callbacks
for PIN codes, passkeys, and confirmations during the pairing process.
"""
import asyncio
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
from dbus_fast import BusType, DBusError
from dbus_fast.aio import MessageBus
from dbus_fast.service import ServiceInterface, method
# Agent constants
AGENT_PATH = "/mcbluetooth/agent"
AGENT_CAPABILITY = "KeyboardDisplay" # Can display and enter PINs
BLUEZ_SERVICE = "org.bluez"
AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
class PairingRequestType(Enum):
"""Types of pairing requests the agent can receive."""
PIN_CODE = "pin_code"
PASSKEY = "passkey"
CONFIRMATION = "confirmation"
AUTHORIZATION = "authorization"
SERVICE_AUTH = "service_authorization"
class PairingMode(Enum):
"""Pairing behavior modes."""
ELICIT = "elicit" # Use MCP elicitation
INTERACTIVE = "interactive" # Wait for bt_pair_confirm
AUTO = "auto" # Auto-accept everything
@dataclass
class PairingRequest:
"""A pending pairing request from BlueZ."""
request_type: PairingRequestType
device_path: str
device_address: str
passkey: int | None = None # For confirmation requests
uuid: str | None = None # For service authorization
timestamp: datetime = field(default_factory=datetime.now)
response_event: asyncio.Event = field(default_factory=asyncio.Event)
response_value: str | int | bool | None = None
response_error: str | None = None
class BlueZAgentError(DBusError):
"""Base class for agent D-Bus errors."""
pass
class Rejected(BlueZAgentError):
"""Pairing was rejected by user."""
def __init__(self):
super().__init__("org.bluez.Error.Rejected", "Pairing rejected")
class Canceled(BlueZAgentError):
"""Pairing was canceled."""
def __init__(self):
super().__init__("org.bluez.Error.Canceled", "Pairing canceled")
def path_to_address(device_path: str) -> str:
"""Extract Bluetooth address from D-Bus object path."""
# /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX -> XX:XX:XX:XX:XX:XX
parts = device_path.split("/")
if len(parts) >= 5 and parts[-1].startswith("dev_"):
return parts[-1][4:].replace("_", ":")
return device_path
class BlueZAgent(ServiceInterface):
"""D-Bus service implementing org.bluez.Agent1 interface.
This agent handles pairing requests from BlueZ and coordinates
with the MCP tools for user interaction.
"""
def __init__(self, mode: PairingMode = PairingMode.INTERACTIVE):
super().__init__("org.bluez.Agent1")
self.mode = mode
self.pending_requests: dict[str, PairingRequest] = {} # keyed by device_path
self._timeout = 60.0 # Seconds to wait for user response
def set_mode(self, mode: PairingMode) -> None:
"""Change the pairing mode."""
self.mode = mode
def get_pending_request(self, device_address: str) -> PairingRequest | None:
"""Get a pending request by device address."""
for req in self.pending_requests.values():
if req.device_address.upper() == device_address.upper():
return req
return None
def respond_to_request(
self,
device_address: str,
accept: bool,
pin_or_passkey: str | int | None = None,
) -> bool:
"""Respond to a pending pairing request.
Returns True if a request was found and responded to.
"""
request = self.get_pending_request(device_address)
if not request:
return False
if accept:
request.response_value = pin_or_passkey
request.response_error = None
else:
request.response_error = "rejected"
request.response_event.set()
return True
async def _wait_for_response(self, request: PairingRequest) -> Any:
"""Wait for user response to a pairing request."""
try:
await asyncio.wait_for(
request.response_event.wait(), timeout=self._timeout
)
except TimeoutError:
request.response_error = "timeout"
raise Canceled() from None
finally:
# Clean up
if request.device_path in self.pending_requests:
del self.pending_requests[request.device_path]
if request.response_error:
raise Rejected()
return request.response_value
def _create_request(
self,
request_type: PairingRequestType,
device_path: str,
passkey: int | None = None,
uuid: str | None = None,
) -> PairingRequest:
"""Create and store a new pairing request."""
address = path_to_address(device_path)
request = PairingRequest(
request_type=request_type,
device_path=device_path,
device_address=address,
passkey=passkey,
uuid=uuid,
)
self.pending_requests[device_path] = request
return request
# ==================== Agent1 Interface Methods ====================
@method()
def Release(self) -> None:
"""Called when the agent is unregistered."""
# Clear any pending requests
for req in self.pending_requests.values():
req.response_error = "released"
req.response_event.set()
self.pending_requests.clear()
@method()
async def RequestPinCode(self, device: "o") -> "s": # noqa: F821
"""Request PIN code for pairing.
Legacy pairing method - returns a string PIN (usually 4-6 digits).
"""
if self.mode == PairingMode.AUTO:
return "0000" # Default PIN for auto mode
request = self._create_request(PairingRequestType.PIN_CODE, device)
# For interactive/elicit, wait for response
response = await self._wait_for_response(request)
return str(response) if response else "0000"
@method()
def DisplayPinCode(self, device: "o", pincode: "s") -> None: # noqa: F821
"""Display PIN code to the user.
Called when the remote device generated the PIN.
"""
# Store as info - the MCP client can retrieve this
self._create_request(PairingRequestType.PIN_CODE, device)
self.pending_requests[device].response_value = pincode
# For display, we don't wait - just inform
# The MCP logging will show this
@method()
async def RequestPasskey(self, device: "o") -> "u": # noqa: F821
"""Request numeric passkey for pairing.
Returns a 6-digit numeric passkey (0-999999).
"""
if self.mode == PairingMode.AUTO:
return 0 # Accept any passkey in auto mode
request = self._create_request(PairingRequestType.PASSKEY, device)
response = await self._wait_for_response(request)
return int(response) if response else 0
@method()
def DisplayPasskey(self, device: "o", passkey: "u", entered: "q") -> None: # noqa: F821
"""Display passkey with progress indicator.
Called to show the passkey being entered on the remote device.
"""
# Store for display purposes
if device not in self.pending_requests:
self._create_request(PairingRequestType.PASSKEY, device, passkey=passkey)
self.pending_requests[device].passkey = passkey
# entered shows how many digits have been entered
@method()
async def RequestConfirmation(self, device: "o", passkey: "u") -> None: # noqa: F821
"""Request confirmation of a passkey.
User should confirm the displayed passkey matches the remote device.
Raise Rejected() to reject, return normally to accept.
"""
if self.mode == PairingMode.AUTO:
return # Auto-accept
request = self._create_request(
PairingRequestType.CONFIRMATION, device, passkey=passkey
)
await self._wait_for_response(request)
# If we get here without exception, confirmation accepted
@method()
async def RequestAuthorization(self, device: "o") -> None: # noqa: F821
"""Request authorization for pairing.
Called when the remote device wants to pair without PIN.
"""
if self.mode == PairingMode.AUTO:
return # Auto-accept
request = self._create_request(PairingRequestType.AUTHORIZATION, device)
await self._wait_for_response(request)
@method()
async def AuthorizeService(self, device: "o", uuid: "s") -> None: # noqa: F821
"""Authorize a service connection.
Called when a device wants to connect to a specific service.
"""
if self.mode == PairingMode.AUTO:
return # Auto-accept
request = self._create_request(
PairingRequestType.SERVICE_AUTH, device, uuid=uuid
)
await self._wait_for_response(request)
@method()
def Cancel(self) -> None:
"""Cancel any ongoing pairing operation."""
# Cancel all pending requests
for req in self.pending_requests.values():
req.response_error = "canceled"
req.response_event.set()
# Global agent instance and bus
_agent: BlueZAgent | None = None
_agent_bus: MessageBus | None = None
_agent_registered: bool = False
async def get_agent() -> BlueZAgent:
"""Get or create the global agent instance."""
global _agent, _agent_bus, _agent_registered
if _agent is None:
_agent = BlueZAgent(PairingMode.INTERACTIVE)
if _agent_bus is None:
_agent_bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
# Export the agent interface
_agent_bus.export(AGENT_PATH, _agent)
if not _agent_registered:
try:
# Get AgentManager1 interface
introspection = await _agent_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
proxy = _agent_bus.get_proxy_object(
BLUEZ_SERVICE, "/org/bluez", introspection
)
agent_manager = proxy.get_interface(AGENT_MANAGER_IFACE)
# Register our agent
await agent_manager.call_register_agent(AGENT_PATH, AGENT_CAPABILITY)
await agent_manager.call_request_default_agent(AGENT_PATH)
_agent_registered = True
except Exception as e:
# Agent might already be registered or other error
# Log but don't fail - pairing might still work with default agent
print(f"Warning: Could not register agent: {e}")
return _agent
async def unregister_agent() -> None:
"""Unregister the agent from BlueZ."""
global _agent, _agent_bus, _agent_registered
if _agent_bus and _agent_registered:
try:
introspection = await _agent_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
proxy = _agent_bus.get_proxy_object(
BLUEZ_SERVICE, "/org/bluez", introspection
)
agent_manager = proxy.get_interface(AGENT_MANAGER_IFACE)
await agent_manager.call_unregister_agent(AGENT_PATH)
except Exception:
pass # Ignore errors during cleanup
_agent_registered = False
if _agent_bus:
_agent_bus.disconnect()
_agent_bus = None
_agent = None
def get_pending_requests() -> list[dict]:
"""Get all pending pairing requests as dicts for MCP response."""
if _agent is None:
return []
return [
{
"device_address": req.device_address,
"request_type": req.request_type.value,
"passkey": req.passkey,
"uuid": req.uuid,
"timestamp": req.timestamp.isoformat(),
}
for req in _agent.pending_requests.values()
]
async def respond_to_pairing(
device_address: str,
accept: bool,
pin_or_passkey: str | int | None = None,
) -> bool:
"""Respond to a pending pairing request."""
if _agent is None:
return False
return _agent.respond_to_request(device_address, accept, pin_or_passkey)
async def set_pairing_mode(mode: str) -> None:
"""Set the pairing mode for the agent."""
agent = await get_agent()
agent.set_mode(PairingMode(mode))

View File

@ -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(list(value), options)
await iface.call_write_value(bytearray(value), options)
async def start_notify(self, char_path: str) -> None:
"""Start notifications for a characteristic."""

892
src/mcbluetooth/hfp_ag.py Normal file
View File

@ -0,0 +1,892 @@
"""HFP Audio Gateway implementation for BlueZ.
Registers as an HFP AG (phone role) via BlueZ ProfileManager1, then speaks
the HFP AT command protocol over the RFCOMM socket handed to us by BlueZ
when an HF device (headset) connects.
This allows Linux to simulate a phone for E2E testing of Bluetooth headsets
(like our ESP32 test harness acting as a Hands-Free Unit).
HFP AT command flow (simplified):
1. HF connects BlueZ calls NewConnection with RFCOMM fd
2. SLC negotiation: BRSF features, CIND indicators, CMER enable
3. AG can then simulate: RING (incoming call), +CLIP (caller ID)
4. HF responds: ATA (answer), AT+CHUP (hangup), AT+VGS (volume)
"""
import asyncio
import logging
import os
import socket
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from dbus_fast import BusType, Variant
from dbus_fast.aio import MessageBus
from dbus_fast.service import ServiceInterface, method
log = logging.getLogger(__name__)
# Bluetooth socket constants — use Python's if available, else Linux values.
# Python is often compiled without bluetooth.h, so these may not exist.
_AF_BLUETOOTH = getattr(socket, "AF_BLUETOOTH", 31)
_BTPROTO_RFCOMM = getattr(socket, "BTPROTO_RFCOMM", 3)
# D-Bus constants
BLUEZ_SERVICE = "org.bluez"
PROFILE_MANAGER_IFACE = "org.bluez.ProfileManager1"
HFP_AG_UUID = "0000111f-0000-1000-8000-00805f9b34fb"
HFP_AG_PROFILE_PATH = "/mcbluetooth/hfp_ag"
# HFP 1.7 AG feature flags
HFP_AG_FEATURES = (
(1 << 0) # Three-way calling
| (1 << 1) # EC/NR function
| (1 << 2) # Voice recognition
| (1 << 3) # In-band ring tone
| (1 << 4) # Voice tag
| (1 << 5) # Reject call
| (1 << 6) # Enhanced call status
| (1 << 7) # Enhanced call control
| (1 << 8) # Extended error result codes
| (1 << 9) # Codec negotiation
)
class CallState(Enum):
IDLE = "idle"
INCOMING = "incoming" # ringing
OUTGOING = "outgoing" # dialing
ALERTING = "alerting" # remote ringing
ACTIVE = "active" # in call
HELD = "held"
class CallDirection(Enum):
OUTGOING = 0
INCOMING = 1
@dataclass
class CallInfo:
index: int
direction: CallDirection
state: CallState
number: str = ""
number_type: int = 129 # 129=unknown, 145=international
@dataclass
class HFPConnection:
"""State for a single HFP connection to an HF device."""
device_path: str
address: str
fd: int
sock: socket.socket | None = None
reader: asyncio.StreamReader | None = None
writer: asyncio.StreamWriter | None = None
slc_established: bool = False
codec: str = "cvsd" # cvsd or msbc
hf_features: int = 0
speaker_volume: int = 7
mic_volume: int = 7
calls: list[CallInfo] = field(default_factory=list)
_hf_supports_msbc: bool = False
_clip_enabled: bool = False # Caller line ID presentation
_ccwa_enabled: bool = False # Call waiting notification
_indicator_active: list[bool] = field(default_factory=list) # per-indicator activation (AT+BIA)
_read_task: asyncio.Task | None = field(default=None, repr=False)
_ring_task: asyncio.Task | None = field(default=None, repr=False)
# HFP indicator definitions (order matters — index referenced by +CIEV)
# (name, range, initial_value)
INDICATORS = [
("service", (0, 1), 1), # 1: network available
("call", (0, 1), 0), # 2: active call
("callsetup", (0, 3), 0), # 3: 0=none, 1=incoming, 2=outgoing, 3=alerting
("callheld", (0, 2), 0), # 4: 0=none, 1=held+active, 2=held only
("signal", (0, 5), 5), # 5: signal strength
("roam", (0, 1), 0), # 6: roaming
("battchg", (0, 5), 5), # 7: battery level
]
def _path_to_address(device_path: str) -> str:
parts = device_path.split("/")
if len(parts) >= 5 and parts[-1].startswith("dev_"):
return parts[-1][4:].replace("_", ":")
return device_path
class HFPAudioGatewayProfile(ServiceInterface):
"""D-Bus Profile1 service for HFP Audio Gateway role."""
def __init__(self) -> None:
super().__init__("org.bluez.Profile1")
self.connections: dict[str, HFPConnection] = {} # keyed by device_path
self.indicator_values = [ind[2] for ind in INDICATORS]
self._event_callbacks: list[Any] = []
def on_event(self, callback):
"""Register a callback for HFP events: callback(event_type, data)."""
self._event_callbacks.append(callback)
def _emit(self, event_type: str, data: dict[str, Any]) -> None:
for cb in self._event_callbacks:
try:
cb(event_type, data)
except Exception:
pass
@method()
def Release(self) -> None:
log.info("HFP AG profile released")
for conn in list(self.connections.values()):
self._cleanup_connection(conn)
self.connections.clear()
@method()
def NewConnection(self, device: "o", fd: "h", properties: "a{sv}") -> None:
address = _path_to_address(device)
log.debug("NewConnection called: device=%s fd=%r type=%s props=%s",
device, fd, type(fd).__name__, properties)
if fd is None or (isinstance(fd, int) and fd < 0):
log.error("INVALID fd received: %r — D-Bus FD negotiation may have failed", fd)
return
# Validate the fd is actually usable
try:
stat = os.fstat(fd)
log.debug("fd %d fstat: mode=0o%o", fd, stat.st_mode)
except OSError as e:
log.error("fd %d fstat failed: %s", fd, e)
log.info("HFP AG: NewConnection from %s (fd=%d)", address, fd)
# Duplicate the fd so we own it independent of dbus-fast
try:
new_fd = os.dup(fd)
log.debug("os.dup(%d) → %d", fd, new_fd)
except OSError:
log.exception("os.dup(%d) FAILED", fd)
log.exception("HFP AG: os.dup(%d) failed for %s", fd, address)
return
conn = HFPConnection(
device_path=device,
address=address,
fd=new_fd,
)
self.connections[device] = conn
# Start async AT command handler
loop = asyncio.get_event_loop()
conn._read_task = loop.create_task(self._handle_connection(conn))
self._emit("hfp_ag_connect", {"address": address})
log.debug("NewConnection done, task created for %s", address)
@method()
def RequestDisconnection(self, device: "o") -> None:
address = _path_to_address(device)
log.info("HFP AG: disconnect requested for %s", address)
conn = self.connections.pop(device, None)
if conn:
self._cleanup_connection(conn)
self._emit("hfp_ag_disconnect", {"address": address})
def _cleanup_connection(self, conn: HFPConnection) -> None:
if conn._ring_task and not conn._ring_task.done():
conn._ring_task.cancel()
if conn._read_task and not conn._read_task.done():
conn._read_task.cancel()
if conn.writer:
try:
conn.writer.close()
except Exception:
pass
if conn.sock:
try:
conn.sock.close()
except Exception:
pass
elif conn.fd >= 0:
try:
os.close(conn.fd)
except Exception:
pass
# ==================== AT Command Protocol ====================
async def _handle_connection(self, conn: HFPConnection) -> None:
"""Read loop for AT commands from the HF device."""
try:
log.debug("_handle_connection start: addr=%s fd=%d", conn.address, conn.fd)
# socket.fromfd() dups the fd internally — close our intermediate copy
conn.sock = socket.fromfd(
conn.fd, _AF_BLUETOOTH, socket.SOCK_STREAM,
_BTPROTO_RFCOMM,
)
log.debug("socket.fromfd OK: fileno=%d family=%s type=%s",
conn.sock.fileno(), conn.sock.family, conn.sock.type)
try:
os.close(conn.fd)
except OSError:
pass
conn.fd = -1 # transferred to socket
conn.sock.setblocking(False)
conn.reader, conn.writer = await asyncio.open_connection(sock=conn.sock)
log.debug("asyncio streams ready for %s, entering read loop", conn.address)
buf = b""
while True:
data = await conn.reader.read(1024)
if not data:
log.debug("EOF from %s (clean disconnect)", conn.address)
break
log.debug("recv %d bytes from %s: %r", len(data), conn.address, data[:80])
buf += data
while b"\r" in buf:
line, buf = buf.split(b"\r", 1)
# Strip leading \n if present
line = line.lstrip(b"\n").strip()
if line:
await self._process_at_command(conn, line.decode("utf-8", errors="replace"))
except (ConnectionResetError, BrokenPipeError, OSError) as e:
log.debug("connection error for %s: %s: %s", conn.address, type(e).__name__, e)
except asyncio.CancelledError:
log.debug("task cancelled for %s", conn.address)
except Exception:
log.exception("UNEXPECTED error for %s", conn.address)
finally:
log.debug("cleanup for %s", conn.address)
self.connections.pop(conn.device_path, None)
self._cleanup_connection(conn)
self._emit("hfp_ag_disconnect", {"address": conn.address})
async def _send(self, conn: HFPConnection, response: str) -> None:
"""Send an AT response to the HF device."""
if conn.writer and not conn.writer.is_closing():
data = f"\r\n{response}\r\n".encode()
log.debug("send %d bytes to %s: %r", len(data), conn.address, data)
conn.writer.write(data)
await conn.writer.drain()
log.debug("HFP AG → %s: %s", conn.address, response)
async def _send_ok(self, conn: HFPConnection) -> None:
await self._send(conn, "OK")
async def _send_error(self, conn: HFPConnection) -> None:
await self._send(conn, "ERROR")
async def _process_at_command(self, conn: HFPConnection, line: str) -> None:
"""Parse and handle an AT command from the HF."""
log.debug("HFP AG ← %s: %s", conn.address, line)
cmd = line.strip().upper()
self._emit("hfp_ag_at_recv", {"address": conn.address, "command": line.strip()})
# SLC establishment commands
if cmd.startswith("AT+BRSF="):
await self._handle_brsf(conn, line)
elif cmd == "AT+CIND=?":
await self._handle_cind_test(conn)
elif cmd == "AT+CIND?":
await self._handle_cind_read(conn)
elif cmd.startswith("AT+CMER="):
await self._handle_cmer(conn, line)
elif cmd.startswith("AT+CHLD=?"):
await self._handle_chld_test(conn)
# Codec negotiation
elif cmd.startswith("AT+BAC="):
await self._handle_bac(conn, line)
elif cmd.startswith("AT+BCS="):
await self._handle_bcs(conn, line)
# Call control
elif cmd == "ATA":
await self._handle_answer(conn)
elif cmd == "AT+CHUP":
await self._handle_hangup(conn)
elif cmd.startswith("ATD"):
await self._handle_dial(conn, line)
elif cmd.startswith("AT+DTMF="):
await self._handle_dtmf(conn, line)
elif cmd.startswith("AT+VTS="):
await self._handle_dtmf(conn, line)
# Volume
elif cmd.startswith("AT+VGS="):
await self._handle_vgs(conn, line)
elif cmd.startswith("AT+VGM="):
await self._handle_vgm(conn, line)
# Status queries
elif cmd.startswith("AT+CLCC"):
await self._handle_clcc(conn)
elif cmd.startswith("AT+COPS"):
await self._handle_cops(conn, line)
elif cmd.startswith("AT+CNUM"):
await self._handle_cnum(conn)
# Voice recognition
elif cmd.startswith("AT+BVRA="):
await self._handle_bvra(conn, line)
# Post-SLC configuration
elif cmd.startswith("AT+BIA="):
await self._handle_bia(conn, line)
elif cmd.startswith("AT+CCWA="):
await self._handle_ccwa(conn, line)
elif cmd.startswith("AT+CLIP="):
await self._handle_clip(conn, line)
# Misc
elif cmd == "AT+CMEE=1":
await self._send_ok(conn)
elif cmd.startswith("AT+NREC="):
await self._send_ok(conn) # Noise reduction - acknowledge
elif cmd.startswith("AT+BTRH"):
await self._send_ok(conn)
elif cmd.startswith("AT+BIND"):
await self._handle_bind(conn, line)
elif cmd.startswith("AT+XAPL="):
await self._handle_xapl(conn, line)
elif cmd == "AT":
await self._send_ok(conn)
else:
log.warning("HFP AG: unknown command from %s: %s", conn.address, line)
await self._send_error(conn)
# ---- SLC negotiation handlers ----
async def _handle_brsf(self, conn: HFPConnection, line: str) -> None:
"""AT+BRSF=<features> — exchange supported features."""
try:
conn.hf_features = int(line.split("=")[1].strip())
except (IndexError, ValueError):
conn.hf_features = 0
await self._send(conn, f"+BRSF: {HFP_AG_FEATURES}")
await self._send_ok(conn)
async def _handle_cind_test(self, conn: HFPConnection) -> None:
"""AT+CIND=? — report indicator mapping."""
parts = []
for name, (lo, hi), _ in INDICATORS:
parts.append(f'("{name}",({lo},{hi}))')
await self._send(conn, "+CIND: " + ",".join(parts))
await self._send_ok(conn)
async def _handle_cind_read(self, conn: HFPConnection) -> None:
"""AT+CIND? — report current indicator values."""
vals = ",".join(str(v) for v in self.indicator_values)
await self._send(conn, f"+CIND: {vals}")
await self._send_ok(conn)
async def _handle_cmer(self, conn: HFPConnection, line: str) -> None:
"""AT+CMER=3,0,0,1 — enable indicator status reporting. SLC done."""
conn.slc_established = True
await self._send_ok(conn)
log.info("HFP AG: SLC established with %s", conn.address)
self._emit("hfp_ag_slc_established", {
"address": conn.address,
"hf_features": conn.hf_features,
})
async def _handle_chld_test(self, conn: HFPConnection) -> None:
"""AT+CHLD=? — report call hold/multiparty capabilities."""
await self._send(conn, "+CHLD: (0,1,2,3)")
await self._send_ok(conn)
async def _handle_bac(self, conn: HFPConnection, line: str) -> None:
"""AT+BAC=<codec_ids> — available codecs from HF.
Just acknowledge and store codec availability. Codec selection (+BCS)
happens after SLC is established, not during setup sending it here
confuses some Bluedroid implementations.
"""
# 1=CVSD, 2=mSBC — store for later codec negotiation
conn._hf_supports_msbc = "2" in line
await self._send_ok(conn)
async def _handle_bcs(self, conn: HFPConnection, line: str) -> None:
"""AT+BCS=<codec_id> — codec confirmation from HF."""
try:
codec_id = int(line.split("=")[1].strip())
conn.codec = "msbc" if codec_id == 2 else "cvsd"
except (IndexError, ValueError):
conn.codec = "cvsd"
await self._send_ok(conn)
log.info("HFP AG: codec selected: %s with %s", conn.codec, conn.address)
# ---- Call control handlers ----
async def _handle_answer(self, conn: HFPConnection) -> None:
"""ATA — HF answers incoming call."""
incoming = [c for c in conn.calls if c.state == CallState.INCOMING]
if incoming:
call = incoming[0]
call.state = CallState.ACTIVE
await self._send_ok(conn)
# Update indicators: call=1, callsetup=0
await self._update_indicator(conn, 2, 1) # call active
await self._update_indicator(conn, 3, 0) # callsetup none
self._emit("hfp_ag_call_answered", {
"address": conn.address,
"number": call.number,
})
# Stop ring task
if conn._ring_task and not conn._ring_task.done():
conn._ring_task.cancel()
conn._ring_task = None
else:
await self._send_error(conn)
async def _handle_hangup(self, conn: HFPConnection) -> None:
"""AT+CHUP — HF hangs up / rejects call."""
active = [c for c in conn.calls if c.state in (CallState.ACTIVE, CallState.INCOMING, CallState.OUTGOING, CallState.ALERTING)]
if active:
call = active[0]
old_state = call.state
conn.calls.remove(call)
await self._send_ok(conn)
# Update indicators
if old_state == CallState.ACTIVE:
await self._update_indicator(conn, 2, 0) # call inactive
elif old_state == CallState.INCOMING:
await self._update_indicator(conn, 3, 0) # callsetup none
self._emit("hfp_ag_call_ended", {
"address": conn.address,
"number": call.number,
"reason": "hangup",
})
# Stop ring task
if conn._ring_task and not conn._ring_task.done():
conn._ring_task.cancel()
conn._ring_task = None
else:
await self._send_ok(conn)
async def _handle_dial(self, conn: HFPConnection, line: str) -> None:
"""ATD<number>; — HF initiates outgoing call."""
number = line[3:].rstrip(";").strip()
call = CallInfo(
index=len(conn.calls) + 1,
direction=CallDirection.OUTGOING,
state=CallState.OUTGOING,
number=number,
)
conn.calls.append(call)
await self._send_ok(conn)
await self._update_indicator(conn, 3, 2) # callsetup=outgoing
self._emit("hfp_ag_outgoing_call", {
"address": conn.address,
"number": number,
})
async def _handle_dtmf(self, conn: HFPConnection, line: str) -> None:
"""AT+VTS=<code> / AT+DTMF=<code> — HF sends DTMF tone."""
try:
code = line.split("=")[1].strip()
except IndexError:
code = ""
await self._send_ok(conn)
self._emit("hfp_ag_dtmf", {"address": conn.address, "code": code})
# ---- Volume handlers ----
async def _handle_vgs(self, conn: HFPConnection, line: str) -> None:
"""AT+VGS=<level> — HF reports speaker volume."""
try:
conn.speaker_volume = int(line.split("=")[1].strip())
except (IndexError, ValueError):
pass
await self._send_ok(conn)
self._emit("hfp_ag_volume", {
"address": conn.address,
"type": "speaker",
"level": conn.speaker_volume,
})
async def _handle_vgm(self, conn: HFPConnection, line: str) -> None:
"""AT+VGM=<level> — HF reports microphone volume."""
try:
conn.mic_volume = int(line.split("=")[1].strip())
except (IndexError, ValueError):
pass
await self._send_ok(conn)
self._emit("hfp_ag_volume", {
"address": conn.address,
"type": "microphone",
"level": conn.mic_volume,
})
# ---- Status queries ----
async def _handle_clcc(self, conn: HFPConnection) -> None:
"""AT+CLCC — list current calls."""
for call in conn.calls:
state_map = {
CallState.ACTIVE: 0,
CallState.HELD: 1,
CallState.OUTGOING: 2,
CallState.ALERTING: 3,
CallState.INCOMING: 4,
}
stat = state_map.get(call.state, 0)
await self._send(
conn,
f"+CLCC: {call.index},{call.direction.value},{stat},0,0"
+ (f',"{call.number}",{call.number_type}' if call.number else ""),
)
await self._send_ok(conn)
async def _handle_cops(self, conn: HFPConnection, line: str) -> None:
"""AT+COPS — operator name query."""
if "?" in line:
await self._send(conn, '+COPS: 0,0,"mcbluetooth"')
await self._send_ok(conn)
async def _handle_cnum(self, conn: HFPConnection) -> None:
"""AT+CNUM — subscriber number."""
await self._send(conn, '+CNUM: ,"5551234567",129,,4')
await self._send_ok(conn)
async def _handle_bvra(self, conn: HFPConnection, line: str) -> None:
"""AT+BVRA=<state> — voice recognition."""
try:
state = int(line.split("=")[1].strip())
except (IndexError, ValueError):
state = 0
await self._send_ok(conn)
self._emit("hfp_ag_voice_recognition", {
"address": conn.address,
"active": bool(state),
})
async def _handle_bind(self, conn: HFPConnection, line: str) -> None:
"""AT+BIND — HF indicators."""
if "=?" in line:
await self._send(conn, "+BIND: (1,2)") # enhanced safety, battery
await self._send_ok(conn)
elif "?" in line:
await self._send(conn, "+BIND: 1,1")
await self._send(conn, "+BIND: 2,1")
await self._send_ok(conn)
elif "=" in line:
await self._send_ok(conn)
else:
await self._send_ok(conn)
async def _handle_xapl(self, conn: HFPConnection, line: str) -> None:
"""AT+XAPL= — Apple-specific extension. Acknowledge."""
await self._send(conn, "+XAPL=iPhone,7")
await self._send_ok(conn)
# ---- Post-SLC configuration handlers ----
async def _handle_bia(self, conn: HFPConnection, line: str) -> None:
"""AT+BIA=<indrep1>,<indrep2>,... — Bluetooth Indicators Activation.
Each parameter is 0 (deactivate) or 1 (activate) for the corresponding
CIND indicator by position. Controls which +CIEV updates the HF wants.
"""
try:
params = line.split("=", 1)[1].strip()
flags = [v.strip() == "1" for v in params.split(",")]
except (IndexError, ValueError):
flags = []
conn._indicator_active = flags
await self._send_ok(conn)
async def _handle_ccwa(self, conn: HFPConnection, line: str) -> None:
"""AT+CCWA=<n> — Call Waiting Notification enable/disable."""
try:
conn._ccwa_enabled = line.split("=")[1].strip() == "1"
except IndexError:
conn._ccwa_enabled = False
await self._send_ok(conn)
async def _handle_clip(self, conn: HFPConnection, line: str) -> None:
"""AT+CLIP=<n> — Calling Line Identification Presentation enable/disable."""
try:
conn._clip_enabled = line.split("=")[1].strip() == "1"
except IndexError:
conn._clip_enabled = False
await self._send_ok(conn)
# ==================== AG-initiated actions ====================
async def _update_indicator(self, conn: HFPConnection, index: int, value: int) -> None:
"""Send +CIEV indicator update to HF (1-based index).
Respects AT+BIA activation flags if the HF deactivated this indicator,
we still store the value but don't send +CIEV over the air.
"""
if 1 <= index <= len(self.indicator_values):
self.indicator_values[index - 1] = value
if conn.slc_established:
# Check AT+BIA flags (0-indexed); if no flags set, send all
bia_idx = index - 1
if conn._indicator_active and bia_idx < len(conn._indicator_active):
if not conn._indicator_active[bia_idx]:
return # HF doesn't want this indicator
await self._send(conn, f"+CIEV: {index},{value}")
async def simulate_incoming_call(
self, address: str, number: str = "5551234567", number_type: int = 129
) -> bool:
"""Simulate an incoming call from the AG to the HF device.
Returns True if the call was initiated successfully.
"""
conn = self._get_connection(address)
if not conn or not conn.slc_established:
return False
call = CallInfo(
index=len(conn.calls) + 1,
direction=CallDirection.INCOMING,
state=CallState.INCOMING,
number=number,
number_type=number_type,
)
conn.calls.append(call)
# Update callsetup indicator: 1 = incoming
await self._update_indicator(conn, 3, 1)
# Start ringing
async def ring_loop():
try:
while call.state == CallState.INCOMING:
await self._send(conn, "RING")
if conn._clip_enabled:
await self._send(conn, f'+CLIP: "{number}",{number_type}')
await asyncio.sleep(3.0)
except asyncio.CancelledError:
pass
except Exception:
pass
conn._ring_task = asyncio.get_event_loop().create_task(ring_loop())
self._emit("hfp_ag_incoming_call", {
"address": address,
"number": number,
})
return True
async def simulate_call_end(self, address: str) -> bool:
"""End any active, ringing, or outgoing call from AG side."""
conn = self._get_connection(address)
if not conn:
return False
active = [c for c in conn.calls if c.state in (
CallState.ACTIVE, CallState.INCOMING,
CallState.OUTGOING, CallState.ALERTING,
)]
if not active:
return False
call = active[0]
was_active = call.state == CallState.ACTIVE
conn.calls.remove(call)
if conn._ring_task and not conn._ring_task.done():
conn._ring_task.cancel()
conn._ring_task = None
if was_active:
await self._update_indicator(conn, 2, 0) # call inactive
await self._update_indicator(conn, 3, 0) # callsetup none
self._emit("hfp_ag_call_ended", {
"address": address,
"number": call.number,
"reason": "ag_hangup",
})
return True
async def set_signal_strength(self, address: str, level: int) -> bool:
"""Update signal strength indicator (0-5)."""
conn = self._get_connection(address)
if not conn:
return False
level = max(0, min(5, level))
await self._update_indicator(conn, 5, level)
return True
async def set_battery_level(self, address: str, level: int) -> bool:
"""Update battery level indicator (0-5)."""
conn = self._get_connection(address)
if not conn:
return False
level = max(0, min(5, level))
await self._update_indicator(conn, 7, level)
return True
async def set_speaker_volume(self, address: str, level: int) -> bool:
"""Send volume change to HF device (0-15)."""
conn = self._get_connection(address)
if not conn or not conn.slc_established:
return False
level = max(0, min(15, level))
conn.speaker_volume = level
await self._send(conn, f"+VGS: {level}")
return True
async def set_mic_volume(self, address: str, level: int) -> bool:
"""Send mic volume change to HF device (0-15)."""
conn = self._get_connection(address)
if not conn or not conn.slc_established:
return False
level = max(0, min(15, level))
conn.mic_volume = level
await self._send(conn, f"+VGM: {level}")
return True
def get_connection(self, address: str) -> HFPConnection | None:
"""Get connection by address (public API)."""
return self._get_connection(address)
def _get_connection(self, address: str) -> HFPConnection | None:
for conn in self.connections.values():
if conn.address.upper() == address.upper():
return conn
return None
def get_status(self) -> dict[str, Any]:
"""Get overall HFP AG status."""
conns = []
for conn in self.connections.values():
calls = []
for call in conn.calls:
calls.append({
"index": call.index,
"direction": call.direction.name.lower(),
"state": call.state.value,
"number": call.number,
})
conns.append({
"address": conn.address,
"slc_established": conn.slc_established,
"codec": conn.codec,
"speaker_volume": conn.speaker_volume,
"mic_volume": conn.mic_volume,
"calls": calls,
})
return {
"registered": _profile_registered,
"connections": conns,
"indicators": {
ind[0]: self.indicator_values[i]
for i, ind in enumerate(INDICATORS)
},
}
# ==================== Module-level lifecycle ====================
_profile: HFPAudioGatewayProfile | None = None
_profile_bus: MessageBus | None = None
_profile_registered: bool = False
async def enable_hfp_ag() -> HFPAudioGatewayProfile:
"""Register the HFP AG profile with BlueZ."""
global _profile, _profile_bus, _profile_registered
if _profile_registered and _profile:
return _profile
if _profile is None:
_profile = HFPAudioGatewayProfile()
if _profile_bus is None:
_profile_bus = await MessageBus(
bus_type=BusType.SYSTEM,
negotiate_unix_fd=True, # Required: BlueZ passes RFCOMM fd via D-Bus
).connect()
log.debug("D-Bus connected: negotiate_unix_fd=%s unique_name=%s",
_profile_bus._negotiate_unix_fd, _profile_bus.unique_name)
_profile_bus.export(HFP_AG_PROFILE_PATH, _profile)
# Register with ProfileManager1
introspection = await _profile_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
proxy = _profile_bus.get_proxy_object(BLUEZ_SERVICE, "/org/bluez", introspection)
profile_mgr = proxy.get_interface(PROFILE_MANAGER_IFACE)
options = {
"Name": Variant("s", "mcbluetooth HFP AG"),
"Role": Variant("s", "server"),
"Channel": Variant("q", 13), # RFCOMM channel for HFP
"Features": Variant("q", HFP_AG_FEATURES & 0xFFFF),
"Version": Variant("q", 0x0107), # HFP 1.7
}
try:
await profile_mgr.call_register_profile(
HFP_AG_PROFILE_PATH,
HFP_AG_UUID,
options,
)
_profile_registered = True
log.info("HFP AG profile registered with BlueZ")
except Exception as e:
if "Already Exists" in str(e):
# Stale registration from a previous process that didn't clean up.
# Unregister the orphaned profile, then re-register with our bus.
log.info("HFP AG profile stale — unregistering and re-registering")
try:
await profile_mgr.call_unregister_profile(HFP_AG_PROFILE_PATH)
except Exception:
pass
await profile_mgr.call_register_profile(
HFP_AG_PROFILE_PATH,
HFP_AG_UUID,
options,
)
_profile_registered = True
log.info("HFP AG profile re-registered with BlueZ")
else:
raise
return _profile
async def disable_hfp_ag() -> None:
"""Unregister the HFP AG profile."""
global _profile, _profile_bus, _profile_registered
if _profile_bus and _profile_registered:
try:
introspection = await _profile_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
proxy = _profile_bus.get_proxy_object(BLUEZ_SERVICE, "/org/bluez", introspection)
profile_mgr = proxy.get_interface(PROFILE_MANAGER_IFACE)
await profile_mgr.call_unregister_profile(HFP_AG_PROFILE_PATH)
except Exception:
pass
_profile_registered = False
if _profile:
_profile.Release()
_profile = None
if _profile_bus:
_profile_bus.disconnect()
_profile_bus = None
async def get_hfp_ag() -> HFPAudioGatewayProfile | None:
"""Get the current HFP AG profile instance (None if not enabled)."""
return _profile

View File

@ -0,0 +1,887 @@
"""obexd D-Bus client wrapper using dbus-fast.
This module provides an async interface to the BlueZ OBEX daemon via D-Bus.
obexd handles Object Exchange (OBEX) profiles:
- OPP (Object Push Profile): Simple file transfer
- FTP (File Transfer Profile): File browsing and transfer
- PBAP (Phonebook Access Profile): Contact/phonebook access
- MAP (Message Access Profile): SMS/MMS/email access
Object paths follow this pattern:
- /org/bluez/obex - Client
- /org/bluez/obex/client/sessionN - Session
- /org/bluez/obex/client/sessionN/transferM - Transfer
Note: obexd runs as a user service and uses the SESSION bus, not system bus.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import Any, Literal
from dbus_fast import BusType, Variant
from dbus_fast.aio import MessageBus, ProxyInterface
# obexd constants
OBEX_SERVICE = "org.bluez.obex"
OBEX_CLIENT_PATH = "/org/bluez/obex"
OBEX_CLIENT_IFACE = "org.bluez.obex.Client1"
OBEX_SESSION_IFACE = "org.bluez.obex.Session1"
OBEX_TRANSFER_IFACE = "org.bluez.obex.Transfer1"
OBEX_OPP_IFACE = "org.bluez.obex.ObjectPush1"
OBEX_FTP_IFACE = "org.bluez.obex.FileTransfer1"
OBEX_PBAP_IFACE = "org.bluez.obex.PhonebookAccess1"
OBEX_MAP_IFACE = "org.bluez.obex.MessageAccess1"
DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
# OBEX target UUIDs
OBEX_TARGETS = {
"opp": "00001105-0000-1000-8000-00805f9b34fb", # Object Push
"ftp": "00001106-0000-1000-8000-00805f9b34fb", # File Transfer
"pbap": "0000112f-0000-1000-8000-00805f9b34fb", # PBAP PCE
"map": "00001132-0000-1000-8000-00805f9b34fb", # MAP MCE
}
# Target type alias
ObexTarget = Literal["opp", "ftp", "pbap", "map"]
def unwrap_variant(value: Any) -> Any:
"""Recursively unwrap dbus-fast Variant objects to plain Python types."""
if isinstance(value, Variant):
return unwrap_variant(value.value)
elif isinstance(value, dict):
return {k: unwrap_variant(v) for k, v in value.items()}
elif isinstance(value, list):
return [unwrap_variant(v) for v in value]
return value
def generate_session_id(address: str, target: str) -> str:
"""Generate a friendly session ID like 'ftp_C87B235568E8'."""
addr_short = address.replace(":", "").upper()
return f"{target}_{addr_short}"
@dataclass
class ObexSession:
"""Information about an active OBEX session."""
session_id: str
path: str
address: str
target: ObexTarget
source: str = "" # Local adapter address
root: str = "" # Root folder (FTP only)
created: float = field(default_factory=time.time)
@dataclass
class ObexTransfer:
"""Information about an OBEX transfer."""
path: str
status: str # "queued", "active", "suspended", "complete", "error"
name: str = ""
size: int = 0
transferred: int = 0
filename: str = ""
session: str = ""
@dataclass
class FolderEntry:
"""Entry in an FTP folder listing."""
name: str
type: str # "folder" or "file"
size: int = 0
permissions: str = ""
modified: str = ""
@dataclass
class PhonebookEntry:
"""Entry in a phonebook listing."""
handle: str
name: str = ""
phone: str = ""
@dataclass
class MessageEntry:
"""Entry in a message listing."""
handle: str
subject: str = ""
datetime: str = ""
sender_name: str = ""
sender_address: str = ""
type: str = "" # SMS, MMS, EMAIL
read: bool = False
class ObexClient:
"""Async client for obexd D-Bus API."""
def __init__(self):
self._bus: MessageBus | None = None
self._client: ProxyInterface | None = None
self._sessions: dict[str, ObexSession] = {}
async def connect(self) -> None:
"""Connect to the session D-Bus."""
if self._bus is not None:
return
self._bus = await MessageBus(bus_type=BusType.SESSION).connect()
# Get the Client interface
introspection = await self._bus.introspect(OBEX_SERVICE, OBEX_CLIENT_PATH)
proxy = self._bus.get_proxy_object(OBEX_SERVICE, OBEX_CLIENT_PATH, introspection)
self._client = proxy.get_interface(OBEX_CLIENT_IFACE)
async def disconnect(self) -> None:
"""Disconnect from D-Bus and clean up sessions."""
# Remove all active sessions
for session_id in list(self._sessions.keys()):
try:
await self.remove_session(session_id)
except Exception:
pass
if self._bus:
self._bus.disconnect()
self._bus = None
self._client = None
async def _ensure_connected(self) -> None:
"""Ensure we're connected to D-Bus."""
if self._bus is None:
await self.connect()
async def _get_interface(self, path: str, interface: str) -> ProxyInterface:
"""Get a proxy interface for an object."""
await self._ensure_connected()
assert self._bus is not None
introspection = await self._bus.introspect(OBEX_SERVICE, path)
proxy = self._bus.get_proxy_object(OBEX_SERVICE, path, introspection)
return proxy.get_interface(interface)
async def _get_property(self, path: str, interface: str, prop: str) -> Any:
"""Get a single property from an object."""
props_iface = await self._get_interface(path, DBUS_PROPS_IFACE)
result = await props_iface.call_get(interface, prop)
return unwrap_variant(result)
async def _get_all_properties(self, path: str, interface: str) -> dict[str, Any]:
"""Get all properties from an object."""
props_iface = await self._get_interface(path, DBUS_PROPS_IFACE)
props = await props_iface.call_get_all(interface)
return {k: unwrap_variant(v) for k, v in props.items()}
# ==================== Session Management ====================
async def create_session(self, address: str, target: ObexTarget) -> ObexSession:
"""Create an OBEX session to a device.
Args:
address: Bluetooth device address
target: OBEX target profile (opp, ftp, pbap, map)
Returns:
ObexSession with session details
"""
await self._ensure_connected()
assert self._client is not None
# Build session options
options: dict[str, Variant] = {
"Target": Variant("s", target),
}
# Create the session
session_path = await self._client.call_create_session(address, options)
# Get session properties
props = await self._get_all_properties(session_path, OBEX_SESSION_IFACE)
# Generate session ID and store
session_id = generate_session_id(address, target)
# Handle duplicate sessions by appending timestamp
if session_id in self._sessions:
session_id = f"{session_id}_{int(time.time())}"
session = ObexSession(
session_id=session_id,
path=session_path,
address=address,
target=target,
source=props.get("Source", ""),
root=props.get("Root", ""),
)
self._sessions[session_id] = session
return session
async def remove_session(self, session_id: str) -> None:
"""Remove/close an OBEX session.
Args:
session_id: Session ID from create_session
"""
if session_id not in self._sessions:
raise ValueError(f"Unknown session: {session_id}")
session = self._sessions[session_id]
await self._ensure_connected()
assert self._client is not None
await self._client.call_remove_session(session.path)
del self._sessions[session_id]
def get_session(self, session_id: str) -> ObexSession | None:
"""Get a session by ID."""
return self._sessions.get(session_id)
def list_sessions(self) -> list[ObexSession]:
"""List all active sessions."""
return list(self._sessions.values())
async def get_session_info(self, session_id: str) -> dict[str, Any]:
"""Get detailed session info from D-Bus."""
if session_id not in self._sessions:
raise ValueError(f"Unknown session: {session_id}")
session = self._sessions[session_id]
props = await self._get_all_properties(session.path, OBEX_SESSION_IFACE)
return {
"session_id": session_id,
"path": session.path,
"address": session.address,
"target": session.target,
**props,
}
# ==================== Transfer Operations ====================
async def get_transfer_status(self, transfer_path: str) -> ObexTransfer:
"""Get the status of a transfer."""
props = await self._get_all_properties(transfer_path, OBEX_TRANSFER_IFACE)
return ObexTransfer(
path=transfer_path,
status=props.get("Status", "unknown"),
name=props.get("Name", ""),
size=props.get("Size", 0),
transferred=props.get("Transferred", 0),
filename=props.get("Filename", ""),
session=props.get("Session", ""),
)
async def wait_for_transfer(
self,
transfer_path: str,
timeout: int = 300,
poll_interval: float = 0.5,
) -> ObexTransfer:
"""Wait for a transfer to complete.
Args:
transfer_path: D-Bus path of the transfer
timeout: Maximum wait time in seconds
poll_interval: How often to check status
Returns:
Final transfer status
"""
start_time = time.time()
while True:
transfer = await self.get_transfer_status(transfer_path)
if transfer.status in ("complete", "error"):
return transfer
if time.time() - start_time > timeout:
raise TimeoutError(f"Transfer timed out after {timeout}s")
await asyncio.sleep(poll_interval)
async def cancel_transfer(self, transfer_path: str) -> None:
"""Cancel an active transfer."""
iface = await self._get_interface(transfer_path, OBEX_TRANSFER_IFACE)
await iface.call_cancel()
# ==================== OPP (Object Push) Operations ====================
async def opp_send_file(self, session_id: str, local_path: str) -> str:
"""Send a file via Object Push Profile.
Args:
session_id: Session ID (must be OPP session)
local_path: Path to local file to send
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "opp":
raise ValueError(f"Session {session_id} is not an OPP session")
iface = await self._get_interface(session.path, OBEX_OPP_IFACE)
transfer_path, _ = await iface.call_send_file(local_path)
return transfer_path
async def opp_pull_business_card(self, session_id: str, target_path: str) -> str:
"""Pull the default business card (vCard) from device.
Args:
session_id: Session ID (must be OPP session)
target_path: Local path to save the vCard
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "opp":
raise ValueError(f"Session {session_id} is not an OPP session")
iface = await self._get_interface(session.path, OBEX_OPP_IFACE)
transfer_path, _ = await iface.call_pull_business_card(target_path)
return transfer_path
async def opp_exchange_business_cards(
self, session_id: str, client_vcard: str, target_path: str
) -> str:
"""Exchange business cards with device.
Args:
session_id: Session ID
client_vcard: Path to local vCard to send
target_path: Path to save received vCard
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "opp":
raise ValueError(f"Session {session_id} is not an OPP session")
iface = await self._get_interface(session.path, OBEX_OPP_IFACE)
transfer_path, _ = await iface.call_exchange_business_cards(client_vcard, target_path)
return transfer_path
# ==================== FTP (File Transfer) Operations ====================
async def ftp_change_folder(self, session_id: str, folder: str) -> None:
"""Change the current folder.
Args:
session_id: Session ID (must be FTP session)
folder: Folder name, ".." for parent, or "/" for root
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
# obexd uses different methods for navigation
if folder == "/":
# Go to root
await iface.call_change_folder("/")
elif folder == "..":
# Go to parent
await iface.call_change_folder("..")
else:
await iface.call_change_folder(folder)
async def ftp_list_folder(self, session_id: str) -> list[FolderEntry]:
"""List contents of current folder.
Args:
session_id: Session ID (must be FTP session)
Returns:
List of folder entries
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
result = await iface.call_list_folder()
entries = []
for item in result:
item = unwrap_variant(item)
entries.append(
FolderEntry(
name=item.get("Name", ""),
type=item.get("Type", "file"),
size=item.get("Size", 0),
permissions=item.get("User-perm", ""),
modified=item.get("Modified", ""),
)
)
return entries
async def ftp_get_file(
self, session_id: str, remote_path: str, local_path: str
) -> str:
"""Download a file from the device.
Args:
session_id: Session ID (must be FTP session)
remote_path: Remote file name in current folder
local_path: Local path to save the file
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
transfer_path, _ = await iface.call_get_file(local_path, remote_path)
return transfer_path
async def ftp_put_file(
self, session_id: str, local_path: str, remote_path: str
) -> str:
"""Upload a file to the device.
Args:
session_id: Session ID (must be FTP session)
local_path: Local file path
remote_path: Remote file name in current folder
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
transfer_path, _ = await iface.call_put_file(local_path, remote_path)
return transfer_path
async def ftp_copy_file(
self, session_id: str, source: str, destination: str
) -> None:
"""Copy a file on the device.
Args:
session_id: Session ID (must be FTP session)
source: Source file name
destination: Destination file name
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
await iface.call_copy_file(source, destination)
async def ftp_move_file(
self, session_id: str, source: str, destination: str
) -> None:
"""Move/rename a file on the device.
Args:
session_id: Session ID (must be FTP session)
source: Source file name
destination: Destination file name
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
await iface.call_move_file(source, destination)
async def ftp_delete(self, session_id: str, name: str) -> None:
"""Delete a file or folder on the device.
Args:
session_id: Session ID (must be FTP session)
name: File or folder name to delete
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
await iface.call_delete(name)
async def ftp_create_folder(self, session_id: str, name: str) -> None:
"""Create a folder on the device.
Args:
session_id: Session ID (must be FTP session)
name: Folder name to create
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "ftp":
raise ValueError(f"Session {session_id} is not an FTP session")
iface = await self._get_interface(session.path, OBEX_FTP_IFACE)
await iface.call_create_folder(name)
# ==================== PBAP (Phonebook) Operations ====================
async def pbap_select(self, session_id: str, location: str, phonebook: str) -> None:
"""Select a phonebook for subsequent operations.
Args:
session_id: Session ID (must be PBAP session)
location: "int" (internal) or "sim" (SIM card)
phonebook: Phonebook type - "pb" (contacts), "ich" (incoming calls),
"och" (outgoing calls), "mch" (missed calls), "cch" (combined)
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "pbap":
raise ValueError(f"Session {session_id} is not a PBAP session")
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
await iface.call_select(location, phonebook)
async def pbap_pull_all(self, session_id: str, target_path: str) -> str:
"""Download the entire phonebook as vCard.
Args:
session_id: Session ID (must be PBAP session)
target_path: Local path to save the vCards
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "pbap":
raise ValueError(f"Session {session_id} is not a PBAP session")
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
transfer_path, _ = await iface.call_pull_all(target_path, {})
return transfer_path
async def pbap_list(self, session_id: str) -> list[PhonebookEntry]:
"""List phonebook entries (handles and names only).
Args:
session_id: Session ID (must be PBAP session)
Returns:
List of phonebook entries with handles
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "pbap":
raise ValueError(f"Session {session_id} is not a PBAP session")
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
result = await iface.call_list({})
entries = []
for item in result:
item = unwrap_variant(item)
entries.append(
PhonebookEntry(
handle=item.get("Handle", ""),
name=item.get("Name", ""),
)
)
return entries
async def pbap_pull(self, session_id: str, handle: str, target_path: str) -> str:
"""Download a specific phonebook entry.
Args:
session_id: Session ID (must be PBAP session)
handle: Entry handle from pbap_list
target_path: Local path to save the vCard
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "pbap":
raise ValueError(f"Session {session_id} is not a PBAP session")
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
transfer_path, _ = await iface.call_pull(handle, target_path, {})
return transfer_path
async def pbap_search(
self, session_id: str, field: str, value: str
) -> list[PhonebookEntry]:
"""Search the phonebook.
Args:
session_id: Session ID (must be PBAP session)
field: Field to search - "name", "number", or "sound"
value: Value to search for
Returns:
List of matching entries
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "pbap":
raise ValueError(f"Session {session_id} is not a PBAP session")
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
result = await iface.call_search(field, value, {})
entries = []
for item in result:
item = unwrap_variant(item)
entries.append(
PhonebookEntry(
handle=item.get("Handle", ""),
name=item.get("Name", ""),
)
)
return entries
async def pbap_get_size(self, session_id: str) -> int:
"""Get the number of entries in the selected phonebook.
Args:
session_id: Session ID (must be PBAP session)
Returns:
Number of entries
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "pbap":
raise ValueError(f"Session {session_id} is not a PBAP session")
iface = await self._get_interface(session.path, OBEX_PBAP_IFACE)
size = await iface.call_get_size()
return size
# ==================== MAP (Message Access) Operations ====================
async def map_set_folder(self, session_id: str, folder: str) -> None:
"""Set the current message folder.
Args:
session_id: Session ID (must be MAP session)
folder: Folder path (e.g., "/telecom/msg/inbox")
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "map":
raise ValueError(f"Session {session_id} is not a MAP session")
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
await iface.call_set_folder(folder)
async def map_list_folders(self, session_id: str) -> list[dict[str, Any]]:
"""List message folders.
Args:
session_id: Session ID (must be MAP session)
Returns:
List of folder dictionaries
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "map":
raise ValueError(f"Session {session_id} is not a MAP session")
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
result = await iface.call_list_folders({})
folders = []
for item in result:
folders.append(unwrap_variant(item))
return folders
async def map_list_messages(
self,
session_id: str,
folder: str,
filters: dict[str, Any] | None = None,
) -> list[MessageEntry]:
"""List messages in a folder.
Args:
session_id: Session ID (must be MAP session)
folder: Folder name
filters: Optional filters (max_count, start_offset, etc.)
Returns:
List of message entries
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "map":
raise ValueError(f"Session {session_id} is not a MAP session")
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
# Convert filters to D-Bus format
dbus_filters: dict[str, Variant] = {}
if filters:
for key, value in filters.items():
if isinstance(value, bool):
dbus_filters[key] = Variant("b", value)
elif isinstance(value, int):
dbus_filters[key] = Variant("u", value)
elif isinstance(value, str):
dbus_filters[key] = Variant("s", value)
result = await iface.call_list_messages(folder, dbus_filters)
entries = []
for item in result:
item = unwrap_variant(item)
entries.append(
MessageEntry(
handle=item.get("Handle", ""),
subject=item.get("Subject", ""),
datetime=item.get("DateTime", ""),
sender_name=item.get("SenderName", ""),
sender_address=item.get("SenderAddress", ""),
type=item.get("Type", ""),
read=item.get("Read", False),
)
)
return entries
async def map_get_message(
self, session_id: str, handle: str, target_path: str, attachment: bool = False
) -> str:
"""Download a specific message.
Args:
session_id: Session ID (must be MAP session)
handle: Message handle
target_path: Local path to save the message
attachment: Whether to include attachments
Returns:
Transfer path for monitoring
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "map":
raise ValueError(f"Session {session_id} is not a MAP session")
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
transfer_path, _ = await iface.call_get_message(
handle, target_path, {"Attachment": Variant("b", attachment)}
)
return transfer_path
async def map_push_message(
self, session_id: str, folder: str, message_path: str
) -> str:
"""Push/send a message.
Args:
session_id: Session ID (must be MAP session)
folder: Target folder
message_path: Path to message file (bMessage format)
Returns:
Message handle
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "map":
raise ValueError(f"Session {session_id} is not a MAP session")
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
handle = await iface.call_push_message(message_path, folder, {})
return handle
async def map_update_inbox(self, session_id: str) -> None:
"""Request the server to refresh the inbox.
Args:
session_id: Session ID (must be MAP session)
"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Unknown session: {session_id}")
if session.target != "map":
raise ValueError(f"Session {session_id} is not a MAP session")
iface = await self._get_interface(session.path, OBEX_MAP_IFACE)
await iface.call_update_inbox()
# Global client instance
_client: ObexClient | None = None
async def get_obex_client() -> ObexClient:
"""Get or create the global OBEX client."""
global _client
if _client is None:
_client = ObexClient()
await _client.connect()
return _client
async def cleanup_stale_sessions() -> None:
"""Clean up sessions that may be stale."""
if _client:
# Check each session is still valid
for session_id in list(_client._sessions.keys()):
try:
await _client.get_session_info(session_id)
except Exception:
# Session no longer exists, remove from tracking
del _client._sessions[session_id]

View File

@ -3,7 +3,7 @@
from fastmcp import FastMCP
from mcbluetooth import resources
from mcbluetooth.tools import adapter, audio, ble, device, monitor
from mcbluetooth.tools import adapter, audio, ble, device, hfp, monitor, obex
mcp = FastMCP(
name="mcbluetooth",
@ -14,6 +14,7 @@ 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
@ -42,8 +43,10 @@ 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():

View File

@ -1,5 +1,5 @@
"""MCP tool modules for Bluetooth management."""
from mcbluetooth.tools import adapter, audio, ble, device, monitor
from mcbluetooth.tools import adapter, audio, ble, device, monitor, obex
__all__ = ["adapter", "device", "audio", "ble", "monitor"]
__all__ = ["adapter", "device", "audio", "ble", "monitor", "obex"]

View File

@ -6,6 +6,8 @@ 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
@ -130,6 +132,7 @@ 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.
@ -144,6 +147,7 @@ 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
@ -160,56 +164,107 @@ 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():
try:
await client.pair_device(adapter, address)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
if pairing_mode == "auto":
# Direct pairing without agent - may fail if PIN required
try:
await client.pair_device(adapter, address)
# 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}
except Exception as e:
else:
if ctx:
await ctx.error(f"Pairing failed: {e}")
return {"status": "error", "error": str(e)}
await ctx.error(f"Pairing failed: {result['error']}")
return {"status": "error", "error": result["error"]}
else:
# For interactive/elicit modes, we need an agent
# For interactive/elicit modes, start pairing in background
# and return immediately if a request is pending
pair_task = asyncio.create_task(do_pair())
# Wait briefly to see if pairing completes quickly or needs input
try:
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}
except Exception as e:
error_msg = str(e)
if "AuthenticationFailed" in error_msg or "AuthenticationCanceled" in error_msg:
result = await asyncio.wait_for(asyncio.shield(pair_task), timeout=3.0)
if result["success"]:
device = await client.get_device(adapter, address)
if ctx:
await ctx.warning("Pairing requires user confirmation or PIN")
await ctx.info(f"Successfully paired with {address}")
return {"status": "paired", "device": asdict(device) if device else None}
else:
# Check if there's a pending request
pending = pairing_agent.get_pending_request(address)
if pending:
if ctx:
await ctx.warning(
f"Pairing requires {pending.request_type.value}: "
f"passkey={pending.passkey}"
)
return {
"status": "awaiting_confirmation",
"request_type": pending.request_type.value,
"passkey": pending.passkey,
"message": "Use bt_pair_confirm to accept or reject",
}
if ctx:
await ctx.error(f"Pairing failed: {result['error']}")
return {"status": "error", "error": result["error"]}
except TimeoutError:
# Pairing is still in progress - check for pending requests
pending = pairing_agent.get_pending_request(address)
if pending:
if ctx:
await ctx.warning(
f"Pairing requires {pending.request_type.value}: "
f"passkey={pending.passkey}"
)
return {
"status": "awaiting_confirmation",
"message": "Pairing requires user confirmation or PIN entry",
"pairing_mode": pairing_mode,
"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.error(f"Pairing failed: {error_msg}")
return {"status": "error", "error": error_msg}
await ctx.info("Pairing in progress, waiting for device...")
return {
"status": "pairing_in_progress",
"message": "Check bt_pairing_status or wait for device response",
}
@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 (usually 4-6 digits)
pin: PIN code if required (string, usually 4-6 digits)
passkey: Numeric passkey if required (0-999999)
accept: True to accept pairing, False to reject
Returns:
@ -217,30 +272,69 @@ 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_cancelled"}
return {"status": "pairing_rejected"}
except Exception as e:
# May already be cancelled
if ctx:
await ctx.error(f"Failed to cancel pairing: {e}")
return {"status": "error", "error": str(e)}
await ctx.debug(f"Cancel pairing: {e}")
return {"status": "pairing_rejected"}
if ctx:
await ctx.info(f"Confirming pairing with {address}")
try:
await client.pair_device(adapter, address)
device = await client.get_device(adapter, address)
# Wait a moment for the agent to process and BlueZ to complete
await asyncio.sleep(1.0)
# Check if pairing succeeded
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) if device else None}
except Exception as e:
return {"status": "paired", "device": asdict(device)}
else:
# Pairing might still be in progress
if ctx:
await ctx.error(f"Pairing confirmation failed: {e}")
return {"status": "error", "error": str(e)}
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),
}
@mcp.tool()
async def bt_unpair(

View File

@ -0,0 +1,212 @@
"""HFP Audio Gateway tools for Bluetooth MCP server.
These tools let Linux act as a phone (Audio Gateway) for testing Bluetooth
headsets and hands-free devices. The ESP32 test harness connects as the
Hands-Free Unit (headset), and these tools simulate call control from the
phone side.
Typical E2E test flow:
1. bt_hfp_ag_enable() Register AG profile with BlueZ
2. ESP32 connects as HF SLC auto-negotiated
3. bt_hfp_ag_simulate_call() Send RING + CLIP to ESP32
4. ESP32 answers (ATA) Call becomes active
5. bt_hfp_ag_end_call() Terminate call
"""
from __future__ import annotations
from typing import Any, Literal
from fastmcp import FastMCP
from mcbluetooth.hfp_ag import (
disable_hfp_ag,
enable_hfp_ag,
get_hfp_ag,
)
def register_tools(mcp: FastMCP) -> None:
"""Register HFP AG tools with the MCP server."""
@mcp.tool()
async def bt_hfp_ag_enable() -> dict[str, Any]:
"""Enable HFP Audio Gateway mode on Linux.
Registers a custom HFP AG profile with BlueZ via ProfileManager1.
After enabling, Bluetooth headsets (HF devices) can connect and
Linux will act as the phone side.
The SLC (Service Level Connection) is auto-negotiated when an HF
device connects feature exchange, indicator setup, and codec
selection happen automatically.
Returns:
Status confirming AG profile registration.
"""
try:
await enable_hfp_ag()
return {"status": "ok", "role": "audio_gateway", "profile": "HFP AG 1.7"}
except Exception as exc:
return {"status": "error", "error": str(exc)}
@mcp.tool()
async def bt_hfp_ag_disable() -> dict[str, Any]:
"""Disable HFP Audio Gateway mode.
Unregisters the AG profile, disconnecting any active HFP sessions.
Returns:
Status confirming AG profile removal.
"""
try:
await disable_hfp_ag()
return {"status": "ok", "disabled": True}
except Exception as exc:
return {"status": "error", "error": str(exc)}
@mcp.tool()
async def bt_hfp_ag_status() -> dict[str, Any]:
"""Get HFP Audio Gateway status.
Returns:
- registered: Whether the AG profile is active
- connections: List of connected HF devices with SLC state,
codec, volumes, and active calls
- indicators: Current indicator values (service, call, signal, etc.)
"""
profile = await get_hfp_ag()
if not profile:
return {"status": "ok", "registered": False, "connections": []}
return {"status": "ok", **profile.get_status()}
@mcp.tool()
async def bt_hfp_ag_simulate_call(
address: str,
number: str = "5551234567",
) -> dict[str, Any]:
"""Simulate an incoming call to a connected HF device.
Sends RING and +CLIP (caller ID) notifications to the headset.
The HF device will see an incoming call and can answer (ATA) or
reject (AT+CHUP).
Ringing repeats every 3 seconds until answered or ended.
Args:
address: Bluetooth address of the connected HF device.
number: Caller phone number to display.
Returns:
Status of call initiation.
"""
profile = await get_hfp_ag()
if not profile:
return {"status": "error", "error": "HFP AG not enabled"}
ok = await profile.simulate_incoming_call(address, number=number)
if ok:
return {"status": "ok", "call_state": "ringing", "number": number}
return {
"status": "error",
"error": "No SLC connection to device (connect HF first)",
}
@mcp.tool()
async def bt_hfp_ag_end_call(address: str) -> dict[str, Any]:
"""End an active or ringing call from the AG side.
Terminates the current call and sends indicator updates to the
HF device.
Args:
address: Bluetooth address of the connected HF device.
Returns:
Status of call termination.
"""
profile = await get_hfp_ag()
if not profile:
return {"status": "error", "error": "HFP AG not enabled"}
ok = await profile.simulate_call_end(address)
if ok:
return {"status": "ok", "call_state": "ended"}
return {"status": "error", "error": "No active call to end"}
@mcp.tool()
async def bt_hfp_ag_set_volume(
address: str,
type: Literal["speaker", "microphone"],
level: int,
) -> dict[str, Any]:
"""Set speaker or microphone volume on the HF device.
Sends +VGS (speaker) or +VGM (mic) to change volume remotely.
Args:
address: Bluetooth address of the connected HF device.
type: "speaker" for output volume, "microphone" for input.
level: Volume level 0-15 (0 = muted, 15 = maximum).
Returns:
Status with applied volume level.
"""
profile = await get_hfp_ag()
if not profile:
return {"status": "error", "error": "HFP AG not enabled"}
if type == "speaker":
ok = await profile.set_speaker_volume(address, level)
else:
ok = await profile.set_mic_volume(address, level)
if ok:
return {"status": "ok", "type": type, "level": level}
return {"status": "error", "error": "No SLC connection to device"}
@mcp.tool()
async def bt_hfp_ag_set_signal(
address: str,
level: int,
) -> dict[str, Any]:
"""Update the signal strength indicator shown on the HF device.
Args:
address: Bluetooth address of the connected HF device.
level: Signal strength 0-5.
Returns:
Status with applied signal level.
"""
profile = await get_hfp_ag()
if not profile:
return {"status": "error", "error": "HFP AG not enabled"}
ok = await profile.set_signal_strength(address, level)
if ok:
return {"status": "ok", "signal_strength": level}
return {"status": "error", "error": "No SLC connection to device"}
@mcp.tool()
async def bt_hfp_ag_set_battery(
address: str,
level: int,
) -> dict[str, Any]:
"""Update the battery level indicator shown on the HF device.
Args:
address: Bluetooth address of the connected HF device.
level: Battery level 0-5.
Returns:
Status with applied battery level.
"""
profile = await get_hfp_ag()
if not profile:
return {"status": "error", "error": "HFP AG not enabled"}
ok = await profile.set_battery_level(address, level)
if ok:
return {"status": "ok", "battery_level": level}
return {"status": "error", "error": "No SLC connection to device"}

View File

@ -9,8 +9,6 @@ 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