Belay is a library that makes it quick and easy to interact with hardware via a MicroPython-compatible microcontroller.
Belay has a single important class,
import belay device = belay.Device("/dev/ttyUSB0")
Device object connects to the board at the provided port.
On connection, the device is reset into REPL mode, and a few common imports are performed on-device, namely:
import os, time, machine from time import sleep from micropython import const from machine import ADC, I2C, Pin, PWM, SPI, Timer
Device class has several useful methods:
__call__- Generic statement/expression string evaluation.
setup- Executes body on-device in a global context.
task- Executes function on-device.
teardown- Executes body on-device in a global context when connection is closed.
thread- Executes function on-device in a background thread.
sync- Generic file synchronization from host to device.
sync_dependencies- For python packages to sync bundled micropython dependencies to board.
These are described in more detail in the subsequent subsections.
Directly calling the
device instance, like a function, invokes a python statement or expression on-device.
Invoking a python statement like:
ret = device("foo = 1 + 2")
would execute the code
foo = 1 + 2 on-device in the global context.
Because this is a statement, the return value,
Invoking a python expression like:
res = device("foo")
results in the return value
res == 3 on host.
setup decorator is a way of invoking code on-device in a global context,
and is commonly used for imports and instantiating objects and hardware.
@device.setup def setup(pin_number): from machine import Pin led = Pin(pin_number) setup(25)
is equivalent to:
device("pin_number = 25") device("from machine import Pin") device("led = Pin(pin_number)")
Functions decorated with
setup should be called only a few times at most.
For repeated functions calls, use the task decorator.
task decorator sends the decorated function to the device, and replaces the host function with a remote-executor.
Consider the following:
@device.task def foo(a): return a * 2
bar = foo(5) on host sends a command to the device to execute the function
foo with argument
10, is sent back to the host and results in
bar == 10.
This is the preferable way to interact with hardware.
foo function will also be available at
setup, but automatically executes whenever
device.close() is called.
Device is used as a context manager,
device.close() is automatically called at context manager exit.
Typically used for cleanup, like turning off LEDs or motors.
thread is similar to
task, but executes the decorated function in the background on-device.
@device.thread def led_loop(period): led = Pin(25, Pin.OUT) while True: led.toggle() sleep(period) led_loop(1.0) # Returns immediately
Not all MicroPython boards support threading, and those that do typically have a maximum of
The decorated function has no return value.
For more complicated hardware interactions, additional python modules/files need to be available on the device's filesystem.
sync takes in a path to a local folder.
The contents of the folder will be synced to the device's root directory.
NOTE: This will delete any existing files currently on device before syncing.
For example, if the local filesystem looks like:
project ├── main.py └── board ├── foo.py └── bar └── baz.py
device.sync("board") is ran from
main.py, the remote filesystem will look like
foo.py bar └── baz.py
Syncs data that has been bundled with a python package.
sync_dependencies is intended to make including micropython dependencies easier for pip-installable host-program.
from belay import Device device = Device("/dev/ttyUSB0") device.sync_dependencies("mypackage", "board") # Alternative usage import mypackage device.sync_dependencies(mypackage, "board")
An intended use-case is to this method inconjunction with Belay's builtin package manager.
pyproject.toml to point inside your python package, i.e.
In doing so, micropython dependencies will be stored inside your package.
For this example, lets assume that
The data can then be synced:
device.sync_dependencies(mypackage, "dependencies/main", "dependencies/dev")
Depending on your build system, other non-belay configurations may need to be performed to ensure other data is included in your python package.
Device can be subclassed and have task/thread methods. Benefits of this approach is better organization, and being able to define tasks/threads before the actual object is instantiated.
Consider the following:
from belay import Device device = Device("/dev/ttyUSB0") @device.task def foo(a): return a * 2
is roughly equivalent to:
from belay import Device class MyDevice(Device): @Device.task def foo(a): return a * 2 device = MyDevice("/dev/ttyUSB0")
Marking methods as tasks/threads in a class requires using the capital
Methods marked with
@Device.task are similar to
@staticmethod in that
they do not contain
self in the method signature.
To the device, each marked method is equivalent to an independent function.
Methods can be marked with
@Device.thread for their respective functionality.
Methods not marked with these decorators are just normal, boring python methods.
For methods decorated with
@Device.setup, the flag
autoinit=True can be set to automatically
call the method at the end of object creation.
The decorated method must have no parameters, otherwise a
ValueError will be raised.
from belay import Device class MyDevice(Device): @Device.setup(autoinit=True) def setup(): foo = 42 device = MyDevice("/dev/ttyUSB0") # Do NOT explicitly call ``device.setup()``, it has already been invoked.
Device class also has some hook methods that can be implemented to give customization to the object initialization process:
__pre_autoinit__- Called near the end of
__init__, after convenience imports have been imported, but before methods marked with
@Device.setup(autoinit=True)are invoked. This is a good location to sync additional micropython dependencies to device.
__post_init__- Called at the very end of
__init__. This is a good location to set custom object attributes.
The following example will (in order):
Synchronize code located at
On-device, declare the global variable
operation_mode_pinrepresenting an input on pin 10.
operation_mode_pinand set the attribute
operation_mode, which could be used in other host methods.
from belay import Device class MyDevice(Device): def __pre_autoinit__(self): # runs before ``setup(autoinit=True)`` decorated methods self.sync_dependencies("my_package", "dependencies/main") @Device.setup(autoinit=True) def setup(): # A hypothetical jumper that controls how the device should function. operation_mode_pin = Pin(10, Pin.IN, Pin.PULL_UP) def __post_init__(self): # runs after ``setup(autoinit=True)`` decorated methods if self("operation_mode_pin.value"): self.operation_mode = "dev" else: self.operation_mode = "prod"