polar-python 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polar_python/__init__.py +11 -0
- polar_python/constants.py +80 -0
- polar_python/device.py +171 -0
- polar_python/exceptions.py +58 -0
- polar_python/utils.py +165 -0
- polar_python-0.0.1.dist-info/LICENSE +21 -0
- polar_python-0.0.1.dist-info/METADATA +152 -0
- polar_python-0.0.1.dist-info/RECORD +10 -0
- polar_python-0.0.1.dist-info/WHEEL +5 -0
- polar_python-0.0.1.dist-info/top_level.txt +1 -0
polar_python/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
# UUIDs for Polar device characteristics
|
|
5
|
+
HEART_RATE_CHAR_UUID: str = "00002a37-0000-1000-8000-00805f9b34fb"
|
|
6
|
+
PMD_CONTROL_POINT_UUID: str = "FB005C81-02E7-F387-1CAD-8ACD2D8DF0C8"
|
|
7
|
+
PMD_DATA_UUID: str = "FB005C82-02E7-F387-1CAD-8ACD2D8DF0C8"
|
|
8
|
+
|
|
9
|
+
# PMD Measurement Types
|
|
10
|
+
PMD_MEASUREMENT_TYPES: List[str] = ["ECG", "PPG", "ACC", "PPI", "RFU", "GYRO", "MAG"]
|
|
11
|
+
|
|
12
|
+
# PMD Control Point Error Codes
|
|
13
|
+
PMD_CONTROL_POINT_ERROR_CODES: List[str] = [
|
|
14
|
+
"SUCCESS",
|
|
15
|
+
"ERROR INVALID OP CODE",
|
|
16
|
+
"ERROR INVALID MEASUREMENT TYPE",
|
|
17
|
+
"ERROR NOT SUPPORTED",
|
|
18
|
+
"ERROR INVALID LENGTH",
|
|
19
|
+
"ERROR INVALID PARAMETER",
|
|
20
|
+
"ERROR ALREADY IN STATE",
|
|
21
|
+
"ERROR INVALID RESOLUTION",
|
|
22
|
+
"ERROR INVALID SAMPLE RATE",
|
|
23
|
+
"ERROR INVALID RANGE",
|
|
24
|
+
"ERROR INVALID MTU",
|
|
25
|
+
"ERROR INVALID NUMBER OF CHANNELS",
|
|
26
|
+
"ERROR INVALID STATE",
|
|
27
|
+
"ERROR DEVICE IN CHARGER",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# PMD Control Operation Codes
|
|
31
|
+
PMD_CONTROL_OPERATION_CODE: dict = {"GET": 0x01, "START": 0x02, "STOP": 0x03}
|
|
32
|
+
|
|
33
|
+
# PMD Setting Types
|
|
34
|
+
PMD_SETTING_TYPES: List[str] = ["SAMPLE_RATE", "RESOLUTION", "RANGE", "RFU", "CHANNELS"]
|
|
35
|
+
|
|
36
|
+
# Timestamp Offset
|
|
37
|
+
TIMESTAMP_OFFSET: int = 946684800000000000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SettingType:
|
|
42
|
+
"""Represents a setting type with its array length and possible values."""
|
|
43
|
+
|
|
44
|
+
type: str
|
|
45
|
+
array_length: int
|
|
46
|
+
values: List[int]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class MeasurementSettings:
|
|
51
|
+
"""Represents measurement settings for a specific type."""
|
|
52
|
+
|
|
53
|
+
measurement_type: str
|
|
54
|
+
settings: List[SettingType]
|
|
55
|
+
error_code: Optional[str] = None
|
|
56
|
+
more_frames: Optional[bool] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ACCData:
|
|
61
|
+
"""Represents accelerometer data."""
|
|
62
|
+
|
|
63
|
+
timestamp: int
|
|
64
|
+
data: List[Tuple[int, int, int]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ECGData:
|
|
69
|
+
"""Represents ECG data."""
|
|
70
|
+
|
|
71
|
+
timestamp: int
|
|
72
|
+
data: List[int]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class HRData:
|
|
77
|
+
"""Represents heart rate data."""
|
|
78
|
+
|
|
79
|
+
heartrate: int
|
|
80
|
+
rr_intervals: List[float]
|
polar_python/device.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from bleak import BleakClient
|
|
3
|
+
from bleak.backends.device import BLEDevice
|
|
4
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
5
|
+
from typing import Union, Callable, List
|
|
6
|
+
|
|
7
|
+
from . import constants, exceptions, utils
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolarDevice:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
address_or_ble_device: Union[str, BLEDevice],
|
|
14
|
+
data_callback: Callable[
|
|
15
|
+
[Union[constants.ECGData, constants.ACCData]], None
|
|
16
|
+
] = None,
|
|
17
|
+
heartrate_callback: Callable[[constants.HRData], None] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Initialize the PolarDevice with a BLE address or device.
|
|
21
|
+
|
|
22
|
+
:param address_or_ble_device: The address or BLEDevice instance of the Polar device.
|
|
23
|
+
:param data_callback: Callback function to handle data streams.
|
|
24
|
+
:param heartrate_callback: Callback function to handle heart rate data.
|
|
25
|
+
"""
|
|
26
|
+
self.client = BleakClient(address_or_ble_device)
|
|
27
|
+
self._queue_pmd_control = asyncio.Queue()
|
|
28
|
+
self._data_callback = data_callback
|
|
29
|
+
self._heartrate_callback = heartrate_callback
|
|
30
|
+
|
|
31
|
+
async def connect(self) -> None:
|
|
32
|
+
"""Connect to the Polar device."""
|
|
33
|
+
try:
|
|
34
|
+
await self.client.connect()
|
|
35
|
+
await self.client.start_notify(
|
|
36
|
+
constants.PMD_CONTROL_POINT_UUID, self._handle_pmd_control
|
|
37
|
+
)
|
|
38
|
+
await self.client.start_notify(
|
|
39
|
+
constants.PMD_DATA_UUID, self._handle_pmd_data
|
|
40
|
+
)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise exceptions.ConnectionError(
|
|
43
|
+
f"Failed to connect to the Polar device: {str(e)}"
|
|
44
|
+
) from e
|
|
45
|
+
|
|
46
|
+
async def disconnect(self) -> None:
|
|
47
|
+
"""Disconnect from the Polar device."""
|
|
48
|
+
try:
|
|
49
|
+
await self.client.disconnect()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise exceptions.DisconnectionError(
|
|
52
|
+
f"Failed to disconnect from the Polar device: {str(e)}"
|
|
53
|
+
) from e
|
|
54
|
+
|
|
55
|
+
async def __aenter__(self):
|
|
56
|
+
"""Support for async context management."""
|
|
57
|
+
await self.connect()
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
61
|
+
"""Support for async context management."""
|
|
62
|
+
await self.disconnect()
|
|
63
|
+
|
|
64
|
+
async def available_features(self) -> List[str]:
|
|
65
|
+
"""Retrieve available features from the Polar device."""
|
|
66
|
+
try:
|
|
67
|
+
data = await self.client.read_gatt_char(constants.PMD_CONTROL_POINT_UUID)
|
|
68
|
+
if data[0] != 0x0F:
|
|
69
|
+
raise exceptions.ControlPointResponseError(
|
|
70
|
+
"Unexpected response from the control point"
|
|
71
|
+
)
|
|
72
|
+
features = data[1]
|
|
73
|
+
bitmap = utils.byte_to_bitmap(features)
|
|
74
|
+
return [
|
|
75
|
+
constants.PMD_MEASUREMENT_TYPES[int(index)]
|
|
76
|
+
for index, bit in enumerate(bitmap)
|
|
77
|
+
if bit
|
|
78
|
+
]
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise exceptions.ReadCharacteristicError(
|
|
81
|
+
f"Failed to read available features: {str(e)}"
|
|
82
|
+
) from e
|
|
83
|
+
|
|
84
|
+
async def request_stream_settings(
|
|
85
|
+
self, measurement_type: str
|
|
86
|
+
) -> constants.MeasurementSettings:
|
|
87
|
+
"""Request stream settings for a specific measurement type."""
|
|
88
|
+
try:
|
|
89
|
+
await self.client.write_gatt_char(
|
|
90
|
+
constants.PMD_CONTROL_POINT_UUID,
|
|
91
|
+
bytearray(
|
|
92
|
+
[
|
|
93
|
+
constants.PMD_CONTROL_OPERATION_CODE["GET"],
|
|
94
|
+
constants.PMD_MEASUREMENT_TYPES.index(measurement_type),
|
|
95
|
+
]
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
return utils.parse_pmd_data(await self._queue_pmd_control.get())
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise exceptions.StreamSettingsError(
|
|
101
|
+
f"Failed to request stream settings for {measurement_type}: {str(e)}"
|
|
102
|
+
) from e
|
|
103
|
+
|
|
104
|
+
async def start_stream(self, settings: constants.MeasurementSettings) -> None:
|
|
105
|
+
"""Start data stream with specified settings."""
|
|
106
|
+
try:
|
|
107
|
+
data = utils.build_measurement_settings(settings)
|
|
108
|
+
await self.client.write_gatt_char(constants.PMD_CONTROL_POINT_UUID, data)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise exceptions.WriteCharacteristicError(
|
|
111
|
+
f"Failed to start stream with settings {settings}: {str(e)}"
|
|
112
|
+
) from e
|
|
113
|
+
|
|
114
|
+
async def stop_stream(self, measurement_type: str) -> None:
|
|
115
|
+
"""Stop data stream for a specific measurement type."""
|
|
116
|
+
try:
|
|
117
|
+
await self.client.write_gatt_char(
|
|
118
|
+
constants.PMD_CONTROL_POINT_UUID,
|
|
119
|
+
bytearray(
|
|
120
|
+
[
|
|
121
|
+
constants.PMD_CONTROL_OPERATION_CODE["STOP"],
|
|
122
|
+
constants.PMD_MEASUREMENT_TYPES.index(measurement_type),
|
|
123
|
+
]
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
raise exceptions.WriteCharacteristicError(
|
|
128
|
+
f"Failed to stop stream for {measurement_type}: {str(e)}"
|
|
129
|
+
) from e
|
|
130
|
+
|
|
131
|
+
async def start_heartrate_stream(self) -> None:
|
|
132
|
+
"""Start heart rate data stream."""
|
|
133
|
+
try:
|
|
134
|
+
await self.client.start_notify(
|
|
135
|
+
constants.HEART_RATE_CHAR_UUID, self._handle_heartrate_measurement
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise exceptions.NotificationError(
|
|
139
|
+
f"Failed to start heart rate stream: {str(e)}"
|
|
140
|
+
) from e
|
|
141
|
+
|
|
142
|
+
async def stop_heartrate_stream(self) -> None:
|
|
143
|
+
"""Stop heart rate data stream."""
|
|
144
|
+
try:
|
|
145
|
+
await self.client.stop_notify(constants.HEART_RATE_CHAR_UUID)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise exceptions.NotificationError(
|
|
148
|
+
f"Failed to stop heart rate stream: {str(e)}"
|
|
149
|
+
) from e
|
|
150
|
+
|
|
151
|
+
def _handle_pmd_control(
|
|
152
|
+
self, sender: BleakGATTCharacteristic, data: bytearray
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Handle PMD control notifications."""
|
|
155
|
+
self._queue_pmd_control.put_nowait(data)
|
|
156
|
+
|
|
157
|
+
def _handle_pmd_data(
|
|
158
|
+
self, sender: BleakGATTCharacteristic, data: bytearray
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Handle PMD data notifications."""
|
|
161
|
+
parsed_data = utils.parse_bluetooth_data(data)
|
|
162
|
+
if self._data_callback:
|
|
163
|
+
self._data_callback(parsed_data)
|
|
164
|
+
|
|
165
|
+
def _handle_heartrate_measurement(
|
|
166
|
+
self, sender: BleakGATTCharacteristic, data: bytearray
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Handle heart rate measurement notifications."""
|
|
169
|
+
parsed_data = utils.parse_heartrate_data(data)
|
|
170
|
+
if self._heartrate_callback:
|
|
171
|
+
self._heartrate_callback(parsed_data)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
class PolarPythonError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ControlPointResponseError(PolarPythonError):
|
|
6
|
+
"""Exception raised when there is an unexpected response from the control point."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message="Unexpected response from the control point"):
|
|
9
|
+
self.message = message
|
|
10
|
+
super().__init__(self.message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionError(PolarPythonError):
|
|
14
|
+
"""Exception raised when the device fails to connect."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message="Failed to connect to the Polar device"):
|
|
17
|
+
self.message = message
|
|
18
|
+
super().__init__(self.message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DisconnectionError(PolarPythonError):
|
|
22
|
+
"""Exception raised when the device fails to disconnect."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message="Failed to disconnect from the Polar device"):
|
|
25
|
+
self.message = message
|
|
26
|
+
super().__init__(self.message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotificationError(PolarPythonError):
|
|
30
|
+
"""Exception raised when notifications cannot be started or stopped."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message="Failed to start or stop notifications"):
|
|
33
|
+
self.message = message
|
|
34
|
+
super().__init__(self.message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ReadCharacteristicError(PolarPythonError):
|
|
38
|
+
"""Exception raised when a GATT characteristic cannot be read."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message="Failed to read GATT characteristic"):
|
|
41
|
+
self.message = message
|
|
42
|
+
super().__init__(self.message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WriteCharacteristicError(PolarPythonError):
|
|
46
|
+
"""Exception raised when a GATT characteristic cannot be written."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message="Failed to write GATT characteristic"):
|
|
49
|
+
self.message = message
|
|
50
|
+
super().__init__(self.message)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StreamSettingsError(PolarPythonError):
|
|
54
|
+
"""Exception raised when stream settings are invalid or cannot be set."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message="Invalid or failed to set stream settings"):
|
|
57
|
+
self.message = message
|
|
58
|
+
super().__init__(self.message)
|
polar_python/utils.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from typing import List, Tuple, Union
|
|
2
|
+
from . import constants
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def byte_to_bitmap(byte: int) -> List[bool]:
|
|
6
|
+
"""Convert a byte to a bitmap (list of booleans)."""
|
|
7
|
+
binary_string = f"{byte:08b}"
|
|
8
|
+
reversed_binary_string = binary_string[::-1]
|
|
9
|
+
return [bit == "1" for bit in reversed_binary_string]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_pmd_data(data: bytearray) -> constants.MeasurementSettings:
|
|
13
|
+
"""Parse PMD data from a bytearray."""
|
|
14
|
+
try:
|
|
15
|
+
measurement_type_index = data[2]
|
|
16
|
+
error_code_index = data[3]
|
|
17
|
+
more_frames = data[4] != 0
|
|
18
|
+
|
|
19
|
+
measurement_type = (
|
|
20
|
+
constants.PMD_MEASUREMENT_TYPES[measurement_type_index]
|
|
21
|
+
if measurement_type_index < len(constants.PMD_MEASUREMENT_TYPES)
|
|
22
|
+
else "UNKNOWN"
|
|
23
|
+
)
|
|
24
|
+
error_code = (
|
|
25
|
+
constants.PMD_CONTROL_POINT_ERROR_CODES[error_code_index]
|
|
26
|
+
if error_code_index < len(constants.PMD_CONTROL_POINT_ERROR_CODES)
|
|
27
|
+
else "UNKNOWN"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
settings = []
|
|
31
|
+
index = 5
|
|
32
|
+
while index < len(data):
|
|
33
|
+
setting_type_index = data[index]
|
|
34
|
+
setting_type = (
|
|
35
|
+
constants.PMD_SETTING_TYPES[setting_type_index]
|
|
36
|
+
if setting_type_index < len(constants.PMD_SETTING_TYPES)
|
|
37
|
+
else "UNKNOWN"
|
|
38
|
+
)
|
|
39
|
+
array_length = data[index + 1]
|
|
40
|
+
setting_values = [
|
|
41
|
+
int.from_bytes(data[index + 2 + 2 * i : index + 4 + 2 * i], "little")
|
|
42
|
+
for i in range(array_length)
|
|
43
|
+
]
|
|
44
|
+
settings.append(
|
|
45
|
+
constants.SettingType(
|
|
46
|
+
type=setting_type, array_length=array_length, values=setting_values
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
index += 2 + 2 * array_length
|
|
50
|
+
|
|
51
|
+
return constants.MeasurementSettings(
|
|
52
|
+
measurement_type=measurement_type,
|
|
53
|
+
error_code=error_code,
|
|
54
|
+
more_frames=more_frames,
|
|
55
|
+
settings=settings,
|
|
56
|
+
)
|
|
57
|
+
except IndexError as e:
|
|
58
|
+
raise ValueError("Failed to parse PMD data: insufficient data length") from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_measurement_settings(
|
|
62
|
+
measurement_settings: constants.MeasurementSettings,
|
|
63
|
+
) -> bytearray:
|
|
64
|
+
"""Build a bytearray from measurement settings."""
|
|
65
|
+
data = bytearray()
|
|
66
|
+
data.append(constants.PMD_CONTROL_OPERATION_CODE["START"])
|
|
67
|
+
|
|
68
|
+
measurement_type_index = constants.PMD_MEASUREMENT_TYPES.index(
|
|
69
|
+
measurement_settings.measurement_type
|
|
70
|
+
)
|
|
71
|
+
data.append(measurement_type_index)
|
|
72
|
+
|
|
73
|
+
for setting in measurement_settings.settings:
|
|
74
|
+
setting_type_index = constants.PMD_SETTING_TYPES.index(setting.type)
|
|
75
|
+
data.append(setting_type_index)
|
|
76
|
+
data.append(setting.array_length)
|
|
77
|
+
for value in setting.values:
|
|
78
|
+
data.extend(value.to_bytes(2, "little"))
|
|
79
|
+
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_ecg_data(data: List[int], timestamp: int) -> constants.ECGData:
|
|
84
|
+
"""Parse ECG data from a list of integers."""
|
|
85
|
+
ecg_data = [
|
|
86
|
+
int.from_bytes(data[i : i + 3], byteorder="little", signed=True)
|
|
87
|
+
for i in range(10, len(data), 3)
|
|
88
|
+
]
|
|
89
|
+
return constants.ECGData(timestamp=timestamp, data=ecg_data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_acc_data(
|
|
93
|
+
data: List[int], timestamp: int, frame_type: int
|
|
94
|
+
) -> constants.ACCData:
|
|
95
|
+
"""Parse accelerometer data from a list of integers based on frame type."""
|
|
96
|
+
acc_data = []
|
|
97
|
+
if frame_type == 0x00:
|
|
98
|
+
acc_data = [
|
|
99
|
+
(
|
|
100
|
+
int.from_bytes(data[i : i + 1], byteorder="little", signed=True),
|
|
101
|
+
int.from_bytes(data[i + 1 : i + 2], byteorder="little", signed=True),
|
|
102
|
+
int.from_bytes(data[i + 2 : i + 3], byteorder="little", signed=True),
|
|
103
|
+
)
|
|
104
|
+
for i in range(10, len(data), 3)
|
|
105
|
+
]
|
|
106
|
+
elif frame_type == 0x01:
|
|
107
|
+
acc_data = [
|
|
108
|
+
(
|
|
109
|
+
int.from_bytes(data[i : i + 2], byteorder="little", signed=True),
|
|
110
|
+
int.from_bytes(data[i + 2 : i + 4], byteorder="little", signed=True),
|
|
111
|
+
int.from_bytes(data[i + 4 : i + 6], byteorder="little", signed=True),
|
|
112
|
+
)
|
|
113
|
+
for i in range(10, len(data), 6)
|
|
114
|
+
]
|
|
115
|
+
elif frame_type == 0x02:
|
|
116
|
+
acc_data = [
|
|
117
|
+
(
|
|
118
|
+
int.from_bytes(data[i : i + 3], byteorder="little", signed=True),
|
|
119
|
+
int.from_bytes(data[i + 3 : i + 6], byteorder="little", signed=True),
|
|
120
|
+
int.from_bytes(data[i + 6 : i + 9], byteorder="little", signed=True),
|
|
121
|
+
)
|
|
122
|
+
for i in range(10, len(data), 9)
|
|
123
|
+
]
|
|
124
|
+
return constants.ACCData(timestamp=timestamp, data=acc_data)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_bluetooth_data(
|
|
128
|
+
data: List[int],
|
|
129
|
+
) -> Union[constants.ECGData, constants.ACCData]:
|
|
130
|
+
"""Parse Bluetooth data and return the appropriate data type."""
|
|
131
|
+
try:
|
|
132
|
+
data_type_index = data[0]
|
|
133
|
+
data_type = constants.PMD_MEASUREMENT_TYPES[data_type_index]
|
|
134
|
+
timestamp = (
|
|
135
|
+
int.from_bytes(data[1:9], byteorder="little") + constants.TIMESTAMP_OFFSET
|
|
136
|
+
)
|
|
137
|
+
frame_type = data[9]
|
|
138
|
+
|
|
139
|
+
if data_type == "ECG":
|
|
140
|
+
return parse_ecg_data(data, timestamp)
|
|
141
|
+
elif data_type == "ACC":
|
|
142
|
+
return parse_acc_data(data, timestamp, frame_type)
|
|
143
|
+
else:
|
|
144
|
+
raise ValueError(f"Unsupported data type: {data_type}")
|
|
145
|
+
except IndexError as e:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"Failed to parse Bluetooth data: insufficient data length"
|
|
148
|
+
) from e
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_heartrate_data(data: bytearray) -> constants.HRData:
|
|
152
|
+
"""Parse heart rate data from a bytearray."""
|
|
153
|
+
try:
|
|
154
|
+
heartrate = int.from_bytes(data[1:2], byteorder="little", signed=False)
|
|
155
|
+
rr_intervals = [
|
|
156
|
+
int.from_bytes(data[i : i + 2], byteorder="little", signed=False)
|
|
157
|
+
/ 1024.0
|
|
158
|
+
* 1024.0
|
|
159
|
+
for i in range(2, len(data), 2)
|
|
160
|
+
]
|
|
161
|
+
return constants.HRData(heartrate, rr_intervals)
|
|
162
|
+
except IndexError as e:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
"Failed to parse heart rate data: insufficient data length"
|
|
165
|
+
) from e
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Zachary Liu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: polar-python
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: polar-python is a Python library for connecting to Polar devices via Bluetooth Low Energy (BLE) using Bleak. It allows querying device capabilities (e.g., ECG, ACC, PPG), exploring configurable options, and streaming parsed data through callback functions.
|
|
5
|
+
Home-page: https://github.com/zHElEARN/polar-python
|
|
6
|
+
Author: Zhe_Learn
|
|
7
|
+
Author-email: personal@zhelearn.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.6
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: bleak
|
|
19
|
+
|
|
20
|
+
# polar-python
|
|
21
|
+
|
|
22
|
+
`polar-python` is a Python library designed for seamless integration with Polar devices using Bluetooth Low Energy (BLE) through the Bleak library. With `polar-python`, you can easily connect to Polar devices, query supported functionalities such as ECG, ACC, and PPG, explore configurable options and their possible values, and start data streaming to receive parsed binary data through callback functions.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Connect to Polar Devices**: Use BLE to connect to Polar devices.
|
|
27
|
+
- **Query Device Capabilities**: Discover supported functionalities like ECG, ACC, and PPG.
|
|
28
|
+
- **Explore Configurable Options**: Query and set measurement settings for each feature.
|
|
29
|
+
- **Stream Data**: Start data streaming and receive parsed binary data via callback functions.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Since `polar-python` is not yet available on PyPI, you can install it locally by following these steps:
|
|
34
|
+
|
|
35
|
+
1. Clone the repository:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
git clone https://github.com/yourusername/polar-python.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
2. Navigate to the project directory:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
cd polar-python
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. Install the package using pip:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pip install .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Below is an example of how to use `polar-python` to connect to a Polar device, query its features, set measurement settings, and start data streaming.
|
|
56
|
+
|
|
57
|
+
### Step 1: Import Libraries and Initialize Console
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from bleak import BleakScanner
|
|
62
|
+
from polar_python import PolarDevice, MeasurementSettings, SettingType, ECGData, ACCData
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Step 2: Define Data Callback Function
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
def data_callback(data: Union[ECGData, ACCData]):
|
|
69
|
+
"""
|
|
70
|
+
Callback function to handle incoming data from the Polar device.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
data (Union[ECGData, ACCData]): The data received from the Polar device.
|
|
74
|
+
"""
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Step 3: Define Main Function to Connect to Polar Device
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
async def main():
|
|
81
|
+
"""
|
|
82
|
+
Main function to connect to a Polar device, query its features,
|
|
83
|
+
set measurement settings, and start data streaming.
|
|
84
|
+
"""
|
|
85
|
+
# Find the Polar H10 device
|
|
86
|
+
device = await BleakScanner.find_device_by_filter(
|
|
87
|
+
lambda bd, ad: bd.name and "Polar H10" in bd.name, timeout=5
|
|
88
|
+
)
|
|
89
|
+
if device is None:
|
|
90
|
+
return
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Step 4: Connect to Polar Device and Query Features
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# Establish connection to the Polar device
|
|
97
|
+
async with PolarDevice(device, data_callback) as polar_device:
|
|
98
|
+
# Query available features
|
|
99
|
+
available_features = await polar_device.available_features()
|
|
100
|
+
|
|
101
|
+
# Query and print stream settings for each feature
|
|
102
|
+
for feature in available_features:
|
|
103
|
+
settings = await polar_device.request_stream_settings(feature)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Step 5: Define and Start Data Streams
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# Define ECG measurement settings
|
|
110
|
+
ecg_settings = MeasurementSettings(
|
|
111
|
+
measurement_type="ECG",
|
|
112
|
+
settings=[
|
|
113
|
+
SettingType(type="SAMPLE_RATE", array_length=1, values=[130]),
|
|
114
|
+
SettingType(type="RESOLUTION", array_length=1, values=[14]),
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Define ACC measurement settings
|
|
119
|
+
acc_settings = MeasurementSettings(
|
|
120
|
+
measurement_type="ACC",
|
|
121
|
+
settings=[
|
|
122
|
+
SettingType(type="SAMPLE_RATE", array_length=1, values=[25]),
|
|
123
|
+
SettingType(type="RESOLUTION", array_length=1, values=[16]),
|
|
124
|
+
SettingType(type="RANGE", array_length=1, values=[2]),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Start data streams for ECG and ACC
|
|
129
|
+
await polar_device.start_stream(ecg_settings)
|
|
130
|
+
await polar_device.start_stream(acc_settings)
|
|
131
|
+
|
|
132
|
+
# Keep the stream running for 120 seconds
|
|
133
|
+
await asyncio.sleep(120)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Step 6: Run the Main Function
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
asyncio.run(main())
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
146
|
+
|
|
147
|
+
## Acknowledgements
|
|
148
|
+
|
|
149
|
+
- [Bleak](https://github.com/hbldh/bleak) - BLE library for Python.
|
|
150
|
+
- [Rich](https://github.com/Textualize/rich) - Python library for rich text and beautiful formatting in the terminal.
|
|
151
|
+
- [bleakheart](https://github.com/fsmeraldi/bleakheart) - For providing inspiration and valuable insights.
|
|
152
|
+
- [Polar BLE SDK](https://github.com/polarofficial/polar-ble-sdk) - For providing official BLE SDK and documentation for Polar devices.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
polar_python/__init__.py,sha256=Fbic2HAqyswlP1NYVg8fYHX6s0ydpZuo3KEJBlMSpOE,238
|
|
2
|
+
polar_python/constants.py,sha256=-rgr_lJ2YjLCPx7ClI522oRMjdJ5o3f8uMODj3h3-u4,1947
|
|
3
|
+
polar_python/device.py,sha256=6kv29xO9NNXjj_y56TZR0dnW8MaFg-nA5gEtO_dSLrQ,6592
|
|
4
|
+
polar_python/exceptions.py,sha256=W_ijsakQDNqPmmFkA_xj6y7I2h38tvV0obpH5FaJvVQ,1907
|
|
5
|
+
polar_python/utils.py,sha256=VvaN_6fSRAlppjcRw8MghIY8rskWldpkWATJbhQi11g,5972
|
|
6
|
+
polar_python-0.0.1.dist-info/LICENSE,sha256=0GAl0AmKuCcFbLXj8gfjjdKOD_bLyyJFTwNG2VkG_E4,1068
|
|
7
|
+
polar_python-0.0.1.dist-info/METADATA,sha256=LZutQ_aZQ6rJyH2WYsHCaMv-NIfFrIX4vjt8NT7nH-E,5262
|
|
8
|
+
polar_python-0.0.1.dist-info/WHEEL,sha256=rWxmBtp7hEUqVLOnTaDOPpR-cZpCDkzhhcBce-Zyd5k,91
|
|
9
|
+
polar_python-0.0.1.dist-info/top_level.txt,sha256=Gj46AvJ0ljNKmSSJGdOcnm49yH391rcZW80AABcibhA,13
|
|
10
|
+
polar_python-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
polar_python
|