- Python 99.7%
- Dockerfile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 51s
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>
|
||
| .forgejo/workflows | ||
| .github/workflows | ||
| bt_proxy | ||
| bt-proxy.service | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.fork.md | ||
| README.md | ||
| test_client_disconnect_frees_ble.py | ||
| test_frame_bound.py | ||
| test_mtu_negotiation.py | ||
| test_passive_scan_flag.py | ||
| test_restarted_client_evicts_stale.py | ||
| test_stale_client_disconnect.py | ||
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
- Starts a BLE scanner using bleak
- Advertises itself via mDNS as
_esphomelib._tcp.local. - Listens on TCP port 6053 for ESPHome Native API connections
- 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=hostis required for mDNS discovery.--privilegedgrants access to the Bluetooth adapter — alternatively use--cap-add=NET_ADMIN --cap-add=NET_RAWwith 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
bluetoothdpath to your system — some distros use/usr/lib/bluetooth/bluetoothd. Check withsystemctl 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.
rfkillmay not be installed by default (sudo apt install rfkill). The unblock is saved across reboots (viasystemd-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 —
dmesgshowshci0: Opcode 0x200c failed: -110/-16. Restartingbluetoothdis 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 -uunit 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