Fork of denvera/bt-proxy: ESPHome-API Bluetooth proxy with active GATT for Linux hosts
  • Python 99.7%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JJ Macalinao bb666c86a9
All checks were successful
test / test (push) Successful in 51s
fix: evict a restarted client's stale session on Hello
When Home Assistant restarts, its new session connects before the old
socket times out. The client count never reaches zero, so _cleanup()
never releases the BLE links the old session held, and a persistently
connected device stays stranded: BlueZ reports it connected while HA
has no session for it. Seen 2026-10-13 with the bedroom EcoFlow after
two HA restarts ("Connection removed, 1 active" each time).

On Hello, older sessions from the same host are now aborted and
removed. If that leaves the new session as the only client, every BLE
connection is released so HA reconnects onto fresh links. Sessions from
other hosts are untouched. abort() is used instead of close() because a
dead peer would leave close() waiting on unsent advertisements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:02:00 +08:00
.forgejo/workflows ci: drop --locked, the repo carries no uv.lock 2026-10-09 23:45:37 +08:00
.github/workflows Update readme, at armv7 Docker build 2026-04-10 23:22:52 +02:00
bt_proxy fix: evict a restarted client's stale session on Hello 2026-10-14 04:02:00 +08:00
bt-proxy.service Fix hung shutdown while connected to Home Assistant (#7) 2026-06-24 23:12:31 +02:00
Dockerfile Remove glib package install 2026-04-10 23:28:28 +02:00
pyproject.toml Better login for rfkill'd adapters, update README 2026-07-07 23:27:13 +02:00
README.fork.md docs: fork denvera/bt-proxy and record why, not the archived snapshot 2026-09-11 20:30:04 +00:00
README.md Better login for rfkill'd adapters, update README 2026-07-07 23:27:13 +02:00
test_client_disconnect_frees_ble.py fix: release a client's BLE connections when its API session ends 2026-09-15 11:34:42 +00:00
test_frame_bound.py test: cover the frame-length bound 2026-09-11 20:27:59 +00:00
test_mtu_negotiation.py fix: negotiate MTU through bleak's public API, not a private coroutine 2026-09-12 06:57:19 +00:00
test_passive_scan_flag.py fix: only advertise passive scanning when BlueZ can actually do it 2026-09-12 02:47:26 +00:00
test_restarted_client_evicts_stale.py fix: evict a restarted client's stale session on Hello 2026-10-14 04:02:00 +08:00
test_stale_client_disconnect.py test: cover stale D-Bus client cleanup on disconnect and connect failure 2026-10-09 23:41:40 +08:00

bt-proxy

ESPHome-compatible Bluetooth Proxy for Raspberry Pi.

This implements the ESPHome Bluetooth Proxy functionality in Python, allowing a Raspberry Pi to act as a BLE proxy for Home Assistant. It speaks the ESPHome Native API protocol so Home Assistant discovers and uses it exactly like an ESP32-based Bluetooth proxy.

WARNING: This project was coded largely with the assistance of an LLM. It works for me, but your mileage may vary.

Features

  • BLE scanning — passive and active scan modes, raw advertisement forwarding
  • Active connections — GATT connect/disconnect, service discovery, read/write characteristics and descriptors, notifications
  • mDNS discovery — automatically advertised so Home Assistant finds it
  • ESPHome Native API — wire-compatible with aioesphomeapi / Home Assistant ESPHome integration

Requirements

  • Raspberry Pi (or any Linux machine) with a Bluetooth adapter
  • uv package manager
  • BlueZ (installed by default on Raspberry Pi OS)

Installation

git clone https://github.com/denvera/bt-proxy.git /opt/bt-proxy
cd /opt/bt-proxy
uv sync

Usage

uv run python -m bt_proxy

Options

Flag Default Description
--name bt-proxy Device name (used in mDNS and API)
--friendly-name Bluetooth Proxy Human-readable name
--port 6053 API server TCP port
--max-connections 3 Max concurrent BLE GATT connections
--adapter system default Bluetooth adapter (e.g. hci0)
--log-level INFO Logging verbosity

Example

uv run python -m bt_proxy --name living-room-proxy --friendly-name "Living Room BT Proxy" --log-level DEBUG

How It Works

  1. Starts a BLE scanner using bleak
  2. Advertises itself via mDNS as _esphomelib._tcp.local.
  3. Listens on TCP port 6053 for ESPHome Native API connections
  4. When Home Assistant connects, it forwards BLE advertisements and handles GATT operations

Note: This uses the ESPHome Native API plaintext variant (no encryption). The Noise-encrypted protocol is currently not supported.

Running with Docker

Pre-built images are available for amd64, arm64, and arm/v7 (Raspberry Pi 2+):

docker run -d \
  --name bt-proxy \
  --restart unless-stopped \
  --net=host \
  --privileged \
  -v /var/run/dbus:/var/run/dbus \
  ghcr.io/denvera/bt-proxy

Pass any CLI options after the image name:

docker run -d \
  --name bt-proxy \
  --restart unless-stopped \
  --net=host \
  --privileged \
  -v /var/run/dbus:/var/run/dbus \
  ghcr.io/denvera/bt-proxy \
  --name living-room-proxy --friendly-name "Living Room BT Proxy" --log-level DEBUG

--net=host is required for mDNS discovery. --privileged grants access to the Bluetooth adapter — alternatively use --cap-add=NET_ADMIN --cap-add=NET_RAW with explicit device mounts.

Running as a Service

Copy the unit file and enable it:

sudo cp bt-proxy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bt-proxy

Check status / logs:

sudo systemctl status bt-proxy
journalctl -u bt-proxy -f

Scanning modes: active vs passive

Home Assistant requests passive scanning by default. bt-proxy honours that when BlueZ supports it, and otherwise falls back to active scanning automatically — so things keep working either way. If passive isn't available you'll see a one-off warning in the log followed by falling back to active scanning; that's harmless.

Passive is generally the better default. It's lighter-weight on the proxy itself (the radio only listens, never transmits, which also reduces RF congestion when several proxies are around), and it can be gentler on some battery sensors:

  • Active scanning may send a scan request to a scannable advertiser, prompting it to transmit an extra scan-response packet. Passive never asks for one.
  • For non-connectable beacons (they can't be scan-requested), and for devices the proxy stays connected to — once connected, the connection dominates a device's power use, not the scan mode. It mainly matters for advertisement-only sensors that happen to be scannable.

Enabling passive scanning

Passive scanning needs BlueZ's experimental features (and BlueZ ≥ 5.56 with Linux kernel ≥ 5.10, which most current systems already have). Run bluetoothd with --experimental.

Rather than editing the packaged unit, drop in an override so updates don't clobber it. Create /etc/systemd/system/bluetooth.service.d/experimental.conf:

[Service]
ExecStart=
ExecStart=/usr/libexec/bluetooth/bluetoothd --experimental

The empty ExecStart= is required to clear the inherited command before setting the new one. Then reload and restart:

sudo systemctl daemon-reload
sudo systemctl restart bluetooth

Match the bluetoothd path to your system — some distros use /usr/lib/bluetooth/bluetoothd. Check with systemctl cat bluetooth | grep ExecStart.

Once enabled, the proxy logs Starting BLE scanner (configured=passive, mode=passive) and the active-fallback warning goes away.

Reliability on Raspberry Pi

Undervolting or brownout/undervoltage conditions on Pi's produce exactly the symptoms that look like flaky bluetooth, often manifested as HCI command timeouts (dmesg: hci0: Opcode 0x200c failed, tx timeout), Frame reassembly failed, dropped connections, even an unresponsive bluetoothd.

Before blaming bluetooth, check power:

vcgencmd get_throttled      # want 0x0 (or at most 0x50000 = a brief boot-time
                            # dip); a non-zero bit 0 means under-voltage NOW

Use a proper 5.1 V / ≥2.5 A supply and a short, thick cable. Undervoltage under load (0x...1 / 0x...5) is the most common cause of "random" BT failures on a Pi.

The proxy also backs off instead of hammering the adapter when connections keep failing, which both protects a flaky controller and keeps the logs readable.

Adapter powered off / blocked by rfkill (common on a fresh install)

If the proxy logs the Bluetooth adapter is powered off or blocked / No powered Bluetooth adapters found ... POWERED_OFF, the adapter simply isn't powered — it's not a wedged controller. On a fresh Raspberry Pi OS install the onboard Bluetooth is often soft-blocked by rfkill. Unblock it on the host:

sudo rfkill unblock bluetooth
sudo hciconfig hci0 up          # only if it's still down afterwards

This is a host-level fix — even a --privileged container can't power on an rfkill-blocked host adapter. The proxy detects this case and keeps retrying (every 60s), so once you unblock it the scanner starts on its own without a restart.

rfkill may not be installed by default (sudo apt install rfkill). The unblock is saved across reboots (via systemd-rfkill), so it's usually a one-time fix.

When bluetooth appears unresponsive

Two distinct failures can occur on the onboard radio, with different fixes:

  • Controller unresponsive — dmesg shows hci0: Opcode 0x200c failed: -110/-16. Restarting bluetoothd is not enough; reset the adapter:
    sudo hciconfig hci0 reset    # or: down && up, or reboot
    
  • Daemon unresponsive — the proxy logs that it looks like "bluetoothd is wedged (phantom connection / no D-Bus reply)". Here the daemon needs a restart (an adapter reset alone won't fix it, and neither will restarting bt-proxy):
    sudo systemctl restart bluetooth
    

bt-proxy tries to detect these conditions, log a recovery hint, and back off so it recovers once the underlying issue clears — but it wont reset the adapter or restart bluetoothd itself (those are host-level actions).

Optional: auto-recover an unresponsive bluetoothd (watchdog)

If you want an automatic, albeit somewhat primitive recovery from the unresponsive daemon case, add a small systemd watchdog on the host that restarts bluetooth when the proxy reports it's frozen. For example, a timer that restarts the daemon when the marker shows up in the proxy's logs:

# /etc/systemd/system/bt-proxy-watchdog.service
[Unit]
Description=Restart bluetoothd if unresponsive

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'journalctl -u bt-proxy --since "-3min" | grep -q "bluetoothd is wedged" && systemctl restart bluetooth || true'
# /etc/systemd/system/bt-proxy-watchdog.timer
[Unit]
Description=Periodically check whether bluetoothd needs restarting

[Timer]
OnUnitActiveSec=2min
AccuracySec=30s

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now bt-proxy-watchdog.timer

Adjust the journalctl -u unit name (and add -t/container filters) to match how you run the proxy. Under Docker, point it at the container logs instead, e.g. docker logs --since 3m bt-proxy 2>&1 | grep -q ....

Architecture

bt_proxy/
├── __init__.py        # Package init
├── __main__.py        # Entry point, CLI, mDNS registration
├── proto.py           # Protobuf encoding/decoding, message IDs, wire protocol
├── ble_manager.py     # BLE scanning and GATT connections (bleak)
└── api_server.py      # ESPHome Native API TCP server