How to Advertise BLE Sensor Data from Arduino UNO Q Using Python and BlueZ

Most BLE tutorials end at the worst possible moment. They show you how to read a characteristic from a peripheral, then leave you with raw bytes in a terminal and nowhere to go.
The Arduino UNO Q has a Bluetooth 5.1 radio baked in. No shield, no breakout board, no extra wiring. It can push sensor data over BLE all day. But if you want a second device (your phone, a dashboard, another embedded system) to see that data without pairing directly to the Arduino, you need a relay: a Linux box that reads from the Arduino and re-advertises that data as its own GATT server.
That’s what we’re building: the full pipeline. Arduino advertises sensor data, a Python script on Linux reads it via BlueZ, and that same script spins up a GATT server so any BLE client can subscribe. One sitting, copy-paste code, working result.
I’m assuming you’ve got a sketch running on the UNO Q that advertises at least one readable characteristic (temperature, humidity, whatever), and that you know what GATT services, characteristics, and UUIDs are. If the Arduino side isn’t ready yet, get that working first and come back.
Prerequisites and Environment Setup
Hardware: Arduino UNO Q with sensor wired and sketch running. A Linux machine (Ubuntu 22.04+ or Raspberry Pi OS) with a built-in or USB BLE adapter.
Software: BlueZ 5.50+, Python 3.8+, and the dbus-next async library.
| Component | Required Version | Check Command |
|---|---|---|
| BlueZ | 5.50+ | bluetoothctl --version |
| Python | 3.8+ | python3 --version |
| dbus-next | latest | pip show dbus-next |
| BlueZ service | running | systemctl status bluetooth |
Run these quick checks:
bluetoothctl --version
sudo hciconfig hci0 up
sudo systemctl status bluetoothInstall the Python dependency:
pip install dbus-nextOne thing that trips people up silently: your user must be in the bluetooth group, or you’ll need to run scripts with sudo. Add yourself with sudo usermod -aG bluetooth $USER and log out/in.
Step 1: Scan for the Arduino UNO Q from Linux
Fire up bluetoothctl and confirm the Arduino is actually visible:
bluetoothctl
> scan on
> devicesYou should see the UNO Q listed by its device name (whatever you set in your Arduino sketch) or its MAC address. Write down the MAC; you’ll need it.
Here’s the Python equivalent using dbus-next, which you’ll eventually fold into the full pipeline:
import asyncio
from dbus_next.aio import MessageBus
from dbus_next.constants import BusType
ARDUINO_NAME = "UNO_Q_Sensor" # Match your Arduino's advertised name
async def scan_for_device(target_name: str, timeout: int = 10) -> str | None:
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
introspect = await bus.introspect("org.bluez", "/org/bluez/hci0")
adapter = bus.get_proxy_object("org.bluez", "/org/bluez/hci0", introspect)
adapter_iface = adapter.get_interface("org.bluez.Adapter1")
await adapter_iface.call_start_discovery()
await asyncio.sleep(timeout)
await adapter_iface.call_stop_discovery()
# Walk the object manager to find our device
introspect_root = await bus.introspect("org.bluez", "/")
obj_mgr = bus.get_proxy_object("org.bluez", "/", introspect_root)
mgr_iface = obj_mgr.get_interface("org.freedesktop.DBus.ObjectManager")
objects = await mgr_iface.call_get_managed_objects()
for path, interfaces in objects.items():
props = interfaces.get("org.bluez.Device1", {})
if props and props.get("Name", {}).value == target_name:
print(f"Found {target_name} at {path}")
return path
return None
asyncio.run(scan_for_device(ARDUINO_NAME))If nothing shows up: check that the Arduino sketch is actually advertising (power cycle it), make sure the BLE adapter is up (hciconfig hci0 up), and confirm the bluetooth service is running.
Step 2: Connect and Read BLE Characteristics
With the device path in hand, you can connect and pull data from a characteristic.
+-------------+ BLE +-------------+ D-Bus +----------------+
| Arduino UNO | ─────────────▶ | BlueZ Stack | ─────────────▶ | Python Script |
| Q (Sensor) | Advertising | (bluetoothd)| org.bluez.* | (dbus-next) |
+-------------+ +-------------+ +----------------+import struct
SENSOR_CHAR_UUID = "00002a6e-0000-1000-8000-00805f9b34fb" # Example: Temperature UUID
async def read_characteristic(bus, device_path: str, char_uuid: str) -> bytes:
# Connect to the device
introspect = await bus.introspect("org.bluez", device_path)
device = bus.get_proxy_object("org.bluez", device_path, introspect)
device_iface = device.get_interface("org.bluez.Device1")
await device_iface.call_connect()
await asyncio.sleep(2) # Give GATT resolution a moment
# Find the characteristic by walking managed objects
introspect_root = await bus.introspect("org.bluez", "/")
obj_mgr = bus.get_proxy_object("org.bluez", "/", introspect_root)
mgr_iface = obj_mgr.get_interface("org.freedesktop.DBus.ObjectManager")
objects = await mgr_iface.call_get_managed_objects()
for path, interfaces in objects.items():
char_props = interfaces.get("org.bluez.GattCharacteristic1", {})
if char_props and char_props.get("UUID", {}).value == char_uuid:
char_introspect = await bus.introspect("org.bluez", path)
char_obj = bus.get_proxy_object("org.bluez", path, char_introspect)
char_iface = char_obj.get_interface("org.bluez.GattCharacteristic1")
value = await char_iface.call_read_value({})
return bytes(value)
raise RuntimeError(f"Characteristic {char_uuid} not found")If your Arduino is sending temperature as a 2-byte little-endian integer (hundredths of a degree), decode it like this:
raw = await read_characteristic(bus, device_path, SENSOR_CHAR_UUID)
temp_celsius = struct.unpack("<h", raw)[0] / 100.0
print(f"Temperature: {temp_celsius}°C")For continuous updates, use StartNotify instead of polling. We’ll wire that up in step 4.
Step 3: Build a GATT Server to Re-Advertise the Data
This is the core of the whole pipeline. Your Python script becomes a BLE peripheral, creating a GATT service with a characteristic that holds the latest sensor reading. Any BLE client (phone, tablet, another Linux box) can discover and read it.
BlueZ’s D-Bus GATT API requires you to register objects that implement specific interfaces. Here’s the full server:
from dbus_next.service import ServiceInterface, method, dbus_property
from dbus_next import Variant
CUSTOM_SERVICE_UUID = "12345678-1234-5678-1234-56789abcdef0"
CUSTOM_CHAR_UUID = "12345678-1234-5678-1234-56789abcdef1"
class GattCharacteristic(ServiceInterface):
def __init__(self):
super().__init__("org.bluez.GattCharacteristic1")
self._value = bytes([0x00, 0x00])
@dbus_property()
def UUID(self) -> "s":
return CUSTOM_CHAR_UUID
@dbus_property()
def Service(self) -> "o":
return "/org/bluez/example/service0"
@dbus_property()
def Flags(self) -> "as":
return ["read", "notify"]
@method()
def ReadValue(self, options: "a{sv}") -> "ay":
return self._value
@method()
def StartNotify(self) -> None:
pass # Notification logic handled in update loop
@method()
def StopNotify(self) -> None:
pass
def update_value(self, new_value: bytes) -> None:
self._value = new_value
# In a full implementation, emit PropertiesChanged here
class GattService(ServiceInterface):
def __init__(self):
super().__init__("org.bluez.GattService1")
@dbus_property()
def UUID(self) -> "s":
return CUSTOM_SERVICE_UUID
@dbus_property()
def Primary(self) -> "b":
return True
@dbus_property()
def Characteristics(self) -> "ao":
return ["/org/bluez/example/service0/char0"]
class GattApplication(ServiceInterface):
def __init__(self):
super().__init__("org.freedesktop.DBus.ObjectManager")
@method()
def GetManagedObjects(self) -> "a{oa{sa{sv}}}":
return {
"/org/bluez/example/service0": {
"org.bluez.GattService1": {
"UUID": Variant("s", CUSTOM_SERVICE_UUID),
"Primary": Variant("b", True),
"Characteristics": Variant("ao", [
"/org/bluez/example/service0/char0"
]),
}
},
"/org/bluez/example/service0/char0": {
"org.bluez.GattCharacteristic1": {
"UUID": Variant("s", CUSTOM_CHAR_UUID),
"Service": Variant("o", "/org/bluez/example/service0"),
"Flags": Variant("as", ["read", "notify"]),
}
},
}To register this with BlueZ and start advertising:
class LEAdvertisement(ServiceInterface):
def __init__(self):
super().__init__("org.bluez.LEAdvertisement1")
@dbus_property()
def Type(self) -> "s":
return "peripheral"
@dbus_property()
def ServiceUUIDs(self) -> "as":
return [CUSTOM_SERVICE_UUID]
@dbus_property()
def LocalName(self) -> "s":
return "UNO_Q_Relay"
@method()
def Release(self) -> None:
pass
async def register_gatt_and_advertise(bus):
# Export objects on the bus
app = GattApplication()
bus.export("/org/bluez/example", app)
char = GattCharacteristic()
bus.export("/org/bluez/example/service0/char0", char)
service = GattService()
bus.export("/org/bluez/example/service0", service)
ad = LEAdvertisement()
bus.export("/org/bluez/example/ad0", ad)
# Register with BlueZ's GattManager1
introspect = await bus.introspect("org.bluez", "/org/bluez/hci0")
adapter = bus.get_proxy_object("org.bluez", "/org/bluez/hci0", introspect)
gatt_mgr = adapter.get_interface("org.bluez.GattManager1")
await gatt_mgr.call_register_application("/org/bluez/example", {})
ad_mgr = adapter.get_interface("org.bluez.LEAdvertisingManager1")
await ad_mgr.call_register_advertisement("/org/bluez/example/ad0", {})
return char # Return so we can update its value laterA note on BlueZ versions: if you’re running BlueZ older than 5.56, you might need to start bluetoothd with the --experimental flag. On recent Ubuntu and Raspberry Pi OS releases, GATT server support is on by default.
Step 4: Wire It All Together
Here’s the main loop tying scanning, reading, and re-advertising into a single async pipeline:
Arduino UNO Q Linux Host BLE Client
│ │ │
│── BLE Advertise ────▶│ │
│ │── Read/Notify ──▶ Python │
│ │ Script │
│ │◀── Update Char ──┘ │
│ │── GATT Advertise ──────────▶│
│ │ │async def main():
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
# 1. Scan for the Arduino UNO Q
device_path = await scan_for_device(ARDUINO_NAME)
if not device_path:
raise RuntimeError("Arduino UNO Q not found")
# 2. Register our GATT server and start advertising
char = await register_gatt_and_advertise(bus)
# 3. Connect to Arduino and subscribe to notifications
introspect = await bus.introspect("org.bluez", device_path)
device = bus.get_proxy_object("org.bluez", device_path, introspect)
device_iface = device.get_interface("org.bluez.Device1")
await device_iface.call_connect()
await asyncio.sleep(2)
# Find and subscribe to the sensor characteristic
# (reuse the object-walking logic from step 2)
# On each notification, update our GATT server's characteristic:
def on_properties_changed(iface: str, changed: dict, invalidated: list):
if "Value" in changed:
new_val = bytes(changed["Value"].value)
char.update_value(new_val)
temp = struct.unpack("<h", new_val)[0] / 100.0
print(f"Relayed: {temp}°C")
# Attach the callback to the characteristic's PropertiesChanged signal
# then call StartNotify to begin streaming
await bus.wait_for_disconnect()
asyncio.run(main())The flow is straightforward once you see it end to end. Every time the Arduino pushes a new reading via BLE notification, the callback fires and updates the GATT characteristic on the Linux side. Any subscribed BLE client then gets the fresh value automatically.
Verification and Testing
Grab another phone or laptop and open nRF Connect (free on iOS and Android). Scan for “UNO_Q_Relay.” You should see the custom service UUID, and reading the characteristic should return the latest sensor value.
For a quick CLI check from another Linux machine:
gatttool -b <RELAY_MAC> --char-read --handle=0x0003Expected output: something like Characteristic value/descriptor: 0c 09 (which would be 23.16°C as a little-endian int16).
Common failures: If the advertisement isn’t visible, run hciconfig hci0 up and check that no other process has claimed the adapter. If the characteristic returns stale data, your notification callback probably isn’t wired correctly; add a print statement inside on_properties_changed to confirm it’s firing. Permission denied errors on D-Bus almost always mean you need to be in the bluetooth group or running as root.
Extending the Relay
You’ve got a working relay: Arduino UNO Q sensor data, read over BLE, re-advertised from Linux via a Python GATT server.
Add more characteristics. If your Arduino exposes humidity, pressure, and battery level, create a characteristic object for each and update them independently.
Write back to the Arduino. Add a writable characteristic to the GATT server, and when a client writes to it, forward that value to the Arduino’s own writable characteristic. That gives you bidirectional control.
Scale with Hubble. If you’re building a fleet of BLE devices rather than a single Arduino prototype, Hubble’s network can pick up standard BLE advertising packets without requiring a local Linux relay at all. Their device SDK supports the kind of advertising packet structures we’ve been working with here, and you can register devices and retrieve packets through their cloud API. Worth looking at once you outgrow the single-host-relay approach. Their guide on structuring BLE advertising packets is particularly relevant if you want your Arduino’s advertising format to be compatible from the start.
The UNO Q’s Bluetooth 5.1 radio also supports direction finding (Angle of Arrival/Angle of Departure), which opens up indoor positioning use cases. That’s a separate project, but the BLE foundation you’ve built here is the starting point.
Hubble Network receives standard BLE advertising packets via satellite—no local gateways or relay infrastructure required. See how it works →