Post

ENG | Raspberry Pi Pico W, testing Bluetooth LE

Quick demonstration of BLE sensor implementations on Raspberry Pi Pico W using MicroPython

ENG | Raspberry Pi Pico W, testing Bluetooth LE

Disclaimer: This article and Python scripts were almost entirely generated by large language models. It exists for the backup and future reference, as these simple programs do not fit into git repository. I just needed some rapid prototypes and to learn basics about Bluetooth Low Energy (BLE)

This post demonstrates two practical implementations of Bluetooth Low Energy (BLE) on a Raspberry Pi Pico W, each suitable for different applications:

  • GATT-Based Connection: For reliable, two-way, connection-oriented communication.

  • Advertising Beacon: For lightweight, broadcast-only scenarios with low power consumption.

Both implementations simulate environmental sensor data (temperature, humidity, pressure).

Prerequisites

  • Raspberry Pi Pico W with MicroPython firmware
  • A host system with Bluetooth LE support (tested on Linux; should work on Windows/macOS with compatible drivers and libraries)
  • Python 3.7+ with bleak library: pip install bleak
  • USB Bluetooth adapter if your system lacks built-in BLE support
  • nRF Connect for Mobile can be useful for diagnostic

Version 1: GATT-Based sensor service

Sensor simulation running on Raspberry Pi Pico W

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import time
import bluetooth
import struct
from micropython import const

# BLE event constants
_IRQ_CENTRAL_CONNECT    = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)

# Standard BLE UUIDs for Environmental Sensing
# This ensures compatibility with generic BLE scanner apps
_ENV_SENSE_UUID = bluetooth.UUID(0x181A)
_TEMP_CHAR_UUID = bluetooth.UUID(0x2A6E)  # Temperature Characteristic
_HUM_CHAR_UUID  = bluetooth.UUID(0x2A6F)  # Humidity Characteristic
_PRESS_CHAR_UUID= bluetooth.UUID(0x2A6D)  # Pressure Characteristic

# Flags for read and notify
_FLAG_READ  = const(0x0001)
_FLAG_NOTIFY= const(0x0010)

# Prepare the GATT server structure
environment_sense_service = (
    _ENV_SENSE_UUID,
    (
        (_TEMP_CHAR_UUID,  _FLAG_READ | _FLAG_NOTIFY),
        (_HUM_CHAR_UUID,   _FLAG_READ | _FLAG_NOTIFY),
        (_PRESS_CHAR_UUID, _FLAG_READ | _FLAG_NOTIFY),
    ),
)

# Advertisement helper for BLE
def advertise(ble, name="PICOW-02"):
    # A simple advertisement for discoverability
    # Including flags and name
    adv_data = bytearray(
        b"\x02\x01\x06"              # Flags indicating BLE general discoverable
        + bytes([len(name) + 1, 0x09])  # Complete local name
        + name.encode()
    )
    ble.gap_advertise(100_000, adv_data)

class BLEEnvSense:
    def __init__(self, ble):
        self._counter = 0
        self._ble = ble
        self._ble.active(True)
        self._ble.irq(self._irq)

        # Holds the connection handle when a central device is connected
        self._conn_handle = None

        # Create a GATT server and register the Environmental Sensing service
        ((self._temp_handle, self._hum_handle, self._press_handle),) = self._ble.gatts_register_services((environment_sense_service,))

        # Start advertising
        advertise(self._ble)

    def _irq(self, event, data):
        if event == _IRQ_CENTRAL_CONNECT:
            # A central connected
            conn_handle, addr_type, addr = data
            self._conn_handle = conn_handle

        elif event == _IRQ_CENTRAL_DISCONNECT:
            # A central disconnected
            conn_handle, addr_type, addr = data
            if conn_handle == self._conn_handle:
                self._conn_handle = None

    def update_values(self):
        # Trial-error fixed point values found values for -12.34C, 43.21% RH, 101325.0Pa
        temp_bytes = struct.pack('<i', -1234 + self._counter)
        hum_bytes  = struct.pack('<i', 4321 + self._counter)
        press_bytes= struct.pack('<i', 1013250 + self._counter)
        self._counter += 1

        self._ble.gatts_write(self._temp_handle, temp_bytes)
        self._ble.gatts_write(self._hum_handle, hum_bytes)
        self._ble.gatts_write(self._press_handle, press_bytes)

        # Notify only if there is a valid connection
        if self._conn_handle is not None:
            self._ble.gatts_notify(self._conn_handle, self._temp_handle)
            self._ble.gatts_notify(self._conn_handle, self._hum_handle)
            self._ble.gatts_notify(self._conn_handle, self._press_handle)

