# Usage Guide

## Why Use bleak-retry-connector?

This package provides robust retry logic and intelligent backoff strategies for establishing BLE connections. Key benefits include:

- **Automatic retry with backoff** - Handles transient connection failures with intelligent retry timing
- **Connection slot management** - Critical for ESPHome Bluetooth proxies that have limited connection slots
- **Service caching** - Speeds up reconnections by caching GATT services
- **Platform-specific optimizations** - Special handling for Linux/BlueZ, macOS, and ESP32 devices
- **Error categorization** - Distinguishes between transient errors, missing devices, and out-of-slots conditions

### Essential for ESPHome Bluetooth Proxies

If you're using ESPHome Bluetooth proxies, this package is **critical** because:

1. **Proper slot management** - ESP32 devices have limited connection slots that must be carefully managed
2. **Handles ESP-specific errors** - Recognizes ESP32 error codes like `ESP_GATT_CONN_CONN_CANCEL` (out of slots)
3. **Appropriate backoff timing** - Uses longer backoff (4 seconds) when slots are exhausted to allow proper cleanup
4. **Prevents slot exhaustion** - Manages connection attempts to avoid overwhelming the proxy

## BleakClientWithServiceCache

`BleakClientWithServiceCache` is a subclass of `BleakClient` that provides service caching capabilities for faster reconnections.

### Basic Usage

```python
from bleak_retry_connector import BleakClientWithServiceCache
from bleak.backends.device import BLEDevice

async def connect_with_cache(device: BLEDevice):
    client = BleakClientWithServiceCache(device)
    await client.connect()

    # Use the client normally
    services = client.services

    # Clear cache if needed (e.g., after service changes)
    await client.clear_cache()

    await client.disconnect()
```

### Key Features

- **Automatic service caching**: Services are cached between connections for faster reconnections
- **Cache clearing**: Call `clear_cache()` to force a fresh service discovery
- **Connection parameter tuning**: Call `set_connection_params()` to adjust BLE connection intervals
- **Drop-in replacement**: Can be used anywhere `BleakClient` is used

### Extension Methods

`BleakClientWithServiceCache` provides extension methods that are forwarded to the underlying backend (e.g., habluetooth). These methods allow integrations to control BLE behavior beyond what standard bleak provides.

#### clear_cache

```python
async def clear_cache(self) -> bool
```

Clears the cached GATT services, forcing a fresh service discovery on the next access. Useful when a device's firmware has been updated or services have changed.

Returns `True` if the cache was successfully cleared, `False` otherwise.

```python
client = await establish_connection(
    BleakClientWithServiceCache, device, name="MyDevice"
)

# If characteristics are missing, clear cache and reconnect
await client.clear_cache()
await client.disconnect()
```

#### set_connection_params

```python
async def set_connection_params(
    self,
    min_interval: int,
    max_interval: int,
    latency: int,
    timeout: int,
) -> None
```

Sets BLE connection parameters on a connected device. This is useful for "Always Connected" devices where battery conservation is important — switching from fast intervals (~7.5ms) to slow intervals (e.g., 1000ms) after the initial data sync can significantly reduce power consumption.

Parameters are in BLE units:

- **min_interval** / **max_interval**: Connection interval in units of 1.25ms (e.g., 800 = 1000ms)
- **latency**: Number of connection events the peripheral can skip (typically 0)
- **timeout**: Supervision timeout in units of 10ms (e.g., 600 = 6000ms)

```python
client = await establish_connection(
    BleakClientWithServiceCache, device, name="MyDevice"
)

# After initial sync, switch to slow intervals to save battery
await client.set_connection_params(
    min_interval=800,   # 1000ms
    max_interval=800,   # 1000ms
    latency=0,
    timeout=600,        # 6000ms
)
```

The method delegates to the backend (habluetooth), which routes to either:

- **ESPHome proxy**: Sends a protobuf message to the ESP32 to call `esp_ble_gap_update_conn_params()`
- **Local BlueZ adapter**: Uses the MGMT API (`MGMT_OP_LOAD_CONN_PARAM`)

## establish_connection

`establish_connection` is the main function for establishing robust BLE connections with automatic retry logic.

### Function Signature

```python
async def establish_connection(
    client_class: type[BleakClient],
    device: BLEDevice,
    name: str,
    disconnected_callback: Callable[[BleakClient], None] | None = None,
    max_attempts: int = 4,
    cached_services: BleakGATTServiceCollection | None = None,
    ble_device_callback: Callable[[], BLEDevice] | None = None,
    use_services_cache: bool = True,
    pair: bool = False,
    **kwargs: Any
) -> BleakClient
```

