Initial implementation of mcbluetooth MCP server
Comprehensive BlueZ MCP server exposing Linux Bluetooth stack to LLMs: - Adapter management (list, power, discoverable, pairable) - Device discovery and management (scan, pair, connect, trust) - Audio profiles with PipeWire/PulseAudio integration (A2DP/HFP switching) - BLE/GATT support (services, characteristics, read/write/notify) Built on: - FastMCP 2.14.4 for MCP protocol - dbus-fast 4.0.0 for async BlueZ D-Bus communication - pulsectl-asyncio 1.2.2 for audio control
This commit is contained in:
commit
013cd0eb2f
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# uv
|
||||
.uv/
|
||||
uv.lock
|
||||
|
||||
# Project specific
|
||||
*.log
|
||||
154
README.md
Normal file
154
README.md
Normal file
@ -0,0 +1,154 @@
|
||||
# mcbluetooth
|
||||
|
||||
A comprehensive MCP server exposing the Linux Bluetooth stack (BlueZ) to LLMs.
|
||||
|
||||
## Features
|
||||
|
||||
### Adapter Management
|
||||
- List, power, and configure Bluetooth adapters
|
||||
- Control discoverable and pairable states
|
||||
- Set adapter aliases
|
||||
|
||||
### 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 .
|
||||
```
|
||||
|
||||
## Usage with Claude Code
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with BlueZ 5.x
|
||||
- Python 3.11+
|
||||
- PipeWire or PulseAudio (for audio features)
|
||||
- User must be in `bluetooth` group or have polkit permissions
|
||||
|
||||
### Permissions
|
||||
|
||||
```bash
|
||||
# Add user to bluetooth group
|
||||
sudo usermod -aG bluetooth $USER
|
||||
|
||||
# Or configure polkit for BlueZ D-Bus access
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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 │ │
|
||||
│ │ Tools │ Tools │ Tools │ Tools │ │
|
||||
│ └─────────┴─────────┴─────────┴─────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ BlueZ D-Bus Client Layer │
|
||||
│ (dbus-fast) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ PipeWire/PulseAudio Integration │
|
||||
│ (pulsectl-asyncio) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
61
pyproject.toml
Normal file
61
pyproject.toml
Normal file
@ -0,0 +1,61 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mcbluetooth"
|
||||
version = "2026.02.02"
|
||||
description = "Comprehensive BlueZ MCP server - expose the full Linux Bluetooth stack to LLMs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
authors = [
|
||||
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
||||
]
|
||||
keywords = ["bluetooth", "bluez", "mcp", "fastmcp", "dbus", "ble", "gatt"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Hardware",
|
||||
"Topic :: Communications",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastmcp>=2.14.4",
|
||||
"dbus-fast>=4.0.0",
|
||||
"pulsectl-asyncio>=1.2.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcbluetooth = "mcbluetooth:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ryanmalloy/mcbluetooth"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcbluetooth"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
5
src/mcbluetooth/__init__.py
Normal file
5
src/mcbluetooth/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""mcbluetooth - Comprehensive BlueZ MCP server for Linux Bluetooth management."""
|
||||
|
||||
from mcbluetooth.server import main, mcp
|
||||
|
||||
__all__ = ["mcp", "main"]
|
||||
300
src/mcbluetooth/audio.py
Normal file
300
src/mcbluetooth/audio.py
Normal file
@ -0,0 +1,300 @@
|
||||
"""PipeWire/PulseAudio integration for Bluetooth audio control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pulsectl_asyncio
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioSink:
|
||||
"""Information about an audio sink (output device)."""
|
||||
|
||||
name: str
|
||||
index: int
|
||||
description: str
|
||||
muted: bool
|
||||
volume_percent: int
|
||||
is_bluetooth: bool
|
||||
bluetooth_address: str | None
|
||||
state: str # "running", "idle", "suspended"
|
||||
properties: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioSource:
|
||||
"""Information about an audio source (input device)."""
|
||||
|
||||
name: str
|
||||
index: int
|
||||
description: str
|
||||
muted: bool
|
||||
volume_percent: int
|
||||
is_bluetooth: bool
|
||||
bluetooth_address: str | None
|
||||
state: str
|
||||
properties: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioCard:
|
||||
"""Information about an audio card (device with profiles)."""
|
||||
|
||||
name: str
|
||||
index: int
|
||||
driver: str
|
||||
active_profile: str
|
||||
profiles: list[str]
|
||||
is_bluetooth: bool
|
||||
bluetooth_address: str | None
|
||||
properties: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _extract_bt_address(props: dict[str, str]) -> str | None:
|
||||
"""Extract Bluetooth address from PulseAudio properties."""
|
||||
# Try various property names used by different versions
|
||||
for key in ["device.string", "api.bluez5.address", "bluez.path"]:
|
||||
if key in props:
|
||||
value = props[key]
|
||||
# Check if it looks like a BT address
|
||||
if len(value) == 17 and value.count(":") == 5:
|
||||
return value.upper()
|
||||
# Extract from path like /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX
|
||||
if "dev_" in value:
|
||||
parts = value.split("dev_")
|
||||
if len(parts) > 1:
|
||||
addr = parts[1].split("/")[0].replace("_", ":")
|
||||
if len(addr) == 17:
|
||||
return addr.upper()
|
||||
return None
|
||||
|
||||
|
||||
def _is_bluetooth(props: dict[str, str]) -> bool:
|
||||
"""Check if device is Bluetooth based on properties."""
|
||||
# Check various indicators
|
||||
if "api.bluez5.address" in props:
|
||||
return True
|
||||
if "bluez" in props.get("device.api", "").lower():
|
||||
return True
|
||||
if "bluetooth" in props.get("device.bus", "").lower():
|
||||
return True
|
||||
if props.get("device.string", "").startswith("/org/bluez"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PulseAudioClient:
|
||||
"""Async client for PulseAudio/PipeWire audio control."""
|
||||
|
||||
def __init__(self):
|
||||
self._pulse: pulsectl_asyncio.PulseAsync | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to PulseAudio/PipeWire server."""
|
||||
if self._pulse is not None:
|
||||
return
|
||||
self._pulse = pulsectl_asyncio.PulseAsync("mcbluetooth")
|
||||
await self._pulse.connect()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from audio server."""
|
||||
if self._pulse:
|
||||
self._pulse.close()
|
||||
self._pulse = None
|
||||
|
||||
async def _ensure_connected(self) -> None:
|
||||
"""Ensure we're connected."""
|
||||
if self._pulse is None:
|
||||
await self.connect()
|
||||
|
||||
async def list_sinks(self) -> list[AudioSink]:
|
||||
"""List all audio sinks (output devices)."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
sinks = []
|
||||
for sink in await self._pulse.sink_list():
|
||||
props = dict(sink.proplist)
|
||||
is_bt = _is_bluetooth(props)
|
||||
bt_addr = _extract_bt_address(props) if is_bt else None
|
||||
|
||||
# Calculate average volume percentage
|
||||
vol_pct = int(sink.volume.value_flat * 100) if sink.volume else 0
|
||||
|
||||
sinks.append(
|
||||
AudioSink(
|
||||
name=sink.name,
|
||||
index=sink.index,
|
||||
description=sink.description or sink.name,
|
||||
muted=sink.mute == 1,
|
||||
volume_percent=vol_pct,
|
||||
is_bluetooth=is_bt,
|
||||
bluetooth_address=bt_addr,
|
||||
state=sink.state.name.lower() if hasattr(sink.state, "name") else str(sink.state),
|
||||
properties=props,
|
||||
)
|
||||
)
|
||||
return sinks
|
||||
|
||||
async def list_sources(self) -> list[AudioSource]:
|
||||
"""List all audio sources (input devices)."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
sources = []
|
||||
for source in await self._pulse.source_list():
|
||||
# Skip monitor sources
|
||||
if ".monitor" in source.name:
|
||||
continue
|
||||
|
||||
props = dict(source.proplist)
|
||||
is_bt = _is_bluetooth(props)
|
||||
bt_addr = _extract_bt_address(props) if is_bt else None
|
||||
|
||||
vol_pct = int(source.volume.value_flat * 100) if source.volume else 0
|
||||
|
||||
sources.append(
|
||||
AudioSource(
|
||||
name=source.name,
|
||||
index=source.index,
|
||||
description=source.description or source.name,
|
||||
muted=source.mute == 1,
|
||||
volume_percent=vol_pct,
|
||||
is_bluetooth=is_bt,
|
||||
bluetooth_address=bt_addr,
|
||||
state=source.state.name.lower() if hasattr(source.state, "name") else str(source.state),
|
||||
properties=props,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
async def list_cards(self) -> list[AudioCard]:
|
||||
"""List all audio cards (devices with profiles)."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
cards = []
|
||||
for card in await self._pulse.card_list():
|
||||
props = dict(card.proplist)
|
||||
is_bt = _is_bluetooth(props)
|
||||
bt_addr = _extract_bt_address(props) if is_bt else None
|
||||
|
||||
profile_names = [p.name for p in card.profile_list]
|
||||
active_profile = card.profile_active.name if card.profile_active else ""
|
||||
|
||||
cards.append(
|
||||
AudioCard(
|
||||
name=card.name,
|
||||
index=card.index,
|
||||
driver=card.driver,
|
||||
active_profile=active_profile,
|
||||
profiles=profile_names,
|
||||
is_bluetooth=is_bt,
|
||||
bluetooth_address=bt_addr,
|
||||
properties=props,
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
async def get_default_sink(self) -> str | None:
|
||||
"""Get the name of the default sink."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
info = await self._pulse.server_info()
|
||||
return info.default_sink_name
|
||||
|
||||
async def get_default_source(self) -> str | None:
|
||||
"""Get the name of the default source."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
info = await self._pulse.server_info()
|
||||
return info.default_source_name
|
||||
|
||||
async def set_default_sink(self, sink_name: str) -> None:
|
||||
"""Set the default audio sink."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
await self._pulse.sink_default_set(sink_name)
|
||||
|
||||
async def set_default_source(self, source_name: str) -> None:
|
||||
"""Set the default audio source."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
await self._pulse.source_default_set(source_name)
|
||||
|
||||
async def set_sink_volume(self, sink_name: str, volume_percent: int) -> None:
|
||||
"""Set sink volume (0-100)."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
volume_percent = max(0, min(150, volume_percent)) # Allow up to 150%
|
||||
vol = volume_percent / 100.0
|
||||
|
||||
# Find the sink
|
||||
for sink in await self._pulse.sink_list():
|
||||
if sink.name == sink_name:
|
||||
await self._pulse.volume_set_all_chans(sink, vol)
|
||||
return
|
||||
raise ValueError(f"Sink not found: {sink_name}")
|
||||
|
||||
async def set_sink_mute(self, sink_name: str, muted: bool) -> None:
|
||||
"""Mute or unmute a sink."""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
for sink in await self._pulse.sink_list():
|
||||
if sink.name == sink_name:
|
||||
await self._pulse.sink_mute(sink.index, muted)
|
||||
return
|
||||
raise ValueError(f"Sink not found: {sink_name}")
|
||||
|
||||
async def set_card_profile(self, card_name: str, profile_name: str) -> None:
|
||||
"""Set the active profile for a card.
|
||||
|
||||
For Bluetooth devices, common profiles include:
|
||||
- a2dp-sink: High quality audio output (A2DP)
|
||||
- headset-head-unit: Lower quality but with microphone (HFP/HSP)
|
||||
- off: Disable the device
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
assert self._pulse is not None
|
||||
|
||||
for card in await self._pulse.card_list():
|
||||
if card.name == card_name:
|
||||
await self._pulse.card_profile_set(card, profile_name)
|
||||
return
|
||||
raise ValueError(f"Card not found: {card_name}")
|
||||
|
||||
async def find_bluetooth_sink(self, address: str) -> AudioSink | None:
|
||||
"""Find a sink by Bluetooth address."""
|
||||
address = address.upper()
|
||||
for sink in await self.list_sinks():
|
||||
if sink.bluetooth_address == address:
|
||||
return sink
|
||||
return None
|
||||
|
||||
async def find_bluetooth_card(self, address: str) -> AudioCard | None:
|
||||
"""Find a card by Bluetooth address."""
|
||||
address = address.upper()
|
||||
for card in await self.list_cards():
|
||||
if card.bluetooth_address == address:
|
||||
return card
|
||||
return None
|
||||
|
||||
|
||||
# Global client instance
|
||||
_audio_client: PulseAudioClient | None = None
|
||||
|
||||
|
||||
async def get_audio_client() -> PulseAudioClient:
|
||||
"""Get or create the global audio client."""
|
||||
global _audio_client
|
||||
if _audio_client is None:
|
||||
_audio_client = PulseAudioClient()
|
||||
await _audio_client.connect()
|
||||
return _audio_client
|
||||
589
src/mcbluetooth/dbus_client.py
Normal file
589
src/mcbluetooth/dbus_client.py
Normal file
@ -0,0 +1,589 @@
|
||||
"""BlueZ D-Bus client wrapper using dbus-fast.
|
||||
|
||||
This module provides an async interface to the BlueZ Bluetooth stack via D-Bus.
|
||||
BlueZ exposes its functionality through several D-Bus interfaces:
|
||||
|
||||
- org.bluez.Adapter1: Adapter control (power, discoverable, scanning)
|
||||
- org.bluez.Device1: Device management (pair, connect, trust)
|
||||
- org.bluez.GattService1: BLE GATT services
|
||||
- org.bluez.GattCharacteristic1: BLE GATT characteristics
|
||||
- org.bluez.Agent1: Pairing agent for PIN/confirmation handling
|
||||
- org.bluez.AgentManager1: Agent registration
|
||||
|
||||
Object paths follow this pattern:
|
||||
- /org/bluez/hci0 - Adapter
|
||||
- /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX - Device
|
||||
- /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/serviceXXXX - GATT service
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from dbus_fast import BusType, Variant
|
||||
from dbus_fast.aio import MessageBus, ProxyInterface
|
||||
|
||||
# BlueZ constants
|
||||
BLUEZ_SERVICE = "org.bluez"
|
||||
BLUEZ_ADAPTER_IFACE = "org.bluez.Adapter1"
|
||||
BLUEZ_DEVICE_IFACE = "org.bluez.Device1"
|
||||
BLUEZ_GATT_SERVICE_IFACE = "org.bluez.GattService1"
|
||||
BLUEZ_GATT_CHAR_IFACE = "org.bluez.GattCharacteristic1"
|
||||
BLUEZ_GATT_DESC_IFACE = "org.bluez.GattDescriptor1"
|
||||
BLUEZ_BATTERY_IFACE = "org.bluez.Battery1"
|
||||
BLUEZ_AGENT_IFACE = "org.bluez.Agent1"
|
||||
BLUEZ_AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
|
||||
|
||||
DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
|
||||
DBUS_OBJMANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
|
||||
|
||||
|
||||
def address_to_path(adapter: str, address: str) -> str:
|
||||
"""Convert a Bluetooth address to D-Bus object path."""
|
||||
addr_path = address.upper().replace(":", "_")
|
||||
return f"/org/bluez/{adapter}/dev_{addr_path}"
|
||||
|
||||
|
||||
def path_to_address(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 = path.split("/")
|
||||
if len(parts) >= 5 and parts[-1].startswith("dev_"):
|
||||
return parts[-1][4:].replace("_", ":")
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterInfo:
|
||||
"""Information about a Bluetooth adapter."""
|
||||
|
||||
path: str
|
||||
name: str # e.g., "hci0"
|
||||
address: str
|
||||
alias: str
|
||||
powered: bool
|
||||
discoverable: bool
|
||||
discoverable_timeout: int
|
||||
pairable: bool
|
||||
pairable_timeout: int
|
||||
discovering: bool
|
||||
uuids: list[str] = field(default_factory=list)
|
||||
modalias: str = ""
|
||||
device_class: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Information about a Bluetooth device."""
|
||||
|
||||
path: str
|
||||
adapter: str
|
||||
address: str
|
||||
name: str
|
||||
alias: str
|
||||
paired: bool
|
||||
bonded: bool
|
||||
trusted: bool
|
||||
blocked: bool
|
||||
connected: bool
|
||||
rssi: int | None
|
||||
tx_power: int | None
|
||||
uuids: list[str] = field(default_factory=list)
|
||||
device_class: int = 0
|
||||
appearance: int = 0
|
||||
icon: str = ""
|
||||
services_resolved: bool = False
|
||||
manufacturer_data: dict[int, bytes] = field(default_factory=dict)
|
||||
service_data: dict[str, bytes] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GattService:
|
||||
"""Information about a GATT service."""
|
||||
|
||||
path: str
|
||||
uuid: str
|
||||
primary: bool
|
||||
device_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GattCharacteristic:
|
||||
"""Information about a GATT characteristic."""
|
||||
|
||||
path: str
|
||||
uuid: str
|
||||
service_path: str
|
||||
flags: list[str] = field(default_factory=list)
|
||||
notifying: bool = False
|
||||
|
||||
|
||||
class BlueZClient:
|
||||
"""Async client for BlueZ D-Bus API."""
|
||||
|
||||
def __init__(self):
|
||||
self._bus: MessageBus | None = None
|
||||
self._object_manager: ProxyInterface | None = None
|
||||
self._objects_cache: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the system D-Bus."""
|
||||
if self._bus is not None:
|
||||
return
|
||||
|
||||
self._bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
|
||||
|
||||
# Get the ObjectManager for BlueZ
|
||||
introspection = await self._bus.introspect(BLUEZ_SERVICE, "/")
|
||||
proxy = self._bus.get_proxy_object(BLUEZ_SERVICE, "/", introspection)
|
||||
self._object_manager = proxy.get_interface(DBUS_OBJMANAGER_IFACE)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from D-Bus."""
|
||||
if self._bus:
|
||||
self._bus.disconnect()
|
||||
self._bus = None
|
||||
self._object_manager = 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_managed_objects(self) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Get all managed objects from BlueZ."""
|
||||
await self._ensure_connected()
|
||||
assert self._object_manager is not None
|
||||
|
||||
objects = await self._object_manager.call_get_managed_objects()
|
||||
# Convert Variant values to Python types
|
||||
result = {}
|
||||
for path, interfaces in objects.items():
|
||||
result[path] = {}
|
||||
for iface, props in interfaces.items():
|
||||
result[path][iface] = {
|
||||
k: v.value if isinstance(v, Variant) else v for k, v in props.items()
|
||||
}
|
||||
return result
|
||||
|
||||
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(BLUEZ_SERVICE, path)
|
||||
proxy = self._bus.get_proxy_object(BLUEZ_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 result.value if isinstance(result, Variant) else result
|
||||
|
||||
async def _set_property(self, path: str, interface: str, prop: str, value: Any) -> None:
|
||||
"""Set a single property on an object."""
|
||||
props_iface = await self._get_interface(path, DBUS_PROPS_IFACE)
|
||||
# Wrap value in appropriate Variant
|
||||
if isinstance(value, bool):
|
||||
variant = Variant("b", value)
|
||||
elif isinstance(value, int):
|
||||
variant = Variant("u", value)
|
||||
elif isinstance(value, str):
|
||||
variant = Variant("s", value)
|
||||
else:
|
||||
variant = Variant("v", value)
|
||||
await props_iface.call_set(interface, prop, variant)
|
||||
|
||||
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: v.value if isinstance(v, Variant) else v for k, v in props.items()}
|
||||
|
||||
# ==================== Adapter Operations ====================
|
||||
|
||||
async def list_adapters(self) -> list[AdapterInfo]:
|
||||
"""List all Bluetooth adapters."""
|
||||
objects = await self._get_managed_objects()
|
||||
adapters = []
|
||||
|
||||
for path, interfaces in objects.items():
|
||||
if BLUEZ_ADAPTER_IFACE in interfaces:
|
||||
props = interfaces[BLUEZ_ADAPTER_IFACE]
|
||||
# Extract adapter name from path: /org/bluez/hci0 -> hci0
|
||||
name = path.split("/")[-1]
|
||||
adapters.append(
|
||||
AdapterInfo(
|
||||
path=path,
|
||||
name=name,
|
||||
address=props.get("Address", ""),
|
||||
alias=props.get("Alias", ""),
|
||||
powered=props.get("Powered", False),
|
||||
discoverable=props.get("Discoverable", False),
|
||||
discoverable_timeout=props.get("DiscoverableTimeout", 0),
|
||||
pairable=props.get("Pairable", False),
|
||||
pairable_timeout=props.get("PairableTimeout", 0),
|
||||
discovering=props.get("Discovering", False),
|
||||
uuids=props.get("UUIDs", []),
|
||||
modalias=props.get("Modalias", ""),
|
||||
device_class=props.get("Class", 0),
|
||||
)
|
||||
)
|
||||
|
||||
return adapters
|
||||
|
||||
async def get_adapter(self, adapter: str) -> AdapterInfo | None:
|
||||
"""Get info for a specific adapter."""
|
||||
adapters = await self.list_adapters()
|
||||
for a in adapters:
|
||||
if a.name == adapter:
|
||||
return a
|
||||
return None
|
||||
|
||||
async def set_adapter_power(self, adapter: str, powered: bool) -> None:
|
||||
"""Set adapter power state."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
await self._set_property(path, BLUEZ_ADAPTER_IFACE, "Powered", powered)
|
||||
|
||||
async def set_adapter_discoverable(
|
||||
self, adapter: str, discoverable: bool, timeout: int = 0
|
||||
) -> None:
|
||||
"""Set adapter discoverable state."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
if timeout > 0:
|
||||
await self._set_property(
|
||||
path, BLUEZ_ADAPTER_IFACE, "DiscoverableTimeout", timeout
|
||||
)
|
||||
await self._set_property(path, BLUEZ_ADAPTER_IFACE, "Discoverable", discoverable)
|
||||
|
||||
async def set_adapter_pairable(self, adapter: str, pairable: bool, timeout: int = 0) -> None:
|
||||
"""Set adapter pairable state."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
if timeout > 0:
|
||||
await self._set_property(path, BLUEZ_ADAPTER_IFACE, "PairableTimeout", timeout)
|
||||
await self._set_property(path, BLUEZ_ADAPTER_IFACE, "Pairable", pairable)
|
||||
|
||||
async def set_adapter_alias(self, adapter: str, alias: str) -> None:
|
||||
"""Set adapter friendly name."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
await self._set_property(path, BLUEZ_ADAPTER_IFACE, "Alias", alias)
|
||||
|
||||
# ==================== Discovery Operations ====================
|
||||
|
||||
async def start_discovery(self, adapter: str) -> None:
|
||||
"""Start device discovery on an adapter."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
iface = await self._get_interface(path, BLUEZ_ADAPTER_IFACE)
|
||||
await iface.call_start_discovery()
|
||||
|
||||
async def stop_discovery(self, adapter: str) -> None:
|
||||
"""Stop device discovery on an adapter."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
iface = await self._get_interface(path, BLUEZ_ADAPTER_IFACE)
|
||||
await iface.call_stop_discovery()
|
||||
|
||||
async def set_discovery_filter(
|
||||
self,
|
||||
adapter: str,
|
||||
uuids: list[str] | None = None,
|
||||
rssi: int | None = None,
|
||||
transport: str = "auto",
|
||||
duplicate_data: bool = False,
|
||||
) -> None:
|
||||
"""Set discovery filter for BLE scanning.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
uuids: List of service UUIDs to filter by
|
||||
rssi: Minimum RSSI threshold
|
||||
transport: "auto", "bredr", or "le"
|
||||
duplicate_data: Report duplicate advertisements
|
||||
"""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
iface = await self._get_interface(path, BLUEZ_ADAPTER_IFACE)
|
||||
|
||||
filter_dict: dict[str, Variant] = {}
|
||||
if uuids:
|
||||
filter_dict["UUIDs"] = Variant("as", uuids)
|
||||
if rssi is not None:
|
||||
filter_dict["RSSI"] = Variant("n", rssi)
|
||||
filter_dict["Transport"] = Variant("s", transport)
|
||||
filter_dict["DuplicateData"] = Variant("b", duplicate_data)
|
||||
|
||||
await iface.call_set_discovery_filter(filter_dict)
|
||||
|
||||
async def remove_discovery_filter(self, adapter: str) -> None:
|
||||
"""Remove discovery filter."""
|
||||
path = f"/org/bluez/{adapter}"
|
||||
iface = await self._get_interface(path, BLUEZ_ADAPTER_IFACE)
|
||||
# Set empty filter to clear
|
||||
await iface.call_set_discovery_filter({})
|
||||
|
||||
# ==================== Device Operations ====================
|
||||
|
||||
async def list_devices(
|
||||
self,
|
||||
adapter: str | None = None,
|
||||
filter_type: str = "all",
|
||||
) -> list[DeviceInfo]:
|
||||
"""List devices, optionally filtered.
|
||||
|
||||
Args:
|
||||
adapter: Filter to devices on this adapter, or None for all
|
||||
filter_type: "all", "paired", "connected", or "trusted"
|
||||
"""
|
||||
objects = await self._get_managed_objects()
|
||||
devices = []
|
||||
|
||||
for path, interfaces in objects.items():
|
||||
if BLUEZ_DEVICE_IFACE not in interfaces:
|
||||
continue
|
||||
|
||||
# Filter by adapter
|
||||
if adapter and not path.startswith(f"/org/bluez/{adapter}/"):
|
||||
continue
|
||||
|
||||
props = interfaces[BLUEZ_DEVICE_IFACE]
|
||||
|
||||
# Apply filter
|
||||
if filter_type == "paired" and not props.get("Paired", False):
|
||||
continue
|
||||
if filter_type == "connected" and not props.get("Connected", False):
|
||||
continue
|
||||
if filter_type == "trusted" and not props.get("Trusted", False):
|
||||
continue
|
||||
|
||||
# Extract adapter name from path
|
||||
adapter_name = path.split("/")[3] if len(path.split("/")) > 3 else ""
|
||||
|
||||
# Parse manufacturer data
|
||||
mfr_data = {}
|
||||
if "ManufacturerData" in props:
|
||||
for k, v in props["ManufacturerData"].items():
|
||||
mfr_data[k] = bytes(v) if isinstance(v, (list, bytearray)) else v
|
||||
|
||||
# Parse service data
|
||||
svc_data = {}
|
||||
if "ServiceData" in props:
|
||||
for k, v in props["ServiceData"].items():
|
||||
svc_data[k] = bytes(v) if isinstance(v, (list, bytearray)) else v
|
||||
|
||||
devices.append(
|
||||
DeviceInfo(
|
||||
path=path,
|
||||
adapter=adapter_name,
|
||||
address=props.get("Address", ""),
|
||||
name=props.get("Name", ""),
|
||||
alias=props.get("Alias", ""),
|
||||
paired=props.get("Paired", False),
|
||||
bonded=props.get("Bonded", False),
|
||||
trusted=props.get("Trusted", False),
|
||||
blocked=props.get("Blocked", False),
|
||||
connected=props.get("Connected", False),
|
||||
rssi=props.get("RSSI"),
|
||||
tx_power=props.get("TxPower"),
|
||||
uuids=props.get("UUIDs", []),
|
||||
device_class=props.get("Class", 0),
|
||||
appearance=props.get("Appearance", 0),
|
||||
icon=props.get("Icon", ""),
|
||||
services_resolved=props.get("ServicesResolved", False),
|
||||
manufacturer_data=mfr_data,
|
||||
service_data=svc_data,
|
||||
)
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
async def get_device(self, adapter: str, address: str) -> DeviceInfo | None:
|
||||
"""Get info for a specific device."""
|
||||
devices = await self.list_devices(adapter=adapter)
|
||||
for d in devices:
|
||||
if d.address.upper() == address.upper():
|
||||
return d
|
||||
return None
|
||||
|
||||
async def pair_device(self, adapter: str, address: str) -> None:
|
||||
"""Initiate pairing with a device.
|
||||
|
||||
Note: This may require an agent for PIN entry or confirmation.
|
||||
"""
|
||||
path = address_to_path(adapter, address)
|
||||
iface = await self._get_interface(path, BLUEZ_DEVICE_IFACE)
|
||||
await iface.call_pair()
|
||||
|
||||
async def cancel_pairing(self, adapter: str, address: str) -> None:
|
||||
"""Cancel ongoing pairing."""
|
||||
path = address_to_path(adapter, address)
|
||||
iface = await self._get_interface(path, BLUEZ_DEVICE_IFACE)
|
||||
await iface.call_cancel_pairing()
|
||||
|
||||
async def connect_device(self, adapter: str, address: str) -> None:
|
||||
"""Connect to a device."""
|
||||
path = address_to_path(adapter, address)
|
||||
iface = await self._get_interface(path, BLUEZ_DEVICE_IFACE)
|
||||
await iface.call_connect()
|
||||
|
||||
async def disconnect_device(self, adapter: str, address: str) -> None:
|
||||
"""Disconnect from a device."""
|
||||
path = address_to_path(adapter, address)
|
||||
iface = await self._get_interface(path, BLUEZ_DEVICE_IFACE)
|
||||
await iface.call_disconnect()
|
||||
|
||||
async def remove_device(self, adapter: str, address: str) -> None:
|
||||
"""Remove a device from the known devices list."""
|
||||
device_path = address_to_path(adapter, address)
|
||||
adapter_path = f"/org/bluez/{adapter}"
|
||||
iface = await self._get_interface(adapter_path, BLUEZ_ADAPTER_IFACE)
|
||||
await iface.call_remove_device(device_path)
|
||||
|
||||
async def set_device_trusted(self, adapter: str, address: str, trusted: bool) -> None:
|
||||
"""Set device trusted state."""
|
||||
path = address_to_path(adapter, address)
|
||||
await self._set_property(path, BLUEZ_DEVICE_IFACE, "Trusted", trusted)
|
||||
|
||||
async def set_device_blocked(self, adapter: str, address: str, blocked: bool) -> None:
|
||||
"""Set device blocked state."""
|
||||
path = address_to_path(adapter, address)
|
||||
await self._set_property(path, BLUEZ_DEVICE_IFACE, "Blocked", blocked)
|
||||
|
||||
async def set_device_alias(self, adapter: str, address: str, alias: str) -> None:
|
||||
"""Set device friendly name."""
|
||||
path = address_to_path(adapter, address)
|
||||
await self._set_property(path, BLUEZ_DEVICE_IFACE, "Alias", alias)
|
||||
|
||||
# ==================== Profile Operations ====================
|
||||
|
||||
async def connect_profile(self, adapter: str, address: str, uuid: str) -> None:
|
||||
"""Connect a specific profile."""
|
||||
path = address_to_path(adapter, address)
|
||||
iface = await self._get_interface(path, BLUEZ_DEVICE_IFACE)
|
||||
await iface.call_connect_profile(uuid)
|
||||
|
||||
async def disconnect_profile(self, adapter: str, address: str, uuid: str) -> None:
|
||||
"""Disconnect a specific profile."""
|
||||
path = address_to_path(adapter, address)
|
||||
iface = await self._get_interface(path, BLUEZ_DEVICE_IFACE)
|
||||
await iface.call_disconnect_profile(uuid)
|
||||
|
||||
# ==================== GATT Operations ====================
|
||||
|
||||
async def list_gatt_services(self, adapter: str, address: str) -> list[GattService]:
|
||||
"""List GATT services for a device."""
|
||||
device_path = address_to_path(adapter, address)
|
||||
objects = await self._get_managed_objects()
|
||||
services = []
|
||||
|
||||
for path, interfaces in objects.items():
|
||||
if not path.startswith(device_path + "/"):
|
||||
continue
|
||||
if BLUEZ_GATT_SERVICE_IFACE not in interfaces:
|
||||
continue
|
||||
|
||||
props = interfaces[BLUEZ_GATT_SERVICE_IFACE]
|
||||
services.append(
|
||||
GattService(
|
||||
path=path,
|
||||
uuid=props.get("UUID", ""),
|
||||
primary=props.get("Primary", True),
|
||||
device_path=props.get("Device", device_path),
|
||||
)
|
||||
)
|
||||
|
||||
return services
|
||||
|
||||
async def list_gatt_characteristics(
|
||||
self, adapter: str, address: str, service_uuid: str | None = None
|
||||
) -> list[GattCharacteristic]:
|
||||
"""List GATT characteristics for a device, optionally filtered by service."""
|
||||
device_path = address_to_path(adapter, address)
|
||||
objects = await self._get_managed_objects()
|
||||
|
||||
# First find services if filtering
|
||||
service_paths = set()
|
||||
if service_uuid:
|
||||
for path, interfaces in objects.items():
|
||||
if BLUEZ_GATT_SERVICE_IFACE in interfaces:
|
||||
if interfaces[BLUEZ_GATT_SERVICE_IFACE].get("UUID", "").lower() == service_uuid.lower():
|
||||
service_paths.add(path)
|
||||
|
||||
characteristics = []
|
||||
for path, interfaces in objects.items():
|
||||
if not path.startswith(device_path + "/"):
|
||||
continue
|
||||
if BLUEZ_GATT_CHAR_IFACE not in interfaces:
|
||||
continue
|
||||
|
||||
props = interfaces[BLUEZ_GATT_CHAR_IFACE]
|
||||
service_path = props.get("Service", "")
|
||||
|
||||
# Filter by service if specified
|
||||
if service_uuid and service_path not in service_paths:
|
||||
continue
|
||||
|
||||
characteristics.append(
|
||||
GattCharacteristic(
|
||||
path=path,
|
||||
uuid=props.get("UUID", ""),
|
||||
service_path=service_path,
|
||||
flags=props.get("Flags", []),
|
||||
notifying=props.get("Notifying", False),
|
||||
)
|
||||
)
|
||||
|
||||
return characteristics
|
||||
|
||||
async def read_characteristic(self, char_path: str) -> bytes:
|
||||
"""Read a GATT characteristic value."""
|
||||
iface = await self._get_interface(char_path, BLUEZ_GATT_CHAR_IFACE)
|
||||
result = await iface.call_read_value({})
|
||||
return bytes(result)
|
||||
|
||||
async def write_characteristic(
|
||||
self, char_path: str, value: bytes, write_type: str = "request"
|
||||
) -> None:
|
||||
"""Write a GATT characteristic value.
|
||||
|
||||
Args:
|
||||
char_path: D-Bus path of the characteristic
|
||||
value: Bytes to write
|
||||
write_type: "request" (with response) or "command" (without response)
|
||||
"""
|
||||
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)
|
||||
|
||||
async def start_notify(self, char_path: str) -> None:
|
||||
"""Start notifications for a characteristic."""
|
||||
iface = await self._get_interface(char_path, BLUEZ_GATT_CHAR_IFACE)
|
||||
await iface.call_start_notify()
|
||||
|
||||
async def stop_notify(self, char_path: str) -> None:
|
||||
"""Stop notifications for a characteristic."""
|
||||
iface = await self._get_interface(char_path, BLUEZ_GATT_CHAR_IFACE)
|
||||
await iface.call_stop_notify()
|
||||
|
||||
# ==================== Battery Operations ====================
|
||||
|
||||
async def get_battery_level(self, adapter: str, address: str) -> int | None:
|
||||
"""Get battery level from Battery1 interface if available."""
|
||||
path = address_to_path(adapter, address)
|
||||
objects = await self._get_managed_objects()
|
||||
|
||||
if path in objects and BLUEZ_BATTERY_IFACE in objects[path]:
|
||||
return objects[path][BLUEZ_BATTERY_IFACE].get("Percentage")
|
||||
return None
|
||||
|
||||
|
||||
# Global client instance
|
||||
_client: BlueZClient | None = None
|
||||
|
||||
|
||||
async def get_client() -> BlueZClient:
|
||||
"""Get or create the global BlueZ client."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = BlueZClient()
|
||||
await _client.connect()
|
||||
return _client
|
||||
51
src/mcbluetooth/server.py
Normal file
51
src/mcbluetooth/server.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""FastMCP server for BlueZ Bluetooth management."""
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcbluetooth.tools import adapter, audio, ble, device
|
||||
|
||||
mcp = FastMCP(
|
||||
name="mcbluetooth",
|
||||
instructions="""Bluetooth management server for Linux (BlueZ).
|
||||
|
||||
This server provides comprehensive control over the Linux Bluetooth stack:
|
||||
- Adapter management (power, discoverable, pairable)
|
||||
- Device discovery and management (scan, pair, connect)
|
||||
- Audio profiles (A2DP, HFP) with PipeWire/PulseAudio integration
|
||||
- BLE/GATT services (read/write characteristics, notifications)
|
||||
|
||||
All tools require an explicit 'adapter' parameter (e.g., "hci0").
|
||||
Use bt_list_adapters() to discover available adapters.
|
||||
|
||||
For pairing, use pairing_mode parameter:
|
||||
- "elicit": Use MCP elicitation to request PIN from user (preferred)
|
||||
- "interactive": Return awaiting status, then call bt_pair_confirm
|
||||
- "auto": Auto-accept pairings (use in trusted environments only)
|
||||
""",
|
||||
)
|
||||
|
||||
# Register tool modules
|
||||
adapter.register_tools(mcp)
|
||||
device.register_tools(mcp)
|
||||
audio.register_tools(mcp)
|
||||
ble.register_tools(mcp)
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the mcbluetooth MCP server."""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
package_version = version("mcbluetooth")
|
||||
except Exception:
|
||||
package_version = "dev"
|
||||
|
||||
print(f"🔵 mcbluetooth v{package_version}")
|
||||
print(" BlueZ MCP Server - Bluetooth management for LLMs")
|
||||
print()
|
||||
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
src/mcbluetooth/tools/__init__.py
Normal file
5
src/mcbluetooth/tools/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""MCP tool modules for Bluetooth management."""
|
||||
|
||||
from mcbluetooth.tools import adapter, audio, ble, device
|
||||
|
||||
__all__ = ["adapter", "device", "audio", "ble"]
|
||||
117
src/mcbluetooth/tools/adapter.py
Normal file
117
src/mcbluetooth/tools/adapter.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Adapter management tools for BlueZ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcbluetooth.dbus_client import get_client
|
||||
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
"""Register adapter management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_list_adapters() -> list[dict[str, Any]]:
|
||||
"""List all Bluetooth adapters on the system.
|
||||
|
||||
Returns a list of adapters with their properties including:
|
||||
- name: Adapter identifier (e.g., "hci0")
|
||||
- address: Bluetooth MAC address
|
||||
- powered: Whether adapter is on
|
||||
- discoverable: Whether adapter is visible to other devices
|
||||
- discovering: Whether currently scanning
|
||||
"""
|
||||
client = await get_client()
|
||||
adapters = await client.list_adapters()
|
||||
return [asdict(a) for a in adapters]
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_adapter_info(adapter: str) -> dict[str, Any] | None:
|
||||
"""Get detailed information about a specific adapter.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
|
||||
Returns:
|
||||
Adapter properties or None if not found
|
||||
"""
|
||||
client = await get_client()
|
||||
info = await client.get_adapter(adapter)
|
||||
return asdict(info) if info else None
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_adapter_power(adapter: str, on: bool) -> dict[str, Any]:
|
||||
"""Power an adapter on or off.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
on: True to power on, False to power off
|
||||
|
||||
Returns:
|
||||
Updated adapter info
|
||||
"""
|
||||
client = await get_client()
|
||||
await client.set_adapter_power(adapter, on)
|
||||
info = await client.get_adapter(adapter)
|
||||
return asdict(info) if info else {"error": "Adapter not found"}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_adapter_discoverable(
|
||||
adapter: str,
|
||||
on: bool,
|
||||
timeout: int = 180,
|
||||
) -> dict[str, Any]:
|
||||
"""Set adapter discoverable (visible to other devices).
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
on: True to enable discoverable, False to disable
|
||||
timeout: Discoverable timeout in seconds (0 = forever, default 180)
|
||||
|
||||
Returns:
|
||||
Updated adapter info
|
||||
"""
|
||||
client = await get_client()
|
||||
await client.set_adapter_discoverable(adapter, on, timeout)
|
||||
info = await client.get_adapter(adapter)
|
||||
return asdict(info) if info else {"error": "Adapter not found"}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_adapter_pairable(
|
||||
adapter: str,
|
||||
on: bool,
|
||||
timeout: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Set adapter pairable state.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
on: True to enable pairing acceptance, False to disable
|
||||
timeout: Pairable timeout in seconds (0 = forever)
|
||||
|
||||
Returns:
|
||||
Updated adapter info
|
||||
"""
|
||||
client = await get_client()
|
||||
await client.set_adapter_pairable(adapter, on, timeout)
|
||||
info = await client.get_adapter(adapter)
|
||||
return asdict(info) if info else {"error": "Adapter not found"}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_adapter_set_alias(adapter: str, alias: str) -> dict[str, Any]:
|
||||
"""Set adapter friendly name (alias).
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
alias: New friendly name for the adapter
|
||||
|
||||
Returns:
|
||||
Updated adapter info
|
||||
"""
|
||||
client = await get_client()
|
||||
await client.set_adapter_alias(adapter, alias)
|
||||
info = await client.get_adapter(adapter)
|
||||
return asdict(info) if info else {"error": "Adapter not found"}
|
||||
287
src/mcbluetooth/tools/audio.py
Normal file
287
src/mcbluetooth/tools/audio.py
Normal file
@ -0,0 +1,287 @@
|
||||
"""Audio profile management tools for Bluetooth devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcbluetooth.audio import get_audio_client
|
||||
from mcbluetooth.dbus_client import get_client
|
||||
|
||||
# Common Bluetooth audio UUIDs
|
||||
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
|
||||
A2DP_SOURCE_UUID = "0000110a-0000-1000-8000-00805f9b34fb"
|
||||
HFP_HF_UUID = "0000111e-0000-1000-8000-00805f9b34fb"
|
||||
HFP_AG_UUID = "0000111f-0000-1000-8000-00805f9b34fb"
|
||||
HSP_HS_UUID = "00001108-0000-1000-8000-00805f9b34fb"
|
||||
HSP_AG_UUID = "00001112-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
"""Register audio management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_list() -> dict[str, Any]:
|
||||
"""List all audio devices including Bluetooth devices.
|
||||
|
||||
Returns information about:
|
||||
- Sinks (audio outputs like speakers, headphones)
|
||||
- Sources (audio inputs like microphones)
|
||||
- Cards (devices with switchable profiles)
|
||||
|
||||
Bluetooth audio devices show their MAC address.
|
||||
"""
|
||||
audio = await get_audio_client()
|
||||
|
||||
sinks = await audio.list_sinks()
|
||||
sources = await audio.list_sources()
|
||||
cards = await audio.list_cards()
|
||||
|
||||
default_sink = await audio.get_default_sink()
|
||||
default_source = await audio.get_default_source()
|
||||
|
||||
return {
|
||||
"default_sink": default_sink,
|
||||
"default_source": default_source,
|
||||
"sinks": [asdict(s) for s in sinks],
|
||||
"sources": [asdict(s) for s in sources],
|
||||
"cards": [asdict(c) for c in cards],
|
||||
"bluetooth_sinks": [asdict(s) for s in sinks if s.is_bluetooth],
|
||||
"bluetooth_cards": [asdict(c) for c in cards if c.is_bluetooth],
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_connect(adapter: str, address: str) -> dict[str, Any]:
|
||||
"""Connect audio profiles to a Bluetooth device.
|
||||
|
||||
This connects the A2DP (high-quality audio) and/or HFP (hands-free)
|
||||
profiles to the specified Bluetooth device.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Connection status and available audio info
|
||||
"""
|
||||
bt_client = await get_client()
|
||||
|
||||
# First ensure device is connected
|
||||
device = await bt_client.get_device(adapter, address)
|
||||
if not device:
|
||||
return {"status": "error", "error": "Device not found"}
|
||||
|
||||
if not device.paired:
|
||||
return {"status": "error", "error": "Device not paired. Pair first with bt_pair."}
|
||||
|
||||
if not device.connected:
|
||||
try:
|
||||
await bt_client.connect_device(adapter, address)
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": f"Connection failed: {e}"}
|
||||
|
||||
# Try to connect audio profiles
|
||||
errors = []
|
||||
connected_profiles = []
|
||||
|
||||
for uuid, name in [(A2DP_SINK_UUID, "A2DP"), (HFP_HF_UUID, "HFP")]:
|
||||
if uuid.lower() in [u.lower() for u in device.uuids]:
|
||||
try:
|
||||
await bt_client.connect_profile(adapter, address, uuid)
|
||||
connected_profiles.append(name)
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: {e}")
|
||||
|
||||
# Check audio system
|
||||
audio = await get_audio_client()
|
||||
sink = await audio.find_bluetooth_sink(address)
|
||||
card = await audio.find_bluetooth_card(address)
|
||||
|
||||
return {
|
||||
"status": "connected" if connected_profiles else "no_audio_profiles",
|
||||
"connected_profiles": connected_profiles,
|
||||
"errors": errors if errors else None,
|
||||
"audio_sink": asdict(sink) if sink else None,
|
||||
"audio_card": asdict(card) if card else None,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_disconnect(adapter: str, address: str) -> dict[str, Any]:
|
||||
"""Disconnect audio profiles from a Bluetooth device.
|
||||
|
||||
This disconnects audio profiles but keeps the device connected
|
||||
for other services (if any).
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Disconnection status
|
||||
"""
|
||||
bt_client = await get_client()
|
||||
|
||||
device = await bt_client.get_device(adapter, address)
|
||||
if not device:
|
||||
return {"status": "error", "error": "Device not found"}
|
||||
|
||||
errors = []
|
||||
disconnected_profiles = []
|
||||
|
||||
for uuid, name in [(A2DP_SINK_UUID, "A2DP"), (HFP_HF_UUID, "HFP")]:
|
||||
if uuid.lower() in [u.lower() for u in device.uuids]:
|
||||
try:
|
||||
await bt_client.disconnect_profile(adapter, address, uuid)
|
||||
disconnected_profiles.append(name)
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: {e}")
|
||||
|
||||
return {
|
||||
"status": "disconnected" if disconnected_profiles else "no_audio_profiles",
|
||||
"disconnected_profiles": disconnected_profiles,
|
||||
"errors": errors if errors else None,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_set_profile(
|
||||
address: str,
|
||||
profile: Literal["a2dp", "hfp", "off"],
|
||||
) -> dict[str, Any]:
|
||||
"""Switch audio profile for a Bluetooth device.
|
||||
|
||||
Bluetooth audio devices support different profiles:
|
||||
- a2dp: High-quality stereo audio (best for music)
|
||||
- hfp: Hands-free profile with microphone (lower quality, for calls)
|
||||
- off: Disable audio for this device
|
||||
|
||||
Args:
|
||||
address: Device Bluetooth address
|
||||
profile: Profile to activate
|
||||
|
||||
Returns:
|
||||
Status and updated card info
|
||||
"""
|
||||
audio = await get_audio_client()
|
||||
|
||||
card = await audio.find_bluetooth_card(address)
|
||||
if not card:
|
||||
return {"status": "error", "error": "Bluetooth audio card not found"}
|
||||
|
||||
# Map profile names to PulseAudio profile names
|
||||
# These vary by PipeWire/PulseAudio version
|
||||
profile_map = {
|
||||
"a2dp": ["a2dp-sink", "a2dp_sink", "a2dp-sink-aac", "a2dp-sink-sbc"],
|
||||
"hfp": ["headset-head-unit", "headset_head_unit", "handsfree_head_unit"],
|
||||
"off": ["off"],
|
||||
}
|
||||
|
||||
target_profiles = profile_map[profile]
|
||||
|
||||
# Find a matching profile
|
||||
matched_profile = None
|
||||
for p in target_profiles:
|
||||
if p in card.profiles:
|
||||
matched_profile = p
|
||||
break
|
||||
|
||||
if not matched_profile:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Profile '{profile}' not available",
|
||||
"available_profiles": card.profiles,
|
||||
}
|
||||
|
||||
try:
|
||||
await audio.set_card_profile(card.name, matched_profile)
|
||||
# Refresh card info
|
||||
card = await audio.find_bluetooth_card(address)
|
||||
return {
|
||||
"status": "profile_changed",
|
||||
"profile": matched_profile,
|
||||
"card": asdict(card) if card else None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_set_default(address: str) -> dict[str, Any]:
|
||||
"""Set a Bluetooth device as the default audio output.
|
||||
|
||||
Args:
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Status and updated default sink info
|
||||
"""
|
||||
audio = await get_audio_client()
|
||||
|
||||
sink = await audio.find_bluetooth_sink(address)
|
||||
if not sink:
|
||||
return {"status": "error", "error": "Bluetooth audio sink not found"}
|
||||
|
||||
try:
|
||||
await audio.set_default_sink(sink.name)
|
||||
return {
|
||||
"status": "default_set",
|
||||
"sink_name": sink.name,
|
||||
"description": sink.description,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_volume(address: str, volume: int) -> dict[str, Any]:
|
||||
"""Set volume for a Bluetooth audio device.
|
||||
|
||||
Args:
|
||||
address: Device Bluetooth address
|
||||
volume: Volume level 0-100 (can go up to 150 for boost)
|
||||
|
||||
Returns:
|
||||
Status and updated volume info
|
||||
"""
|
||||
audio = await get_audio_client()
|
||||
|
||||
sink = await audio.find_bluetooth_sink(address)
|
||||
if not sink:
|
||||
return {"status": "error", "error": "Bluetooth audio sink not found"}
|
||||
|
||||
try:
|
||||
await audio.set_sink_volume(sink.name, volume)
|
||||
# Refresh sink info
|
||||
sink = await audio.find_bluetooth_sink(address)
|
||||
return {
|
||||
"status": "volume_set",
|
||||
"volume": sink.volume_percent if sink else volume,
|
||||
"sink_name": sink.name if sink else None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_audio_mute(address: str, muted: bool) -> dict[str, Any]:
|
||||
"""Mute or unmute a Bluetooth audio device.
|
||||
|
||||
Args:
|
||||
address: Device Bluetooth address
|
||||
muted: True to mute, False to unmute
|
||||
|
||||
Returns:
|
||||
Status and mute state
|
||||
"""
|
||||
audio = await get_audio_client()
|
||||
|
||||
sink = await audio.find_bluetooth_sink(address)
|
||||
if not sink:
|
||||
return {"status": "error", "error": "Bluetooth audio sink not found"}
|
||||
|
||||
try:
|
||||
await audio.set_sink_mute(sink.name, muted)
|
||||
return {
|
||||
"status": "muted" if muted else "unmuted",
|
||||
"sink_name": sink.name,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
403
src/mcbluetooth/tools/ble.py
Normal file
403
src/mcbluetooth/tools/ble.py
Normal file
@ -0,0 +1,403 @@
|
||||
"""Bluetooth Low Energy (BLE) and GATT tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcbluetooth.dbus_client import get_client
|
||||
|
||||
# Common BLE service UUIDs
|
||||
BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
|
||||
BATTERY_LEVEL_CHAR_UUID = "00002a19-0000-1000-8000-00805f9b34fb"
|
||||
DEVICE_INFO_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb"
|
||||
HEART_RATE_SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb"
|
||||
HEART_RATE_MEASUREMENT_UUID = "00002a37-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def _format_uuid(uuid: str) -> str:
|
||||
"""Format UUID for display - short form for standard UUIDs."""
|
||||
# Standard Bluetooth UUIDs have the form 0000XXXX-0000-1000-8000-00805f9b34fb
|
||||
if uuid.lower().endswith("-0000-1000-8000-00805f9b34fb"):
|
||||
short = uuid[:8].lstrip("0") or "0"
|
||||
return f"0x{short.upper()}"
|
||||
return uuid
|
||||
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
"""Register BLE/GATT tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_scan(
|
||||
adapter: str,
|
||||
timeout: int = 10,
|
||||
name_filter: str | None = None,
|
||||
service_filter: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Scan for BLE (Bluetooth Low Energy) devices.
|
||||
|
||||
BLE devices advertise their presence and can be filtered by name
|
||||
or service UUID. Common services include:
|
||||
- 0x180F: Battery Service
|
||||
- 0x180D: Heart Rate Service
|
||||
- 0x180A: Device Information
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
timeout: Scan duration in seconds
|
||||
name_filter: Only return devices with name containing this string
|
||||
service_filter: Only return devices advertising this service UUID
|
||||
|
||||
Returns:
|
||||
List of BLE devices with advertisement data
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# Set BLE-specific filter
|
||||
uuids = [service_filter] if service_filter else None
|
||||
await client.set_discovery_filter(
|
||||
adapter, uuids=uuids, transport="le", duplicate_data=True
|
||||
)
|
||||
|
||||
await client.start_discovery(adapter)
|
||||
try:
|
||||
await asyncio.sleep(timeout)
|
||||
finally:
|
||||
await client.stop_discovery(adapter)
|
||||
await client.remove_discovery_filter(adapter)
|
||||
|
||||
devices = await client.list_devices(adapter=adapter)
|
||||
|
||||
# Apply name filter
|
||||
if name_filter:
|
||||
name_filter_lower = name_filter.lower()
|
||||
devices = [d for d in devices if name_filter_lower in d.name.lower()]
|
||||
|
||||
# Format for BLE-specific output
|
||||
result = []
|
||||
for d in devices:
|
||||
# Parse manufacturer data for display
|
||||
mfr_data_hex = {
|
||||
k: v.hex() if isinstance(v, bytes) else str(v)
|
||||
for k, v in d.manufacturer_data.items()
|
||||
}
|
||||
svc_data_hex = {
|
||||
k: v.hex() if isinstance(v, bytes) else str(v)
|
||||
for k, v in d.service_data.items()
|
||||
}
|
||||
|
||||
result.append({
|
||||
"address": d.address,
|
||||
"name": d.name or "(unknown)",
|
||||
"rssi": d.rssi,
|
||||
"paired": d.paired,
|
||||
"connected": d.connected,
|
||||
"uuids": [_format_uuid(u) for u in d.uuids],
|
||||
"manufacturer_data": mfr_data_hex,
|
||||
"service_data": svc_data_hex,
|
||||
"appearance": d.appearance,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_services(adapter: str, address: str) -> list[dict[str, Any]]:
|
||||
"""List GATT services for a connected BLE device.
|
||||
|
||||
The device must be connected for service discovery.
|
||||
Services contain characteristics that can be read/written.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
List of GATT services with their UUIDs
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# Check device is connected
|
||||
device = await client.get_device(adapter, address)
|
||||
if not device:
|
||||
return [{"error": "Device not found"}]
|
||||
if not device.connected:
|
||||
return [{"error": "Device not connected. Use bt_connect first."}]
|
||||
if not device.services_resolved:
|
||||
# Wait a bit for services to resolve
|
||||
await asyncio.sleep(2)
|
||||
device = await client.get_device(adapter, address)
|
||||
if not device or not device.services_resolved:
|
||||
return [{"error": "Services not yet resolved. Try again in a moment."}]
|
||||
|
||||
services = await client.list_gatt_services(adapter, address)
|
||||
return [
|
||||
{
|
||||
"uuid": s.uuid,
|
||||
"uuid_short": _format_uuid(s.uuid),
|
||||
"primary": s.primary,
|
||||
"path": s.path,
|
||||
}
|
||||
for s in services
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_characteristics(
|
||||
adapter: str,
|
||||
address: str,
|
||||
service_uuid: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List GATT characteristics for a BLE device.
|
||||
|
||||
Characteristics are the data points within a service that can be
|
||||
read, written, or subscribed to for notifications.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
service_uuid: Filter to characteristics of this service (optional)
|
||||
|
||||
Returns:
|
||||
List of characteristics with their properties
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
device = await client.get_device(adapter, address)
|
||||
if not device or not device.connected:
|
||||
return [{"error": "Device not connected"}]
|
||||
|
||||
chars = await client.list_gatt_characteristics(adapter, address, service_uuid)
|
||||
return [
|
||||
{
|
||||
"uuid": c.uuid,
|
||||
"uuid_short": _format_uuid(c.uuid),
|
||||
"path": c.path,
|
||||
"flags": c.flags,
|
||||
"notifying": c.notifying,
|
||||
"readable": "read" in c.flags,
|
||||
"writable": "write" in c.flags or "write-without-response" in c.flags,
|
||||
"notifiable": "notify" in c.flags or "indicate" in c.flags,
|
||||
}
|
||||
for c in chars
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_read(
|
||||
adapter: str,
|
||||
address: str,
|
||||
char_uuid: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Read a GATT characteristic value.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
char_uuid: Characteristic UUID to read
|
||||
|
||||
Returns:
|
||||
The characteristic value as hex and decoded (if possible)
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# Find the characteristic
|
||||
chars = await client.list_gatt_characteristics(adapter, address)
|
||||
target_char = None
|
||||
for c in chars:
|
||||
if c.uuid.lower() == char_uuid.lower():
|
||||
target_char = c
|
||||
break
|
||||
|
||||
if not target_char:
|
||||
return {"error": f"Characteristic {char_uuid} not found"}
|
||||
|
||||
if "read" not in target_char.flags:
|
||||
return {"error": "Characteristic is not readable"}
|
||||
|
||||
try:
|
||||
value = await client.read_characteristic(target_char.path)
|
||||
result = {
|
||||
"uuid": char_uuid,
|
||||
"value_hex": value.hex(),
|
||||
"value_bytes": list(value),
|
||||
"length": len(value),
|
||||
}
|
||||
|
||||
# Try to decode as UTF-8 string
|
||||
try:
|
||||
result["value_string"] = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
# Try to decode as integer (common for single-byte values)
|
||||
if len(value) == 1:
|
||||
result["value_int"] = value[0]
|
||||
elif len(value) == 2:
|
||||
result["value_int"] = int.from_bytes(value, "little")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_write(
|
||||
adapter: str,
|
||||
address: str,
|
||||
char_uuid: str,
|
||||
value: str,
|
||||
value_type: str = "hex",
|
||||
with_response: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Write a value to a GATT characteristic.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
char_uuid: Characteristic UUID to write
|
||||
value: Value to write
|
||||
value_type: How to interpret value - "hex" (e.g., "0102ff"),
|
||||
"string" (UTF-8), or "int" (decimal number)
|
||||
with_response: Whether to wait for write confirmation
|
||||
|
||||
Returns:
|
||||
Write status
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# Find the characteristic
|
||||
chars = await client.list_gatt_characteristics(adapter, address)
|
||||
target_char = None
|
||||
for c in chars:
|
||||
if c.uuid.lower() == char_uuid.lower():
|
||||
target_char = c
|
||||
break
|
||||
|
||||
if not target_char:
|
||||
return {"error": f"Characteristic {char_uuid} not found"}
|
||||
|
||||
if "write" not in target_char.flags and "write-without-response" not in target_char.flags:
|
||||
return {"error": "Characteristic is not writable"}
|
||||
|
||||
# Convert value to bytes
|
||||
try:
|
||||
if value_type == "hex":
|
||||
data = bytes.fromhex(value)
|
||||
elif value_type == "string":
|
||||
data = value.encode("utf-8")
|
||||
elif value_type == "int":
|
||||
int_val = int(value)
|
||||
# Determine byte length needed
|
||||
byte_len = max(1, (int_val.bit_length() + 7) // 8)
|
||||
data = int_val.to_bytes(byte_len, "little")
|
||||
else:
|
||||
return {"error": f"Unknown value_type: {value_type}"}
|
||||
except ValueError as e:
|
||||
return {"error": f"Invalid value format: {e}"}
|
||||
|
||||
try:
|
||||
write_type = "request" if with_response else "command"
|
||||
await client.write_characteristic(target_char.path, data, write_type)
|
||||
return {
|
||||
"status": "written",
|
||||
"uuid": char_uuid,
|
||||
"value_hex": data.hex(),
|
||||
"length": len(data),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_notify(
|
||||
adapter: str,
|
||||
address: str,
|
||||
char_uuid: str,
|
||||
enable: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Enable or disable notifications for a characteristic.
|
||||
|
||||
When enabled, the device will send updates when the characteristic
|
||||
value changes (e.g., heart rate measurements, sensor data).
|
||||
|
||||
Note: To receive actual notifications, you would need to set up
|
||||
a callback - this tool just enables/disables the notification mode.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
char_uuid: Characteristic UUID
|
||||
enable: True to enable notifications, False to disable
|
||||
|
||||
Returns:
|
||||
Notification status
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
chars = await client.list_gatt_characteristics(adapter, address)
|
||||
target_char = None
|
||||
for c in chars:
|
||||
if c.uuid.lower() == char_uuid.lower():
|
||||
target_char = c
|
||||
break
|
||||
|
||||
if not target_char:
|
||||
return {"error": f"Characteristic {char_uuid} not found"}
|
||||
|
||||
if "notify" not in target_char.flags and "indicate" not in target_char.flags:
|
||||
return {"error": "Characteristic does not support notifications"}
|
||||
|
||||
try:
|
||||
if enable:
|
||||
await client.start_notify(target_char.path)
|
||||
else:
|
||||
await client.stop_notify(target_char.path)
|
||||
|
||||
return {
|
||||
"status": "notifications_enabled" if enable else "notifications_disabled",
|
||||
"uuid": char_uuid,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_ble_battery(adapter: str, address: str) -> dict[str, Any]:
|
||||
"""Read battery level from a BLE device.
|
||||
|
||||
Many BLE devices support the standard Battery Service (0x180F).
|
||||
This is a convenience function that reads from that service.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Battery percentage (0-100) or error
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# First try BlueZ Battery1 interface (cached value)
|
||||
battery_level = await client.get_battery_level(adapter, address)
|
||||
if battery_level is not None:
|
||||
return {"battery_percent": battery_level, "source": "bluez_battery1"}
|
||||
|
||||
# Fall back to reading GATT characteristic
|
||||
device = await client.get_device(adapter, address)
|
||||
if not device or not device.connected:
|
||||
return {"error": "Device not connected"}
|
||||
|
||||
chars = await client.list_gatt_characteristics(adapter, address, BATTERY_SERVICE_UUID)
|
||||
battery_char = None
|
||||
for c in chars:
|
||||
if c.uuid.lower() == BATTERY_LEVEL_CHAR_UUID.lower():
|
||||
battery_char = c
|
||||
break
|
||||
|
||||
if not battery_char:
|
||||
return {"error": "Battery service not available on this device"}
|
||||
|
||||
try:
|
||||
value = await client.read_characteristic(battery_char.path)
|
||||
if len(value) >= 1:
|
||||
return {"battery_percent": value[0], "source": "gatt_characteristic"}
|
||||
return {"error": "Invalid battery value"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
314
src/mcbluetooth/tools/device.py
Normal file
314
src/mcbluetooth/tools/device.py
Normal file
@ -0,0 +1,314 @@
|
||||
"""Device discovery and management tools for BlueZ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcbluetooth.dbus_client import get_client
|
||||
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
"""Register device management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_scan(
|
||||
adapter: str,
|
||||
timeout: int = 10,
|
||||
mode: Literal["classic", "ble", "both"] = "both",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Scan for nearby Bluetooth devices.
|
||||
|
||||
Starts discovery, waits for the specified timeout, then returns
|
||||
all discovered devices. For BLE devices, ensure they are advertising.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
timeout: Scan duration in seconds (default 10)
|
||||
mode: Scan mode - "classic" (BR/EDR), "ble" (Low Energy), or "both"
|
||||
|
||||
Returns:
|
||||
List of discovered devices with their properties
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# Set discovery filter based on mode
|
||||
transport = {"classic": "bredr", "ble": "le", "both": "auto"}[mode]
|
||||
await client.set_discovery_filter(adapter, transport=transport, duplicate_data=True)
|
||||
|
||||
# Start discovery
|
||||
await client.start_discovery(adapter)
|
||||
|
||||
try:
|
||||
# Wait for scan duration
|
||||
await asyncio.sleep(timeout)
|
||||
finally:
|
||||
# Always stop discovery
|
||||
await client.stop_discovery(adapter)
|
||||
await client.remove_discovery_filter(adapter)
|
||||
|
||||
# Return discovered devices
|
||||
devices = await client.list_devices(adapter=adapter)
|
||||
return [asdict(d) for d in devices]
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_list_devices(
|
||||
adapter: str,
|
||||
filter: Literal["all", "paired", "connected", "trusted"] = "all",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List known Bluetooth devices.
|
||||
|
||||
Returns devices that have been discovered or paired previously.
|
||||
Use bt_scan to discover new devices.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
filter: Filter type - "all", "paired", "connected", or "trusted"
|
||||
|
||||
Returns:
|
||||
List of devices matching the filter
|
||||
"""
|
||||
client = await get_client()
|
||||
devices = await client.list_devices(adapter=adapter, filter_type=filter)
|
||||
return [asdict(d) for d in devices]
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_device_info(adapter: str, address: str) -> dict[str, Any] | None:
|
||||
"""Get detailed information about a specific device.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address (e.g., "AA:BB:CC:DD:EE:FF")
|
||||
|
||||
Returns:
|
||||
Device properties or None if not found
|
||||
"""
|
||||
client = await get_client()
|
||||
info = await client.get_device(adapter, address)
|
||||
return asdict(info) if info else None
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_pair(
|
||||
adapter: str,
|
||||
address: str,
|
||||
pairing_mode: Literal["elicit", "interactive", "auto"] = "interactive",
|
||||
) -> dict[str, Any]:
|
||||
"""Initiate pairing with a device.
|
||||
|
||||
Pairing establishes a trusted relationship with a device. Some devices
|
||||
require PIN entry or confirmation during pairing.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
pairing_mode: How to handle pairing requests:
|
||||
- "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)
|
||||
|
||||
Returns:
|
||||
Pairing status including whether confirmation is needed
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
# Check if already paired
|
||||
device = await client.get_device(adapter, address)
|
||||
if device and device.paired:
|
||||
return {"status": "already_paired", "device": asdict(device)}
|
||||
|
||||
if pairing_mode == "auto":
|
||||
# Direct pairing without agent - may fail if PIN required
|
||||
try:
|
||||
await client.pair_device(adapter, address)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "paired", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
else:
|
||||
# For interactive/elicit modes, we need an agent
|
||||
# Return status indicating pairing initiated
|
||||
try:
|
||||
# Start pairing (this is async and may block for confirmation)
|
||||
# In a real implementation, this would register an agent
|
||||
await client.pair_device(adapter, address)
|
||||
device = await client.get_device(adapter, 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:
|
||||
return {
|
||||
"status": "awaiting_confirmation",
|
||||
"message": "Pairing requires user confirmation or PIN entry",
|
||||
"pairing_mode": pairing_mode,
|
||||
}
|
||||
return {"status": "error", "error": error_msg}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_pair_confirm(
|
||||
adapter: str,
|
||||
address: str,
|
||||
pin: str | None = None,
|
||||
accept: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm or reject a pairing request.
|
||||
|
||||
Use this after bt_pair returns "awaiting_confirmation" status.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name
|
||||
address: Device Bluetooth address
|
||||
pin: PIN code if required (usually 4-6 digits)
|
||||
accept: True to accept pairing, False to reject
|
||||
|
||||
Returns:
|
||||
Pairing result
|
||||
"""
|
||||
client = await get_client()
|
||||
|
||||
if not accept:
|
||||
try:
|
||||
await client.cancel_pairing(adapter, address)
|
||||
return {"status": "pairing_cancelled"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
# Re-attempt pairing - agent would handle PIN
|
||||
# For now, this is a simplified implementation
|
||||
try:
|
||||
await client.pair_device(adapter, address)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "paired", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_unpair(adapter: str, address: str) -> dict[str, str]:
|
||||
"""Remove pairing with a device.
|
||||
|
||||
This removes the device from the list of known devices and
|
||||
deletes all pairing information.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Status of the operation
|
||||
"""
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.remove_device(adapter, address)
|
||||
return {"status": "removed", "address": address}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_connect(adapter: str, address: str) -> dict[str, Any]:
|
||||
"""Connect to a paired device.
|
||||
|
||||
Establishes an active connection to a previously paired device.
|
||||
For audio devices, this will also connect audio profiles.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Connection status and device info
|
||||
"""
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.connect_device(adapter, address)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "connected", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_disconnect(adapter: str, address: str) -> dict[str, Any]:
|
||||
"""Disconnect from a device.
|
||||
|
||||
Terminates the active connection but preserves pairing.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
|
||||
Returns:
|
||||
Status of the operation
|
||||
"""
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.disconnect_device(adapter, address)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "disconnected", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_trust(adapter: str, address: str, trusted: bool) -> dict[str, Any]:
|
||||
"""Set device trust status.
|
||||
|
||||
Trusted devices can connect automatically without explicit
|
||||
authorization.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
trusted: True to trust, False to untrust
|
||||
|
||||
Returns:
|
||||
Updated device info
|
||||
"""
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.set_device_trusted(adapter, address, trusted)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "updated", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_block(adapter: str, address: str, blocked: bool) -> dict[str, Any]:
|
||||
"""Block or unblock a device.
|
||||
|
||||
Blocked devices cannot connect to this adapter.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
blocked: True to block, False to unblock
|
||||
|
||||
Returns:
|
||||
Updated device info
|
||||
"""
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.set_device_blocked(adapter, address, blocked)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "updated", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
async def bt_device_set_alias(adapter: str, address: str, alias: str) -> dict[str, Any]:
|
||||
"""Set a friendly name for a device.
|
||||
|
||||
Args:
|
||||
adapter: Adapter name (e.g., "hci0")
|
||||
address: Device Bluetooth address
|
||||
alias: New friendly name
|
||||
|
||||
Returns:
|
||||
Updated device info
|
||||
"""
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.set_device_alias(adapter, address, alias)
|
||||
device = await client.get_device(adapter, address)
|
||||
return {"status": "updated", "device": asdict(device) if device else None}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for mcbluetooth."""
|
||||
93
tests/test_dbus_client.py
Normal file
93
tests/test_dbus_client.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""Tests for the BlueZ D-Bus client."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from mcbluetooth.dbus_client import (
|
||||
BlueZClient,
|
||||
AdapterInfo,
|
||||
DeviceInfo,
|
||||
address_to_path,
|
||||
path_to_address,
|
||||
)
|
||||
|
||||
|
||||
class TestPathConversion:
|
||||
"""Test address/path conversion utilities."""
|
||||
|
||||
def test_address_to_path(self):
|
||||
assert address_to_path("hci0", "AA:BB:CC:DD:EE:FF") == "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"
|
||||
assert address_to_path("hci1", "11:22:33:44:55:66") == "/org/bluez/hci1/dev_11_22_33_44_55_66"
|
||||
|
||||
def test_address_to_path_lowercase(self):
|
||||
# Should uppercase the address
|
||||
assert address_to_path("hci0", "aa:bb:cc:dd:ee:ff") == "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"
|
||||
|
||||
def test_path_to_address(self):
|
||||
assert path_to_address("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") == "AA:BB:CC:DD:EE:FF"
|
||||
assert path_to_address("/org/bluez/hci1/dev_11_22_33_44_55_66") == "11:22:33:44:55:66"
|
||||
|
||||
def test_path_to_address_invalid(self):
|
||||
assert path_to_address("/org/bluez/hci0") == ""
|
||||
assert path_to_address("/invalid/path") == ""
|
||||
|
||||
|
||||
class TestAdapterInfo:
|
||||
"""Test AdapterInfo dataclass."""
|
||||
|
||||
def test_adapter_info_creation(self):
|
||||
info = AdapterInfo(
|
||||
path="/org/bluez/hci0",
|
||||
name="hci0",
|
||||
address="AA:BB:CC:DD:EE:FF",
|
||||
alias="My Adapter",
|
||||
powered=True,
|
||||
discoverable=False,
|
||||
discoverable_timeout=180,
|
||||
pairable=True,
|
||||
pairable_timeout=0,
|
||||
discovering=False,
|
||||
)
|
||||
assert info.name == "hci0"
|
||||
assert info.powered is True
|
||||
assert info.discoverable is False
|
||||
|
||||
|
||||
class TestDeviceInfo:
|
||||
"""Test DeviceInfo dataclass."""
|
||||
|
||||
def test_device_info_creation(self):
|
||||
info = DeviceInfo(
|
||||
path="/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
adapter="hci0",
|
||||
address="AA:BB:CC:DD:EE:FF",
|
||||
name="Test Device",
|
||||
alias="My Device",
|
||||
paired=True,
|
||||
bonded=True,
|
||||
trusted=True,
|
||||
blocked=False,
|
||||
connected=True,
|
||||
rssi=-50,
|
||||
tx_power=4,
|
||||
)
|
||||
assert info.address == "AA:BB:CC:DD:EE:FF"
|
||||
assert info.paired is True
|
||||
assert info.connected is True
|
||||
assert info.rssi == -50
|
||||
|
||||
|
||||
class TestBlueZClient:
|
||||
"""Test BlueZClient methods with mocked D-Bus."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
return BlueZClient()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_not_connected_initially(self, client):
|
||||
assert client._bus is None
|
||||
assert client._object_manager is None
|
||||
|
||||
# Additional tests would require more extensive mocking of dbus-fast
|
||||
# which is better done as integration tests with a real BlueZ stack
|
||||
Loading…
x
Reference in New Issue
Block a user