Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
pelinski committed Mar 8, 2024
2 parents 385d6dc + 02d7adc commit f89087f
Show file tree
Hide file tree
Showing 9 changed files with 387 additions and 27 deletions.
121 changes: 121 additions & 0 deletions pybela/Controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import asyncio
from .Watcher import Watcher
from .utils import print_info, print_warning


class Controller(Watcher):
def __init__(self, ip="192.168.7.2", port=5555, data_add="gui_data", control_add="gui_control"):
"""Controller class
Note: All values set with the controller class will be only visible through the "get_value()" method, or the "value" field in the list() function. Values streamed with the streamer, logger or monitor classes will not be affected.
Args:
ip (str, optional): Remote address IP. If using internet over USB, the IP won't work, pass "bela.local". Defaults to "192.168.7.2".
port (int, optional): Remote address port. Defaults to 5555.
data_add (str, optional): Data endpoint. Defaults to "gui_data".
control_add (str, optional): Control endpoint. Defaults to "gui_control".
"""
super(Controller, self).__init__(ip, port, data_add, control_add)

self._mode = "CONTROL"

def start_controlling(self, variables=[]):
"""Starts controlling given variables. This function will block until all requested variables are set to 'controlled' in the list.
Note: All values set with the controller class will be only visible through the "get_value()" method, or the "value" field in the list() function. Values streamed with the streamer, logger or monitor classes will not be affected.
Args:
variables (list, optional): List of variables to control. If no variables are specified, stream all watcher variables (default).
"""

variables = self._var_arg_checker(variables)

self.send_ctrl_msg(
{"watcher": [{"cmd": "control", "watchers": variables}]})

async def async_wait_for_control_mode_to_be_set(variables=variables):
# wait for variables to be set as 'controlled' in list
_controlled_status = self.get_controlled_status(
variables) # avoid multiple calls to list
while not all([_controlled_status[var] for var in variables]):
await asyncio.sleep(0.5)

asyncio.run(async_wait_for_control_mode_to_be_set(variables=variables))

print_info(
f"Started controlling variables {variables}... Run stop_controlling() to stop controlling the variable values.")

def stop_controlling(self, variables=[]):
"""Stops controlling given variables. This function will block until all requested variables are set to 'uncontrolled' in the list.
Note: All values set with the controller class will be only visible through the "get_value()" method, or the "value" field in the list() function.
Args:
variables (list, optional): List of variables to control. If no variables are specified, stream all watcher variables (default).
"""

variables = self._var_arg_checker(variables)

self.send_ctrl_msg(
{"watcher": [{"cmd": "uncontrol", "watchers": variables}]})

async def async_wait_for_control_mode_to_be_set(variables=variables):
# wait for variables to be set as 'uncontrolled' in list
_controlled_status = self.get_controlled_status(
variables) # avoid multiple calls to list
while all([_controlled_status[var] for var in variables]):
await asyncio.sleep(0.5)

asyncio.run(async_wait_for_control_mode_to_be_set(variables=variables))

print_info(f"Stopped controlling variables {variables}.")

def send_value(self, variables, values):
"""Send a value to the given variables.
Note: All values set with this function will be only visible through the "get_value()" method, or the "value" field in the list() function. Values streamed with the streamer, logger or monitor classes will not be affected.
Args:
variables (list, required): List of variables to control.
values (list, required): Values to be set for each variable.
"""

assert isinstance(values, list) and len(
values) > 0, "At least one value per variable should be provided."

variables = self._var_arg_checker(variables)

assert len(variables) == len(
values), "The number of variables and values should be the same."

for var in variables:
_type = self.get_prop_of_var(var, "type")

value = values[variables.index(var)]

if value % 1 != 0 and _type in ["i", "j"]:
print_warning(
f"Value {value} is not an integer, but the variable {var} is of type {_type}. Only the integer part will be sent.")

self.send_ctrl_msg(
{"watcher": [{"cmd": "set", "watchers": variables, "values": values}]})

def get_controlled_status(self, variables=[]):
"""Gets the controlled status (controlled or uncontrolled) of the variables
Args:
variables (list of str, optional): List of variables to check. Defaults to all variables in the watcher.
Returns:
list of str: List of controlled status of the variables
"""
variables = self._var_arg_checker(variables)
return {var['name']: var['controlled'] for var in self.list()['watchers'] if var['name'] in variables}

