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, time, 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/main
withinmy_package
to on-device/lib
.On-device, declare the global variable
operation_mode_pin
representing an input on pin 10.On-host, query
operation_mode_pin
and 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"