Реалізація потокового зчитування даних з медичних IoT-пристроїв
Медичні IoT-пристрої — портативні ЕКГ (AliveCor KardiaMobile, Холтер-монітори), пульсоксиметри (Nonin, Masimo), глюкометри з BLE (Abbott Libre, Dexcom G7), тонометри (Omron, Withings) — працюють за стандартними профілями Bluetooth LE від Bluetooth SIG або за закритими протоколами. Розробка мобільного клієнта для таких пристроїв знаходиться на перетині кількох вимог, які рідко зустрічаються разом: потокове зчитування сирового сигналу в реальному часі, клінічна точність обробки та відповідність нормативним вимогам (FDA 21 CFR Part 11, MDR в Європі, вимоги МОЗ).
Стандартні медичні GATT-профілі
Для сумісних пристроїв Bluetooth SIG визначив профілі:
| Профіль | UUID | Пристрій |
|---|---|---|
| Health Thermometer (HTP) | 0x1809 |
Термометри |
| Blood Pressure (BLP) | 0x1810 |
Тонометри |
| Pulse Oximeter (PLX) | 0x1822 |
Пульсоксиметри |
| Glucose Profile (GLP) | 0x1808 |
Глюкометри |
| Continuous Glucose (CGP) | 0x181F |
CGM-сенсори (Libre, Dexcom) |
| ECG Profile | 0x1843 |
ЕКГ-пристрої |
Приклад розбору 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
// Значення у форматі IEEE-11073 SFLOAT (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 експонента + 12-bit мантиса
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 — це не стандартний float32 і не int16 у одиницях 0,1. Плутанина тут приводить до систолічного тиску "1270 мм рт.ст." на екрані — класична помилка.
Потоковість ЕКГ: буфер і MTU
Портативні ЕКГ — найбільш вимоглива задача. AliveCor KardiaMobile 6L подає 12-канальну ЕКГ на 300 sps. Через стандартний BLE Notify (MTU 23 байти = 20 байт payload) пропускної здатності ледве вистачає на 1-канальну ЕКГ на 250 sps. Для багатоканальної потрібно договорити MTU 247+ байт:
gatt.requestMtu(247)
// Один пакет ЕКГ: timestamp(4) + 12 каналів * 3 байти = 40 байт
// При MTU 247: ~5 кадрів на notification = 250 sps * 12 каналів = 3000 значень/сек
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 каналів
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 потрібен, тому що 16-bit недостатній для клінічної амплітуди ЕКГ (діапазон ±5 мВ при розрізненні 1 мкВ = 10 000 рівнів, потрібен мінімум 14 біт; клінічно використовують 24).
Буфер та відображення сигналу
Потокова ЕКГ на екрані — задача з жорсткими вимогами до пам'яті та FPS. Круговий буфер на 10 секунд при 300 sps = 3000 точок на канал = 36 000 значень для 12 каналів. Зберігаємо в FloatArray, щоб не розподіляти об'єкти в потоці відображення.
На Android — кастомний View з Canvas, малюємо через Paint.setPathEffect(null) та накопичуємо Path безпосередньо. На iOS — CALayer + Core Graphics або Metal для високого навантаження. ChartsUI та MPAndroidChart для ЕКГ не підходять — вони не розраховані на безперервний append в 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() // просимо перерисування
}
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() без postInvalidateOnAnimation() — для мінімальної затримки. Vsync сам обмежить до 60/120 FPS.
Зберігання та передача: FHIR та GDPR
Медичні дані — персональні дані з найвищим рівнем захисту. Зберігання на пристрої: шифрування через Android Keystore / iOS Data Protection (клас NSFileProtectionComplete). Передача на сервер — тільки по TLS 1.2+, переважно mTLS.
Для інтеграції з медичними системами (МІС, HL7) — форматування даних у FHIR R4: ресурс Observation для вимірювань, DiagnosticReport для ЕКГ-звітів. Apple HealthKit зберігає дані у форматі, сумісному з FHIR, починаючи з iOS 12.
// Збереження вимірювання артеріального тиску в 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])
}
Нормативні вимоги та обмеження
Якщо програма є медичним виробом (видає діагноз, рекомендує лікування) — потрібна реєстрація в МОЗ (Україна) або CE MDR (Європа). Програма-"переглядач" без клінічних рішень зазвичай виходить за периметр регулювання — але це питання вирішується з юристами в галузі медичного права до початку розробки.
Розробка мобільного клієнта для медичного IoT-пристрою з потоковим зчитуванням даних, клінічно точним розбором та інтеграцією з HealthKit: 10–16 тижнів. Складність значно зростає при проприетарному протоколі пристрою або вимогах FHIR-інтеграції. Вартість розраховується індивідуально після аналізу специфікації пристрою та нормативного контексту.







