├── .gitignore ├── README.md ├── __main__.py ├── poetry.lock ├── polarpy ├── __init__.py ├── callbacks.py ├── commands.py ├── constants.py ├── device.py ├── header_parser.py ├── headers.py ├── measurement_parser.py ├── measurement_settings_parser.py ├── parser.py ├── settings.py └── stream_reader.py ├── pyproject.toml ├── requirements.txt └── tests └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | gen 3 | *.egg-info 4 | __pycache__ 5 | *.pyc 6 | .venv 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # polarpy 2 | 3 | Tools for reading and fusing live data streams from Polar OH1 (PPG) and H10 (ECG) sensors. 4 | 5 | ## Requirements 6 | 7 | If installing from the repo you need [`pygatttool`](https://github.com/wideopensource/pygatttool) (`pip install pygatttool`). 8 | 9 | ## Installation 10 | 11 | ``` 12 | pip install polarpy 13 | ``` 14 | 15 | ## Usage 16 | 17 | The following code starts the raw PPG and IMU streams on a Polar OH1, fuses the blocks pf data in the two streams at 135Hz, and provides a single output stream, each record having a timestamp, the PPG signal values for each of the 3 pairs of LEDs, and the corresponding accerelometer x, y and z readings. 18 | 19 | ``` 20 | from polarpy import OH1 21 | 22 | OH1_ADDR = "A0:9E:1A:7D:3C:5D" 23 | OH1_CONTROL_ATTRIBUTE_HANDLE = 0x003f 24 | OH1_DATA_ATTRIBUTE_HANDLE = 0x0042 25 | 26 | def callback(type: str, timestamp: float, payload: dict): 27 | print(f'{timestamp} {payload}') 28 | 29 | if '__main__' == __name__: 30 | device = OH1(address=OH1_ADDR, 31 | control_handle=OH1_CONTROL_ATTRIBUTE_HANDLE, 32 | data_handle=OH1_DATA_ATTRIBUTE_HANDLE, 33 | callback=callback) 34 | 35 | if device.start(): 36 | while device.run(): 37 | pass 38 | ``` 39 | 40 | The output looks something like this: 41 | 42 | ``` 43 | 3.94 {'ppg0': 263249, 'ppg1': 351764, 'ppg2': 351928, 'ax': 0.775, 'ay': -0.42, 'az': 0.476} 44 | 3.947 {'ppg0': 263297, 'ppg1': 351964, 'ppg2': 352077, 'ax': 0.775, 'ay': -0.42, 'az': 0.476} 45 | 3.954 {'ppg0': 263319, 'ppg1': 352062, 'ppg2': 352013, 'ax': 0.778, 'ay': -0.417, 'az': 0.481} 46 | 3.962 {'ppg0': 263293, 'ppg1': 352106, 'ppg2': 352082, 'ax': 0.778, 'ay': -0.417, 'az': 0.481} 47 | 3.969 {'ppg0': 263440, 'ppg1': 352273, 'ppg2': 352199, 'ax': 0.778, 'ay': -0.417, 'az': 0.481} 48 | 49 | ... 50 | ``` 51 | 52 | The callback is used (rather than returning data from `run()`) because the blocks of PPG, ECG and IMU data arrive with different lengths and at different speeds. The individual samples from each channel must be buffered and interleaved, timestamps interpolated, then delivered asynchronously through the callback. 53 | 54 | The address and attribute handles for your particular device can be found using `gatttool` or another BLE tool such as nRF Connect. 55 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | from polarpy import OH1 2 | 3 | OH1_ADDR = "A0:9E:1A:7D:3C:5D" 4 | OH1_CONTROL_ATTRIBUTE_HANDLE = 0x003f 5 | OH1_DATA_ATTRIBUTE_HANDLE = 0x0042 6 | 7 | 8 | def callback(type: str, timestamp: float, payload: dict): 9 | print(f'{timestamp} {payload}') 10 | 11 | 12 | if '__main__' == __name__: 13 | device = OH1(address=OH1_ADDR, 14 | control_handle=OH1_CONTROL_ATTRIBUTE_HANDLE, 15 | data_handle=OH1_DATA_ATTRIBUTE_HANDLE, 16 | callback=callback) 17 | 18 | if device.start(): 19 | while device.run(): 20 | pass 21 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "pexpect" 5 | version = "4.8.0" 6 | description = "Pexpect allows easy control of interactive console applications." 7 | category = "main" 8 | optional = false 9 | python-versions = "*" 10 | files = [ 11 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 12 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 13 | ] 14 | 15 | [package.dependencies] 16 | ptyprocess = ">=0.5" 17 | 18 | [[package]] 19 | name = "ptyprocess" 20 | version = "0.7.0" 21 | description = "Run a subprocess in a pseudo terminal" 22 | category = "main" 23 | optional = false 24 | python-versions = "*" 25 | files = [ 26 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 27 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 28 | ] 29 | 30 | [[package]] 31 | name = "pygatttool" 32 | version = "0.1.1a3" 33 | description = "BLE client package wrapping gatttool" 34 | category = "main" 35 | optional = false 36 | python-versions = ">=3.8,<4.0" 37 | files = [ 38 | {file = "pygatttool-0.1.1a3-py3-none-any.whl", hash = "sha256:1fa3a22d56945f943a1ea7f5b0518ace71a9441fbe70af776e33160f84253e03"}, 39 | {file = "pygatttool-0.1.1a3.tar.gz", hash = "sha256:f277dfcdea627837028fadacae3d739122157eb9da5ca551147fbd6c5c078bec"}, 40 | ] 41 | 42 | [package.dependencies] 43 | pexpect = ">=4.8.0,<5.0.0" 44 | 45 | [metadata] 46 | lock-version = "2.0" 47 | python-versions = "^3.8" 48 | content-hash = "2058a6996c2d5b833c5b0119aaf973c9fa0920630892b41ed9a949257596e62b" 49 | -------------------------------------------------------------------------------- /polarpy/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import Constants 2 | from .constants import CommandType, DeviceType, MeasurementType 3 | from .constants import ACCFrameType, PPGFrameType 4 | from .constants import SettingType, SampleRateSetting, RangeSetting, ResolutionSetting 5 | from .constants import ControlPointResponseType, ResponseOpCode 6 | from .commands import Commands 7 | from .callbacks import Callbacks 8 | 9 | from .device import OH1 10 | -------------------------------------------------------------------------------- /polarpy/callbacks.py: -------------------------------------------------------------------------------- 1 | from .constants import MeasurementType 2 | 3 | 4 | class Callbacks: 5 | def __init__(self): 6 | pass 7 | 8 | def on_measurement(self, type: MeasurementType, payload): 9 | pass 10 | -------------------------------------------------------------------------------- /polarpy/commands.py: -------------------------------------------------------------------------------- 1 | if "__main__" == __name__: 2 | from constants import MeasurementType, CommandType 3 | from constants import SettingType, RangeSetting, SampleRateSetting, ResolutionSetting 4 | else: 5 | from .constants import MeasurementType, CommandType 6 | from .constants import SettingType, RangeSetting, SampleRateSetting, ResolutionSetting 7 | 8 | import io 9 | from enum import Enum 10 | 11 | 12 | class CommandBuilder: 13 | def __init__(self): 14 | self._stream = io.BytesIO() 15 | 16 | def _write_byte(self, value: int): 17 | self._stream.write(bytes([value])) 18 | 19 | def _write_int16(self, value: int): 20 | self._stream.write(bytes([value & 0xff, (value >> 8) & 0xff])) 21 | 22 | def _write_setting_value(self, value: int): 23 | self._write_byte(1) 24 | self._write_int16(value) 25 | 26 | @ staticmethod 27 | def start_measurement(type: MeasurementType): 28 | command = CommandBuilder() 29 | command._write_byte(CommandType.StartMeasurement) 30 | command._write_byte(type) 31 | return command 32 | 33 | @ staticmethod 34 | def get_measurement_settings(type: MeasurementType): 35 | command = CommandBuilder() 36 | command._write_byte(CommandType.GetMeasurementSettings) 37 | command._write_byte(type) 38 | return command 39 | 40 | def build(self) -> bytearray: 41 | return self._stream.getvalue() 42 | 43 | def add_range(self, range: RangeSetting): 44 | self._write_byte(SettingType.Range) 45 | self._write_setting_value(range) 46 | return self 47 | 48 | def add_sample_rate(self, rate: SampleRateSetting): 49 | self._write_byte(SettingType.SampleRate) 50 | self._write_setting_value(rate) 51 | return self 52 | 53 | def add_resolution(self, resolution: ResolutionSetting): 54 | self._write_byte(SettingType.Resolution) 55 | self._write_setting_value(resolution) 56 | return self 57 | 58 | 59 | class Commands: 60 | GetACCSettings = CommandBuilder \ 61 | .get_measurement_settings(MeasurementType.ACC) \ 62 | .build() 63 | 64 | GetPPGSettings = CommandBuilder \ 65 | .get_measurement_settings(MeasurementType.PPG) \ 66 | .build() 67 | 68 | OH1StartACC = CommandBuilder \ 69 | .start_measurement(MeasurementType.ACC) \ 70 | .add_range(RangeSetting.Range8G) \ 71 | .add_sample_rate(SampleRateSetting.SampleRate50) \ 72 | .add_resolution(ResolutionSetting.Resolution16) \ 73 | .build() 74 | 75 | OH1StartPPG = CommandBuilder \ 76 | .start_measurement(MeasurementType.PPG) \ 77 | .add_sample_rate(SampleRateSetting.SampleRate135) \ 78 | .add_resolution(ResolutionSetting.Resolution22) \ 79 | .build() 80 | 81 | H10StartACC = CommandBuilder \ 82 | .start_measurement(MeasurementType.ACC) \ 83 | .add_range(RangeSetting.Range8G) \ 84 | .add_sample_rate(SampleRateSetting.SampleRate200) \ 85 | .add_resolution(ResolutionSetting.Resolution16) \ 86 | .build() 87 | 88 | 89 | if "__main__" == __name__: 90 | print(f'OH1StartPPG: {Commands.OH1StartPPG}') 91 | print(f'OH1StartACC: {Commands.OH1StartACC}') 92 | -------------------------------------------------------------------------------- /polarpy/constants.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class DeviceType(IntEnum): 5 | H10 = 1, 6 | OH1 = 2 7 | 8 | 9 | class CommandType(IntEnum): 10 | GetMeasurementSettings = 1, 11 | StartMeasurement = 2 12 | 13 | 14 | class SettingType(IntEnum): 15 | SampleRate = 0, 16 | Resolution = 1, 17 | Range = 2, 18 | 19 | 20 | class RangeSetting(IntEnum): 21 | Range8G = 0x0008, 22 | 23 | 24 | class SampleRateSetting(IntEnum): 25 | SampleRate50 = 0x0032 26 | SampleRate135 = 0x0082 27 | SampleRate200 = 0x00c8 28 | SampleRateUnknown = -1 29 | 30 | 31 | class ResolutionSetting(IntEnum): 32 | Resolution16 = 0x0010 33 | Resolution22 = 0x0016 34 | 35 | 36 | class ACCFrameType(IntEnum): 37 | ACCFrameType8 = 0, 38 | ACCFrameType16 = 1, 39 | ACCFrameType24 = 2, 40 | ACCFrameTypeDelta = 128 41 | 42 | 43 | class PPGFrameType(IntEnum): 44 | PPGFrameType24 = 0, 45 | PPGFrameTypeDelta = 128 46 | 47 | 48 | class ControlPointResponseType(IntEnum): 49 | FeatureRead = 0x0f, 50 | Response = 0xf0 51 | 52 | 53 | class ResponseOpCode(IntEnum): 54 | GetMeasurementSettings = 1, 55 | StartMeasurement = 2 56 | 57 | 58 | class MeasurementType(IntEnum): 59 | ECG = 0, 60 | PPG = 1, 61 | ACC = 2, 62 | PPI = 3, 63 | GYRO = 5, 64 | MAG = 6 65 | 66 | 67 | class Constants: 68 | def frame_size(measurement_type: MeasurementType, frame_type) -> int: 69 | if MeasurementType.ACC == measurement_type: 70 | if ACCFrameType.ACCFrameType8 == frame_type: 71 | return 3 72 | if ACCFrameType.ACCFrameType16 == frame_type: 73 | return 6 74 | if ACCFrameType.ACCFrameType24 == frame_type: 75 | return 9 76 | return 0 77 | 78 | if MeasurementType.PPG == measurement_type: 79 | if PPGFrameType.PPGFrameType24 == frame_type: 80 | return 12 81 | return 0 82 | 83 | return 0 84 | 85 | def sample_period_us(sample_rate: SampleRateSetting) -> int: 86 | if SampleRateSetting.SampleRate135 == sample_rate: 87 | return 1000000 / 135 88 | if SampleRateSetting.SampleRate200 == sample_rate: 89 | return 1000000 / 200 90 | if SampleRateSetting.SampleRate50 == sample_rate: 91 | return 1000000 / 50 92 | 93 | return 0 94 | 95 | 96 | if __name__ == "__main__": 97 | print(MeasurementType.ACC.value) 98 | -------------------------------------------------------------------------------- /polarpy/device.py: -------------------------------------------------------------------------------- 1 | from .constants import MeasurementType, DeviceType, SampleRateSetting 2 | from .parser import Parser 3 | from .callbacks import Callbacks 4 | from .settings import StreamSettings 5 | from .commands import Commands 6 | 7 | from collections import deque 8 | from pygatttool import PyGatttool 9 | 10 | 11 | class CallbacksImpl(Callbacks): 12 | def __init__(self, callback=None): 13 | self._callback = callback 14 | 15 | self.ppg_queue = deque() 16 | self.acc_queue = deque() 17 | 18 | def _update_one(self) -> bool: 19 | 20 | if not self.ppg_queue or not self.acc_queue: 21 | return False 22 | 23 | if self.acc_queue[0]['ts'] < self.ppg_queue[0]['ts']: 24 | self.acc_queue.popleft() 25 | return True 26 | 27 | acc_front = self.acc_queue[0] 28 | ax = acc_front['ax'] 29 | ay = acc_front['ay'] 30 | az = acc_front['az'] 31 | 32 | ppg_front = self.ppg_queue[0] 33 | ts = ppg_front['ts'] 34 | ppg0 = ppg_front['ppg0'] 35 | ppg1 = ppg_front['ppg1'] 36 | ppg2 = ppg_front['ppg2'] 37 | self.ppg_queue.popleft() 38 | 39 | if self._callback: 40 | result = {'ppg0': ppg0, 'ppg1': ppg1, 41 | 'ppg2': ppg2, 'ax': ax, 'ay': ay, 'az': az} 42 | self._callback(DeviceType.OH1.name, ts, result) 43 | 44 | return True 45 | 46 | def _update(self): 47 | while self._update_one(): 48 | pass 49 | 50 | def on_measurement(self, type: MeasurementType, payload): 51 | measurement_type = MeasurementType(type) 52 | ts = int(payload[0] / 1000) / 1000 53 | 54 | if MeasurementType.PPG == measurement_type: 55 | ambient = payload[4] 56 | ppg0 = payload[1] - ambient 57 | ppg1 = payload[2] - ambient 58 | ppg2 = payload[3] - ambient 59 | 60 | self.ppg_queue.append( 61 | {"ts": ts, "ppg0": ppg0, "ppg1": ppg1, "ppg2": ppg2}) 62 | 63 | self._update() 64 | 65 | if MeasurementType.ACC == measurement_type: 66 | x = payload[1] / 1000 67 | y = payload[2] / 1000 68 | z = payload[3] / 1000 69 | 70 | d = {"ts": ts, 'ax': x, 'ay': y, 'az': z} 71 | 72 | self.acc_queue.append(d) 73 | 74 | self._update() 75 | 76 | 77 | class Device: 78 | def __init__(self, type: DeviceType, address: str, control_handle: int, 79 | data_handle: int, callback=None): 80 | self._type = type 81 | self._addr = address 82 | self._control_handle = control_handle 83 | self._data_handle = data_handle 84 | self._control_ccc_handle = control_handle + 1 85 | self._data_ccc_handle = data_handle + 1 86 | self.stream_settings = StreamSettings() 87 | self._callbacks = CallbacksImpl(callback=callback) 88 | 89 | self._ble = PyGatttool(address=self._addr) 90 | 91 | self._parser = Parser( 92 | stream_settings=self.stream_settings, callbacks=self._callbacks) 93 | 94 | if DeviceType.H10 == type: 95 | self.stream_settings.ACC_sample_rate = SampleRateSetting.SampleRate200 96 | self.stream_settings.ECG_sample_rate = SampleRateSetting.SampleRate200 97 | 98 | if DeviceType.OH1 == type: 99 | self.stream_settings.ACC_sample_rate = SampleRateSetting.SampleRate50 100 | self.stream_settings.PPG_sample_rate = SampleRateSetting.SampleRate135 101 | 102 | def connect(self) -> bool: 103 | if not self._ble.connect(): 104 | return False 105 | 106 | self._ble.char_write_req(handle=self._control_ccc_handle, value=0x200) 107 | self._ble.char_write_req(handle=self._data_ccc_handle, value=0x100) 108 | self._ble.mtu(232) 109 | value = self._ble.char_read_hnd(self._control_handle) 110 | self._parser.parse(value) 111 | 112 | return True 113 | 114 | def send_command(self, command: bytearray): 115 | result = self._ble.char_write_cmd(self._control_handle, command) 116 | return self._parser.parse(result) 117 | 118 | def start(self) -> bool: 119 | if not self.connect(): 120 | return False 121 | 122 | self.send_command(Commands.GetACCSettings) 123 | self.send_command(Commands.GetPPGSettings) 124 | 125 | self.send_command(Commands.OH1StartPPG) 126 | self.send_command(Commands.OH1StartACC) 127 | 128 | return True 129 | 130 | def run(self): 131 | data = self._ble.wait_for_notification(handle=self._data_handle) 132 | return self._parser.parse(data) 133 | 134 | 135 | class OH1(Device): 136 | def __init__(self, address: str, control_handle: int, 137 | data_handle: int, callback=None): 138 | 139 | super().__init__(DeviceType.OH1, address=address, 140 | control_handle=control_handle, 141 | data_handle=data_handle, 142 | callback=callback) 143 | -------------------------------------------------------------------------------- /polarpy/header_parser.py: -------------------------------------------------------------------------------- 1 | from .constants import Constants, ControlPointResponseType, ResponseOpCode, MeasurementType 2 | from .headers import FeaturesHeader, MeasurementSettingsHeader, StartMeasurementHeader, MeasurementPacketHeader 3 | 4 | from .stream_reader import StreamReader 5 | 6 | # todo foss: proper error handling 7 | 8 | 9 | class PacketHeaderParser: 10 | @staticmethod 11 | def parse(reader: StreamReader): 12 | if reader.EOF: 13 | print("unexpected EOF") 14 | return None 15 | 16 | response_type = reader.pull_int8() 17 | 18 | if response_type in iter(ControlPointResponseType): 19 | control_type_response_type = ControlPointResponseType( 20 | response_type) 21 | 22 | if ControlPointResponseType.FeatureRead == control_type_response_type: 23 | return FeaturesHeaderParser.parse(reader) 24 | 25 | if ControlPointResponseType.Response == control_type_response_type: 26 | opcode = ResponseOpCode(reader.pull_int8()) 27 | 28 | if ResponseOpCode.GetMeasurementSettings == opcode: 29 | return MeasurementSettingsHeaderParser.parse(reader) 30 | 31 | if ResponseOpCode.StartMeasurement == opcode: 32 | return StartMeasurementHeaderParser.parse(reader) 33 | 34 | print(f"unknown opcode {opcode}") 35 | 36 | return None 37 | 38 | measurement_type = MeasurementType(response_type) 39 | 40 | return MeasurementHeaderParser.parse(measurement_type, reader) 41 | 42 | 43 | class FeaturesHeaderParser: 44 | @ staticmethod 45 | def parse(reader: StreamReader) -> FeaturesHeader: 46 | return FeaturesHeader() 47 | 48 | 49 | class MeasurementSettingsHeaderParser: 50 | @ staticmethod 51 | def parse(reader: StreamReader) -> MeasurementSettingsHeader: 52 | measurement_type = MeasurementType(reader.pull_int8()) 53 | error_code = reader.pull_int8() 54 | more_frames = reader.pull_int8() != 0 55 | 56 | return MeasurementSettingsHeader(measurement_type=measurement_type, error_code=error_code, more_frames=more_frames) 57 | 58 | 59 | class StartMeasurementHeaderParser: 60 | @ staticmethod 61 | def parse(reader: StreamReader) -> StartMeasurementHeader: 62 | measurement_type = MeasurementType(reader.pull_int8()) 63 | error_code = reader.pull_int8() 64 | more_frames = reader.pull_int8() != 0 65 | 66 | return StartMeasurementHeader(measurement_type=measurement_type, error_code=error_code, more_frames=more_frames) 67 | 68 | 69 | class MeasurementHeaderParser: 70 | @ staticmethod 71 | def parse(measurement_type: MeasurementType, reader: StreamReader, period_us: int = 135) -> MeasurementPacketHeader: 72 | end_timestamp_us = reader.pull_timestamp() 73 | frame_type = reader.pull_int8() 74 | 75 | frame_size = Constants.frame_size(measurement_type, frame_type) 76 | if 0 == frame_size: 77 | print(f"unknown frame size {measurement_type.name}, {frame_type}") 78 | 79 | return None 80 | 81 | number_of_frames = reader._bytes_remaining / frame_size 82 | 83 | start_timestamp_us = end_timestamp_us - \ 84 | (number_of_frames - 1) * period_us 85 | 86 | return MeasurementPacketHeader( 87 | measurement_type=measurement_type, frame_type=frame_type, start_timestamp_us=start_timestamp_us, period_us=period_us) 88 | -------------------------------------------------------------------------------- /polarpy/headers.py: -------------------------------------------------------------------------------- 1 | from .constants import MeasurementType 2 | 3 | 4 | class FeaturesHeader: 5 | pass 6 | 7 | 8 | class MeasurementSettingsHeader: 9 | def __init__(self, measurement_type: MeasurementType, error_code: int = 0, more_frames: bool = False): 10 | self.measurement_type = measurement_type 11 | self.error_code = error_code 12 | self.more_frames = more_frames 13 | 14 | 15 | class StartMeasurementHeader: 16 | def __init__(self, measurement_type: MeasurementType, error_code: int = 0, more_frames: bool = False): 17 | self.measurement_type = measurement_type 18 | 19 | 20 | class MeasurementPacketHeader: 21 | def __init__(self, measurement_type: MeasurementType, frame_type: int, start_timestamp_us: int, period_us: int): 22 | self.measurement_type = measurement_type 23 | self.frame_type = frame_type 24 | self.start_timestamp_us = start_timestamp_us 25 | self.period_us = period_us 26 | 27 | # self.error = 0 28 | # self.end_timestamp_us = 0 29 | # self.frame_length_bytes = 0 30 | # self.number_of_frames = 0 31 | -------------------------------------------------------------------------------- /polarpy/measurement_parser.py: -------------------------------------------------------------------------------- 1 | from .constants import Constants, PPGFrameType, ACCFrameType, MeasurementType 2 | from .callbacks import Callbacks 3 | 4 | from .stream_reader import StreamReader 5 | from .headers import MeasurementPacketHeader, MeasurementSettingsHeader 6 | from .settings import StreamSettings 7 | 8 | # todo foss: proper error handling 9 | 10 | 11 | class MeasurementParser: 12 | def __init__(self, header: MeasurementPacketHeader, reader: StreamReader, settings: StreamSettings, callbacks: Callbacks): 13 | self._header = header 14 | self._reader = reader 15 | self._settings = settings 16 | self._callbacks = callbacks 17 | 18 | def parse_PPG_24bit(self): 19 | timestamp_us = self._header.start_timestamp_us 20 | period_us = Constants.sample_period_us(self._settings.PPG_sample_rate) 21 | 22 | while not self._reader.EOF: 23 | ppg0 = self._reader.pull_int22() 24 | ppg1 = self._reader.pull_int22() 25 | ppg2 = self._reader.pull_int22() 26 | ambient = self._reader.pull_int22() 27 | 28 | if self._callbacks.on_measurement: 29 | payload = (int(timestamp_us), ppg0, ppg1, ppg2, ambient,) 30 | self._callbacks.on_measurement( 31 | MeasurementType.PPG, payload) 32 | 33 | timestamp_us += period_us 34 | 35 | self._final_timestamp_us = timestamp_us - period_us 36 | 37 | return True 38 | 39 | def parse_PPG(self): 40 | if PPGFrameType.PPGFrameType24 == self._header.frame_type: 41 | rv = self.parse_PPG_24bit() 42 | else: 43 | print(f"unknown PPG frame type {self._header.frame_type}") 44 | rv = False 45 | 46 | return rv 47 | 48 | def parse_ACC_16bit(self): 49 | timestamp_us = self._header.start_timestamp_us 50 | period_us = Constants.sample_period_us(self._settings.ACC_sample_rate) 51 | 52 | while not self._reader.EOF: 53 | x = self._reader.pull_int16() 54 | y = self._reader.pull_int16() 55 | z = self._reader.pull_int16() 56 | 57 | if self._callbacks.on_measurement: 58 | payload = (int(timestamp_us), x, y, z,) 59 | self._callbacks.on_measurement( 60 | MeasurementType.ACC, payload) 61 | 62 | timestamp_us += period_us 63 | 64 | self._final_timestamp_us = timestamp_us - period_us 65 | 66 | return True 67 | 68 | def parse_ACC(self): 69 | if ACCFrameType.ACCFrameType16 == self._header.frame_type: 70 | rv = self.parse_ACC_16bit() 71 | else: 72 | print(f"unknown ACC frame type {self._header.frame_type}") 73 | rv = False 74 | 75 | return rv 76 | 77 | def _parse(self): 78 | if MeasurementType.PPG == self._header.measurement_type: 79 | return self.parse_PPG() 80 | 81 | if MeasurementType.ACC == self._header.measurement_type: 82 | return self.parse_ACC() 83 | 84 | print(f"unknown measurement type {self._header.measurement_type}") 85 | 86 | return False 87 | 88 | @staticmethod 89 | def parse(header: MeasurementSettingsHeader, reader: StreamReader, settings: StreamSettings, callbacks): 90 | parser = MeasurementParser(header, reader, settings, callbacks) 91 | return parser._parse() 92 | -------------------------------------------------------------------------------- /polarpy/measurement_settings_parser.py: -------------------------------------------------------------------------------- 1 | from .constants import MeasurementType 2 | from .header_parser import MeasurementSettingsHeader 3 | from .stream_reader import StreamReader 4 | 5 | # todo foss: proper error handling 6 | 7 | 8 | class MeasurementSettingsParser: 9 | def __init__(self, header: MeasurementSettingsHeader, reader: StreamReader): 10 | self._header = header 11 | self._reader = reader 12 | 13 | def parse_sample_rate(self): 14 | array_length = self._reader.pull_int8() 15 | 16 | for i in range(array_length): 17 | self._reader.pull_int16() 18 | 19 | return True 20 | 21 | def parse_resolution(self): 22 | array_length = self._reader.pull_int8() 23 | 24 | for i in range(array_length): 25 | self._reader.pull_int16() 26 | 27 | return True 28 | 29 | def parse_range(self): 30 | array_length = self._reader.pull_int8() 31 | 32 | for i in range(array_length): 33 | self._reader.pull_int16() 34 | 35 | return True 36 | 37 | def parse_setting(self): 38 | setting_type = self._reader.pull_int8() 39 | 40 | if 0x00 == setting_type: 41 | return self.parse_sample_rate() 42 | 43 | if 0x01 == setting_type: 44 | return self.parse_resolution() 45 | 46 | if 0x02 == setting_type: 47 | return self.parse_range() 48 | 49 | print(f"bad setting type {setting_type}") 50 | 51 | return False 52 | 53 | def parse_PPG_settings_response(self): 54 | while not self._reader.EOF: 55 | self.parse_setting() 56 | 57 | return True 58 | 59 | def parse_ACC_settings_response(self): 60 | while not self._reader.EOF: 61 | self.parse_setting() 62 | 63 | return True 64 | 65 | def _parse(self): 66 | measurement_type = self._header.measurement_type 67 | 68 | if MeasurementType.ECG == measurement_type: 69 | return self.parse_ECG_settings_response() 70 | 71 | if MeasurementType.PPG == measurement_type: 72 | return self.parse_PPG_settings_response() 73 | 74 | if MeasurementType.ACC == measurement_type: 75 | return self.parse_ACC_settings_response() 76 | 77 | if MeasurementType.PPI == measurement_type: 78 | return self.parse_PPI_settings_response() 79 | 80 | if MeasurementType.GYRO == measurement_type: 81 | return self.parse_GYRO_settings_response() 82 | 83 | if MeasurementType.MAG == measurement_type: 84 | return self.parse_MAG_settings_response() 85 | 86 | print(f"bad measurement type {measurement_type.name}") 87 | 88 | return False 89 | 90 | @staticmethod 91 | def parse(header: MeasurementSettingsHeader, reader: StreamReader): 92 | parser = MeasurementSettingsParser(header=header, reader=reader) 93 | parser._parse() 94 | -------------------------------------------------------------------------------- /polarpy/parser.py: -------------------------------------------------------------------------------- 1 | from .callbacks import Callbacks 2 | from .measurement_parser import MeasurementParser 3 | from .measurement_settings_parser import MeasurementSettingsParser 4 | from .headers import FeaturesHeader, MeasurementSettingsHeader, StartMeasurementHeader, MeasurementPacketHeader 5 | from .header_parser import PacketHeaderParser 6 | from .stream_reader import StreamReader 7 | from .settings import StreamSettings 8 | 9 | # todo foss: proper error handling 10 | 11 | 12 | class Parser: 13 | def __init__(self, stream_settings: StreamSettings, callbacks: Callbacks): 14 | self._stream_settings = stream_settings 15 | self._callbacks = callbacks 16 | 17 | self.EOF = True 18 | self._epoch_us = 0 19 | self._bytes_remaining = 0 20 | self._reader = None 21 | 22 | def parse_features_packet(self, header: FeaturesHeader) -> bool: 23 | features = self._reader.pull_int8() 24 | 25 | # todo foss: something useful with this 26 | # print(f"ECG: {features & 0x01}") 27 | # print(f"PPG: {features & 0x02}") 28 | # print(f"ACC: {features & 0x04}") 29 | # print(f"PPI: {features & 0x08}") 30 | # print(f"RFU: {features & 0x10}") 31 | # print(f"GYRO: {features & 0x20}") 32 | # print(f"MAG: {features & 0x40}") 33 | 34 | return True 35 | 36 | def parse_measurement_settings_packet(self, header: MeasurementSettingsHeader): 37 | return MeasurementSettingsParser.parse(header, self._reader) 38 | 39 | def parse_start_measurement_packet(self, header: StartMeasurementHeader): 40 | reserved = self._reader.pull_int8() # todo foss: something 41 | 42 | return True 43 | 44 | def parse_stream(self): 45 | header = PacketHeaderParser.parse(self._reader) 46 | 47 | if isinstance(header, FeaturesHeader): 48 | return self.parse_features_packet(header) 49 | 50 | if isinstance(header, MeasurementSettingsHeader): 51 | return self.parse_measurement_settings_packet(header) 52 | 53 | if isinstance(header, StartMeasurementHeader): 54 | return self.parse_start_measurement_packet(header) 55 | 56 | if isinstance(header, MeasurementPacketHeader): 57 | return MeasurementParser.parse(header, self._reader, self._stream_settings, self._callbacks) 58 | 59 | print(f"unknown response type") 60 | 61 | return False 62 | 63 | def parse(self, data: str): 64 | self._reader = StreamReader( 65 | data, epoch_us=self._stream_settings.epoch_us) 66 | 67 | if self._reader.EOF: 68 | print("unexpected EOF 1") 69 | return False 70 | 71 | rv = self.parse_stream() 72 | 73 | if 0 == self._stream_settings.epoch_us: 74 | self._stream_settings.epoch_us = self._reader._epoch_us 75 | 76 | return rv 77 | -------------------------------------------------------------------------------- /polarpy/settings.py: -------------------------------------------------------------------------------- 1 | from .constants import SampleRateSetting 2 | 3 | 4 | class StreamSettings: 5 | def __init__(self): 6 | self.ACC_sample_rate = SampleRateSetting.SampleRateUnknown 7 | self.PPG_sample_rate = SampleRateSetting.SampleRateUnknown 8 | self.ECG_sample_rate = SampleRateSetting.SampleRateUnknown 9 | self.epoch_us = 0 10 | -------------------------------------------------------------------------------- /polarpy/stream_reader.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | 4 | class StreamReader: 5 | def __init__(self, data: str, epoch_us: int): 6 | parts = data.strip().split(' ') 7 | bytes = bytearray([int(s, 16) for s in parts]) 8 | 9 | self._data_len = len(bytes) 10 | self._bytes_remaining = self._data_len 11 | self._stream = BytesIO(bytes) 12 | self._read_next_byte() 13 | self._epoch_us = epoch_us 14 | 15 | self.EOF = False 16 | 17 | def _read_next_byte(self) -> int: 18 | next_byte = self._stream.read(1) 19 | self.EOF = 0 == len(next_byte) 20 | self._next_byte = -1 if self.EOF else next_byte[0] 21 | 22 | def _pull_byte(self): 23 | b = self._next_byte 24 | self._read_next_byte() 25 | self._bytes_remaining -= 1 26 | return b 27 | 28 | def pull_int8(self): 29 | return self._pull_byte() 30 | 31 | def pull_int16(self): 32 | l = self._pull_byte() 33 | h = self._pull_byte() 34 | v = l + (h << 8) 35 | if v >= 0x8000: 36 | v = -(0xffff - v) 37 | 38 | return v 39 | 40 | def pull_int22(self) -> int: 41 | l = self._pull_byte() 42 | m = self._pull_byte() 43 | h = self._pull_byte() & 0x3f 44 | 45 | v = (l & 0xff) + ((m & 0xff) << 8) + ((h & 0xff) << 16) & 0x3fffff 46 | 47 | if v >= 0x200000: 48 | v = -(0x3fffff - v) 49 | 50 | return v 51 | 52 | def pull_int64(self): 53 | d0 = self._pull_byte() 54 | d1 = self._pull_byte() 55 | d2 = self._pull_byte() 56 | d3 = self._pull_byte() 57 | d4 = self._pull_byte() 58 | d5 = self._pull_byte() 59 | d6 = self._pull_byte() 60 | d7 = self._pull_byte() 61 | v = d0 + (d1 << 8) + (d2 << 16) + (d3 << 24) + \ 62 | (d4 << 32) + (d5 << 40) + (d6 << 48) + (d7 << 56) 63 | 64 | return v 65 | 66 | def pull_timestamp(self) -> int: 67 | timestamp_us = self.pull_int64() / 1000 68 | 69 | if 0 == self._epoch_us: 70 | self._epoch_us = timestamp_us 71 | 72 | timestamp_us -= self._epoch_us 73 | 74 | return timestamp_us 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "polarpy" 3 | version = "0.1.1" 4 | description = "Tools for reading and fusing live data streams from Polar OH1 (PPG) and H10 (ECG) sensors." 5 | authors = ["wideopentech "] 6 | license = "MIT" 7 | readme = "README.md" 8 | classifiers = [ 9 | "Programming Language :: Python :: 3", 10 | "License :: OSI Approved :: MIT License", 11 | "Operating System :: POSIX :: Linux", 12 | ] 13 | repository = "https://github.com/wideopensource/polarpy" 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.8" 17 | pygatttool = "^0.1.1a3" 18 | 19 | 20 | [build-system] 21 | requires = ["poetry-core"] 22 | build-backend = "poetry.core.masonry.api" 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pexpect==4.8.0 ; python_version >= "3.8" and python_version < "4.0" 2 | ptyprocess==0.7.0 ; python_version >= "3.8" and python_version < "4.0" 3 | pygatttool==0.1.1a3 ; python_version >= "3.8" and python_version < "4.0" 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wideopensource/polarpy/7176492201f4114a4ca8f48de40dc573620c6c40/tests/__init__.py --------------------------------------------------------------------------------