def get_value(self, variables=[]):
""" Gets the value of the variables
Args:
variables (list of str, optional): List of variables to get the value from. Defaults to all variables in the watcher.
Returns:
list of numbers: List of controlled values of the variables
"""
variables = self._var_arg_checker(variables)
return {var['name']: var['value'] for var in self.list()['watchers'] if var['name'] in variables}
6 changes: 3 additions & 3 deletions pybela/Monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ def __init__(self, ip="192.168.7.2", port=5555, data_add="gui_data", control_add
self._mode = "MONITOR"

def connect(self):
super().connect()
# longer queue for monitor since each buffer has only one value
self.streaming_buffers_queue_length = 2000
if (super().connect()):
# longer queue for monitor since each buffer has only one value
self.streaming_buffers_queue_length = 2000

@property
def values(self):
Expand Down
26 changes: 15 additions & 11 deletions pybela/Watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ def __init__(self, ip="192.168.7.2", port=5555, data_add="gui_data", control_add
_pybela_ws_register = {"WATCH": {},
"STREAM": {},
"LOG": {},
"MONITOR": {}}
"MONITOR": {},
"CONTROL": {}}

self._pybela_ws_register = _pybela_ws_register

Expand Down Expand Up @@ -106,12 +107,19 @@ def connect(self):

async def _async_connect():
try:
# Close any open ctrl websocket for the same mode (STREAM, LOG, MONITOR, WATCH)
# Close any open ctrl websocket open for the same mode (STREAM, LOG, MONITOR, WATCH)
if self._pybela_ws_register[self._mode].get(self.ws_ctrl_add) is not None and self._pybela_ws_register[self._mode][self.ws_ctrl_add].open:
print_warning(
f"pybela doesn't support more than one active connection at a time for a given mode. Closing previous connection for {self._mode} at {self.ws_ctrl_add}.")
await self._pybela_ws_register[self._mode][self.ws_ctrl_add].close()

# Control and monitor can't be used at the same time
if (self._mode == "MONITOR" and self._pybela_ws_register["CONTROL"].get(self.ws_ctrl_add) is not None and self._pybela_ws_register["CONTROL"][self.ws_ctrl_add].open) or (self._mode == "CONTROL" and self._pybela_ws_register["MONITOR"].get(self.ws_ctrl_add) is not None and self._pybela_ws_register["MONITOR"][self.ws_ctrl_add].open):
print_warning(
f"pybela doesn't support running control and monitor modes at the same time. You are currently running {'CONTROL' if self._mode=='MONITOR' else 'MONITOR'} at {self.ws_ctrl_add}. You can close it running controller.disconnect()")
print_error("Connection failed")
return 0

# Connect to the control websocket
self.ws_ctrl = await websockets.connect(self.ws_ctrl_add)
self._pybela_ws_register[self._mode][self.ws_ctrl_add] = self.ws_ctrl
Expand All @@ -123,12 +131,6 @@ async def _async_connect():
# Send connection reply to establish the connection
self.send_ctrl_msg({"event": "connection-reply"})

# Close any open data websocket for the same mode (STREAM, LOG, MONITOR, WATCH)
if self._pybela_ws_register[self._mode].get(self.ws_data_add) is not None and self._pybela_ws_register[self._mode][self.ws_data_add].open:
print_warning(
f"pybela doesn't support more than one active connection at a time for a given mode. Closing previous connection for {self._mode} at {self.data_add}.")
await self._pybela_ws_register[self._mode][self.ws_data_add].close()

# Connect to the data websocket
self.ws_data = await websockets.connect(self.ws_data_add)

Expand All @@ -142,8 +144,10 @@ async def _async_connect():
self._watcher_vars = self._filtered_watcher_vars(self._list["watchers"],
lambda var: True)
print_ok("Connection successful")
return 1
else:
print_error("Connection failed")
return 0
except Exception as e:
raise ConnectionError(f"Connection failed: {str(e)}.")

Expand Down Expand Up @@ -300,7 +304,7 @@ def _parse_binary_data(self, binary_data, timestamp_mode, _type):

ref_timestamp, * \
data = struct.unpack(
'Q' + f"{_type}"*int((len(binary_data) - struct.calcsize('Q'))/struct.calcsize(_type)), binary_data)
'Q' + f"{_type}"*int((len(binary_data) - struct.calcsize('Q'))/struct.calcsize(_type)), binary_data)

