Real-time Data Streaming from Medical IoT Devices
Medical IoT devices—portable ECGs (AliveCor KardiaMobile, Holter monitors), pulse oximeters (Nonin, Masimo), Bluetooth-capable glucose meters (Abbott Libre, Dexcom G7), blood pressure monitors (Omron, Withings)—operate using standard Bluetooth LE profiles defined by Bluetooth SIG or proprietary protocols. Developing a mobile client for such devices sits at the intersection of several rarely-combined requirements: real-time raw signal streaming, clinically accurate processing, and regulatory compliance (FDA 21 CFR Part 11, MDR in Europe, Roszdravnadzor requirements).
Standard Medical GATT Profiles
Bluetooth SIG defines profiles for compatible devices:
| Profile | UUID | Device |
|---|---|---|
| Health Thermometer (HTP) | 0x1809 |
Thermometers |
| Blood Pressure (BLP) | 0x1810 |
Blood pressure monitors |
| Pulse Oximeter (PLX) | 0x1822 |
Pulse oximeters |
| Glucose Profile (GLP) | 0x1808 |
Glucose meters |
| Continuous Glucose (CGP) | 0x181F |
CGM sensors (Libre, Dexcom) |
| ECG Profile | 0x1843 |
ECG devices |
Example parsing Blood Pressure Measurement (UUID 0x2A35):
func parseBloodPressure(_ data: Data) -> BloodPressureReading {
var offset = 0
let flags = data[offset]; offset += 1
let isMMHg = (flags & 0x01) == 0
let timestampPresent = (flags & 0x02) != 0
let pulseRatePresent = (flags & 0x04) != 0
// Values in IEEE-11073 SFLOAT format (16-bit)
let systolic = parseSFloat(high: data[offset + 1], low: data[offset])
offset += 2
let diastolic = parseSFloat(high: data[offset + 1], low: data[offset])
offset += 2
let map = parseSFloat(high: data[offset + 1], low: data[offset])
offset += 2
return BloodPressureReading(systolic: systolic, diastolic: diastolic,
meanArterialPressure: map, inMMHg: isMMHg)
}
// IEEE-11073 SFLOAT: 4-bit exponent + 12-bit mantissa
func parseSFloat(high: UInt8, low: UInt8) -> Double {
let rawValue = Int16(high) << 8 | Int16(low)
let exponent = Int(rawValue >> 12)
let mantissa = Int(rawValue & 0x0FFF)
let signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa
return Double(signedMantissa) * pow(10.0, Double(exponent))
}
IEEE-11073 SFLOAT is neither a standard float32 nor an int16 in units of 0.1. Confusion here leads to systolic blood pressure readings of "1270 mmHg" on screen—a classic error.
ECG Streaming: Buffer and MTU
Portable ECGs are the most demanding task. AliveCor KardiaMobile 6L outputs 12-channel ECG at 300 sps. Through standard BLE Notify (MTU 23 bytes = 20 bytes payload), bandwidth barely supports 1-channel ECG at 250 sps. For multi-channel, negotiate MTU 247+ bytes:
gatt.requestMtu(247)
// One ECG packet: timestamp(4) + 12 channels * 3 bytes = 40 bytes
// At MTU 247: ~5 frames per notification = 250 sps * 12 channels = 3000 values/sec
data class EcgPacket(
val timestamp: Long,
val samples: Array<IntArray>, // [channel][sample], signed 24-bit
)
fun parseEcgNotification(data: ByteArray): EcgPacket {
var offset = 0
val timestamp = ByteBuffer.wrap(data, offset, 4).int.also { offset += 4 }
val samples = Array(12) { IntArray(data.size / 36) } // 12 channels
var sampleIdx = 0
while (offset + 36 <= data.size) {
for (ch in 0..11) {
// 24-bit signed little-endian
val raw = (data[offset].toInt() and 0xFF) or
((data[offset + 1].toInt() and 0xFF) shl 8) or
((data[offset + 2].toInt()) shl 16)
samples[ch][sampleIdx] = raw
offset += 3
}
sampleIdx++
}
return EcgPacket(timestamp, samples)
}
24-bit signed is necessary because 16-bit is insufficient for clinical ECG amplitude (range ±5 mV at 1 µV resolution = 10,000 levels, requires minimum 14 bits; clinically, 24 bits is standard).
Signal Rendering and Buffer
ECG streaming on-screen has strict memory and FPS requirements. A circular buffer for 10 seconds at 300 sps = 3,000 points per channel = 36,000 values for 12 channels. Store in FloatArray to avoid object allocation in the render thread.
On Android—custom View with Canvas, draw via Paint.setPathEffect(null) and accumulate Path directly. On iOS—CALayer + Core Graphics or Metal for high load. ChartsUI and MPAndroidChart are unsuitable for ECG—they're not designed for continuous append in hot path.
class EcgView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: View(context, attrs) {
private val buffer = CircularFloatBuffer(capacity = 3000)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GREEN
strokeWidth = 1.5f
style = Paint.Style.STROKE
}
fun appendSamples(samples: FloatArray) {
buffer.append(samples)
invalidate() // request redraw
}
override fun onDraw(canvas: Canvas) {
val data = buffer.snapshot()
val path = Path()
val scaleX = width.toFloat() / data.size
val scaleY = height / 2f
data.forEachIndexed { i, value ->
val x = i * scaleX
val y = scaleY - value * scaleY / MAX_AMPLITUDE
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
canvas.drawPath(path, paint)
}
}
invalidate() without postInvalidateOnAnimation() minimizes latency. Vsync naturally caps rendering to 60/120 FPS.
Storage and Transmission: FHIR and GDPR
Medical data is personally identifiable information with the highest protection level. On-device storage: encryption via Android Keystore / iOS Data Protection (class NSFileProtectionComplete). Server transmission: only TLS 1.2+, preferably mTLS.
For integration with medical systems (EHR, HL7)—format data as FHIR R4: Observation resource for measurements, DiagnosticReport for ECG reports. Apple HealthKit stores data in FHIR-compatible format since iOS 12.
// Save blood pressure measurement to HealthKit
func saveBloodPressure(_ reading: BloodPressureReading) async throws {
let systolicType = HKQuantityType(.bloodPressureSystolic)
let diastolicType = HKQuantityType(.bloodPressureDiastolic)
let mmHg = HKUnit.millimeterOfMercury()
let systolicSample = HKQuantitySample(type: systolicType,
quantity: HKQuantity(unit: mmHg, doubleValue: reading.systolic),
start: reading.timestamp, end: reading.timestamp)
let diastolicSample = HKQuantitySample(type: diastolicType,
quantity: HKQuantity(unit: mmHg, doubleValue: reading.diastolic),
start: reading.timestamp, end: reading.timestamp)
try await healthStore.save([systolicSample, diastolicSample])
}
Regulatory Requirements and Constraints
If the application qualifies as a medical device (provides diagnosis, recommends treatment)—registration with Roszdravnadzor (Russia) or CE MDR (Europe) is required. A "viewer" application without clinical decision-making typically falls outside regulatory scope, but this must be confirmed with medical law specialists before development begins.
Developing a mobile client for medical IoT device with real-time data streaming, clinically accurate parsing, and HealthKit integration: 10–16 weeks. Complexity increases significantly with proprietary device protocols or FHIR integration requirements. Cost is individually quoted after device specification and regulatory context analysis.







