A Raspberry Pi BLE to MQTT bridge turns cheap battery-powered Bluetooth sensors into networked devices you can graph, alert on and automate. In this tutorial you will use the Pi's built-in Bluetooth radio, the bleak library and paho-mqtt to listen for sensor advertisements, decode temperature, humidity and battery values, and publish them to an MQTT broker at a sensible rate. You will test against broker.connectmq.com first and then move to private credentials.
Why Bridge BLE to MQTT
Bluetooth Low Energy sensors are small, inexpensive and run for a year or more on a coin cell, but their range is a room or two and they cannot reach the internet on their own. A Raspberry Pi with an onboard radio (Pi 3B and later, Zero W and Zero 2 W) can sit in the middle of a house, greenhouse or cold room, hear every sensor nearby and forward the data over Wi-Fi or Ethernet.
Most sensors broadcast their readings in advertisement packets. That is convenient: the bridge never needs to connect to the sensor, so dozens of devices can be read passively without draining their batteries. Connection-based (GATT) reading is still possible with bleak, but it is slower, uses more power on the sensor and limits how many devices you can handle at once. This guide focuses on advertisements, with a note on GATT at the end.
What You Need
| Item | Notes |
|---|---|
| Raspberry Pi with Bluetooth | Pi 3B, 4, 5, Zero W or Zero 2 W; a USB BLE dongle also works |
| Raspberry Pi OS Bookworm | BlueZ is installed by default |
| BLE sensors | This example decodes Xiaomi LYWSD03MMC thermometers running the popular open-source custom firmware |
| Python 3.11+ | Ships with Bookworm |
The decoding logic is device-specific, but the bridge structure is not. If you have a different sensor, you only change one function.
Install bleak and paho-mqtt
bleak uses BlueZ over D-Bus on Linux, so no root access or extra system packages are required on a standard install. Check that the radio is up, then create a virtual environment:
bluetoothctl show | grep -E "Powered|Name"
sudo rfkill unblock bluetooth
python3 -m venv ~/ble-bridge/.venv
source ~/ble-bridge/.venv/bin/activate
pip install bleak "paho-mqtt>=2.0"
A quick scan confirms bleak can see devices:
python -c "import asyncio; from bleak import BleakScanner; print(*asyncio.run(BleakScanner.discover(timeout=8)), sep='\n')"
Decoding Advertisements
The custom firmware for LYWSD03MMC sensors broadcasts readings as service data under the Environmental Sensing UUID 0x181A. Two layouts are common, and you can tell them apart by length:
- pvvx format, 15 bytes, little-endian: MAC (6 bytes, reversed), temperature as int16 in 0.01 degrees C, humidity as uint16 in 0.01 percent, battery millivolts as uint16, battery percent, a frame counter and flags.
- ATC1441 format, 13 bytes, big-endian: MAC (6 bytes), temperature as int16 in 0.1 degrees C, humidity percent, battery percent, battery millivolts as uint16 and a counter.
Decoding those with struct is short and testable. For other devices, look at the manufacturer_data dictionary (keyed by company ID) or service_data (keyed by UUID string) in the advertisement and apply the vendor's documented format.
The Raspberry Pi BLE to MQTT Bridge Script
The script runs a continuous scanner with a detection callback. Every advertisement from a known sensor is decoded and stored; a separate async task publishes each sensor's latest reading at most once every 30 seconds. paho-mqtt runs its own network thread via loop_start(), so it coexists cleanly with asyncio.
import asyncio
import json
import os
import struct
import time
import uuid
import paho.mqtt.client as mqtt
from bleak import BleakScanner
BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"ble-bridge-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("TOPIC_PREFIX", "connectmq/demo/b4n8r2/ble")
PUBLISH_EVERY = 30 # seconds per sensor
ESS_UUID = "0000181a-0000-1000-8000-00805f9b34fb"
latest = {} # mac -> reading
last_sent = {} # mac -> timestamp
def decode_181a(data: bytes):
if len(data) == 15: # pvvx custom format
temp, hum, mv, batt = struct.unpack_from("<hHHB", data, 6)
return {"temperature": temp / 100, "humidity": hum / 100,
"battery_mv": mv, "battery": batt}
if len(data) == 13: # ATC1441 format
temp, hum, batt, mv = struct.unpack_from(">hBBH", data, 6)
return {"temperature": temp / 10, "humidity": hum,
"battery_mv": mv, "battery": batt}
return None
def on_advertisement(device, adv):
data = adv.service_data.get(ESS_UUID)
if not data:
return
reading = decode_181a(data)
if reading:
reading["rssi"] = adv.rssi
reading["ts"] = int(time.time())
latest[device.address.replace(":", "").lower()] = reading
def on_connect(client, userdata, flags, reason_code, properties):
print("MQTT connected:", reason_code)
client.publish(f"{PREFIX}/bridge/status", "online", qos=1, retain=True)
def make_client():
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
if USERNAME:
c.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
c.tls_set()
c.will_set(f"{PREFIX}/bridge/status", "offline", qos=1, retain=True)
c.reconnect_delay_set(1, 60)
c.on_connect = on_connect
c.connect_async(BROKER, PORT, keepalive=60)
c.loop_start()
return c
async def publisher(client):
while True:
now = time.time()
for mac, reading in list(latest.items()):
if now - last_sent.get(mac, 0) >= PUBLISH_EVERY and client.is_connected():
client.publish(f"{PREFIX}/{mac}", json.dumps(reading), qos=1, retain=True)
last_sent[mac] = now
await asyncio.sleep(1)
async def main():
client = make_client()
scanner = BleakScanner(detection_callback=on_advertisement)
await scanner.start()
print("scanning...")
try:
await publisher(client)
finally:
await scanner.stop()
client.publish(f"{PREFIX}/bridge/status", "offline", qos=1, retain=True)
client.loop_stop()
client.disconnect()
if __name__ == "__main__":
asyncio.run(main())
Subscribe from your laptop to watch readings arrive:
mosquitto_sub -h broker.connectmq.com -p 1883 -i ble-viewer-b4n8r2 -t "connectmq/demo/b4n8r2/ble/#" -v
Each sensor gets its own topic named after its MAC address, and the reading is retained, so a dashboard that connects later immediately sees the most recent value. If you prefer friendly names, keep a small dictionary that maps MAC addresses to room names and use those in the topic.
Design Choices That Matter
Throttle per device. Sensors may advertise every few seconds. Publishing each one wastes bandwidth and, on the free public MQTT broker, quickly hits the 10 messages per second per client limit when you have many sensors. A 30-second window per sensor is plenty for room climate.
Retain the latest state, not the history. Retained messages give new subscribers the current value instantly. History belongs in a database: a subscriber writing to SQLite or InfluxDB, such as the one in the Raspberry Pi Grafana and InfluxDB guide, is the right place for it.
Include RSSI. Signal strength tells you when a sensor is about to fall out of range or when the bridge needs to move. It is also a cheap way to detect a dying sensor before its battery reading drops.
Watch for silence. A sensor that stops advertising is invisible to the bridge. Add the ts field to dashboards, or alert when it gets old. ConnectMQ threshold alert rules work on numeric values, so a subscriber that publishes "seconds since last reading" per sensor gives you a simple staleness alarm.
Troubleshooting
- No devices found: run
bluetoothctl power onandrfkill list. On a Pi with Wi-Fi and Bluetooth sharing one chip, heavy 2.4 GHz Wi-Fi traffic can reduce BLE reception; Ethernet or 5 GHz Wi-Fi helps. - Devices found but no service data: the sensor is running stock firmware, which encrypts its advertisements, or it uses a different format. Print
advin the callback to inspect the raw fields. org.bluez.Error.InProgress: another process is already scanning. Stop other scanners, or run only one bridge per adapter.- Readings stop after hours: BlueZ sometimes needs a restart on older kernels. Running the bridge as a systemd service with
Restart=alwayscovers this, as described in running a Python MQTT script on boot. - Weak range: a USB extension cable to move a dongle away from the Pi's metal case and USB 3 ports can make a noticeable difference, since USB 3 is known to cause 2.4 GHz interference.
Reading GATT Characteristics
Some devices expose data only through a connection. bleak handles that with BleakClient:
from bleak import BleakClient
async def read_battery(address):
async with BleakClient(address) as client:
value = await client.read_gatt_char("00002a19-0000-1000-8000-00805f9b34fb")
return value[0]
Connections are expensive, so read GATT values on a slow schedule and stop scanning while connecting if your adapter struggles with both at once. The bleak documentation covers notifications, which are the efficient way to stream connected data.
Going to Production
When the bridge works, move off the public broker, which is readable by anyone. Create a private broker on ConnectMQ, set MQTT_HOST, MQTT_PORT=8883, MQTT_USERNAME, MQTT_PASSWORD and MQTT_CLIENT_ID from the portal, and change the topic prefix to something like home/ble. The bridge needs no inbound ports, only an outbound TLS connection, so it works behind any home router or cellular modem. A managed MQTT broker also means there is no broker process on the Pi to maintain.
If you are new to MQTT on the Pi, the Raspberry Pi MQTT tutorial covers the basics, and the Raspberry Pi IoT gateway article shows how to combine BLE with other protocols on one device.
Connect Your BLE Sensors to ConnectMQ
Sign up at app.connectmq.com for the free Serverless plan, no credit card needed, and you will have broker credentials in a couple of minutes. Point the bridge at them and open the live topic explorer to see every sensor appear under its own topic.
Frequently Asked Questions
How many BLE sensors can one Raspberry Pi handle?
For passive advertisement scanning, dozens of sensors within radio range are typical, because the Pi only listens. Range and walls usually limit you before CPU does.
Do I need to pair the sensors?
No. Advertisement-based sensors broadcast to anyone listening, so no pairing or connection is required. Encrypted advertisements are the exception and need the device's key.
Can I run this on a Raspberry Pi Zero 2 W?
Yes. The bridge uses little CPU and memory, and the Zero 2 W has built-in Bluetooth and Wi-Fi, which makes it a good low-cost bridge per room or building.
Should I use QoS 0 or QoS 1 for sensor readings?
QoS 1 is a good default for readings you want delivered at least once. For very frequent values where the next reading replaces the last, QoS 0 is fine.



