Controlling Monocle from a python script
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.
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:
- Munges through the services and characteristics on the Monocle to find the UART endpoints
- Connects a notification endpoint to them
- 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.
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 print
ing 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!