Controlling Monocle from a python script

A monocle on my chair

I recently got my hands on a Brilliant Labs Monocle, which is a nifty little tiny-screen device that connects to a host machine via Bluetooth Low-Energy protocol and offers a simple API for controlling its content and receiving input. The documentation is a little thin-on-the-ground on how to interface it to a host, but after a little bit of hacking on it I pieced it together.

This post explains how to use a Python script running on a host machine to connect to and control the MicroPython instance running on the Monocle hardware to display a simple “Hello” screen. It’s assumed you’ve read the MircoPython API quick start that Brilliant has provided but want to move forward from there.

About the default Monocle firmware

As explained by the documentation, Monocle comes pre-loaded with a small OS that can be interfaced using MicroPython. The provided examples use a web-based interface, but that interface doesn’t offer any obvious hooks to connect the logic running on the Monocle to other data sources or control logic.

However, the interface to the Monocle is an extremely simple Nordic UART service accessed via a Bluetooth Low-Energy interface. We don’t have to understand the nuances of BLE and UART to access it, just know that it provides an interface that acts a lot like a stdin / stdout pair connected to the MicroPython console. Once we connect to it, we can ship data into the console and execute it as if we were typing it in to the REPL running on the Monocle.

So that’s the plan: write a script that uses BLE to connect to the UART service on the Monocle and ship a script of our choosing over.

Block diagram of code, showing python running on a machine, micropython running on a monocle, and two edges labeled 'Send on RX interface' and 'Notifications on TX interface'

Block diagram of system

Setup

installing bleak

I’m using pip and Python 3.8 on an Ubuntu laptop with Bluetooth for this project.

First, we need to set up the bleak library, which allows for async connection to a Bluetooth Low Energy client.

pip install bleak

(Note: I also tried using PyBluez, but found it to be a dead-end since the project seems under-supported. But I installed bleak after installing PyBluez, so YMMV on how fast and simple the bleak install is).

Discovering devices

Turn on your monocle and run the following script to find it (as documented on Bleak’s GitHub page):

import asyncio
from bleak import BleakScanner

async def main():
    devices = await BleakScanner.discover()
    for d in devices:
        print(d)

asyncio.run(main())

You should see a MAC address and “monocle” in the output.

Connecting to the monocle

The UART service on the Monocle requires a notify connection (on the TX characteristic) to let Monocle tell the desktop when it has new information. Once that is configured, we can just transmit bytes to the RX characteristic and the MicroPython REPL will interpret them.

The following program does these things:

  1. Munges through the services and characteristics on the Monocle to find the UART endpoints
  2. Connects a notification endpoint to them
  3. Uploads a simple program to turn the screen blue and say “hello!”

Note one quirk of the UART protocol: being an old serial terminal protocol, it’s expecting \r\n (Windows-style) newlines to indicate line breaks.

Note: You’ll need to set DEVICE_ADDRESS to the address you found for your device from the previous script.

# Copyright 2023 Mark T. Tomczak
# Released under the MIT license, https://opensource.org/license/mit/
#
# Proof-of-concept script to connect to Monocle

import asyncio
from bleak import BleakClient
import time

DEVICE_ADDRESS = "E1:B6:F6:6F:29:17"  # Enter your device address here

COMMAND = """
import display
display.fill(0x0000cc)
display.text("hello!", 250, 150, 0xffffff)
display.show()
""".replace("\n", "\r\n")

def notify_callback(channel, bytes_in):
    print(bytes_in.decode("utf-8"))

async def transact(address):
    async with BleakClient(address) as client:
        print("Connecting...")
        uart_service = None
        tx_characteristic = None
        rx_characteristic = None
        for service in client.services:
            # print(service)
            if service.description == "Nordic UART Service":
                uart_service = service
        for characteristic in uart_service.characteristics:
            # print(characteristic)
            if characteristic.description == "Nordic UART TX":
                tx_characteristic = characteristic
            if characteristic.description == "Nordic UART RX":
                rx_characteristic = characteristic

        print("Connected. Sending...")

        await client.start_notify(tx_characteristic, notify_callback)
        await client.write_gatt_char(rx_characteristic, COMMAND.encode())

if __name__ == "__main__":
    asyncio.run(transact(DEVICE_ADDRESS))
    # Just sleep to hold the asyncio connections open
    time.sleep(30000)

Running the program should connect and execute it, changing the screen display. You’ll also see the program echoed back via the script’s stdout, with the telltale >>> triplets indicating the Python REPL received the input.

My camera quality is a bit too low to tell, but running this script blanks the screen to blue and prints ‘hello!’ in the middle.

My monocle showing a blue field and ‘hello!'

What can we do with this?

Now that we have control of the Monocle UI from Python, it’s up to us. You can fetch any data you want with Python libraries and push it to the Monocle’s display. You can also fetch data from the Monocle by printing it in the MicroPython code and parsing it in the notification input (just detect >>> and ... string prefixes and throw them away to avoid receiving your own echo-back).

I haven’t decided yet what I’m doing with my Monocle, but now that I have control over it the possibilities are pretty open, and it’s a very simple architecture to play with if you have a bit of Python knowledge. I’m impressed by this as a prototyping platform!