Skip to content
Snippets Groups Projects
Commit e1e82abe authored by Quentin Bolsee's avatar Quentin Bolsee
Browse files

Basic tests and README

parents
Branches
No related tags found
No related merge requests found
*.psd
README.md 0 → 100644
# Python <-> MicroPython bridge
![](docs/logo.png)
This project lets you interact with MicroPython devices by directly having access to an equivalent Python object on your host computer. All functions are dynamically generated based on the signatures found in the source code of the MicroPython project.
Sending and receiving Python objects is seamlessly supported, as long as those objects have a string representation.
## How to use
Let's assume the following MicroPython file `main.py` is present on the device:
```python
# main.py (MicroPython)
import machine
# create a pin object
p = machine.Pin(4, machine.Pin.OUT)
def led_on():
# turn the LED on
p.on()
```
The following Python code on the host computer lets you instantiate the device and call the function that was automatically discovered:
```python
# test_led.py (Python)
import mpy_bridge
# instantiate the device on serial port COM1
d = mpy_bridge.Device("COM1")
# call the function
d.led_on()
```
Function parameters and return values are supported, but they need to have valid string representations. This is the case for all built-in types.
Here is a more complex example of a MicroPython script:
```python
# main.py (MicroPython)
import machine
# create a pin object
pins = {}
def setup_inputs(pin_list):
global pins
# create a dictionary of {pin number: pin object}
pins = {p: machine.Pin(p, machine.Pin.IN) for p in pin_list}
def read_inputs():
# return a dictionary of {pin number: pin value}
return {p: pins[p].value for p in pins.keys()}
```
A host program can send Python objects, and receive some in return. In this case, an entire dictionary of pin values is returned:
```python
# test_pins.py (Python)
import mpy_bridge
# instantiate the device on serial port COM5
d = mpy_bridge.Device("COM5")
# setup 3 pins at once
d.setup_inputs([2, 6, 15])
# return values of all requested pins
values = d.read_inputs()
print(values) # prints {2: 1, 6: 0, 15: 1}
```
## Error handling
Exceptions on the MicroPython side are turned into Python exceptions, which can help you debug the embedded code. You can catch them and react accordingly:
```python
# test_exceptions.py (Python)
import mpy_bridge
d = mpy_bridge.Device("COM5")
try:
d.read_inputs()
except mpy_bridge.MicroPythonError as e:
print(f"Error on the device: {e}")
```
## Upload code
This module includes a basic tool to update the main file on the MicroPython device:
```python
# test_upload.py (Python)
import mpy_bridge
# instantiate the device on serial port COM34
d = mpy_bridge.Device("COM34")
# upload main file
d.upload("embedded/main.py", "main.py")
# see new function list
print(d)
```
The object is automatically updated after each upload. By default, functions are searched in a file named `main.py`. You can have other files on the device, such as libraries, but those functions won't appear in the generated Python object.
## Results
To evaluate the speed of the transmission back and forth, a simple round-trip time estimation was performed between an rp2040 and a typical laptop. The result show an average of ~3.4ms per function call in the most basic scenario:
![](docs/rtt.png)
## License
This project is provided under the MIT License.
docs/logo.png

39.2 KiB

docs/rtt.png

18.6 KiB