def main():
    counter=0
    ble = bluetooth.BLE()
    print("BLE created")
    env_sense = BLEEnvSense(ble)
    print("BLEEnvSense created")
    while True:
        # Update BLE characteristics with constants for demonstration
        env_sense.update_values()

        # Delay before updating again
        time.sleep(5)

if __name__ == "__main__":
    main()

Testing on Linux

This requires some USB Bluetooth adapter with Bluetooth low energy.

BLE can’t scan and stay connected simultaneously in typical stacks. For example when nRF Connect on mobile reads sensor data, scan won’t discover it.

To verify your Bluetooth adapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
[pavel@marten -=- /home/pavel/dev-blog]$ lsusb
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 011: ID 2357:0604 TP-Link TP-Link UB500 Adapter
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub

[pavel@marten -=- /home/pavel/dev-py/ble-server]$ bluetoothctl list
Controller E8:48:B8:C8:20:00 marten [default]

[pavel@marten -=- /home/pavel/dev-py/ble-server]$ btmgmt info
Index list with 1 item
hci0:   Primary controller
        addr E8:48:B8:C8:20:00 version 10 manufacturer 93 class 0x7c0104
        supported settings: powered connectable fast-connectable discoverable bondable link-security ssp br/edr le advertising secure-conn debug-keys privacy static-addr phy-configuration wide-band-speech
        current settings: powered bondable ssp br/edr le secure-conn wide-band-speech
        name marten
        short name

[pavel@marten -=- /home/pavel/dev-py/ble-server]$ bluetoothctl
Agent registered
[bluetoothctl]> scan le
SetDiscoveryFilter success
Discovery started
[CHG] Controller E8:48:B8:C8:20:00 Discovering: yes
[NEW] Device 2C:CF:67:EF:1B:9B PICOW-02
[NEW] Device 54:48:E6:79:4B:1E 54-48-E6-79-4B-1E
[NEW] Device 1F:CB:79:74:E8:74 1F-CB-79-74-E8-74
[NEW] Device 65:65:32:FA:16:D7 Apple Pencil
[NEW] Device 00:68:82:1B:CE:8D Intezze CLIQ
[NEW] Device 7C:43:73:5B:A2:EE 7C-43-73-5B-A2-EE
[CHG] Device 3E:5F:5B:C4:74:B5 RSSI: 0xffffffb6 (-74)
[CHG] Device 2C:CF:67:EF:1B:9B RSSI: 0xffffffb0 (-80)
[CHG] Device 54:48:E6:79:4B:1E RSSI: 0xffffffac (-84)
[CHG] Device 29:6F:8D:2A:80:3A RSSI: 0xffffff9e (-98)
[CHG] Device 54:48:E6:79:4B:1E RSSI: 0xffffffac (-84)
[CHG] Device 66:B3:E5:09:F3:EA RSSI: 0xffffff9a (-102)
[NEW] Device DE:27:B9:79:21:22 JBL Tune 520BT-LE
[CHG] Device 66:B3:E5:09:F3:EA RSSI: 0xffffff9c (-100)
[CHG] Device 66:B3:E5:09:F3:EA Name: JBL Tune 520BT-LE
[CHG] Device 66:B3:E5:09:F3:EA Alias: JBL Tune 520BT-LE
[CHG] Device 05:0C:55:50:CD:3A RSSI: 0xffffffa0 (-96)
[NEW] Device C5:E5:E2:0A:8F:1E MAJOR V [LE]

Reading sensor data in Python

1
pip install bleak
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/usr/bin/env python3
import asyncio
from bleak import BleakClient, BleakScanner
import struct
import time

# UUIDs from your Pico W code
ENV_SENSE_UUID  = "0000181a-0000-1000-8000-00805f9b34fb"
TEMP_CHAR_UUID  = "00002a6e-0000-1000-8000-00805f9b34fb"
HUM_CHAR_UUID   = "00002a6f-0000-1000-8000-00805f9b34fb"
PRESS_CHAR_UUID = "00002a6d-0000-1000-8000-00805f9b34fb"

