Web & Python Bluetooth

Web & Python Bluetooth
AI prompt: "an esp32 giving a temperature measurement to a laptop over bluetooth"

I've been playing with micropython on the esp32 lately, and I think connecting to the esp32 BLE server from a web browser was interesting.

ESP32

First step is to get micropython on an esp32 board. For hardware, I'm using the Elecrow 3.5" ESP32. This is based on the ESP32-WROVER-B microcontroller with 8MB SPIRAM, so I downloaded "Firmware (Support for SPIRAM / WROVER)" binary from https://www.micropython.org/download/ESP32_GENERIC/ (filename ESP32_GENERIC-SPIRAM-20240602-v1.23.0.bin).

Next I used Thonny to upload the image to my board. (Run > Configure Interpreter > Install or update MicroPython (esptool)). I set the Target Port, MicroPython Family & variant, increased the "Install speed" to 460800, and then chose "Select local MicroPython image".

I connected the board to wifi and used the micropython package manager mip to install the async ble library:

network.country("US")
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
    print('connecting to network...')
    wlan.connect("put your ssid here", "put your wifi pw here")
    while not wlan.isconnected():
        pass
import mip
mip.install("aioble")

After this is done, you can see the aioble files on the esp32's filesystem:

aioble files

Now you can replace the boot.py file with a BLE server example. I started with the example temp_sensor.py, but changed it to use the esp32's internal temperature sensor instead of random values.

from micropython import const

import asyncio
import aioble
import bluetooth

import esp32

import struct

# org.bluetooth.service.environmental_sensing
_ENV_SENSE_UUID = bluetooth.UUID(0x181A)
# org.bluetooth.characteristic.temperature
_ENV_SENSE_TEMP_UUID = bluetooth.UUID(0x2A6E)
# org.bluetooth.characteristic.gap.appearance.xml
_ADV_APPEARANCE_GENERIC_THERMOMETER = const(768)

# How frequently to send advertising beacons.
_ADV_INTERVAL_MS = 250_000


# Register GATT server.
temp_service = aioble.Service(_ENV_SENSE_UUID)
temp_characteristic = aioble.Characteristic(
    temp_service, _ENV_SENSE_TEMP_UUID, read=True, notify=True
)
aioble.register_services(temp_service)


# Helper to encode the temperature characteristic encoding (sint16, hundredths of a degree).
def _encode_temperature(temp_deg_c):
    return struct.pack("<h", int(temp_deg_c * 100))

# Make the hostname unique based on the mac address, so you can tell them apart
def get_hostname():
    import network
    wlan = network.WLAN(network.STA_IF)
    mac_bytes = wlan.config('mac')
    mac = struct.unpack("6B", mac_bytes)
    return "esp32-temperature-{:x}{:x}".format(mac[4], mac[5])


# This would be periodically polling a hardware sensor.
async def sensor_task():
    while True:
        f = esp32.raw_temperature()
        c = (f - 32) * 5 / 9
        temp_characteristic.write(_encode_temperature(c), send_update=True)
        print(f"F={f} C={c}")
        await asyncio.sleep_ms(1000)


# Serially wait for connections. Don't advertise while a central is
# connected.
async def peripheral_task():
    hostname = get_hostname()
    print(f"advertising as {hostname}")
    while True:
        async with await aioble.advertise(
            _ADV_INTERVAL_MS,
            name=hostname,
            services=[_ENV_SENSE_UUID],
            appearance=_ADV_APPEARANCE_GENERIC_THERMOMETER,
        ) as connection:
            print("Connection from", connection.device)
            await connection.disconnected(timeout_ms=None)


# Run both tasks.
async def main():
    t1 = asyncio.create_task(sensor_task())
    t2 = asyncio.create_task(peripheral_task())
    await asyncio.gather(t1, t2)


asyncio.run(main())

This creates two tasks: one that polls the sensor every second, and one that advertises BLE services and waits for a connection.

BLE server running on the esp32

Chrome Client

Next, you need a client to see these values. There a many different clients, like the nRF connect Android app or the Linux command line gatttool.

But since web browsers are on every platform, let's use a web page. Chrome has the ability to talk to bluetooth devices. Support for this API is limited.

<html><head>
  <title>Bluetooth Test</title>
</head>
<script>
async function bluetoothScan() {
  const statusbox = document.getElementById("status");
  const temperaturebox = document.getElementById("temperature");

  statusbox.innerText = "scanning";
  const device = await navigator.bluetooth.requestDevice({
    filters: [{ services: ['environmental_sensing'] }] 
  });

  statusbox.innerText = `connecting to ${device.name}`;
  const connection = await device.gatt.connect();
  
  statusbox.innerText = `connected to ${device.name}, requesting service`;
  const service = await connection.getPrimaryService("environmental_sensing");
  
  statusbox.innerText = "got service, requesting characteristic";
  const characteristic = await service.getCharacteristic("temperature");
  
  statusbox.innerText = "read complete";
  const t = await characteristic.readValue();
  temperaturebox.innerText = t.getUint16(0, true)/100;
  
  // allow the esp32 to go back to advertising
  device.gatt.disconnect();
}
</script>
<body>
<button onClick="bluetoothScan()">Bluetooth Scan</button>
<p>status: <span id="status"></span></p>
<p>temperature: <span id="temperature"></span></p>
</body>
</html>

This client contains three major things:

  • A button that kicks off the bluetooth scan
  • A "status" text box showing the progress
  • A "temperature" text box showing the final value read

When you click on the bluetooth scan button, it starts a bluetooth scan. The filters ask for only BLE devices that have the environmental_sensing service.

Scan results

After choosing the device and clicking pair, it connects to the device, gets the BLE service, then the temperature characteristic in the service, reads the value, formats it and displays it:

temperature value read complete

This also works on Chrome on Android:

android BLE

Things that went wrong

When using the lvgl micropython version, I tried to use mip to install aioble, and it failed due to lack of ram. That version did not have the SPI ram enabled.

>>> import urequests
>>> urequests.get("http://dan.drown.org/blank.html")
<Response object at 3ffeda20>
>>> urequests.get("https://dan.drown.org/blank.html")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "urequests.py", line 180, in get
  File "urequests.py", line 93, in request
OSError: [Errno 12] ENOMEM
>>> gc.mem_free()
102128

Switching to micropython with SPIRAM:

>>> gc.mem_free()
4138192