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.
- 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
- Raspberry Pi (or any Linux machine) with a Bluetooth adapter
- uv package manager
- BlueZ (installed by default on Raspberry Pi OS)
git clone https://github.com/denvera/bt-proxy.git /opt/bt-proxy
cd /opt/bt-proxy
uv syncuv run python -m bt_proxy| 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 |
uv run python -m bt_proxy --name living-room-proxy --friendly-name "Living Room BT Proxy" --log-level DEBUG- 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.
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-proxyPass 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.
Copy the unit file and enable it:
sudo cp bt-proxy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bt-proxyCheck status / logs:
sudo systemctl status bt-proxy
journalctl -u bt-proxy -fHome 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.
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 --experimentalThe 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 bluetoothMatch 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.
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 NOWUse 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.
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).
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.targetsudo systemctl daemon-reload
sudo systemctl enable --now bt-proxy-watchdog.timerAdjust 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 ....
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