From 07b12ad8061cd0197b823af3f61c5a1598a1f0be Mon Sep 17 00:00:00 2001 From: Andrey Parfenov Date: Tue, 10 Nov 2026 00:55:17 +0100 Subject: [PATCH 1/2] Add Polar H10 BLE ECG and HR/RR acquisition with transport-mocked tests --- .../brainflow/board_controller_library.cs | 3 +- docs/SupportedBoards.rst | 62 +++ .../src/main/java/brainflow/BoardIds.java | 3 +- julia_package/brainflow/src/board_shim.jl | 1 + matlab_package/brainflow/BoardIds.m | 1 + nodejs_package/brainflow/brainflow.types.ts | 3 +- python_package/brainflow/board_shim.py | 1 + rust_package/brainflow/src/ffi/constants.rs | 1 + src/board_controller/ble_lib_board.cpp | 33 ++ src/board_controller/board_controller.cpp | 4 + src/board_controller/brainflow_boards.cpp | 23 ++ src/board_controller/build.cmake | 2 + src/board_controller/inc/ble_lib_board.h | 6 + src/board_controller/polar/inc/polar_h10.h | 50 +++ .../polar/inc/polar_protocol.h | 100 +++++ src/board_controller/polar/polar_h10.cpp | 386 ++++++++++++++++++ .../board_controller/polar_h10_unittest.cpp | 196 +++++++++ src/tests/build.cmake | 2 + src/utils/inc/brainflow_constants.h | 3 +- .../Sources/BrainFlow/BrainFlowEnums.swift | 1 + 20 files changed, 877 insertions(+), 4 deletions(-) create mode 100644 src/board_controller/polar/inc/polar_h10.h create mode 100644 src/board_controller/polar/inc/polar_protocol.h create mode 100644 src/board_controller/polar/polar_h10.cpp create mode 100644 src/tests/board_controller/polar_h10_unittest.cpp diff --git a/csharp_package/brainflow/brainflow/board_controller_library.cs b/csharp_package/brainflow/brainflow/board_controller_library.cs index 83d134a4c..8f0d64e0b 100644 --- a/csharp_package/brainflow/brainflow/board_controller_library.cs +++ b/csharp_package/brainflow/brainflow/board_controller_library.cs @@ -124,7 +124,8 @@ public enum BoardIds IRONBCI_32_BOARD = 65, NEUROPAWN_KNIGHT_BOARD_IMU = 66, MUSE_S_ATHENA_BOARD = 67, - SHIMMER3_BOARD = 68 + SHIMMER3_BOARD = 68, + POLAR_H10_BOARD = 73 }; diff --git a/docs/SupportedBoards.rst b/docs/SupportedBoards.rst index 533d8fda3..d0d06f475 100644 --- a/docs/SupportedBoards.rst +++ b/docs/SupportedBoards.rst @@ -1581,3 +1581,65 @@ Common sensor bits: - :code:`0x000008`, EXG2 24-bit - :code:`0x000004`, GSR, exposed as EDA - :code:`0x002000`, battery + +Polar H10 +--------- + +``BoardIds.POLAR_H10_BOARD`` (73) uses Bluetooth LE through SimpleBLE to acquire +H10 ECG at 130 Hz and Heart Rate Service notifications. Wear the strap to wake +it and enable skin contact. ``mac_address`` can select its BLE address; +``serial_number`` can select the device ID at the end of its advertised +``Polar H10 `` name (useful on macOS). Without either, the first matching +H10 found by adapter 0 is used. ``timeout`` controls discovery, PMD replies and +first-ECG waiting in seconds (default 5, maximum 60). Use a BrainFlow distribution +built with BLE support and grant the operating system's Bluetooth permissions. + +Preparation subscribes to PMD control indications (or notifications if advertised), +PMD data and HR notifications, then verifies the device offers 130 Hz / 14-bit +ECG. Acquisition waits for a successful PMD START reply and a valid ECG packet. +PMD errors are returned to the caller. H9, Verity Sense, compressed PMD frames, +accelerometry, offline recordings and ECG settings other than this profile are +not supported by this initial driver. + +The default preset contains ECG in microvolts. Its ``other_channels`` contains +the PMD device timestamp in seconds, interpolated from the packet's last sample. +The device clock is not assumed to be Unix time. The BrainFlow timestamp anchors +the last sample to host receive time, with samples spaced at 130 Hz. Battery is +a preparation-time snapshot in percent, or NaN when unavailable. The package +counter is generated by the host. Duplicate/non-increasing PMD timestamps are +ignored; reprepare the session if the device clock resets. + +The **auxiliary preset is irregular**, so its declared sampling rate is **0**. +Its ``other_channels`` contains heart rate (beats/minute), RR interval (milliseconds) +and contact state (0/1, or NaN if the device does not report contact). Every RR +interval is emitted separately, including multiple intervals in one notification. +A notification without RR values emits one row with NaN for RR. Timestamps are +host arrival estimates, backdated using subsequent RR intervals in that +notification. They are not synchronized beat timestamps. Do not pass the auxiliary +preset's zero sampling rate to uniform-sampling DSP routines. + +.. code-block:: python + + from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds, BrainFlowPresets + + params = BrainFlowInputParams() + params.serial_number = "YOUR_H10_DEVICE_ID" + board = BoardShim(BoardIds.POLAR_H10_BOARD, params) + board.prepare_session() + try: + board.start_stream() + # During recording: + ecg = board.get_board_data() + heart_rate = board.get_board_data(preset=BrainFlowPresets.AUXILIARY_PRESET) + board.stop_stream() + finally: + board.release_session() + +A control error disconnects the transport to avoid accepting a delayed response +as a later command's acknowledgement. Release and reprepare to reconnect. +Validation uses Polar's published ECG fixture, HR/RR parser tests and a fake +transport exercising the real driver's handshake, acquisition, errors, timeouts +and restarts. BLE discovery, subscription timing and a physical H10 still need +validation on each supported operating system. + +Reference: https://github.com/polarofficial/polar-ble-sdk diff --git a/java_package/brainflow/src/main/java/brainflow/BoardIds.java b/java_package/brainflow/src/main/java/brainflow/BoardIds.java index e6b24ab11..a1babb27f 100644 --- a/java_package/brainflow/src/main/java/brainflow/BoardIds.java +++ b/java_package/brainflow/src/main/java/brainflow/BoardIds.java @@ -74,7 +74,8 @@ public enum BoardIds IRONBCI_32_BOARD(65), NEUROPAWN_KNIGHT_BOARD_IMU(66), MUSE_S_ATHENA_BOARD(67), - SHIMMER3_BOARD(68); + SHIMMER3_BOARD(68), + POLAR_H10_BOARD(73); private final int board_id; private static final Map bi_map = new HashMap (); diff --git a/julia_package/brainflow/src/board_shim.jl b/julia_package/brainflow/src/board_shim.jl index b1e05f60c..f54a57530 100644 --- a/julia_package/brainflow/src/board_shim.jl +++ b/julia_package/brainflow/src/board_shim.jl @@ -70,6 +70,7 @@ export BrainFlowInputParams NEUROPAWN_KNIGHT_BOARD_IMU = 66 MUSE_S_ATHENA_BOARD = 67 SHIMMER3_BOARD = 68 + POLAR_H10_BOARD = 73 end diff --git a/matlab_package/brainflow/BoardIds.m b/matlab_package/brainflow/BoardIds.m index a30390909..4bb0b5c98 100644 --- a/matlab_package/brainflow/BoardIds.m +++ b/matlab_package/brainflow/BoardIds.m @@ -68,5 +68,6 @@ NEUROPAWN_KNIGHT_BOARD_IMU(66) MUSE_S_ATHENA_BOARD(67) SHIMMER3_BOARD(68) + POLAR_H10_BOARD(73) end end diff --git a/nodejs_package/brainflow/brainflow.types.ts b/nodejs_package/brainflow/brainflow.types.ts index 5aabeaed5..e0ae471f4 100644 --- a/nodejs_package/brainflow/brainflow.types.ts +++ b/nodejs_package/brainflow/brainflow.types.ts @@ -77,7 +77,8 @@ export enum BoardIds { IRONBCI_32_BOARD = 65, NEUROPAWN_KNIGHT_BOARD_IMU = 66, MUSE_S_ATHENA_BOARD = 67, - SHIMMER3_BOARD = 68 + SHIMMER3_BOARD = 68, + POLAR_H10_BOARD = 73 } export enum IpProtocolTypes { diff --git a/python_package/brainflow/board_shim.py b/python_package/brainflow/board_shim.py index e18211334..777a91b36 100644 --- a/python_package/brainflow/board_shim.py +++ b/python_package/brainflow/board_shim.py @@ -83,6 +83,7 @@ class BoardIds(enum.IntEnum): NEUROPAWN_KNIGHT_BOARD_IMU = 66 #: MUSE_S_ATHENA_BOARD = 67 #: SHIMMER3_BOARD = 68 #: + POLAR_H10_BOARD = 73 #: class IpProtocolTypes(enum.IntEnum): diff --git a/rust_package/brainflow/src/ffi/constants.rs b/rust_package/brainflow/src/ffi/constants.rs index 37d78ddfa..a923b1348 100644 --- a/rust_package/brainflow/src/ffi/constants.rs +++ b/rust_package/brainflow/src/ffi/constants.rs @@ -104,6 +104,7 @@ pub enum BoardIds { NeuropawnKnightBoardImu = 66, MuseSAthenaBoard = 67, Shimmer3Board = 68, + PolarH10Board = 73, } #[repr(i32)] #[derive(FromPrimitive, ToPrimitive, Debug, Copy, Clone, Hash, PartialEq, Eq)] diff --git a/src/board_controller/ble_lib_board.cpp b/src/board_controller/ble_lib_board.cpp index ecd275773..8d424c609 100644 --- a/src/board_controller/ble_lib_board.cpp +++ b/src/board_controller/ble_lib_board.cpp @@ -646,6 +646,39 @@ simpleble_err_t BLELibBoard::simpleble_peripheral_notify (simpleble_peripheral_t #endif } +simpleble_err_t BLELibBoard::simpleble_peripheral_indicate (simpleble_peripheral_t handle, + simpleble_uuid_t service, simpleble_uuid_t characteristic, + void (*callback) (simpleble_peripheral_t handle, simpleble_uuid_t service, + simpleble_uuid_t characteristic, const uint8_t *data, size_t data_length, void *userdata), + void *userdata) +{ +#ifndef STATIC_SIMPLEBLE + std::lock_guard lock (BLELibBoard::mutex); + if (BLELibBoard::dll_loader == NULL) + { + safe_logger (spdlog::level::err, "BLELibBoard::dll_loader is not initialized"); + return SIMPLEBLE_FAILURE; + } + simpleble_err_t (*func) (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, + void (*) (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, const uint8_t *, + size_t, void *), + void *) = (simpleble_err_t (*) (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, + void (*) (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, const uint8_t *, + size_t, void *), + void *))BLELibBoard::dll_loader->get_address ("simpleble_peripheral_indicate"); + if (func == NULL) + { + safe_logger ( + spdlog::level::err, "failed to get function address for simpleble_peripheral_indicate"); + return SIMPLEBLE_FAILURE; + } + + return func (handle, service, characteristic, callback, userdata); +#else + return ::simpleble_peripheral_indicate (handle, service, characteristic, callback, userdata); +#endif +} + simpleble_err_t BLELibBoard::simpleble_peripheral_unsubscribe ( simpleble_peripheral_t handle, simpleble_uuid_t service, simpleble_uuid_t characteristic) { diff --git a/src/board_controller/board_controller.cpp b/src/board_controller/board_controller.cpp index 612965994..f31fdf86f 100644 --- a/src/board_controller/board_controller.cpp +++ b/src/board_controller/board_controller.cpp @@ -53,6 +53,7 @@ #include "ntl_wifi.h" #include "pieeg_board.h" #include "playback_file_board.h" +#include "polar_h10.h" #include "shimmer3.h" #include "streaming_board.h" #include "synchroni_board.h" @@ -315,6 +316,9 @@ int prepare_session (int board_id, const char *json_brainflow_input_params) board = std::shared_ptr ( new KnightIMU ((int)BoardIds::NEUROPAWN_KNIGHT_BOARD_IMU, params)); break; + case BoardIds::POLAR_H10_BOARD: + board = std::shared_ptr (new PolarH10 (params)); + break; case BoardIds::SHIMMER3_BOARD: board = std::shared_ptr (new Shimmer3 (params)); break; diff --git a/src/board_controller/brainflow_boards.cpp b/src/board_controller/brainflow_boards.cpp index 9493e3f9a..3ba56db02 100644 --- a/src/board_controller/brainflow_boards.cpp +++ b/src/board_controller/brainflow_boards.cpp @@ -1217,6 +1217,29 @@ BrainFlowBoards::BrainFlowBoards() {"battery_channel", 16}, {"other_channels", {17}} }; + brainflow_boards_json["boards"]["73"]["default"] = + { + {"name", "PolarH10"}, + {"sampling_rate", 130}, + {"package_num_channel", 0}, + {"ecg_channels", {1}}, + {"other_channels", {2}}, + {"battery_channel", 3}, + {"timestamp_channel", 4}, + {"marker_channel", 5}, + {"num_rows", 6} + }; + brainflow_boards_json["boards"]["73"]["auxiliary"] = + { + {"name", "PolarH10HeartRate"}, + {"sampling_rate", 0}, // Irregular HR/RR events, not a uniformly sampled waveform. + {"package_num_channel", 0}, + {"other_channels", {1, 2, 3}}, + {"timestamp_channel", 4}, + {"marker_channel", 5}, + {"num_rows", 6} + }; + } BrainFlowBoards boards_struct; diff --git a/src/board_controller/build.cmake b/src/board_controller/build.cmake index f3fb0d790..308f1d71c 100644 --- a/src/board_controller/build.cmake +++ b/src/board_controller/build.cmake @@ -88,6 +88,7 @@ SET (BOARD_CONTROLLER_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/neuropawn/knight_base.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/neuropawn/knight_imu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/biolistener/biolistener.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/polar/polar_h10.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/shimmer/shimmer3.cpp ) @@ -157,6 +158,7 @@ target_include_directories ( ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/synchroni/inc ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/neuropawn/inc ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/biolistener/inc + ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/polar/inc ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/shimmer/inc ) diff --git a/src/board_controller/inc/ble_lib_board.h b/src/board_controller/inc/ble_lib_board.h index 52bd4c957..53756cfb2 100644 --- a/src/board_controller/inc/ble_lib_board.h +++ b/src/board_controller/inc/ble_lib_board.h @@ -58,6 +58,12 @@ class BLELibBoard : public Board simpleble_uuid_t characteristic, const uint8_t *data, size_t data_length, void *userdata), void *userdata); + simpleble_err_t simpleble_peripheral_indicate (simpleble_peripheral_t handle, + simpleble_uuid_t service, simpleble_uuid_t characteristic, + void (*callback) (simpleble_peripheral_t handle, simpleble_uuid_t service, + simpleble_uuid_t characteristic, const uint8_t *data, size_t data_length, + void *userdata), + void *userdata); simpleble_err_t simpleble_peripheral_unsubscribe ( simpleble_peripheral_t handle, simpleble_uuid_t service, simpleble_uuid_t characteristic); size_t simpleble_peripheral_manufacturer_data_count (simpleble_peripheral_t handle); diff --git a/src/board_controller/polar/inc/polar_h10.h b/src/board_controller/polar/inc/polar_h10.h new file mode 100644 index 000000000..309d1f073 --- /dev/null +++ b/src/board_controller/polar/inc/polar_h10.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include "ble_lib_board.h" + +class PolarH10 : public BLELibBoard +{ + simpleble_adapter_t adapter; + simpleble_peripheral_t peripheral; + std::vector discovered; + bool control_subscribed; + bool data_subscribed; + bool hr_subscribed; + bool initialized; + bool streaming; + bool accept_data; + bool ready; + bool awaiting; + uint8_t operation; + int control_result; + std::vector control_parameters; + uint32_t ecg_counter; + uint32_t hr_counter; + uint64_t last_timestamp; + double battery; + std::mutex mutex; + std::condition_variable received; + int transact (const std::vector &command, std::vector &response); + +protected: + // Transport seam: protocol/lifecycle tests override only these BLE operations. + virtual int connect_device (); + virtual bool write_control (const std::vector &command); + virtual void disconnect_device (); + void on_control (const uint8_t *data, size_t size); + void on_ecg (const uint8_t *data, size_t size); + void on_heart_rate (const uint8_t *data, size_t size); + +public: + explicit PolarH10 (struct BrainFlowInputParams params); + ~PolarH10 () override; + int prepare_session () override; + int start_stream (int buffer_size, const char *streamer_params) override; + int stop_stream () override; + int release_session () override; + int config_board (std::string config, std::string &response) override; +}; diff --git a/src/board_controller/polar/inc/polar_protocol.h b/src/board_controller/polar/inc/polar_protocol.h new file mode 100644 index 000000000..0768284c8 --- /dev/null +++ b/src/board_controller/polar/inc/polar_protocol.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include + +namespace polar +{ + inline uint16_t u16 (const uint8_t *data) + { + return uint16_t (data[0]) | (uint16_t (data[1]) << 8); + } + + struct Ecg + { + uint64_t timestamp; + std::vector samples; + }; + + inline bool ecg (const uint8_t *data, size_t size, Ecg &result) + { + if ((size < 13) || (data[0] != 0) || (data[9] != 0) || ((size - 10) % 3 != 0)) + return false; + Ecg pending; + pending.timestamp = 0; + for (size_t i = 0; i < 8; ++i) + pending.timestamp |= uint64_t (data[i + 1]) << (8 * i); + for (size_t i = 10; i < size; i += 3) + { + int32_t value = + int32_t (data[i]) | (int32_t (data[i + 1]) << 8) | (int32_t (data[i + 2]) << 16); + if (value & 0x800000) + value -= 0x1000000; + pending.samples.push_back (value); // Already microvolts, no ADC gain conversion. + } + result = pending; + return true; + } + + inline bool supports_ecg_profile (const std::vector &data) + { + bool rate = false, resolution = false; + size_t offset = 0; + // PMD setting widths, excluding security and unknown/new setting kinds. + const size_t widths[] = {2, 2, 2, 4, 1, 4}; + while (offset < data.size ()) + { + if (data.size () - offset < 2) + return false; + uint8_t type = data[offset++]; + size_t count = data[offset++]; + if ((type >= 6) || (count > (data.size () - offset) / widths[type])) + return false; + for (size_t i = 0; i < count; ++i, offset += widths[type]) + { + if ((type == 0) && (u16 (data.data () + offset) == 130)) + rate = true; + if ((type == 1) && (u16 (data.data () + offset) == 14)) + resolution = true; + } + } + return rate && resolution; + } + + struct HeartRate + { + double bpm; + double contact; + std::vector rr_ms; + }; + + inline bool heart_rate (const uint8_t *data, size_t size, HeartRate &result) + { + if (size < 2) + return false; + uint8_t flags = data[0]; + size_t offset = (flags & 1) ? 3 : 2; + if (size < offset) + return false; + HeartRate pending; + pending.bpm = (flags & 1) ? u16 (data + 1) : data[1]; + pending.contact = + (flags & 4) ? ((flags & 2) ? 1.0 : 0.0) : std::numeric_limits::quiet_NaN (); + if (flags & 8) + offset += 2; // Energy expended is not exposed by this profile. + if (offset > size) + return false; + if (flags & 16) + { + if ((size == offset) || ((size - offset) % 2 != 0)) + return false; + for (; offset < size; offset += 2) + pending.rr_ms.push_back (u16 (data + offset) * (1000.0 / 1024.0)); + } + if (offset != size) + return false; + result = pending; + return true; + } +} diff --git a/src/board_controller/polar/polar_h10.cpp b/src/board_controller/polar/polar_h10.cpp new file mode 100644 index 000000000..37bc6918e --- /dev/null +++ b/src/board_controller/polar/polar_h10.cpp @@ -0,0 +1,386 @@ +#include "polar_h10.h" +#include "polar_protocol.h" +#include "timestamp.h" + +#include +#include +#include +#include +#include + +namespace +{ + const simpleble_uuid_t pmd_service = {"fb005c80-02e7-f387-1cad-8acd2d8df0c8"}; + const simpleble_uuid_t pmd_control = {"fb005c81-02e7-f387-1cad-8acd2d8df0c8"}; + const simpleble_uuid_t pmd_data = {"fb005c82-02e7-f387-1cad-8acd2d8df0c8"}; + const simpleble_uuid_t hr_service = {"0000180d-0000-1000-8000-00805f9b34fb"}; + const simpleble_uuid_t hr_data = {"00002a37-0000-1000-8000-00805f9b34fb"}; + const simpleble_uuid_t battery_service = {"0000180f-0000-1000-8000-00805f9b34fb"}; + const simpleble_uuid_t battery_level = {"00002a19-0000-1000-8000-00805f9b34fb"}; + + std::string lower (std::string value) + { + std::transform (value.begin (), value.end (), value.begin (), + [] (unsigned char c) { return char (std::tolower (c)); }); + return value; + } +} + +PolarH10::PolarH10 (struct BrainFlowInputParams params) + : BLELibBoard ((int)BoardIds::POLAR_H10_BOARD, params) + , adapter (nullptr) + , peripheral (nullptr) + , control_subscribed (false) + , data_subscribed (false) + , hr_subscribed (false) + , initialized (false) + , streaming (false) + , accept_data (false) + , ready (false) + , awaiting (false) + , operation (0) + , control_result (-1) + , ecg_counter (0) + , hr_counter (0) + , last_timestamp (0) + , battery (std::numeric_limits::quiet_NaN ()) +{ +} + +PolarH10::~PolarH10 () +{ + skip_logs = true; + release_session (); +} + +int PolarH10::connect_device () +{ + if (!init_dll_loader () || (simpleble_adapter_get_count () == 0)) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + adapter = simpleble_adapter_get_handle (0); + if (!adapter) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + auto found = [] (simpleble_adapter_t, simpleble_peripheral_t candidate, void *context) + { + auto self = static_cast (context); + std::lock_guard lock (self->mutex); + // No BLE calls here: scan_for holds BLELibBoard's loader mutex. + self->discovered.push_back (candidate); + }; + if (simpleble_adapter_set_callback_on_scan_found (adapter, found, this) != SIMPLEBLE_SUCCESS || + simpleble_adapter_scan_for (adapter, params.timeout * 1000) != SIMPLEBLE_SUCCESS) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + std::vector candidates; + { + std::lock_guard lock (mutex); + candidates.swap (discovered); + } + for (auto candidate : candidates) + { + char *id = simpleble_peripheral_identifier (candidate); + char *address = simpleble_peripheral_address (candidate); + std::string name = id ? id : ""; + std::string mac = address ? address : ""; + simpleble_free (id); + simpleble_free (address); + bool match = name.find ("Polar H10 ") == 0 && + (params.mac_address.empty () || lower (params.mac_address) == lower (mac)) && + (params.serial_number.empty () || + lower (name.substr (10)) == lower (params.serial_number)); + if (match && !peripheral) + peripheral = candidate; + else + simpleble_peripheral_release_handle (candidate); + } + if (!peripheral || simpleble_peripheral_connect (peripheral) != SIMPLEBLE_SUCCESS) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + bool has_control = false, indicate = false, has_data = false, has_hr = false; + for (size_t i = 0; i < simpleble_peripheral_services_count (peripheral); ++i) + { + simpleble_service_t service; + if (simpleble_peripheral_services_get (peripheral, i, &service) != SIMPLEBLE_SUCCESS) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + for (size_t j = 0; j < service.characteristic_count; ++j) + { + const auto &characteristic = service.characteristics[j]; + std::string uuid = lower (characteristic.uuid.value); + if (lower (service.uuid.value) == pmd_service.value && uuid == pmd_control.value) + { + has_control = characteristic.can_write_request && + (characteristic.can_indicate || characteristic.can_notify); + indicate = characteristic.can_indicate; + } + if (lower (service.uuid.value) == pmd_service.value && uuid == pmd_data.value) + has_data = characteristic.can_notify; + if (lower (service.uuid.value) == hr_service.value && uuid == hr_data.value) + has_hr = characteristic.can_notify; + } + } + if (!has_control || !has_data || !has_hr) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + auto control_callback = [] (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, + const uint8_t *data, size_t size, void *context) + { static_cast (context)->on_control (data, size); }; + control_subscribed = (indicate ? simpleble_peripheral_indicate (peripheral, pmd_service, + pmd_control, control_callback, this) : + simpleble_peripheral_notify (peripheral, pmd_service, + pmd_control, control_callback, this)) == SIMPLEBLE_SUCCESS; + if (!control_subscribed) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + data_subscribed = simpleble_peripheral_notify ( + peripheral, pmd_service, pmd_data, + [] (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, + const uint8_t *data, size_t size, void *context) + { static_cast (context)->on_ecg (data, size); }, + this) == SIMPLEBLE_SUCCESS; + hr_subscribed = simpleble_peripheral_notify ( + peripheral, hr_service, hr_data, + [] (simpleble_peripheral_t, simpleble_uuid_t, simpleble_uuid_t, + const uint8_t *data, size_t size, void *context) + { static_cast (context)->on_heart_rate (data, size); }, + this) == SIMPLEBLE_SUCCESS; + if (!data_subscribed || !hr_subscribed) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + uint8_t *level = nullptr; + size_t size = 0; + if (simpleble_peripheral_read (peripheral, battery_service, battery_level, &level, &size) == + SIMPLEBLE_SUCCESS) + { + if ((size == 1) && level && (level[0] <= 100)) + battery = level[0]; + } + simpleble_free (level); + return (int)BrainFlowExitCodes::STATUS_OK; +} + +bool PolarH10::write_control (const std::vector &command) +{ + auto bytes = command; + return simpleble_peripheral_write_request (peripheral, pmd_service, pmd_control, bytes.data (), + bytes.size ()) == SIMPLEBLE_SUCCESS; +} + +void PolarH10::disconnect_device () +{ + if (peripheral) + { + if (hr_subscribed) + simpleble_peripheral_unsubscribe (peripheral, hr_service, hr_data); + if (data_subscribed) + simpleble_peripheral_unsubscribe (peripheral, pmd_service, pmd_data); + if (control_subscribed) + simpleble_peripheral_unsubscribe (peripheral, pmd_service, pmd_control); + simpleble_peripheral_disconnect (peripheral); + simpleble_peripheral_release_handle (peripheral); + peripheral = nullptr; + } + hr_subscribed = data_subscribed = control_subscribed = false; + if (adapter) + { + simpleble_adapter_release_handle (adapter); + adapter = nullptr; + } + std::vector remaining; + { + std::lock_guard lock (mutex); + remaining.swap (discovered); + } + for (auto candidate : remaining) + simpleble_peripheral_release_handle (candidate); +} + +int PolarH10::transact (const std::vector &command, std::vector &response) +{ + { + std::lock_guard lock (mutex); + operation = command[0]; + awaiting = true; + control_result = -1; + control_parameters.clear (); + } + if (!write_control (command)) + { + std::lock_guard lock (mutex); + awaiting = false; + return (int)BrainFlowExitCodes::BOARD_WRITE_ERROR; + } + std::unique_lock lock (mutex); + if (!received.wait_for ( + lock, std::chrono::seconds (params.timeout), [this] { return !awaiting; })) + { + awaiting = false; + return (int)BrainFlowExitCodes::SYNC_TIMEOUT_ERROR; + } + if (control_result != 0) + { + safe_logger (spdlog::level::err, "Polar PMD command {} rejected with status {}", operation, + control_result); + return (int)BrainFlowExitCodes::BOARD_WRITE_ERROR; + } + response = control_parameters; + return (int)BrainFlowExitCodes::STATUS_OK; +} + +void PolarH10::on_control (const uint8_t *data, size_t size) +{ + std::lock_guard lock (mutex); + if (!awaiting || (size < 4) || (data[0] != 0xf0) || (data[1] != operation) || (data[2] != 0)) + return; + control_result = data[3]; + if (control_result == 0 && size >= 5) + { + if (control_parameters.size () + size - 5 > 2048 || data[4] > 1) + control_result = -2; + else + { + control_parameters.insert (control_parameters.end (), data + 5, data + size); + if (data[4] != 0) + return; + } + } + awaiting = false; + received.notify_all (); +} + +int PolarH10::prepare_session () +{ + if (initialized) + return (int)BrainFlowExitCodes::STATUS_OK; + if ((params.timeout < 0) || (params.timeout > 60)) + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + if (params.timeout == 0) + params.timeout = 5; + battery = std::numeric_limits::quiet_NaN (); + int result = connect_device (); + if (result == (int)BrainFlowExitCodes::STATUS_OK) + { + std::vector settings; + result = transact ({1, 0}, settings); + if (result == (int)BrainFlowExitCodes::STATUS_OK && !polar::supports_ecg_profile (settings)) + result = (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + } + if (result != (int)BrainFlowExitCodes::STATUS_OK) + disconnect_device (); + else + initialized = true; + return result; +} + +int PolarH10::start_stream (int buffer_size, const char *streamer_params) +{ + if (!initialized) + return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; + if (streaming) + return (int)BrainFlowExitCodes::STREAM_ALREADY_RUN_ERROR; + int result = prepare_for_acquisition (buffer_size, streamer_params); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + return result; + std::vector response; + result = transact ({2, 0, 0, 1, 130, 0, 1, 1, 14, 0}, response); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + disconnect_device (); + initialized = false; + return result; + } + streaming = true; + std::unique_lock lock (mutex); + ecg_counter = hr_counter = 0; + last_timestamp = 0; + ready = false; + accept_data = true; + bool success = + received.wait_for (lock, std::chrono::seconds (params.timeout), [this] { return ready; }); + lock.unlock (); + if (!success) + { + stop_stream (); + return (int)BrainFlowExitCodes::SYNC_TIMEOUT_ERROR; + } + return (int)BrainFlowExitCodes::STATUS_OK; +} + +void PolarH10::on_ecg (const uint8_t *data, size_t size) +{ + std::lock_guard lock (mutex); + if (!accept_data) + return; + polar::Ecg frame; + if (!polar::ecg (data, size, frame)) + return; + if (last_timestamp && frame.timestamp <= last_timestamp) + return; + last_timestamp = frame.timestamp; + double timestamp = get_timestamp (); + for (size_t i = 0; i < frame.samples.size (); ++i) + { + double offset = (frame.samples.size () - 1 - i) / 130.0; + double package[6] = {double (ecg_counter++), double (frame.samples[i]), + frame.timestamp / 1e9 - offset, battery, timestamp - offset, 0}; + push_package (package); + } + ready = true; + received.notify_all (); +} + +void PolarH10::on_heart_rate (const uint8_t *data, size_t size) +{ + std::lock_guard lock (mutex); + if (!accept_data) + return; + polar::HeartRate measurement; + if (!polar::heart_rate (data, size, measurement)) + return; + double timestamp = get_timestamp (); + if (measurement.rr_ms.empty ()) + measurement.rr_ms.push_back (std::numeric_limits::quiet_NaN ()); + double remaining = 0; + for (size_t i = 1; i < measurement.rr_ms.size (); ++i) + remaining += measurement.rr_ms[i] / 1000.0; + for (size_t i = 0; i < measurement.rr_ms.size (); ++i) + { + double package[6] = {double (hr_counter++), measurement.bpm, measurement.rr_ms[i], + measurement.contact, timestamp - remaining, 0}; + push_package (package, (int)BrainFlowPresets::AUXILIARY_PRESET); + if (i + 1 < measurement.rr_ms.size ()) + remaining -= measurement.rr_ms[i + 1] / 1000.0; + } +} + +int PolarH10::stop_stream () +{ + if (!streaming) + return (int)BrainFlowExitCodes::STREAM_THREAD_IS_NOT_RUNNING; + { + std::lock_guard lock (mutex); + accept_data = false; + } + std::vector response; + int result = transact ({3, 0}, response); + streaming = false; + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + disconnect_device (); + initialized = false; + } + return result; +} + +int PolarH10::release_session () +{ + if (streaming) + stop_stream (); + { + std::lock_guard lock (mutex); + accept_data = false; + } + disconnect_device (); + initialized = false; + free_packages (); + return (int)BrainFlowExitCodes::STATUS_OK; +} + +int PolarH10::config_board (std::string, std::string &) +{ + return (int)BrainFlowExitCodes::UNSUPPORTED_BOARD_ERROR; +} diff --git a/src/tests/board_controller/polar_h10_unittest.cpp b/src/tests/board_controller/polar_h10_unittest.cpp new file mode 100644 index 000000000..b255d2372 --- /dev/null +++ b/src/tests/board_controller/polar_h10_unittest.cpp @@ -0,0 +1,196 @@ +#include "polar_h10.h" +#include "polar_protocol.h" + +#include +#include +#include +#include +#include + +namespace +{ + const std::vector ecg_packet = { + 0, 0, 0x94, 0x35, 0x77, 0, 0, 0, 0, 0, 0x02, 0x80, 0xff, 0x02, 0x80, 0}; + + class FakePolar : public PolarH10 + { + std::atomic running; + std::thread producer; + + protected: + int connect_device () override + { + return 0; + } + void disconnect_device () override + { + running = false; + if (producer.joinable ()) + producer.join (); + } + bool write_control (const std::vector &command) override + { + if (drop_response) + return true; + if (command[0] == 1) + { + const uint8_t first[] = {0xf0, 1, 0, 0, 1, 0, 1, 130, 0}; + const uint8_t last[] = {0xf0, 1, 0, 0, 0, 1, 1, 14, 0}; + on_control (first, sizeof (first)); + on_control (last, sizeof (last)); + } + else + { + uint8_t unrelated[] = {0xf0, command[0], 2, 0}; + on_control ( + unrelated, sizeof (unrelated)); // Wrong measurement type must be ignored. + uint8_t response[] = {0xf0, command[0], 0, uint8_t (reject ? 8 : 0)}; + on_control (response, sizeof (response)); + if (command[0] == 3) + disconnect_device (); + else if (command[0] == 2 && !reject && produce) + { + const std::vector expected = {2, 0, 0, 1, 130, 0, 1, 1, 14, 0}; + EXPECT_EQ (command, expected); + running = true; + producer = std::thread ( + [this] + { + uint64_t timestamp = 2000000000; + while (running) + { + std::this_thread::sleep_for (std::chrono::milliseconds (10)); + auto packet = ecg_packet; + for (size_t i = 0; i < 8; ++i) + packet[i + 1] = uint8_t (timestamp >> (8 * i)); + const uint8_t hr[] = {0x16, 60, 0, 4, 0, 2}; + on_heart_rate (hr, sizeof (hr)); + on_ecg (packet.data (), packet.size ()); + timestamp += 1000000000; + } + }); + } + } + return true; + } + + public: + bool reject = false; + bool produce = true; + bool drop_response = false; + explicit FakePolar (BrainFlowInputParams params) : PolarH10 (params), running (false) + { + } + ~FakePolar () override + { + release_session (); + disconnect_device (); + } + void late_sample () + { + on_ecg (ecg_packet.data (), ecg_packet.size ()); + } + }; +} + +TEST (PolarH10, OfficialEcgFixtureAndMalformedFrames) +{ + polar::Ecg result; + ASSERT_TRUE (polar::ecg (ecg_packet.data (), ecg_packet.size (), result)); + ASSERT_EQ (result.samples.size (), 2u); + EXPECT_EQ (result.timestamp, 2000000000u); + EXPECT_EQ (result.samples[0], -32766); + EXPECT_EQ (result.samples[1], 32770); + for (size_t size = 0; size < 13; ++size) + EXPECT_FALSE (polar::ecg (ecg_packet.data (), size, result)); + EXPECT_FALSE (polar::ecg (ecg_packet.data (), 14, result)); + auto unsupported = ecg_packet; + unsupported[9] = 0x80; + EXPECT_FALSE (polar::ecg (unsupported.data (), unsupported.size (), result)); +} + +TEST (PolarH10, ParsesSettingsAndRejectsTruncation) +{ + EXPECT_TRUE (polar::supports_ecg_profile ({0, 1, 130, 0, 1, 1, 14, 0})); + EXPECT_FALSE (polar::supports_ecg_profile ({0, 1, 100, 0, 1, 1, 14, 0})); + EXPECT_FALSE (polar::supports_ecg_profile ({0, 1, 130, 0, 1, 1, 14})); + EXPECT_FALSE (polar::supports_ecg_profile ({0, 1, 130, 0, 1, 1, 14, 0, 0xff, 1})); +} + +TEST (PolarH10, PreservesEveryRrIntervalAndOptionalHrFields) +{ + polar::HeartRate result; + const uint8_t data[] = {0x1f, 0x2c, 1, 0, 0, 0, 4, 0, 2}; + ASSERT_TRUE (polar::heart_rate (data, sizeof (data), result)); + EXPECT_EQ (result.bpm, 300); + EXPECT_EQ (result.contact, 1); + EXPECT_EQ (result.rr_ms, (std::vector {1000, 500})); + EXPECT_FALSE (polar::heart_rate (data, sizeof (data) - 1, result)); + const uint8_t no_rr[] = {0, 60}; + ASSERT_TRUE (polar::heart_rate (no_rr, sizeof (no_rr), result)); + EXPECT_TRUE (result.rr_ms.empty ()); + EXPECT_TRUE (std::isnan (result.contact)); +} + +TEST (PolarH10, EmulatedHandshakeAcquisitionAndRestart) +{ + BrainFlowInputParams params; + params.timeout = 1; + FakePolar board (params); + ASSERT_EQ (board.prepare_session (), 0); + for (int iteration = 0; iteration < 2; ++iteration) + { + ASSERT_EQ (board.start_stream (128, ""), 0); + EXPECT_EQ (board.start_stream (128, ""), 8); + ASSERT_EQ (board.stop_stream (), 0); + int count = 0; + ASSERT_EQ (board.get_board_data_count (0, &count), 0); + ASSERT_GE (count, 2); + std::vector data (6 * count); + ASSERT_EQ (board.get_board_data (count, 0, data.data ()), 0); + EXPECT_EQ (data[count], -32766); + EXPECT_EQ (data[count + 1], 32770); + EXPECT_TRUE (std::isnan (data[3 * count])); + EXPECT_NEAR (data[4 * count + 1] - data[4 * count], 1.0 / 130, 1e-6); + ASSERT_EQ (board.get_board_data_count (1, &count), 0); + ASSERT_GE (count, 2); + data.resize (6 * count); + ASSERT_EQ (board.get_board_data (count, 1, data.data ()), 0); + EXPECT_EQ (data[count], 60); + EXPECT_EQ (data[2 * count], 1000); + EXPECT_EQ (data[2 * count + 1], 500); + EXPECT_NEAR (data[4 * count + 1] - data[4 * count], 0.5, 1e-6); + board.late_sample (); + ASSERT_EQ (board.get_board_data_count (0, &count), 0); + EXPECT_EQ (count, 0); + } + EXPECT_EQ (board.release_session (), 0); + EXPECT_EQ (board.prepare_session (), 0); + EXPECT_EQ (board.start_stream (128, ""), 0); + EXPECT_EQ (board.release_session (), 0); +} + +TEST (PolarH10, DataTimeoutAndControlRejectionRecoverCleanly) +{ + BrainFlowInputParams params; + params.timeout = 1; + FakePolar board (params); + ASSERT_EQ (board.prepare_session (), 0); + board.produce = false; + EXPECT_EQ (board.start_stream (128, ""), 18); + board.produce = true; + ASSERT_EQ (board.start_stream (128, ""), 0); + ASSERT_EQ (board.stop_stream (), 0); + board.reject = true; + EXPECT_EQ (board.start_stream (128, ""), 4); + EXPECT_EQ (board.start_stream (128, ""), 7); + board.reject = false; + ASSERT_EQ (board.prepare_session (), 0); + ASSERT_EQ (board.start_stream (128, ""), 0); + ASSERT_EQ (board.stop_stream (), 0); + ASSERT_EQ (board.release_session (), 0); + board.drop_response = true; + EXPECT_EQ (board.prepare_session (), 18); + board.drop_response = false; + EXPECT_EQ (board.prepare_session (), 0); +} diff --git a/src/tests/build.cmake b/src/tests/build.cmake index 37af0e0fe..6b67958c6 100644 --- a/src/tests/build.cmake +++ b/src/tests/build.cmake @@ -34,6 +34,7 @@ SET (TESTS_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/brainflow_boards.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/ble_lib_board.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/brainalive/brainalive.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/polar/polar_h10.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/file_streamer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/multicast_streamer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/plotjuggler_udp_streamer.cpp @@ -42,6 +43,7 @@ SET (TESTS_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/multicast_server.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/socket_client_udp.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/timestamp.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/tests/board_controller/polar_h10_unittest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/tests/board_controller/brainalive_unittest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/tests/utils/bluetooth/socket_bluetooth_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/tests/utils/bluetooth/bluetooth_functions_unittest.cpp diff --git a/src/utils/inc/brainflow_constants.h b/src/utils/inc/brainflow_constants.h index 8b31b0df2..e7ebbccb0 100644 --- a/src/utils/inc/brainflow_constants.h +++ b/src/utils/inc/brainflow_constants.h @@ -97,9 +97,10 @@ enum class BoardIds : int NEUROPAWN_KNIGHT_BOARD_IMU = 66, MUSE_S_ATHENA_BOARD = 67, SHIMMER3_BOARD = 68, + POLAR_H10_BOARD = 73, // use it to iterate FIRST = PLAYBACK_FILE_BOARD, - LAST = SHIMMER3_BOARD + LAST = POLAR_H10_BOARD }; enum class IpProtocolTypes : int diff --git a/swift_package/Sources/BrainFlow/BrainFlowEnums.swift b/swift_package/Sources/BrainFlow/BrainFlowEnums.swift index 9e98fa9a0..c6ef5f3c1 100644 --- a/swift_package/Sources/BrainFlow/BrainFlowEnums.swift +++ b/swift_package/Sources/BrainFlow/BrainFlowEnums.swift @@ -66,6 +66,7 @@ public enum BoardIds: Int, CaseIterable, Sendable { case NEUROPAWN_KNIGHT_BOARD_IMU = 66 case MUSE_S_ATHENA_BOARD = 67 case SHIMMER3_BOARD = 68 + case POLAR_H10_BOARD = 73 public var code: Int { rawValue } } From 5c4d80fdde9d1ef1673050fe769cd9b71abcaa02 Mon Sep 17 00:00:00 2001 From: Andrey Parfenov Date: Tue, 10 Nov 2026 01:05:17 +0100 Subject: [PATCH 2/2] Make protocol parser header independently includable --- src/board_controller/polar/inc/polar_protocol.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/board_controller/polar/inc/polar_protocol.h b/src/board_controller/polar/inc/polar_protocol.h index 0768284c8..966529188 100644 --- a/src/board_controller/polar/inc/polar_protocol.h +++ b/src/board_controller/polar/inc/polar_protocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include