### Parameters

- **client_class**: The BleakClient class to use (typically `BleakClientWithServiceCache`)
- **device**: The BLE device to connect to
- **name**: A descriptive name for the device (used in logging)
- **disconnected_callback**: Optional callback when device disconnects unexpectedly
- **max_attempts**: Maximum connection attempts before giving up (default: 4)
- **cached_services**: Deprecated; the collection itself is no longer threaded into the
  client. Only the parameter's truthiness still has any effect — passing any non-`None`
  value forces `dangerous_use_bleak_cache=True` for that connect call, identical to
  setting `use_services_cache=True`. Bleak 0.17+ ships built-in service caching, which
  this library calls into directly. Prefer `use_services_cache=True` and stop constructing
  a stand-in `BleakGATTServiceCollection` for this parameter.
- **ble_device_callback**: Deprecated and unused; retained for API compatibility. The
  callback is never invoked. Pass the freshest `BLEDevice` to `device` instead — Home
  Assistant and other discovery layers already supply an up-to-date path on each retry.
- **use_services_cache**: Whether to use service caching (default: True)
- **pair**: Whether to pair with the device on connect (default: False)
- **kwargs**: Additional arguments passed to the client class constructor

### Return Value

Returns the connected client instance of the specified `client_class`.

### Exceptions

`establish_connection` can raise the following exceptions after exhausting retry attempts:

- **BleakNotFoundError**: Device was not found or disappeared

  - Raised when the device cannot be found
  - Raised on `asyncio.TimeoutError` after all retries
  - Raised when `BleakDeviceNotFoundError` occurs
  - Raised when device is missing from the adapter

- **BleakOutOfConnectionSlotsError**: Adapter/proxy has no available connection slots

  - Raised when local Bluetooth adapters or ESP32 proxies are out of connection slots
  - Common with errors containing "ESP_GATT_CONN_CONN_CANCEL", "connection slot", or "available connection"
  - For local adapters: disconnect unused devices or use a different adapter
  - For ESP32 proxies: add more proxies or disconnect other devices

- **BleakAbortedError**: Connection was aborted due to interference or range issues

  - Raised for transient connection failures that suggest environmental issues
  - Common with errors like "le-connection-abort-by-local", "br-connection-canceled"
  - Indicates interference, range problems, or USB 3.0 port interference

- **BleakConnectionError**: General connection failure after all retries
  - Raised for any other connection errors that don't fit the above categories
  - The fallback exception when connection cannot be established

### Basic Example

```python
from bleak_retry_connector import establish_connection, BleakClientWithServiceCache
from bleak.backends.device import BLEDevice

async def connect_to_device(device: BLEDevice):
    # Simple connection with retry
    client = await establish_connection(
        BleakClientWithServiceCache,
        device,
        name=device.name or device.address
    )

    # Use the client
    services = client.services

    # Disconnect when done
    await client.disconnect()

    return client
```

### Example with Disconnection Callback

```python
async def connect_with_callback(device: BLEDevice):
    def on_disconnect(client):
        print(f"Device {device.address} disconnected unexpectedly")

    client = await establish_connection(
        BleakClientWithServiceCache,
        device,
        name=device.name or device.address,
        disconnected_callback=on_disconnect,
        max_attempts=5  # Try up to 5 times
    )

    return client
```

### Example with Custom Client Class

```python
from bleak import BleakClient

class CustomClient(BleakClient):
    async def custom_method(self):
        # Custom functionality
        pass

async def connect_with_custom_client(device: BLEDevice):
    client = await establish_connection(
        CustomClient,
        device,
        name=device.name,
        max_attempts=3
    )

    # Use custom methods
    await client.custom_method()

    return client
```

### Error Handling Example

```python
from bleak_retry_connector import (
    establish_connection,
    BleakClientWithServiceCache,
    BleakNotFoundError,
    BleakOutOfConnectionSlotsError,
    BleakAbortedError,
    BleakConnectionError
)

async def connect_with_error_handling(device: BLEDevice):
    try:
        client = await establish_connection(
            BleakClientWithServiceCache,
            device,
            name=device.name
        )
        return client

    except BleakNotFoundError:
        print("Device not found - it may have moved out of range")
        return None

    except BleakOutOfConnectionSlotsError:
        print("No connection slots available - try disconnecting other devices")
        return None

    except BleakAbortedError:
        print("Connection aborted - check for interference or move closer")
        return None

    except BleakConnectionError as e:
        print(f"Connection failed: {e}")
        return None
```

