Реализация потокового считывания данных с медицинских IoT-устройств
Медицинские IoT-устройства — портативные ЭКГ (AliveCor KardiaMobile, Holter-мониторы), пульсоксиметры (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 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 — не обычный float32 и не int16 в единицах 0.1. Путаница здесь приводит к систолическому давлению «1270 мм рт.ст.» на экране — классическая ошибка.
Стриминг ЭКГ: буфер и MTU
Портативные ЭКГ — самая требовательная задача. AliveCor KardiaMobile 6L отдаёт 12-канальное ЭКГ 300 sps. Через стандартный BLE Notify (MTU 23 байта = 20 байт payload) пропускной способности едва хватает на 1-канальное ЭКГ 250 sps. Для многоканального нужен negotiate 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-интеграции. Стоимость рассчитывается индивидуально после анализа спецификации устройства и регуляторного контекста.







