diff --git a/README.md b/README.md index b6c3360..178fdff 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,63 @@ -Zepyhr BioHarness LSL Integration +Zephyr BioHarness LSL Integration ================================= -This is an LSL adapter for Medtronic / Zephyr BioModule and BioHarness -Bluetooth devices. If you encounter any issues with this program, please use the -GitHub issue tracker for this project to report them. +This LSL adapter facilitates the integration of Medtronic / Zephyr BioModule and BioHarness Bluetooth devices. If you encounter any issues with this program, please report them using the GitHub issue tracker for this project. Prerequisites -============ +============= -- **Linux:** make sure that you have Bluetooth development headers installed - (e.g. on Ubuntu 20.04: `sudo apt install libbluetooth-dev`) -- **Every Platform:** make sure that you have Miniconda installed and that the - `conda` command-line interface is on your path +- **Linux:** Ensure that you have Bluetooth development headers installed + (e.g., on Ubuntu 20.04: `sudo apt install libbluetooth-dev`). +- **Every Platform:** Ensure that you have Miniconda installed and that the `conda` command-line interface is in your system's path. -Since the Zephyr uses Bluetooth Low Energy (BLE), your machine needs to have the -necessary wireless hardware installed (either built in as with many recent laptops, -or using a BLE-capable USB dongle). +Since the Zephyr uses Bluetooth Low Energy (BLE), your machine needs to have the necessary wireless hardware installed (either built-in, as with many recent laptops, or by using a BLE-capable USB dongle). Installing/Running ================== -- **Windows:** Invoke the script `run.cmd`, which will, if necessary, create a fresh Python - environment and install the necessary dependencies into it -- **Linux/MacOS:** Invoke the script `run.sh`, which will, if necessary, create a fresh Python - environment and install the necessary dependencies into it -- **Alternative manual install:** you can also follow the instructions in - `conda-environment.yml` to install a Python environment yourself or to add the - necessary requirements to an existing environment, and then you can use that - interpreter to run `main.py` - +- **Windows:** Execute the script `run.cmd`, which will, if necessary, create a fresh Python environment and install the required dependencies into it. +- **Linux/MacOS:** Execute the script `run.sh`, which will, if necessary, create a fresh Python environment and install the required dependencies into it. +- **Alternative manual install:** Alternatively, you can follow the instructions in `conda-environment.yml` to install a Python environment yourself or to add the necessary requirements to an existing environment. Then, use that interpreter to run `examples/sender.py`. Usage ===== -1. Switch on the sensor using the button (it should either blink orange or - have a constant orange LED). -2. Optionally configure the device using the tools provided with the device - (e.g., set subject info, device clock, configure logging, and so on). -3. Optionally check that you can access the device from a vendor-provided - desktop software (e.g., the one used to retrieve log files), particularly if - this program appears unable to connect by itself. -4. Run this application, optionally with command-line arguments, to stream - real-time data over LSL. No other software is necessary to interface with the - device. - - if you know the Bluetooth MAC address of the device (e.g., from a prior run), - you can specify that via the command line as in `--address=12:34:56:78:9A` to - speed up the startup time - - you can additionally override what modalities you want to stream using the - `--stream` argument (using fewer than all might extend the device's battery - running time). See Emitted Streams for a brief summary, and the vendor - documentation for the full details. By default, everything is streamed, - which is equivalent to the argument - `--stream=ecg,respiration,accel100mg,accel,rtor,events,summary,general`. - - if you have multiple devices, it can be a good idea to use the - `--streamprefix` argument to prefix the stream name with a string of - your choice (e.g., `--streamprefix=Subject1`) to disambiguate multiple - devices. - - additional command-line arguments are available to further customize the - program's behavior, for further information on these, run the program - with the `--help` argument - +1. Turn on the sensor using the button (it should either blink orange or have a constant orange LED). +2. Optionally, configure the device using the tools provided with the device (e.g., setting subject info, device clock, configuring logging, etc.). +3. Optionally, verify that you can access the device using vendor-provided desktop software (e.g., software used to retrieve log files), especially if this program seems unable to connect on its own. +4. Run the `sender.py` file from the examples folder, optionally with command-line arguments, to stream real-time data over LSL. No other software is necessary to interface with the device. + - If you know the Bluetooth MAC address of the device (e.g., from a prior run), you can specify it via the command line as in `--address=12:34:56:78:9A` to expedite the startup time. + - You can also override which modalities you want to stream using the `--stream` argument (streaming fewer modalities might extend the device's battery life). See Emitted Streams for a summary and the vendor documentation for complete details. By default, all data is streamed, equivalent to the argument `--stream=ecg,respiration,accel100mg,accel,rtor,events,summary,general`. + - If you have multiple devices, it's advisable to use the `--streamprefix` argument to prefix the stream name with a string of your choice (e.g., `--streamprefix=Subject1`) to distinguish between multiple devices. + - Additional command-line arguments are available to further customize the program's behavior. For more information on these, run the program with the `--help` argument. +5. Optionally, you can also run the `all_in_one.py` file from the examples folder. This will connect and stream the device data and also display it in real-time using matplotlib animation. Currently, only ECG and respiration data are enabled for real-time viewing, as they are the only 1D data streams live from the device. +6. Optionally, you can run `sender.py` in one instance and then run `receiver.py` in a different instance if you want more flexibility in running the project. + +Important Concepts +================== + +This app transmits Zephyr device data over a pylsl object. You can read about it at (https://github.com/chkothe/pylsl). In brief, we create a separate data stream for each data channel in the `sender.py` file and access it from the `receiver.py` file. You are encouraged to examine the animate function in the `receiver.py` file to understand how it works better. + Emitted Streams =============== -The following streams can be generated by the application (assuming here the Zephyr -stream name prefix). See vendor manual for full details. - -* **ZephyrECG** the raw ECG waveform, in mV at 250 Hz (1 channel). -* **ZephyrResp** the raw respiration (breathing) waveform, measuring chest - expansion, at ca. 17.8 Hz, in unscaled units. -* **ZephyrAccel100mg** a 3-channel stream with acceleration along X, Y, and Z - axes (coordinate system is configurable via vendor tools) at 50Hz, in units of - g (earth acceleration). -* **ZephyrAccel** same as Accel100mg, but higher numeric precision (>2x), - in unscaled units. -* **ZephyrRtoR** the interval between the most recent two ECG R-waves, in ms, at - ca. 17.8 Hz. The value is held constant until the next R-wave is detected, and - alternates the sign with each new incoming R-wave. -* **ZephyrMarkers** a marker stream with some device events (e.g., button pressed, - battery low, worn status changed and a handful of others, along with some - event-specific numeric payload). -* **ZephyrSummary** summary metrics calculated at 1Hz. This has over 60 channels, - including things like heart rate, respiration rate (beats/breaths per minute), - HRV, various accelerometer-derived measures, confidence measures, and some - system status channels, among others. -* **ZephyrGeneral** an alternative set of summary metrics (subset of summary, - plus a handful of channels of limited utility). - +The application can generate the following streams (assuming the Zephyr stream name prefix). Refer to the vendor manual for complete details. + +* **ZephyrECG:** The raw ECG waveform, in mV at 250 Hz (1 channel). +* **ZephyrResp:** The raw respiration (breathing) waveform, measuring chest expansion, at approximately 17.8 Hz, in unscaled units. +* **ZephyrAccel100mg:** A 3-channel stream with acceleration along the X, Y, and Z axes (coordinate system is configurable via vendor tools) at 50Hz, in units of g (earth acceleration). +* **ZephyrAccel:** Same as Accel100mg, but with higher numeric precision (>2x), in unscaled units. +* **ZephyrRtoR:** The interval between the most recent two ECG R-waves, in ms, at approximately 17.8 Hz. The value remains constant until the next R-wave is detected and alternates its sign with each new incoming R-wave. +* **ZephyrMarkers:** A marker stream with some device events (e.g., button pressed, battery low, worn status changed, and others), along with some event-specific numeric payload. +* **ZephyrSummary:** Summary metrics calculated at 1Hz. This stream includes over 60 channels, such as heart rate, respiration rate (beats/breaths per minute), HRV, various accelerometer-derived measures, confidence measures, and system status channels, among others. +* **ZephyrGeneral:** An alternative set of summary metrics (subset of summary, plus some channels of limited utility). + Acknowledgements ================ This open-source LSL application was developed and is maintained by Intheon (www.intheon.io). -For support inquiries please file a GitHub issue on this repo (preferred) or contact support@intheon.io. +For support inquiries, please file a GitHub issue on this repo (preferred) or contact support@intheon.io. Copyright (C) 2020-2021 Syntrogi Inc. dba Intheon. @@ -101,8 +66,4 @@ This work was funded by the Army Research Laboratory under Cooperative Agreement Known Issues ============ -Python's Bluetooth (LE) support on current Mac OS appears to be in an incomplete and/or -broken state at this point, and this application is known to not run on Mac OS -Big Sur, neither with conda's Python 3.7 nor the default Python 3.7, or with PyBlueZ 0.23 -or its current GitHub version (PyBlueZ 0.30). Older Mac OS versions might work but have -not been tested. +Python's Bluetooth (LE) support on current macOS versions appears to be incomplete or broken, and this application is known not to run on macOS Big Sur, neither with conda's Python 3.7, the default Python 3.7, PyBlueZ 0.23, nor its current GitHub version (PyBlueZ 0.30). Older macOS versions might work but have not been tested. diff --git a/conda-environment.yml b/conda-environment.yml index 9fdb4e6..fff4a0e 100644 --- a/conda-environment.yml +++ b/conda-environment.yml @@ -15,10 +15,9 @@ channels: - conda-forge - defaults dependencies: -- python=3.7 +- python +- matplotlib +- numpy - pip - pip: - - cbitstruct - - pybluez ; platform_system != "Windows" - - https://s3.amazonaws.com/resources.neuropype.io/wheels/PyBluez-0.22-cp37-cp37m-win_amd64.whl ; platform_system == "Windows" - - pylsl==1.13.1 + - -r file:requirements.txt diff --git a/core/bluetooth.py b/core/connection.py similarity index 99% rename from core/bluetooth.py rename to core/connection.py index bd04134..fcb591f 100644 --- a/core/bluetooth.py +++ b/core/connection.py @@ -17,7 +17,7 @@ class BioharnessIO: - def __init__(self, address='', port=1, lifesign_interval=2, reconnect=True, + def __init__(self, address='', port=5, lifesign_interval=2, reconnect=True, daemon=False): """Handle message-level communication with a Bioharness device via BLE. diff --git a/core/interface.py b/core/interface.py index e2cf87b..799136b 100644 --- a/core/interface.py +++ b/core/interface.py @@ -5,7 +5,7 @@ import asyncio from concurrent import futures -from .bluetooth import BioharnessIO +from .connection import BioharnessIO from .protocol import Message, MI, periodic_messages, transmit_state2data_packet logger = logging.getLogger(__name__) diff --git a/examples/all_in_one.py b/examples/all_in_one.py new file mode 100644 index 0000000..55b51da --- /dev/null +++ b/examples/all_in_one.py @@ -0,0 +1,419 @@ +"""Command line interface for the Zephyr BioHarness LSL integration.""" + +import threading +import time +from datetime import datetime + +import numpy as np +from matplotlib import pyplot as plt +from pylsl import StreamInlet +from matplotlib.animation import FuncAnimation +import matplotlib.ticker as ticker +from collections import deque + +import logging +import datetime +import asyncio +import argparse + +import pylsl + +from core import BioHarness +from core.protocol import * + +logger = logging.getLogger(__name__) + +from collections import deque + +# One buffer per stream +buffers = {} +def animate(i, signals: {str: StreamInlet}, axs: dict,first_time_stamp:list[float]): + window_seconds = 10 + sample_rate = 250 + max_samples = window_seconds * sample_rate + lines = [] + + for stream_name, stream in signals.items(): + samples, timestamps = stream.pull_chunk() + if len(samples) == 0: + continue + + # Flatten if needed + xs = [sample[0] if isinstance(sample, list) else float(sample) for sample in samples] + + # Align time + if first_time_stamp[0] == 0: + first_time_stamp[0] = timestamps[0] + timestamps = [t - first_time_stamp[0] for t in timestamps] + + # Initialize buffer + if stream_name not in buffers: + buffers[stream_name] = deque(maxlen=max_samples) + + # Store in buffer + buffers[stream_name].extend(zip(timestamps, xs)) + + # Prepare for plot + times_vals = list(buffers[stream_name]) + times, values = zip(*times_vals) + + ax = axs[stream_name] + ax.clear() + line = ax.plot(times, values)[0] + ax.set_xlim(max(0, times[-1] - window_seconds), times[-1]) + ax.set_title(stream_name) + lines.append(line) + + return lines +''' + xs = [] + for sample in samples: + if isinstance(sample, list): + xs.append(sample[0]) + else: + xs.append(float(sample)) + if first_time_stamp[0] == 0: + first_time_stamp[0] = timestamps[0] + timestamps = np.array(timestamps) - first_time_stamp[0] + lines.append(axs[stream_name].plot(timestamps, xs)[0]) + return list(axs.values()) + + ''' + + +def receive(): + global modalities + signals = {} + for mod in modalities: + streams = pylsl.resolve_stream('type', mod) + if len(streams) > 0: + signals[mod] = pylsl.StreamInlet(streams[0]) + fig, axs = plt.subplots(len(signals.keys()), 1, figsize=(10, 6)) + plt.subplots_adjust(hspace=1 / len(signals.keys())) + axes = {} + sample_rate = 250 + interval_size = 1000 // sample_rate + first_time_stamp = [0] + for i, stream_name in enumerate(signals.keys()): + axes[stream_name] = axs[i] + ax = axes[stream_name] + ax.set_title(f"{stream_name}") + ax.ticklabel_format(useOffset=False, style='plain', axis='x') + ax.xaxis.set_major_formatter(ticker.ScalarFormatter()) + + # Create a StreamInlet to read from the stream. + ani = FuncAnimation(fig, animate, fargs=(signals, axes,first_time_stamp), interval=interval_size, cache_frame_data=False) + plt.show() + + +def add_manufacturer(desc): + """Add manufacturer into to a stream's desc""" + acq = desc.append_child('acquisition') + acq.append_child_value('manufacturer', 'Medtronic') + acq.append_child_value('model', 'Zephyr BioHarness') + + +# noinspection PyUnusedLocal +async def enable_ecg(link, nameprefix, idprefix, **kwargs): + """Enable the ECG data stream. This is the raw ECG waveform.""" + info = pylsl.StreamInfo(nameprefix + 'ECG', 'ECG', 1, + nominal_srate=ECGWaveformMessage.srate, + source_id=idprefix + '-ECG') + desc = info.desc() + chn = desc.append_child('channels').append_child('channel') + chn.append_child_value('label', 'ECG1') + chn.append_child_value('type', 'ECG') + chn.append_child_value('unit', 'millivolts') + add_manufacturer(desc) + outlet = pylsl.StreamOutlet(info) + + def on_ecg(msg): + outlet.push_chunk([[v] for v in msg.waveform]) + + await link.toggle_ecg(on_ecg) + + +# noinspection PyUnusedLocal +async def enable_respiration(link, nameprefix, idprefix, **kwargs): + """Enable the respiration data stream. This is the raw respiration (chest + expansion) waveform.""" + info = pylsl.StreamInfo(nameprefix + 'Resp', 'Respiration', 1, + nominal_srate=BreathingWaveformMessage.srate, + source_id=idprefix + '-Resp') + desc = info.desc() + chn = desc.append_child('channels').append_child('channel') + chn.append_child_value('label', 'Respiration') + chn.append_child_value('type', 'EXG') + chn.append_child_value('unit', 'unnormalized') + add_manufacturer(desc) + outlet = pylsl.StreamOutlet(info) + + def on_breathing(msg): + outlet.push_chunk([[v] for v in msg.waveform]) + + await link.toggle_breathing(on_breathing) + + +# noinspection PyUnusedLocal +async def enable_accel100mg(link, nameprefix, idprefix, **kwargs): + """Enable the accelerometer data stream. This is a 3-channel stream in units + of 1 g (earth gravity).""" + info = pylsl.StreamInfo(nameprefix + 'Accel100mg', 'Accel100mg', 3, + nominal_srate=Accelerometer100MgWaveformMessage.srate, + source_id=idprefix + '-Accel100mg') + desc = info.desc() + chns = desc.append_child('channels') + for lab in ['X', 'Y', 'Z']: + chn = chns.append_child('channel') + chn.append_child_value('label', lab) + chn.append_child_value('unit', 'g') + chn.append_child_value('type', 'Acceleration' + lab) + add_manufacturer(desc) + outlet = pylsl.StreamOutlet(info) + + def on_accel100mg(msg): + outlet.push_chunk([[x, y, z] for x, y, z in zip(msg.accel_x, msg.accel_y, msg.accel_z)]) + + await link.toggle_accel100mg(on_accel100mg) + + +# noinspection PyUnusedLocal +async def enable_accel(link, nameprefix, idprefix, **kwargs): + """Enable the regular accelerometer data stream. This is a 3-channel stream + with slightly higher res than accel100mg (I believe around 2x), but """ + info = pylsl.StreamInfo(nameprefix + 'Accel', 'Accel', 3, + nominal_srate=AccelerometerWaveformMessage.srate, + source_id=idprefix + '-Accel') + desc = info.desc() + chns = desc.append_child('channels') + for lab in ['X', 'Y', 'Z']: + chn = chns.append_child('channel') + chn.append_child_value('label', lab) + chn.append_child_value('type', 'Acceleration' + lab) + chn.append_child_value('unit', 'unnormalized') + add_manufacturer(desc) + outlet = pylsl.StreamOutlet(info) + + def on_accel(msg): + outlet.push_chunk([[x, y, z] for x, y, z in zip(msg.accel_x, msg.accel_y, msg.accel_z)]) + + await link.toggle_accel(on_accel) + + +# noinspection PyUnusedLocal +async def enable_rtor(link, nameprefix, idprefix, **kwargs): + """Enable the RR interval data stream. This has the interval between the + most recent two ECG R-waves, in ms (held constant until the next R-peak), + and the sign of the reading alternates with each new R peak.""" + info = pylsl.StreamInfo(nameprefix + 'RtoR', 'RtoR', 1, + nominal_srate=RtoRMessage.srate, + source_id=idprefix + '-RtoR') + desc = info.desc() + chn = desc.append_child('channels').append_child('channel') + chn.append_child_value('label', 'RtoR') + chn.append_child_value('unit', 'milliseconds') + chn.append_child_value('type', 'Misc') + + add_manufacturer(desc) + outlet = pylsl.StreamOutlet(info) + + def on_rtor(msg): + outlet.push_chunk([[v] for v in msg.waveform]) + + await link.toggle_rtor(on_rtor) + + +async def enable_events(link, nameprefix, idprefix, **kwargs): + """Enable the events data stream. This has a few system events like button + pressed, battery low, worn status changed.""" + info = pylsl.StreamInfo(nameprefix + 'Events', 'Events', 1, + nominal_srate=0, + channel_format=pylsl.cf_string, + source_id=idprefix + '-Events') + outlet = pylsl.StreamOutlet(info) + + def on_event(msg): + if kwargs.get('localtime', '1') == '1': + stamp = datetime.datetime.fromtimestamp(msg.stamp) + else: + stamp = datetime.datetime.utcfromtimestamp(msg.stamp) + timestr = stamp.strftime('%Y-%m-%d %H:%M:%S') + event_str = f'{msg.event_string}/{msg.event_data}@{timestr}' + outlet.push_sample([event_str]) + logger.debug(f'event detected: {event_str}') + + await link.toggle_events(on_event) + + +# noinspection PyUnusedLocal +async def enable_summary(link, nameprefix, idprefix, **kwargs): + """Enable the summary data stream. This has most of the derived data + channels in it.""" + # we're delaying creation of these objects until we got data since we don't + # know in advance if we're getting summary packet V2 or V3 + info, outlet = None, None + + def on_summary(msg): + nonlocal info, outlet + content = msg.as_dict() + if info is None: + info = pylsl.StreamInfo(nameprefix + 'Summary', 'Summary', len(content), + nominal_srate=1, + channel_format=pylsl.cf_float32, + source_id=idprefix + '-Summary') + desc = info.desc() + add_manufacturer(desc) + chns = desc.append_child('channels') + for key in content: + chn = chns.append_child('channel') + chn.append_child_value('label', key) + unit = get_unit(key) + if unit is not None: + chn.append_child_value('unit', unit) + outlet = pylsl.StreamOutlet(info) + outlet.push_sample(list(content.values())) + + await link.toggle_summary(on_summary) + + +# noinspection PyUnusedLocal +async def enable_general(link, nameprefix, idprefix, **kwargs): + """Enable the general data stream. This has summary metrics, but fewer than + the summary stream, plus a handful of less-useful channels.""" + # we're delaying creation of these objects until we got data since we're + # deriving the channel count and channel labels from the data packet + info, outlet = None, None + + def on_general(msg): + nonlocal info, outlet + content = msg.as_dict() + if info is None: + info = pylsl.StreamInfo(nameprefix + 'General', 'General', len(content), + nominal_srate=1, + channel_format=pylsl.cf_float32, + source_id=idprefix + '-General') + desc = info.desc() + add_manufacturer(desc) + chns = desc.append_child('channels') + for key in content: + chn = chns.append_child('channel') + chn.append_child_value('label', key) + unit = get_unit(key) + if unit is not None: + chn.append_child_value('unit', unit) + outlet = pylsl.StreamOutlet(info) + outlet.push_sample(list(content.values())) + + await link.toggle_general(on_general) + + +# map of functions that enable various streams and hook in the respective handlers +enablers = { + 'ECG': enable_ecg, + 'Respiration': enable_respiration, + 'Accel100mg': enable_accel100mg, + 'Accel': enable_accel, + 'RtoR': enable_rtor, + 'Events': enable_events, + 'Summary': enable_summary, + 'General': enable_general, +} + + +# our BioHarness link + + +async def init(): + global link + global wait + global modalities + try: + # parse args + p = argparse.ArgumentParser( + description='Stream data from the Zephyr BioHarness.') + p.add_argument('--address', help="Bluetooth MAC address of the device " + "to use (autodiscover if not given).", + default='') + p.add_argument('--port', help='Bluetooth port of the device (rarely ' + 'used).', + default=1) + p.add_argument('--stream', help='Comma-separated list of data to stream (no spaces).' + 'Note that using unnecessary streams will ' + 'likely drain the battery faster.', + default=','.join(enablers.keys())) + p.add_argument('--loglevel', help="Logging level (DEBUG, INFO, WARN, ERROR).", + default='INFO', choices=['DEBUG', 'INFO', 'WARN', 'ERROR']) + p.add_argument('--streamprefix', help='Stream name prefix. This is pre-pended ' + 'to the name of all LSL streams.', + default='Zephyr') + p.add_argument('--timeout', help='Command timeout. If a command takes longer ' + 'than this many seconds to succeed or fail, ' + 'an error is raised and the app exits.', + default=20) + p.add_argument('--localtime', help="Whether event time stamps are in " + "local time (otherwise UTC is assumed).", + default='1', choices=['0', '1']) + args = p.parse_args() + + # set up logging + logging.basicConfig(level=logging.getLevelName(args.loglevel), + format='%(asctime)s %(levelname)s: %(message)s') + logger.info("starting up...") + + # sanity checking + modalities = args.stream.split(',') + unknown = set(modalities) - set(enablers.keys()) + if unknown: + raise ValueError(f"Unknown modalities to stream: {unknown}") + + # connect to bioharness + link = BioHarness(args.address, port=int(args.port), timeout=int(args.timeout)) + infos = await link.get_infos() + info_str = '\n'.join([f' * {k}: {v}' for k, v in infos.items()]) + logger.info(f"Device info is:\n{info_str}") + id_prefix = infos['serial'] + + # enable various kinds of streams and install handlers + logger.info("Enabling streams...") + for mod in modalities: + logger.info(f" enabling {mod}...") + enabler = enablers[mod] + await enabler(link, nameprefix=args.streamprefix, + idprefix=id_prefix, **vars(args)) + + logger.info('Now streaming...') + wait = False + except SystemExit: + asyncio.get_event_loop().stop() + except TimeoutError as e: + logger.error(f"Operation timed out: {e}") + asyncio.get_event_loop().stop() + except Exception as e: + logger.exception(e) + asyncio.get_event_loop().stop() + + +def start_async_loop(l): + asyncio.set_event_loop(l) + try: + l.run_forever() + except KeyboardInterrupt: + logger.info("Ctrl-C pressed.") + finally: + if link: + # noinspection PyUnresolvedReferences + link.shutdown() + l.close() + + +if __name__ == "__main__": + link = None + modalities = [] + wait = True + loop = asyncio.new_event_loop() + asyncio.run_coroutine_threadsafe(init(), loop) + thread = threading.Thread(target=start_async_loop, args=(loop,)) + thread.start() + while wait: + time.sleep(0.1) + receive() diff --git a/examples/recevier.py b/examples/recevier.py new file mode 100644 index 0000000..7299993 --- /dev/null +++ b/examples/recevier.py @@ -0,0 +1,70 @@ +"""Command line interface for the Zephyr BioHarness LSL integration.""" +import numpy as np +from matplotlib import pyplot as plt +from pylsl import StreamInlet +from matplotlib.animation import FuncAnimation +import matplotlib.ticker as ticker + +import logging +import argparse + +import pylsl +logger = logging.getLogger(__name__) + + +def animate(i, signals: {str: StreamInlet}, axs: dict, first_time_stamp: list[float]): + lines = [] + for stream_name, stream in signals.items(): + samples, timestamps = stream.pull_chunk() + if len(samples) > 0: + xs = [] + for sample in samples: + if isinstance(sample, list): + xs.append(sample[0]) + else: + xs.append(float(sample)) + if first_time_stamp[0] == 0: + first_time_stamp[0] = timestamps[0] + timestamps = np.array(timestamps) - first_time_stamp[0] + lines.append(axs[stream_name].plot(timestamps, xs)[0]) + return list(axs.values()) + + +def receive(): + all_signals_names = ['ECG', 'Respiration'] + p = argparse.ArgumentParser( + description='Receive data from the Zephyr BioHarness.') + p.add_argument('--stream', help='Comma-separated list of data to stream (no spaces).' + 'Note that using unnecessary streams will ' + 'likely drain the battery faster.', + default=','.join(all_signals_names)) + args = p.parse_args() + modalities = args.stream.split(',') + signals = {} + unknown = set(modalities) - set(all_signals_names) + if unknown: + raise ValueError(f"Unknown modalities to stream: {unknown}") + for mod in modalities: + streams = pylsl.resolve_stream('type', mod) + if len(streams) > 0: + signals[mod] = pylsl.StreamInlet(streams[0]) + fig, axs = plt.subplots(len(signals.keys()), 1, figsize=(10, 6)) + plt.subplots_adjust(hspace=1 / len(signals.keys())) + axes = {} + sample_rate = 250 + interval_size = 1000 // sample_rate + first_time_stamp = [0] + for i, stream_name in enumerate(signals.keys()): + axes[stream_name] = axs[i] + ax = axes[stream_name] + ax.set_title(f"{stream_name}") + ax.ticklabel_format(useOffset=False, style='plain', axis='x') + ax.xaxis.set_major_formatter(ticker.ScalarFormatter()) + + # Create a StreamInlet to read from the stream. + ani = FuncAnimation(fig, animate, fargs=(signals, axes, first_time_stamp), interval=interval_size) + plt.show() + + +if __name__ == "__main__": + receive() diff --git a/main.py b/examples/sender.py similarity index 94% rename from main.py rename to examples/sender.py index 625a965..fc24abe 100644 --- a/main.py +++ b/examples/sender.py @@ -65,7 +65,7 @@ def on_breathing(msg): async def enable_accel100mg(link, nameprefix, idprefix, **kwargs): """Enable the accelerometer data stream. This is a 3-channel stream in units of 1 g (earth gravity).""" - info = pylsl.StreamInfo(nameprefix+'Accel100mg', 'Mocap', 3, + info = pylsl.StreamInfo(nameprefix+'Accel100mg', 'Accel100mg', 3, nominal_srate=Accelerometer100MgWaveformMessage.srate, source_id=idprefix+'-Accel100mg') desc = info.desc() @@ -88,7 +88,7 @@ def on_accel100mg(msg): async def enable_accel(link, nameprefix, idprefix, **kwargs): """Enable the regular accelerometer data stream. This is a 3-channel stream with slightly higher res than accel100mg (I believe around 2x), but """ - info = pylsl.StreamInfo(nameprefix+'Accel', 'Mocap', 3, + info = pylsl.StreamInfo(nameprefix+'Accel', 'Accel', 3, nominal_srate=AccelerometerWaveformMessage.srate, source_id=idprefix+'-Accel') desc = info.desc() @@ -112,7 +112,7 @@ async def enable_rtor(link, nameprefix, idprefix, **kwargs): """Enable the RR interval data stream. This has the interval between the most recent two ECG R-waves, in ms (held constant until the next R-peak), and the sign of the reading alternates with each new R peak.""" - info = pylsl.StreamInfo(nameprefix+'RtoR', 'Misc', 1, + info = pylsl.StreamInfo(nameprefix+'RtoR', 'RtoR', 1, nominal_srate=RtoRMessage.srate, source_id=idprefix+'-RtoR') desc = info.desc() @@ -133,10 +133,10 @@ def on_rtor(msg): async def enable_events(link, nameprefix, idprefix, **kwargs): """Enable the events data stream. This has a few system events like button pressed, battery low, worn status changed.""" - info = pylsl.StreamInfo(nameprefix+'Markers', 'Markers', 1, + info = pylsl.StreamInfo(nameprefix+'Events', 'Events', 1, nominal_srate=0, channel_format=pylsl.cf_string, - source_id=idprefix+'-Markers') + source_id=idprefix+'-Events') outlet = pylsl.StreamOutlet(info) def on_event(msg): @@ -164,7 +164,7 @@ def on_summary(msg): nonlocal info, outlet content = msg.as_dict() if info is None: - info = pylsl.StreamInfo(nameprefix+'Summary', 'Misc', len(content), + info = pylsl.StreamInfo(nameprefix+'Summary', 'Summary', len(content), nominal_srate=1, channel_format=pylsl.cf_float32, source_id=idprefix+'-Summary') @@ -195,7 +195,7 @@ def on_general(msg): nonlocal info, outlet content = msg.as_dict() if info is None: - info = pylsl.StreamInfo(nameprefix+'General', 'Misc', len(content), + info = pylsl.StreamInfo(nameprefix+'General', 'General', len(content), nominal_srate=1, channel_format=pylsl.cf_float32, source_id=idprefix+'-General') @@ -216,14 +216,14 @@ def on_general(msg): # map of functions that enable various streams and hook in the respective handlers enablers = { - 'ecg': enable_ecg, - 'respiration': enable_respiration, - 'accel100mg': enable_accel100mg, - 'accel': enable_accel, - 'rtor': enable_rtor, - 'events': enable_events, - 'summary': enable_summary, - 'general': enable_general, + 'ECG': enable_ecg, + 'Respiration': enable_respiration, + 'Accel100mg': enable_accel100mg, + 'Accel': enable_accel, + 'RtoR': enable_rtor, + 'Events': enable_events, + 'Summary': enable_summary, + 'General': enable_general, } # our BioHarness link diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5cd54f3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +numpy +git+https://github.com/pybluez/pybluez.git +matplotlib +pylsl +cbitstruct \ No newline at end of file diff --git a/run.cmd b/run.cmd index 4f716bf..7f4448a 100644 --- a/run.cmd +++ b/run.cmd @@ -22,4 +22,4 @@ if errorlevel 1 ( ) echo Launching application... -call conda activate pyzephyr && python main.py %* +call conda activate pyzephyr && python examples\\sender.py %* diff --git a/run.sh b/run.sh index 098e968..88f7004 100755 --- a/run.sh +++ b/run.sh @@ -21,4 +21,4 @@ if [ $? -eq 1 ]; then conda env create -n pyzephyr -f conda-environment.yml fi -conda activate pyzephyr && python main.py $@ +conda activate pyzephyr && python examples/sender.py $@