There are some reliable and affordable Polar H10 ECG reader apps available on the App Store: I’ve been using one for a couple of years. However, I recently needed to incorporate an ECG capability into an app that already uses the Polar H10 for RR Interval monitoring, but the documentation online for Polar ECG is scarce and sometimes incorrect. Polar provides an SDK, but this covers many different devices and so is quite complex. Also, it’s based on RxSwift - which I prefer not to use given that my existing app uses native Swift async and concurrency approaches. I therefore offer this description of my solution in the hope that it helps someone, somewhere, sometime.
The Polar H10 transmits ECG data via Bluetooth LE as a stream of frames. Each frame is length 229 bytes, with a 10 byte leader and then 73 ECG data points of 3 bytes each (microvolts as little-endian integer, two’s complement negatives). The leader’s byte 0 is 0x00, bytes 1 - 8 are a timestamp (unknown epoch) and byte 9 is 0x00. The H10’s sampling rate is 130Hz (my 2 devices are a tiny fraction higher), which means that each frame is transmitted approximately every half second (73/130). However, given the latencies of bluetooth transmission and the app’s processing, any application of a timestamp to each data point should be based on a fixed time interval between each data point, i.e. milliseconds interval = 1000 / actual sampling rate. From my testing, the time interval between successive frame timestamps is constant and so the actual sampling interval is that interval divided by 73 (the number of samples per frame).
I’ve noticed, with both the 3rd party app and my own coding, that for about a second (sometimes more) the reported voltages are very high or low before settling to “normal” oscillation around the isoelectric line. This is especially true when the sensor electrode strap has only just been placed on the chest. To help overcome this, I use the Heart Rate service UUID “180D” and set notify on characteristic "2A37" to get the heart rate and RR interval data, of which the first byte contains flags including a sensor contact flag (2 bits - both set when sensor contact is OK, upon which I setNotifyValue on the ECG data characteristic to start frame delivery).
Having discovered your Polar H10, connected to it and discovered its services you need to discover the PMD Control Characteristic within the PMD Service then use it to request Streaming and to request the ECG stream (there are other streams). Once the requests have been accepted (didWriteValueFor Characteristic) then you start the Stream. Thereafter, frames are delivered by the delegate callback func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?)
for the characteristic.uuid == pmdDataUUID
The following code snippets, the key aspects of the solution, assume a working knowledge of CoreBluetooth. Also, decoding of data (code not provided) requires a knowledge of byte and bit-wise operations in Swift (or Objective-C).
// CBUUIDs and command data
let pmdServiceUUID = CBUUID.init( string:"FB005C80-02E7-F387-1CAD-8ACD2D8DF0C8" )
let pmdControlUUID = CBUUID.init( string:"FB005C81-02E7-F387-1CAD-8ACD2D8DF0C8" )
let pmdDataUUID = CBUUID.init( string:"FB005C82-02E7-F387-1CAD-8ACD2D8DF0C8" )
let reqStream = Data([0x01,0x02])
let reqECG = Data([0x01,0x00])
let startStream = Data([0x02, 0x00, 0x00, 0x01, 0x82, 0x00, 0x01, 0x01, 0x0E, 0x00])
// Request streaming of ECG data
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?)
if service.uuid == pmdServiceUUID {
for pmdChar in service.characteristics! {
if pmdChar.uuid == pmdControlUUID {
peripheral.setNotifyValue(true, for: pmdChar)
peripheral.writeValue(reqStream, for: pmdChar, type: .withResponse)
peripheral.writeValue(reqECG, for: pmdChar, type: .withResponse)
}
}
}
}
// Request delivery of ECG frames - actual delivery subject to setNotify value
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
// this responds to the reqStream and reqECG write values
if error != nil {
print("**** write error")
return
}
if ecgStreamStarted { return } // I use a flag to prevent extraneous stream start commands
guard let charVal = characteristic.value else { return }
if charVal[0] == 0xF0 && charVal[1] == 0x01 {
peripheral.writeValue(startStream, for: characteristic, type: .withResponse)
ecgStreamStarted = true
}
}
For “live” charting, I create an array of data points, appending each frame’s set on arrival, then provide those points to a SwiftUI View with a TimeLineView(.periodic(from: .now, by:actual sampling interval)) and using Path .addlines with the Y value scaled appropriately using GeometryReader. So far, I’ve found no way of cancelling such a TimeLineView period, so any suggestions are welcome on that one. An alternative approach is to refresh a SwiftUI Chart View on receipt and decoding of each frame, but this creates a stuttered appearance due to the approximately half-second interval between frames.
Regards, Michaela