parsed_buffer = {
"ref_timestamp": ref_timestamp, "data": data}
Expand Down Expand Up @@ -342,7 +346,7 @@ def _filtered_watcher_vars(self, watchers, filter_func):
return [{
"name": var["name"],
"type": var["type"],
"timestamp_mode":"sparse" if var["timestampMode"] == 1 else "dense" if var["timestampMode"] == 0 else None,
"timestamp_mode": "sparse" if var["timestampMode"] == 1 else "dense" if var["timestampMode"] == 0 else None,
# "log_filename": var["logFileName"], # this is updated every time log is called so better not to store it
"data_length": self.get_data_length(var["type"], "sparse" if var["timestampMode"] == 1 else "dense" if var["timestampMode"] == 0 else None,),
"monitor": var["monitor"]
Expand All @@ -357,7 +361,7 @@ def _var_arg_checker(self, variables):
"""

if len(variables) == 0:
# if no variables are specified, stream all watcher variables (default)
# if no variables are specified, return all watcher variables (default)
return [var["name"] for var in self.watcher_vars]

variables = variables if isinstance(variables, list) else [
Expand Down
3 changes: 2 additions & 1 deletion pybela/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
from .Streamer import Streamer
from .Logger import Logger
from .Monitor import Monitor
from .Controller import Controller

__all__ = ['Watcher', 'Streamer', 'Logger', 'Monitor']
__all__ = ['Watcher', 'Streamer', 'Logger', 'Monitor', 'Controller']
20 changes: 12 additions & 8 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# pybela

pybela allows interfacing with [Bela](https://bela.io/), the embedded audio platform, using Python. pybela provides a convenient way to stream, log, and monitor sensor data from your Bela device to your laptop.
pybela allows interfacing with [Bela](https://bela.io/), the embedded audio platform, using Python. pybela provides a convenient way to stream, log, monitor sensor data from your Bela device to your laptop. It also allows you to control the value of variables in your Bela code from your laptop.

This library is currently under development and has been tested with Bela at `dev` branch commit `69cdf75a` and watcher at `main` commit `903573a`.
Below, you can find instructions to install pybela. You can find code examples at `tutorials/` and `test/`.

pybela was developed with a machine learning use case in mind. For a complete pipeline including data acquisition, processing, model training, and deployment (including rapid cross-compilation) check the [pybela-pytorch-xc-tutorial](https://github.com/pelinski/pybela-pytorch-xc-tutorial).

## [Installation and set up](#installation)
You will need to (1) install the python package in your laptop, (2) set the Bela branch to `dev` and (3) add the watcher library to your Bela project.

### 1. Installing the python package

Expand Down Expand Up @@ -95,9 +98,10 @@ pybela has three different modes of operation:

- **Streaming**: continuously send data from your Bela device to your laptop.
- **Logging**: log data in your Bela device and then retrieve it from your laptop.
- **Monitoring**: monitor the state of variables in the Bela code from your laptop.
- **Monitoring**: monitor the value of variables in the Bela code from your laptop.
- **Controlling**: control the value of variables in the Bela code from your laptop.

You can check the **tutorials** at `tutorials/` for more detailed information and usage of each of the modes.
You can check the **tutorials** at `tutorials/` for more detailed information and usage of each of the modes. You can also check the `test/test.py` for a quick overview of the library.

### Running the examples

Expand Down Expand Up @@ -173,6 +177,8 @@ streamer.stop_streaming()

## Testing

This library has been tested with Bela at `dev` branch commit `69cdf75a` and watcher at `main` commit `903573a`.

To run pybela's tests first copy the `bela-test` code into your Bela, compile and run it:

```bash
Expand All @@ -198,12 +204,10 @@ pipenv run python -m build --sdist # builds the .tar.gz file

## To do and known issues

- [ ] **To do:** Upload to pyPI (so that the package can be installed using `pip`)
- [ ] **To do:** Upload built package to `releases` (so that the package can be installed using `pip install pybela-<version>.tar.gz`)
- [ ] **Issue:** Monitor and streamer can't be used simultaneously –  This is due to both monitor and streamer both using the same websocket connection and message format. This could be fixed by having a different message format for the monitor and the streamer (e.g., adding a header to the message)
- [ ] **Issue:** Monitor and streamer/controller can't be used simultaneously –  This is due to both monitor and streamer both using the same websocket connection and message format. This could be fixed by having a different message format for the monitor and the streamer (e.g., adding a header to the message)
- [ ] **Issue:** The plotting routine does not work when variables are updated at different rates.
- [ ] **Issue**: The plotting routine does not work for the monitor (it only works for the streamer)
- [ ] **Code refactor:** There's two routines for generating filenames (for Streamer and for Logger). This should be unified.
- [ ] **Code refactor:** There are two routines for generating filenames (for Streamer and for Logger). This should be unified.
- [ ] **Possible feature:** Flexible backend buffer size for streaming: if the assign rate of variables is too slow, the buffers might not be filled and hence not sent (since the data flushed is not collected in the frontend), and there will be long delays between the variable assign and the data being sent to the frontend.
- [ ] **Issue:** Flushed buffers are not collected after `stop_streaming` in the frontend.
- [ ] **Bug:** `OSError: [Errno 12] Cannot allocate memory`
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

setuptools.setup(
name="pybela",
version="0.0.1",
version="0.1.0",
author="Teresa Pelinski",
author_email="[email protected]",
description="pybela allows interfacing with Bela, the embedded audio platform, using Python. pybela provides a convenient way to stream, log, and monitor sensor data from Bela to the host machine.",
description="pybela allows interfacing with Bela, the embedded audio platform, using Python. pybela provides a convenient way to stream, log, and monitor sensor data from your Bela device to your laptop, or alternatively, to send values to a Bela program from your laptop.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/BelaPlatform/pybela",
Expand Down
57 changes: 56 additions & 1 deletion test/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import asyncio
import os
import numpy as np
from pybela import Watcher, Streamer, Logger, Monitor
from pybela import Watcher, Streamer, Logger, Monitor, Controller

# all tests should be run with Bela connected and the bela-test project (in test/bela-test) running on the board

Expand Down Expand Up @@ -353,6 +353,57 @@ async def async_test_save_monitor():
asyncio.run(async_test_save_monitor())


class test_Controller(unittest.TestCase):
def setUp(self):
self.controlled_vars = ["myvar", "myvar2", "myvar3", "myvar4"]

self.controller = Controller()
self.controller.connect()

def tearDown(self):
self.controller.__del__()

def test_start_stop_controlling(self):

async def async_test_start_stop_controlling():

self.controller.start_controlling(variables=self.controlled_vars)

self.assertEqual(self.controller.get_controlled_status(variables=self.controlled_vars), {
var: True for var in self.controlled_vars}, "The controlled status of the variables should be True after start_controlling")

self.controller.stop_controlling(variables=self.controlled_vars)

self.assertEqual(self.controller.get_controlled_status(variables=self.controlled_vars), {
var: False for var in self.controlled_vars}, "The controlled status of the variables should be False after stop_controlling")

asyncio.run(async_test_start_stop_controlling())

def test_send_value(self):
async def async_test_send_value():
# TODO add streamer to check values are being sent
self.controller.start_controlling(variables=self.controlled_vars)

set_value = 4.6

self.controller.send_value(
variables=self.controlled_vars, values=[set_value]*len(self.controlled_vars))
await asyncio.sleep(0.1) # wait for the values to be set

_controlled_values = self.controller.get_value(
variables=self.controlled_vars) # avoid multiple calls to list

integer_types = ["i", "j"]
expected_values = [int(set_value) if self.controller.get_prop_of_var(
var, "type") in integer_types else set_value for var in self.controlled_vars]

for idx, var in enumerate(self.controlled_vars):
self.assertTrue(
_controlled_values[var] == expected_values[idx], "The controlled value should be 4")

asyncio.run(async_test_send_value())


def remove_file(file_path):
if os.path.exists(file_path):
os.remove(file_path)
Expand Down Expand Up @@ -390,5 +441,9 @@ def remove_file(file_path):
suite.addTest(test_Monitor('test_monitor_n_values'))
suite.addTest(test_Monitor('test_save_monitor'))

if 1:
suite.addTest(test_Controller('test_start_stop_controlling'))
suite.addTest(test_Controller('test_send_value'))

runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
Loading

0 comments on commit f89087f

Please sign in to comment.