### Example with Cache Clearing on Missing Characteristic

When a device's firmware changes or services are updated, you might encounter missing characteristics. Here's how to handle this scenario by clearing the cache and retrying:

```python
from bleak_retry_connector import establish_connection, BleakClientWithServiceCache
from bleak.exc import BleakError

class CharacteristicMissingError(Exception):
    """Raised when a required characteristic is missing."""
    pass

async def connect_and_validate_services(device: BLEDevice):
    """Connect and validate required characteristics exist."""

    client = await establish_connection(
        BleakClientWithServiceCache,
        device,
        name=device.name or device.address,
        use_services_cache=True
    )

    try:
        # Check for required characteristics
        required_service_uuid = "cba20d00-224d-11e6-9fb8-0002a5d5c51b"
        required_char_uuid = "cba20002-224d-11e6-9fb8-0002a5d5c51b"

        service = client.services.get_service(required_service_uuid)
        if not service:
            raise CharacteristicMissingError(f"Service {required_service_uuid} not found")

        char = service.get_characteristic(required_char_uuid)
        if not char:
            raise CharacteristicMissingError(f"Characteristic {required_char_uuid} not found")

    except (CharacteristicMissingError, KeyError) as ex:
        # Services might have changed, clear cache and reconnect
        print(f"Characteristic missing, clearing cache: {ex}")
        await client.clear_cache()
        await client.disconnect()

        # Reconnect without cache
        client = await establish_connection(
            BleakClientWithServiceCache,
            device,
            name=device.name or device.address,
            use_services_cache=False  # Force fresh service discovery
        )

        # Validate again
        service = client.services.get_service(required_service_uuid)
        if not service:
            await client.disconnect()
            raise CharacteristicMissingError(f"Service {required_service_uuid} still not found after cache clear")

        char = service.get_characteristic(required_char_uuid)
        if not char:
            await client.disconnect()
            raise CharacteristicMissingError(f"Characteristic {required_char_uuid} still not found after cache clear")

    return client
```

### Advanced Configuration

```python
async def connect_with_full_options(device: BLEDevice):
    client = await establish_connection(
        BleakClientWithServiceCache,
        device,
        name="MyDevice",
        disconnected_callback=lambda c: print("Disconnected"),
        max_attempts=6,  # More attempts for difficult devices
        use_services_cache=True,  # Use caching for faster reconnects
        timeout=30.0  # Pass additional kwargs to BleakClient
    )

    return client
```

## Complete Working Example

```python
import asyncio
from bleak import BleakScanner
from bleak_retry_connector import (
    establish_connection,
    BleakClientWithServiceCache,
    BleakNotFoundError,
    BleakOutOfConnectionSlotsError,
    BleakAbortedError,
    BleakConnectionError
)

async def main():
    # Scan for devices
    print("Scanning for devices...")
    devices = await BleakScanner.discover()

    if not devices:
        print("No devices found")
        return

    # Connect to the first device found
    device = devices[0]
    print(f"Connecting to {device.name or device.address}...")

    try:
        # Establish connection with retry
        client = await establish_connection(
            BleakClientWithServiceCache,
            device,
            name=device.name or device.address,
            max_attempts=4
        )

        print("Connected successfully!")

        # List services
        for service in client.services:
            print(f"  Service: {service.uuid}")
            for char in service.characteristics:
                print(f"    Characteristic: {char.uuid}")

        # Disconnect
        await client.disconnect()
        print("Disconnected")

    except (BleakNotFoundError, BleakOutOfConnectionSlotsError,
            BleakAbortedError, BleakConnectionError) as e:
        print(f"Failed to connect: {e}")

if __name__ == "__main__":
    asyncio.run(main())
```

## retry_bluetooth_connection_error

A decorator that wraps an async function and retries it on transient Bleak
errors. Useful for short GATT operations (reads, writes, notifications) that
can be disconnected mid-flight by the device.

### Function Signature

```python
def retry_bluetooth_connection_error(
    attempts: int = 2,
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]
```

### Parameters

- **attempts**: Number of times to attempt the wrapped call before re-raising
  the underlying error (default: 2).