import time
import machine
def get_pin_value(pin):
return machine.Pin(pin).value()
def echo_function(*args, **kwargs):
return f"You sent args: {args}, kwargs: {kwargs}"
def led_on():
p_led.off()
def led_off():
p_led.on()
p_led = machine.Pin(16, machine.Pin.OUT)
import mpy_bridge
import time
def main():
# open device, read functions
d = mpy_bridge.Device("COM5")
# print out functions
print(d)
# blink LED once
d.led_on()
time.sleep(0.2)
d.led_off()
time.sleep(0.2)
if __name__ == "__main__":
main()
import datetime
import mpy_bridge
import numpy as np
import matplotlib.pyplot as plt
def main():
d = mpy_bridge.Device("COM34")
n_exp = 1024
results = []
for i in range(n_exp):
t1 = datetime.datetime.now()
d.get_pin_value(4)
t2 = datetime.datetime.now()
results.append((t2 - t1).total_seconds())
print(np.mean(results))
print(np.std(results))
results_ms = [1000*x for x in results]
plt.figure()
plt.ylabel("count")
plt.xlabel("Round-trip time (ms)")
plt.title("Round-trip time histogram, 1024 samples")
plt.hist(results_ms, 64)
plt.show()
if __name__ == "__main__":
main()
import mpy_bridge
def main():
d = mpy_bridge.Device("COM34")
d.upload("micropython/main.py", "main.py")
print(d)
d.close()
if __name__ == "__main__":
main()
# Quentin Bolsée 2023-10-01
# MIT Center for Bits and Atoms
#
# This work may be reproduced, modified, distributed,
# performed, and displayed for any purpose, but must
# acknowledge this project. Copyright is retained and
# must be preserved. The work is provided as is; no
# warranty is provided, and users accept all liability.
import os.path
import serial
import time
import re
import ast
class MicroPythonError(RuntimeError):
pass
class Device:
init_delay = 0.1
def __init__(self, port, main_filename="main.py"):
self.ser_obj = serial.Serial(port)
self.function_args = {}
self.function_callable = {}
self.main_filename = main_filename
self.main_module = main_filename.replace(".py", "")
# initialize
self.init()
self.read_functions()
def init(self):
s = self.ser_obj
s.write(b'\x04')
s.write(b'\r\n\x03\r\n')
s.write(b'\x01')
time.sleep(self.init_delay)
# clean slate
s.flushInput()
# make sure main file is imported
self.run(f'import {self.main_module}')
def run(self, cmd, show=False, end="\n"):
s = self.ser_obj
s.write((cmd+end).encode("utf-8"))
s.write(b'\x04') # ^d reset
# >OK<RETURN>\x04
txt_ret = s.read_until(b"\x04")[3:-1].decode("utf-8")
# <ERROR>\x04
txt_err = s.read_until(b"\x04")[:-1].decode("utf-8")
if len(txt_err) > 0:
raise MicroPythonError(txt_err)
if show:
print(f"RETURN: '{txt_ret.rstrip()}'")
return txt_ret.rstrip()
def run_func(self, func_name, *args, **kwargs):
args_list = list(repr(x) for x in args)
kwargs_list = list(f"{a}={repr(b)}" for a, b in kwargs.items())
cmd_txt = f"print(repr({self.main_module}.{func_name}({','.join(args_list+kwargs_list)})))"
ret_txt = self.run(cmd_txt)
return ast.literal_eval(ret_txt)
def read_functions(self):
try:
self.run(f'f=open("{self.main_filename}","rb")')
except MicroPythonError:
raise FileNotFoundError(f"Could not find {self.main_filename} on device!")
# read main txt file
main_txt = ast.literal_eval(self.run('print(f.read())')).decode("utf-8")
# find all functions
matches = re.finditer(r"def\s+([^(]+)\((.*)\):", main_txt)
for m in matches:
name = m.group(1)
# generate function
func = lambda *args, func_name=name, **kwargs: self.run_func(func_name, *args, **kwargs)
self.function_args[name] = m.group(2)
self.function_callable[name] = func
setattr(self, name, func)
def __str__(self):
txt = f"MicroPython Device at {self.ser_obj.port}, available functions:\n"
if len(self.function_args) == 0:
txt += " none\n"
else:
for a, b in self.function_args.items():
txt += f" {a}({b})\n"
return txt[:-1]
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def update(self):
for name in self.function_args:
delattr(self, name)
self.function_args = {}
self.function_callable = {}
self.init()
self.read_functions()
def upload_main(self, filename):
self.upload(filename, self.main_filename)
def upload(self, filename, destination=None, update=True):
if destination is None:
_, destination = os.path.split(filename)
with open(filename, "r") as f:
file_txt = f.read()
self.run(f'f = open("{destination}", "wb")')
self.run(f'f.write({repr(file_txt)})')
self.run(f'f.close()')
if update:
self.update()
def remove(self, filename):
self.run('import os')
self.run(f'os.remove("{filename}")')
def close(self):
self.ser_obj.close()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment