Skip to content

Commit

Permalink
Merge pull request #4 from BrianPugh/multiple-devices
Browse files Browse the repository at this point in the history
Multiple device support
  • Loading branch information
BrianPugh authored Aug 13, 2022
2 parents 395466f + 6fbd63a commit ff15593
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 79 deletions.
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ max-line-length = 88
# F401: Module imported but unused.
# D100-D107: Missing docstrings
# D200: One-line docstring should fit on one line with quotes.
extend-ignore = E203,E402,E501,F401,D100,D101,D102,D103,D104,D105,D106,D107,D200
extend-ignore = E203,E402,E501,F401,D100,D101,D102,D103,D104,D105,D106,D107,D200,D401
docstring-convention = numpy
# Ignore missing docstrings within unit testing functions.
per-file-ignores = **/tests/:D100,D101,D102,D103,D104,D105,D106,D107
Expand Down
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ repos:
rev: v1.9.0
hooks:
- id: python-check-blanket-noqa
- id: python-check-blanket-type-ignore
- id: python-check-mock-methods
- id: python-no-log-warn
- id: rst-backticks
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Belay
Belay is a library that enables the rapid development of projects that interact with hardware via a micropython-compatible board.

Belay works by automatically using the REPL interface of a micropython board from Python code running on PC.
Belay works by interacting with the REPL interface of a micropython board from Python code running on PC.

`Quick Video of Belay in 22 seconds.`_

Expand Down
2 changes: 1 addition & 1 deletion belay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"PyboardException",
]
from ._minify import minify
from .device import Device, SpecialFilenameError
from .device import Device, SpecialFilenameError, SpecialFunctionNameError
from .pyboard import PyboardException
236 changes: 161 additions & 75 deletions belay/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import linecache
import tempfile
from abc import ABC, abstractmethod
from functools import wraps
from pathlib import Path
from typing import Callable, Dict, List, Optional, Union
Expand Down Expand Up @@ -75,6 +76,10 @@ class SpecialFilenameError(Exception):
"""Not allowed filename like ``boot.py`` or ``main.py``."""


class SpecialFunctionNameError(Exception):
"""Not allowed function name."""


def local_hash_file(fn):
hasher = hashlib.sha256()
with open(fn, "rb") as f: # noqa: PL123
Expand All @@ -86,6 +91,157 @@ def local_hash_file(fn):
return binascii.hexlify(hasher.digest()).decode()


class _Executer(ABC):
def __init__(self, device):
# To avoid Executer.__setattr__ raising an error
object.__setattr__(self, "_belay_device", device)

def __setattr__(self, name: str, value: Callable):
if name.startswith("_belay") or (name.startswith("__") and name.endswith("__")):
raise SpecialFunctionNameError(
f'Not allowed to register function named "{name}".'
)
super().__setattr__(name, value)

def __getattr__(self, name: str) -> Callable:
# Just here for linting purposes.
raise AttributeError

@abstractmethod
def __call__(self):
raise NotImplementedError


class _TaskExecuter(_Executer):
def __call__(
self,
f: Optional[Callable[..., JsonSerializeable]] = None,
/,
minify: bool = True,
register: bool = True,
) -> Callable[..., JsonSerializeable]:
"""Decorator that send code to device that executes when decorated function is called on-host.
Parameters
----------
f: Callable
Function to decorate.
minify: bool
Minify ``cmd`` code prior to sending.
Defaults to ``True``.
register: bool
Assign an attribute to ``self`` with same name as ``f``.
Defaults to ``True``.
Returns
-------
Callable
Remote-executor function.
"""
if f is None:
return self # type: ignore

name = f.__name__
src_code, src_lineno, src_file = getsource(f)

# Add the json_decorator decorator for handling serialization.
src_code = "@json_decorator\n" + src_code

# Send the source code over to the device.
self._belay_device(src_code, minify=minify)

@wraps(f)
def executer(*args, **kwargs):
cmd = f"{_BELAY_PREFIX + name}(*{args}, **{kwargs})"

return self._belay_device._traceback_execute(
src_file, src_lineno, name, cmd
)

@wraps(f)
def multi_executer(*args, **kwargs):
res = executer(*args, **kwargs)
if hasattr(f, "_belay_level"):
# Call next device's wrapper.
if f._belay_level == 1:
res = [f(*args, **kwargs), res]
else:
res = [*f(*args, **kwargs), res]

return res

multi_executer._belay_level = 1
if hasattr(f, "_belay_level"):
multi_executer._belay_level += f._belay_level

if register:
setattr(self, name, executer)

return multi_executer


class _ThreadExecuter(_Executer):
def __call__(
self,
f: Optional[Callable[..., None]] = None,
/,
minify: bool = True,
register: bool = True,
) -> Callable[..., None]:
"""Decorator that send code to device that spawns a thread when executed.
Parameters
----------
f: Callable
Function to decorate.
minify: bool
Minify ``cmd`` code prior to sending.
Defaults to ``True``.
register: bool
Assign an attribute to ``self`` with same name as ``f``.
Defaults to ``True``.
Returns
-------
Callable
Remote-executor function.
"""
if f is None:
return self # type: ignore

name = f.__name__
src_code, src_lineno, src_file = getsource(f)

# Send the source code over to the device.
self._belay_device(src_code, minify=minify)

@wraps(f)
def executer(*args, **kwargs):
cmd = f"import _thread; _thread.start_new_thread({name}, {args}, {kwargs})"
self._belay_device._traceback_execute(src_file, src_lineno, name, cmd)

