Quick Start
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, Device:
import belay
device = belay.Device("/dev/ttyUSB0")
Creating a 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, machine
from time import sleep
from micropython import const
from machine import ADC, I2C, Pin, PWM, SPI, Timer
The 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.
call
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, ret is None.
Invoking a python expression like:
res = device("foo")
results in the return value res == 3 on host.
setup
The 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.
For example:
@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
The 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
Invoking bar = foo(5) on host sends a command to the device to execute the function foo with argument 5.
The result, 10, is sent back to the host and results in bar == 10.
This is the preferable way to interact with hardware.
Alternatively, the foo function will also be available at device.task.foo.
teardown
Same as setup, but automatically executes whenever device.close() is called.
If 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
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 1 thread.
The decorated function has no return value.
sync
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
Then, after device.sync("board") is ran from main.py, the remote filesystem will look like
foo.py
bar
└── baz.py
sync_dependencies
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.
Configure dependencies_path in pyproject.toml to point inside your python package, i.e. dependencies_path="mypackage/dependencies".
In doing so, micropython dependencies will be stored inside your package.
For this example, lets assume that pyproject.toml defines main and dev dependencies.
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.
Subclassing Device
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 @Device.task decorator.
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.setup or @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.
The 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
dependencies/mainwithinmy_packageto on-device/lib.On-device, declare the global variable
operation_mode_pinrepresenting an input on pin 10.On-host, query
operation_mode_pinand set the attributeoperation_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"