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

Broadcasting BLE sensor data from an Arduino UNO Q to a Linux host 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.

ComponentRequired VersionCheck Command
BlueZ5.50+bluetoothctl --version
Python3.8+python3 --version
dbus-nextlatestpip show dbus-next
BlueZ servicerunningsystemctl status bluetooth

Run these quick checks:

bluetoothctl --version
sudo hciconfig hci0 up
sudo systemctl status bluetooth

Install the Python dependency:

pip install dbus-next

One 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
> devices

You 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 later

A 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=0x0003

Expected 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 →