class PicoWClient:
    def __init__(self):
        self.client = None
        self.device_address = None

    async def scan_for_device(self, device_name="PICOW-02", timeout=10):
        """Scan for the Pico W device"""
        print(f"Scanning for device '{device_name}'...")

        devices = await BleakScanner.discover(timeout=timeout)
        for device in devices:
            if device.name == device_name:
                print(f"Found device: {device.name} ({device.address})")
                self.device_address = device.address
                return True

        print(f"Device '{device_name}' not found")
        return False

    def notification_handler(self, sender, data):
        """Handle notifications from characteristics"""
        # Convert UUID handle back to readable format
        char_uuid = str(sender.uuid).lower()

        if len(data) >= 4:
            # Unpack as signed 32-bit integer (little endian)
            value = struct.unpack('<i', data[:4])[0]

            if TEMP_CHAR_UUID.lower() in char_uuid:
                temp_celsius = value / 100.0
                print(f"Temperature: {temp_celsius:.2f}°C (raw: {value})")

            elif HUM_CHAR_UUID.lower() in char_uuid:
                humidity = value / 100.0
                print(f"Humidity: {humidity:.2f}% (raw: {value})")

            elif PRESS_CHAR_UUID.lower() in char_uuid:
                pressure = value / 10.0
                print(f"Pressure: {pressure:.1f}Pa (raw: {value})")

    async def connect_and_monitor(self):
        """Connect to device and start monitoring"""
        if not self.device_address:
            print("No device address available")
            return

        async with BleakClient(self.device_address) as client:
            self.client = client
            print(f"Connected to {self.device_address}")

            # Subscribe to notifications for all characteristics
            await client.start_notify(TEMP_CHAR_UUID, self.notification_handler)
            await client.start_notify(HUM_CHAR_UUID, self.notification_handler)
            await client.start_notify(PRESS_CHAR_UUID, self.notification_handler)

            print("Subscribed to notifications. Waiting for data...")

            try:
                # Keep the connection alive and listen for notifications
                while True:
                    await asyncio.sleep(1)

            except KeyboardInterrupt:
                print("\nDisconnecting...")
                await client.stop_notify(TEMP_CHAR_UUID)
                await client.stop_notify(HUM_CHAR_UUID)
                await client.stop_notify(PRESS_CHAR_UUID)

async def main():
    client = PicoWClient()

    # Scan for the device
    if await client.scan_for_device():
        # Connect and start monitoring
        await client.connect_and_monitor()
    else:
        print("Could not find the Pico W device")

if __name__ == "__main__":
    asyncio.run(main())
1
2
chmod +x main.py
./main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Scanning for device 'PICOW-02'...
Found device: PICOW-02 (2C:CF:67:EF:1B:9B)
Connected to 2C:CF:67:EF:1B:9B
Subscribed to notifications. Waiting for data...
Temperature: -9.56°C (raw: -956)
Humidity: 45.99% (raw: 4599)
Pressure: 101352.8Pa (raw: 1013528)
Temperature: -9.55°C (raw: -955)
Humidity: 46.00% (raw: 4600)
Pressure: 101352.9Pa (raw: 1013529)
Temperature: -9.54°C (raw: -954)
Humidity: 46.01% (raw: 4601)
Pressure: 101353.0Pa (raw: 1013530)
^C

Version 2: Advertising Beacon Only

A simpler, connectionless approach where the Pico W periodically advertises sensor data encoded in an advertising packet.

Rationale

Advantages:

  • Multiple scanners can receive broadcasts simultaneously.
  • Lower power usage by avoiding persistent connections.

Limitations:

  • BLE advertising payloads are limited to 31 bytes, which constrains how much sensor data and metadata can be included.
  • WARNING: Deep sleep on the Pico is incompatible with USB serial connections and will require to erase flash memory and reflashing MicroPython.

Raspberry Pico W code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import time
import bluetooth
import struct
import machine
from micropython import const

# Custom service UUID for our sensor beacon
_SENSOR_BEACON_UUID = 0x1234

