SAM.PIKESLEY.ORG

Driving a microcontroller from your phone

Using MicroPython and some (shonky) JavaScript

Disclaimer before we begin: the JavaScript stuff works on Chrome for OSX and Android. I’m led to understand that it won’t work on an iPhone, and it definitely doesn’t work on Chromium on Mint

I got some ESP32-C3s for a couple of NeoPixel projects, but once I’d got them doing what I needed with the LEDs, I started wondering if I might be able to do anything fun with the on-board Bluetooth Low Energy modules.

I found various tutorials and blogposts but nothing really comprehensive, so this is an attempt to present a complete, end-to-end prototype.

And it turns out it’s all actually much easier than I expected.

The microcontroller side

This is all done using MicroPython. I started with this tutorial where I made futile attempts to understand GATTs and Characteristics and so on, and I was eventually able to feign enough knowledge to whittle the code down to this:

import asyncio

import aioble
import bluetooth

bluetooth_uuids = {
    "service": "this-is-a-fake-uuid-and-it-wont-work",
    "parameter": "this-one-also-is-phony-and-wont-work",
}

ble_service_uuid = bluetooth.UUID(bluetooth_uuids["service"])
ble_parameter_uuid = bluetooth.UUID(bluetooth_uuids["parameter"])
device_name = "Dummy device"

adv_interval = 25_000

ble_service = aioble.Service(ble_service_uuid)
parameter_write_characteristic = aioble.Characteristic(
    ble_service, ble_parameter_uuid, read=True, write=True, notify=True, capture=True
)

async def await_connection():
    """Await connection."""
    print("Bluetooth commencing")

    while True:
        try:
            async with await aioble.advertise(
                adv_interval,
                name=device_name,
                services=[ble_service_uuid],
            ) as connection:
                print("Connection from", connection.device)
                await connection.disconnected()

        except asyncio.CancelledError:
            print("Peripheral task cancelled")

        except Exception as e:  # noqa: BLE001
            print("Error in peripheral_task:", e)

        finally:
            # Ensure the loop continues to the next iteration
            await asyncio.sleep_ms(100)


async def wait_for_write():
    """Receive data on the `parameter` characteristic."""
    while True:
        try:
            _, data = await parameter_write_characteristic.written()
            data = data.decode()
            do_something_with_this(data)

        except asyncio.CancelledError:
            print("Peripheral task cancelled")

        except Exception as e:  # noqa: BLE001
            print("Error in peripheral_task:", e)

        finally:
            # Ensure the loop continues to the next iteration
            await asyncio.sleep_ms(100)


def do_something_with_this(data):
    """Do something interesting here."""
    print(f"Received data: `{data}`. You can now do something with this")


async def main():
    """Run."""
    tasks = [
        asyncio.create_task(await_connection()),
        asyncio.create_task(wait_for_write()),
    ]
    await asyncio.gather(*tasks)


aioble.register_services(ble_service)
asyncio.run(main())

What’s going on here?

I’m not going to explain all of this here (because I don’t really understand some of it), but I do have some notes (mostly derived from things that bit me along the way):

Use real UUIDs

I had (naively) assumed that I could use any old random strings for those identifiers, but no, it turns out we need real UUIDs. They’re readily available though.

asyncio

We’re using aioble, an “asyncio-based wrapper for MicroPython’s bluetooth API”, so we have do everything with asyncio, and pretend to understand async and await and all that. It’s fine, it works.

Parameters

The naming around all this stuff is confusing (at least to me). I think maybe the parameter for which we’re expecting data is actually a Characteristic in this context, but these terms seem overloaded in bewildering ways.

The point here really, though, is that if we want to advertise multiple “parameters”, we need a distinct wait_for_write() method (and a corresponding task) for each one. I think.

Running it

You’ll need aioble installed on your device. Some MicroPython distributions have it already bundled but if yours doesn’t, you can easily get it with mip:

python -m mpremote mip install aioble

This also presumes you have mpremote working but if you’re reading this, I guess you probably do

If you can connect to your device and run:

>>> import aioble

without any errors then you’re good to go.

So save this code as main.py, then push it to your device:

python -m mpremote cp -r main.py :

Then connect:

python -m mpremote

And ctrl-D, and you should see something like this:

MPY: soft reboot
Bluetooth commencing

and then… nothing. Next, we need a Client.

Actually, to get this far, I was testing with nRF Connect, which is a really handy tool, but this blogpost is here to get us past the need for that

Pretending to be good at JavaScript

Chrome has built-in Bluetooth support, we just need to know the right JavaScript magic spells. I battled with this for a while (JavaScript isn’t really my thing), and what I came up with can be reduced to something like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Bluetooth Controller Demo</title>
  </head>

  <body>
    <h1>Bluetooth Controller Demo</h1>

    <div id="buttons">
      <button data-value="A">A</button>
      <button data-value="B">B</button>
      <button data-value="C">C</button>
    </div>

    <script>
      bluetooth_uuids = {
        "service": "this-is-a-fake-uuid-and-it-wont-work",
        "parameter": "this-one-also-is-phony-and-wont-work",
      }

      const buttons = document.getElementById("buttons");

      Array.from(buttons.children).forEach(function (button) {
        makeButton(button);
      });

      function makeButton(button) {
        const value = button.dataset.value;

        button.addEventListener("pointerup", function (event) {
          navigator.bluetooth
            .requestDevice({
              filters: [
                {
                  services: [bluetooth_uuids.service],
                },
              ],
            })
            .then(function (device) {
              return device.gatt.connect();
            })
            .then(function (server) {
              return server.getPrimaryService(bluetooth_uuids.service);
            })
            .then(function (service) {
              return service.getCharacteristic(bluetooth_uuids.parameter);
            })
            .then(function (characteristic) {
              console.log("Sending data: " + value);
              characteristic.writeValue(new TextEncoder().encode(value));
            })
            .catch((error) => {
              console.error(error);
            });
        });
      }
    </script>
  </body>
</html>

How does this work then?

Again, just some notes:

UUIDs

In case it’s not clear, the bluetooth_uuids need to be the same ones from the Python script. And they need to be real UUIDs.

Event types

The EventListener only works with a specific list of event types. I got bitten by this when attempting to add a listener to a range slider with an event-type of change, which gave me a very shitty message in the console.

Running it

The easiest way to run this locally is to paste it as index.html somewhere, then in that directory do

python -m http.server

and then go to http://localhost:8000.

Now open the JavaScript console, and presuming your microcontroller is still running somewhere, click one of the buttons. You should get a “localhost:8000 wants to pair” popup, with a single-item list containing your device. Pair with the device, and you should see something like

Sending data: B

in the console.

And then the output from the controller should have something like:

Connection from Device(ADDR_PUBLIC, 00:00:00:00:00:00, CONNECTED)
Received data: `B`. You can now do something with this

Congratulations, you’ve just sent some data from your browser to your microcontroller over BLE. I hope this has just sparked a whole load of ideas.

Actually running it

Running it off your laptop is all very well, but that doesn’t really translate to the real world, where you probably want to drive this with your phone. Fortunately you can get free, SSL-enabled static hosting on Netlify. Just drag-and-drop a folder containing your HTML/CSS/JavaScript and you’re all set.

Notes


These comments are generated from replies to this Mastodon post