The decorator catches the same `BLEAK_EXCEPTIONS` group used internally by
`establish_connection` and backs off with `calculate_backoff_time()` between
attempts. After the final attempt fails, the original exception propagates.

### Example

```python
from bleak_retry_connector import (
    establish_connection,
    BleakClientWithServiceCache,
    retry_bluetooth_connection_error,
)

@retry_bluetooth_connection_error(attempts=3)
async def read_battery(client: BleakClientWithServiceCache) -> int:
    data = await client.read_gatt_char("00002a19-0000-1000-8000-00805f9b34fb")
    return data[0]

async def main(device):
    client = await establish_connection(
        BleakClientWithServiceCache, device, name=device.name
    )
    try:
        level = await read_battery(client)
        print(f"Battery: {level}%")
    finally:
        await client.disconnect()
```

## close_stale_connections

On Linux/BlueZ, BlueZ may report a device as connected even when another
adapter or a crashed process owns the connection. `close_stale_connections`
disconnects those existing connections so a fresh `establish_connection`
attempt can proceed.

Two variants are exported:

```python
async def close_stale_connections(
    device: BLEDevice, only_other_adapters: bool = False
) -> None

async def close_stale_connections_by_address(
    address: str, only_other_adapters: bool = False
) -> None
```

- **device** / **address**: The target device or its MAC address.
- **only_other_adapters**: If `True`, only disconnect instances on adapters
  different from the one the supplied `device` is on. Useful when you want
  to keep your own active connection alive while clearing duplicates that
  appeared on another adapter.

Both functions are no-ops on non-Linux platforms.

### Example

```python
from bleak_retry_connector import (
    close_stale_connections_by_address,
    establish_connection,
    BleakClientWithServiceCache,
)

# Before reconnecting after a service restart, clear stale BlueZ state:
await close_stale_connections_by_address("AA:BB:CC:DD:EE:FF")

client = await establish_connection(
    BleakClientWithServiceCache, device, name=device.name
)
```

## clear_cache

Removes a device from BlueZ via the `RemoveDevice` D-Bus method. This clears
cached GATT services and any stale `Connected=True` state BlueZ may be
holding for the address.

```python
async def clear_cache(address: str) -> bool
```

- **address**: The MAC address of the device to remove.
- **Returns**: `True` if the device was removed, `False` otherwise (including
  on non-Linux platforms).

`clear_cache()` is safe to call unconditionally — it suppresses all errors
internally and returns `False` rather than raising. There is also an instance
method `BleakClientWithServiceCache.clear_cache()` (documented above) which
clears only the bleak-level service cache for an already-connected client;
the module-level `clear_cache(address)` operates on BlueZ directly and does
not require a client.

### Example

```python
from bleak_retry_connector import clear_cache

# After a firmware update, force BlueZ to forget cached services:
await clear_cache("AA:BB:CC:DD:EE:FF")
```

## restore_discoveries

On Linux/BlueZ, advertisement data tracked by BlueZ can be lost when a
scanner is recreated. `restore_discoveries` re-seeds a freshly created
`BleakScanner` with the devices BlueZ already knows about, so callers don't
have to wait for the next advertisement to see existing devices.

```python
async def restore_discoveries(scanner: BleakScanner, adapter: str) -> None
```

- **scanner**: The newly created `BleakScanner` instance.
- **adapter**: The HCI adapter name (e.g. `"hci0"`).

No-op on non-Linux platforms.

## get_device / get_device_by_adapter

Look up a `BLEDevice` by MAC address against BlueZ's current view of the bus.
Useful when a caller has lost its `BLEDevice` handle (e.g. after a scanner
restart) but still knows the address.

```python
async def get_device(address: str) -> BLEDevice | None
async def get_device_by_adapter(address: str, adapter: str) -> BLEDevice | None
```

- **address**: The MAC address of the device.
- **adapter** (`get_device_by_adapter` only): The HCI adapter name (e.g.
  `"hci0"`) to restrict the lookup to a single controller.

`get_device` searches every adapter and returns the device with the strongest
RSSI; `get_device_by_adapter` only inspects the BlueZ object at
`/org/bluez/<adapter>/dev_<ADDR>` and returns `None` if no device exists on
that adapter.

Both return `None` on non-Linux platforms and when BlueZ has no matching
object.