class BLESensorBeacon:
    def __init__(self, ble, device_name="SENSOR-BEACON"):
        self._ble = ble
        self._ble.active(True)
        self._device_name = device_name
        self._counter = 0

    def _create_adv_data(self, temp, humidity, pressure):
        """Create advertising data with sensor values"""
        name = self._device_name

        # Pack sensor data: temp (2 bytes), humidity (2 bytes), pressure (4 bytes)
        # Using signed 16-bit for temp/humidity, signed 32-bit for pressure
        sensor_data = struct.pack('<hhI', temp, humidity, pressure)

        # Build advertising packet
        adv_data = bytearray()

        # Flags (standard BLE discoverable flags)
        adv_data.extend(b"\x02\x01\x06")

        # Complete local name
        adv_data.extend(bytes([len(name) + 1, 0x09]))
        adv_data.extend(name.encode())

        # Custom service data with our sensor readings
        service_data = struct.pack('<H', _SENSOR_BEACON_UUID) + sensor_data
        adv_data.extend(bytes([len(service_data) + 1, 0x16]))
        adv_data.extend(service_data)

        return adv_data

    def advertise_sensor_data(self, temp, humidity, pressure):
        """Advertise sensor data in beacon format"""
        adv_data = self._create_adv_data(temp, humidity, pressure)

        # Advertise for 1 second, then stop
        self._ble.gap_advertise(1000_000, adv_data)  # 1 second in microseconds
        print(f"Broadcasting: T={temp/100:.2f}°C, H={humidity/100:.2f}%, P={pressure/10:.1f}Pa")

        time.sleep(1)  # Keep advertising active for 1 second
        self._ble.gap_advertise(None)  # Stop advertising

    def deep_sleep(self, seconds):
        """Enter deep sleep to save power"""
        print(f"Entering deep sleep for {seconds} seconds...")
        machine.deepsleep(seconds * 1000)  # Convert to milliseconds

def main():
    ble = bluetooth.BLE()
    beacon = BLESensorBeacon(ble)

    counter = 0

    while True:
        # Simulate sensor readings (same as your original code)
        temp = -1234 + counter      # Represents -12.34°C + counter
        humidity = 4321 + counter   # Represents 43.21% + counter
        pressure = 1013250 + counter # Represents 101325.0Pa + counter

        # Broadcast sensor data
        beacon.advertise_sensor_data(temp, humidity, pressure)

        counter += 1

        # For ultra-low power: use deep sleep instead of regular sleep
        # beacon.deep_sleep(60)  # Sleep for 60 seconds

        # For testing: use regular sleep (easier to interrupt)
        print("Sleeping for 10 seconds...")
        time.sleep(10)

if __name__ == "__main__":
    main()
1
2
3
4
5
6
7
8
9
10
MicroPython v1.25.0 on 2025-04-15; Raspberry Pi Pico 2 W with RP2350
Type "help()" for more information or .help for custom vREPL commands.

>>>
Broadcasting: T=-12.34°C, H=43.21%, P=101325.0Pa
Sleeping for 10 seconds...
Broadcasting: T=-12.33°C, H=43.22%, P=101325.1Pa
Sleeping for 10 seconds...
Broadcasting: T=-12.32°C, H=43.23%, P=101325.2Pa
Sleeping for 10 seconds...

Linux code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#!/usr/bin/env python3
import asyncio
from bleak import BleakScanner
import struct
import time

# Custom service UUID from beacon
SENSOR_BEACON_UUID = 0x1234

class BeaconListener:
    def __init__(self, device_name="SENSOR-BEACON"):
        self.device_name = device_name
        self.last_data = {}

    def parse_sensor_data(self, advertisement_data):
        """Parse sensor data from advertisement"""
        service_data = advertisement_data.service_data

        # Look for our custom service UUID
        service_uuid = f"0000{SENSOR_BEACON_UUID:04x}-0000-1000-8000-00805f9b34fb"

        if service_uuid in service_data:
            data = service_data[service_uuid]
            if len(data) >= 8:  # 2+2+4 bytes for temp, humidity, pressure
                temp, humidity, pressure = struct.unpack('<hhI', data[:8])
                return {
                    'temperature': temp / 100.0,
                    'humidity': humidity / 100.0,
                    'pressure': pressure / 10.0,
                    'timestamp': time.time()
                }
        return None

    def detection_callback(self, device, advertisement_data):
        """Called when a BLE device is detected"""
        if device.name == self.device_name:
            sensor_data = self.parse_sensor_data(advertisement_data)
            if sensor_data:
                # Check if this is new data (avoid duplicates)
                data_key = f"{sensor_data['temperature']}-{sensor_data['humidity']}-{sensor_data['pressure']}"

                if data_key not in self.last_data or time.time() - self.last_data[data_key] > 5:
                    print(f"[{time.strftime('%H:%M:%S')}] {device.name} ({device.address}):")
                    print(f"  Temperature: {sensor_data['temperature']:.2f}°C")
                    print(f"  Humidity: {sensor_data['humidity']:.2f}%")
                    print(f"  Pressure: {sensor_data['pressure']:.1f}Pa")
                    print(f"  RSSI: {advertisement_data.rssi} dBm")
                    print()

                    self.last_data[data_key] = time.time()

    async def start_listening(self):
        """Start scanning for beacon advertisements"""
        print(f"Listening for beacon advertisements from '{self.device_name}'...")
        print("Press Ctrl+C to stop\n")

        scanner = BleakScanner(detection_callback=self.detection_callback)

        try:
            await scanner.start()

            # Keep scanning until interrupted
            while True:
                await asyncio.sleep(1)

        except KeyboardInterrupt:
            print("\nStopping scanner...")

        finally:
            await scanner.stop()