@wraps(f)
def multi_executer(*args, **kwargs):
res = executer(*args, **kwargs)
if hasattr(f, "_belay_level"):
# Call next device's wrapper.
if f._belay_level == 1:
res = [f(*args, **kwargs), res]
else:
res = [*f(*args, **kwargs), res]

return res

multi_executer._belay_level = 1
if hasattr(f, "_belay_level"):
multi_executer._belay_level += f._belay_level

if register:
setattr(self, name, executer)

return multi_executer


class Device:
"""Belay interface into a micropython device."""

Expand All @@ -104,6 +260,10 @@ def __init__(
"""
self._board = Pyboard(*args, **kwargs)
self._board.enter_raw_repl()

self.task = _TaskExecuter(self)
self.thread = _ThreadExecuter(self)

self(_BELAY_STARTUP_CODE)
if startup:
self(startup)
Expand Down Expand Up @@ -172,7 +332,7 @@ def sync(
# This is so we know what to clean up after done syncing.
self(_BEGIN_SYNC_CODE)

@self.task
@self.task(register=False)
def remote_hash_file(fn):
hasher = hashlib.sha256()
try:
Expand Down Expand Up @@ -218,80 +378,6 @@ def remote_hash_file(fn):
# Remove all the files and directories that did not exist in local filesystem.
self(_CLEANUP_SYNC_CODE)

def thread(
self,
f: Optional[Callable[..., None]] = None,
minify: bool = True,
) -> Callable[..., None]:
"""Send code to device that spawns a thread when executed.
Parameters
----------
f: Callable
Function to decorate.
minify: bool
Minify ``cmd`` code prior to sending.
Returns
-------
Callable
Remote-executor function.
"""
if f is None:
return self

name = f.__name__
src_code, src_lineno, src_file = getsource(f)

# Send the source code over to the device.
self(src_code, minify=minify)

@wraps(f)
def wrap(*args, **kwargs):
cmd = f"import _thread; _thread.start_new_thread({name}, {args}, {kwargs})"
self._traceback_execute(src_file, src_lineno, name, cmd)

return wrap

def task(
self,
f: Optional[Callable[..., JsonSerializeable]] = None,
/,
minify: bool = True,
) -> Callable[..., JsonSerializeable]:
"""Send code to device that executes when decorated function is called on-host.
Parameters
----------
f: Callable
Function to decorate.
minify: bool
Minify ``cmd`` code prior to sending.
Returns
-------
Callable
Remote-executor function.
"""
if f is None:
return self

name = f.__name__
src_code, src_lineno, src_file = getsource(f)

# Add the json_decorator decorator for handling serialization.
src_code = "@json_decorator\n" + src_code

# Send the source code over to the device.
self(src_code, minify=minify)

@wraps(f)
def wrap(*args, **kwargs):
cmd = f"{_BELAY_PREFIX + name}(*{args}, **{kwargs})"
return self._traceback_execute(src_file, src_lineno, name, cmd)

return wrap

def _traceback_execute(
self,
src_file: Union[str, Path],
Expand Down
9 changes: 9 additions & 0 deletions docs/source/Quick Start.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ Invoking ``bar = foo(5)`` on host sends a command to the device to execute the f
The result, ``10``, is sent back to the host and results in ``bar == 10``.
This is the preferable way to interact with hardware.

If a task is registered to multiple Belay devices, it will execute sequentially on the devices in the order that they were decorated (bottom upwards).
The return value would be a list of results in order.

To explicitly call a task on just one device, it can be invoked ``device.task.foo()``.

thread
^^^^^^

Expand All @@ -68,6 +73,10 @@ thread
Not all MicroPython boards support threading, and those that do typically have a maximum of ``1`` thread.
The decorated function has no return value.

If a thread is registered to multiple Belay devices, it will execute sequentially on the devices in the order that they were decorated (bottom upwards).

To explicitly call a thread on just one device, it can be invoked ``device.thread.led_loop()``.

sync
^^^^
For more complicated hardware interactions, additional python modules/files need to be available on the device's filesystem.
Expand Down
22 changes: 22 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
API
===

.. autoclass:: belay.Device
:members:

.. method:: task(f: Optional[Callable[..., None]] = None, /, minify: bool = True, register: bool = True)

Decorator that send code to device that executes when decorated function is called on-host.

:param Callable f: Function to decorate.
:param bool minify: Minify ``cmd`` code prior to sending. Defaults to ``True``.
:param bool register: Assign an attribute to ``self.task`` with same name as ``f``. Defaults to ``True``.

.. method:: thread(f: Optional[Callable[..., None]] = None, /, minify: bool = True, register: bool = True)

Decorator that send code to device that spawns a thread when executed.

:param Callable f: Function to decorate.
:param bool minify: Minify ``cmd`` code prior to sending. Defaults to ``True``.
:param bool register: Assign an attribute to ``self.thread`` with same name as ``f``. Defaults to ``True``.



.. automodule:: belay
:members:
:undoc-members:
:show-inheritance:
:exclude-members: Device
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
exclude_patterns = []

smartquotes = False
add_module_names = False

# Napoleon settings
napoleon_google_docstring = True
Expand Down
9 changes: 9 additions & 0 deletions examples/08_multiple_devices/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Example 08: Multiple Devices
============================

Belay can interact with multiple micropython boards on different ports.
Tasks and Threads can be decorated multiple times from different devices.
When invoked, the resulting decorated function will execute on the devices in the order that they were decorated (bottom upwards).

To execute a task on just one specific device, it can be accessed like ``device1.task.set_led``.
Similarly, to execute a thread on just one specific device, call ``device1.thread.blink_loop``.
Loading

0 comments on commit ff15593

Please sign in to comment.