```python
from bleak_retry_connector import get_device, establish_connection, BleakClientWithServiceCache

device = await get_device("AA:BB:CC:DD:EE:FF")
if device is None:
    raise RuntimeError("device not currently known to BlueZ")

client = await establish_connection(
    BleakClientWithServiceCache, device, name=device.name
)
```

## device_source

Return the `source` tag from a `BLEDevice`'s `details` mapping, or `None` if
the tag is absent. The source is set by the scanner that produced the
advertisement — for example, ESPHome Bluetooth proxies tag their devices with
the proxy name. Native BlueZ devices typically have no source.

```python
def device_source(device: BLEDevice) -> str | None
```

```python
from bleak_retry_connector import device_source

if device_source(device) is None:
    # Local adapter device — slot management applies.
    ...
else:
    # Came from an ESPHome proxy — handle ESP-specific errors.
    ...
```

## ble_device_description

Format a `BLEDevice` into a short, log-friendly string of the form
`<address> - <name> -> <path-or-source>`. Used by `establish_connection`
internally for log lines; exported so callers can produce the same format in
their own diagnostics.

```python
def ble_device_description(device: BLEDevice) -> str
```

The trailing `-> ...` is only appended when the device's `details` carry a
BlueZ `path` (truncated to 15 characters) or a `source` tag. Devices with
neither are described as `<address> - <name>` (or just `<address>` when the
name equals the address).

## BleakSlotManager

`BleakSlotManager` tracks how many BLE connection slots each local BlueZ
adapter has free and which addresses currently hold a slot. It is intended
for callers that orchestrate multiple connections across multiple adapters
(e.g. Home Assistant) and need to make scheduling decisions before calling
`establish_connection`.

```python
from bleak_retry_connector import BleakSlotManager

manager = BleakSlotManager()
await manager.async_setup()

# Tell the manager about each adapter and its slot capacity:
manager.register_adapter("hci0", slots=5)
manager.register_adapter("hci1", slots=5)

allocations = manager.get_allocations("hci0")
print(allocations.free, allocations.allocated)
```

Key methods:

- **`async_setup()`** — Attach to the global BlueZ manager. Must be awaited
  before any other call.
- **`register_adapter(adapter, slots)`** / **`remove_adapter(adapter)`** —
  Declare or forget an adapter and its slot capacity. On registration,
  devices that BlueZ already reports as connected on the adapter are
  pre-allocated.
- **`get_allocations(adapter)`** — Return an `Allocations` dataclass
  describing the adapter (`slots`, `free`, list of allocated addresses).
- **`release_slot(device)`** — Manually release a slot held by `device`.
  Normally unnecessary: the manager watches BlueZ's `Connected` property and
  releases automatically on disconnect.
- **`register_allocation_callback(callback)`** — Subscribe to
  `AllocationChangeEvent`s (allocated / released). Returns an unsubscribe
  callable.
- **`diagnostics()`** — Return a JSON-friendly snapshot for logging.

`BleakSlotManager` only sees BlueZ adapters; ESPHome proxy slots are tracked
by the proxy itself and reported through habluetooth. On non-Linux platforms
the manager can be constructed but `async_setup()` will not find a BlueZ
manager to attach to.

## Constants

- **`BLEAK_RETRY_EXCEPTIONS`**: A tuple of exception classes that
  `establish_connection` treats as transient and retryable:
  `AttributeError`, `BleakError`, `EOFError`, `BrokenPipeError`, and
  `asyncio.TimeoutError`. Re-exported so callers layering their own retry
  logic on top can match the same set. `retry_bluetooth_connection_error`
  retries the same set except `asyncio.TimeoutError`, which is deliberately
  excluded so timeouts set by the caller are not multiplied by the retry
  attempts.

- **`NO_RSSI_VALUE`** (`-127`): Sentinel value used internally when an
  advertisement carries no RSSI. Exported so callers ranking devices by
  signal strength can use the same floor.

- **`RSSI_SWITCH_THRESHOLD`** (`5`): Minimum RSSI delta in dBm that
  `establish_connection` requires before switching to a stronger advertised
  path mid-retry. Exposed for callers that want to apply the same hysteresis
  to their own adapter-selection logic.

- **`DISCONNECT_TIMEOUT`** (`5`): Maximum time in seconds that
  bleak-retry-connector will wait for a D-Bus disconnect operation —
  including `clear_cache`'s `RemoveDevice` calls and the post-failure
  disconnect wait in `establish_connection` — to complete before giving up.
  Exposed so callers wrapping the same D-Bus operations can apply a matching
  ceiling.