async def main():
    listener = BeaconListener()
    await listener.start_listening()

if __name__ == "__main__":
    asyncio.run(main())
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
[pavel@marten -=- /home/pavel/dev-py/ble-server]$ ./listen-beacon.py
Listening for beacon advertisements from 'SENSOR-BEACON'...
Press Ctrl+C to stop

[11:20:41] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.34°C
  Humidity: 43.21%
  Pressure: 101325.0Pa
  RSSI: -84 dBm

[11:21:14] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.31°C
  Humidity: 43.24%
  Pressure: 101325.3Pa
  RSSI: -84 dBm

[11:21:25] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.30°C
  Humidity: 43.25%
  Pressure: 101325.4Pa
  RSSI: -86 dBm

[11:21:58] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.27°C
  Humidity: 43.28%
  Pressure: 101325.7Pa
  RSSI: -84 dBm

[11:22:09] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.26°C
  Humidity: 43.29%
  Pressure: 101325.8Pa
  RSSI: -96 dBm

[11:22:21] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.25°C
  Humidity: 43.30%
  Pressure: 101325.9Pa
  RSSI: -68 dBm

[11:22:32] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.24°C
  Humidity: 43.31%
  Pressure: 101326.0Pa
  RSSI: -66 dBm

[11:22:44] SENSOR-BEACON (2C:CF:67:EF:1B:9B):
  Temperature: -12.23°C
  Humidity: 43.32%
  Pressure: 101326.1Pa
  RSSI: -76 dBm

Note

Data are transmitted three times rather than once for a second. This is hopefully more reliable. But because this is not connection oriented, reliable data reception cannot be guaranteed.

Comparison

FeatureGATT ConnectionBeacon Advertising
Power ConsumptionHigher (continuous connection)Ultra-low (less than 5mA during sleep)
Data ReliabilityHigh (acknowledgments)Variable (broadcast only)
Multiple ClientsNo (typically 1:1)Yes (1:many)
Payload SizeLarge (up to 512 bytes)Limited (31 bytes)
ComplexityHigher (pairing, services)Lower (just advertising)

When to Use GATT-Based Approach

  • Pros: Reliable data delivery, bidirectional communication, standard BLE service discovery
  • Cons: Higher power consumption, single client connection, more complex pairing
  • Use Cases: Interactive devices, configuration interfaces, continuous monitoring

When to Use Beacon Approach

  • Pros: Ultra-low power consumption (under 10mA during sleep) , multiple simultaneous receivers, no pairing required
  • Cons: 31-byte payload limit, no delivery confirmation, potential data loss
  • Use Cases: Environmental monitoring, asset tracking, proximity detection

NOTE: Battery life and power consumption were not verified, Raspberry Pi Pico W is not suitable for low-power applications in the first place. Data loss in beacon mode is a real issue.

Conclussion

This quick experiment tested two distinct BLE approaches on the Raspberry Pi Pico W. The initial GATT-based implementation proved functional but revealed several limitations during testing — notably with persistent connections, discovery quirks, and the overhead of maintaining active connections in simple sensor scenarios.

The second, beacon-style approach was introduced as a practical alternative, addressing these issues by offering connectionless broadcasts, simplifying client-side data collection, and reducing complexity for low-power, multi-reader environments.

Both examples serve as practical prototypes for selecting the right BLE pattern depending on application constraints — connection-oriented for interactive control and data exchange, or connectionless for periodic sensor broadcasting.

Oh, wait, do these approaches need to be entirely exclusive?

This post is licensed under CC BY 4.0 by the author.