MEITT · DOCUMENTACIÓN TÉCNICA

Arquitectura de Componentes

Referencia completa de pantallas, widgets y motores.

3
Pantallas
14
Componentes
3
Motores IA
100%
Local · Sin cloud
KMP
Android + iOS
01 — Sistema
Arquitectura General

Fuente de Datos

Pulsera Meitt SDK 2208 vía BLE 5.0. Todos los datos se obtienen exclusivamente del hardware. Sin cloud, sin APIs externas.


BleRepositoryAndroid.kt
→

Persistencia

SQLite local (Room). Historial de FC, sueño, SpO2, pasos, sesiones de actividad. Todo en el dispositivo del usuario.


shared/data/local/
→

ViewModel / MVI

Patrón MVI: Intent → State → Effect. DashboardViewModel centraliza el estado de salud. Coroutines para streaming y sync.


DashboardViewModel.kt
→

UI — Compose

Kotlin Multiplatform + Compose Multiplatform. Android e iOS desde el mismo código. Tema oscuro OLED (#121416).


composeApp/ui/
Estado global — DashboardState
data class DashboardState(
  selectedDate:     LocalDate,
  latestVitals:     VitalReading?,      // streaming live
  hrHistory:        List<HeartRateRecord>,
  sleepHistory:     List<SleepRecord>,
  spo2History:      List<Spo2Record>,
  selectedDaySteps: StepsRecord?,
  physiologyInsight: PhysiologyInsight?,  // HRV engine
  trueAgeInsight:    TrueAgeInsight?,     // TrueAge™ engine
  trueAgeYears:     Int?,
  activityScore:    Int,
  isSyncing:        Boolean,
  isStreaming:      Boolean,
)
Ciclo de sincronización
// Pull-to-refresh (HealthScreen)
SyncHistoricalData → syncData()

// Si pulsera conectada → sync real:
syncHeartRate() + syncSteps()
+ syncSleepHistory() + syncSpo2()

// Si desconectada → solo recarga local:
fetchDataForSelectedDate()

// Stream en tiempo real (Dashboard):
StartVitalsStream → bleRepository
  .startVitalsStream()
  .collect { vitals → updateState }
Regla clave: pull-to-refresh comprueba connectionState.value is Connected de forma inmediata. Si no hay conexión, recarga desde SQLite sin intentar reconectar.
02 — Navegación
Mapa de Pantallas

Dashboard

DashboardScreen.kt
  • TrueAge™ Widget (hito central)
  • Índice de Resiliencia (Radar)
  • HealthSectionContent (compartido)
  • Actividades Recientes (últimas 3)
  • Coach IA (coming soon)
  • Botones Sync / Disconnect
ViewModels: DashboardViewModel + ActivityViewModel

Salud

HealthScreen.kt
  • Header "Salud"
  • Pull-to-refresh (icono top bar)
  • HealthSectionContent (compartido)
  • DateCarousel para navegar días
ViewModel: DashboardViewModel
Auto-reload en onResume con AppLifecycleEvents

Actividad

ActivityScreen.kt
  • Historial completo de sesiones
  • Detalle por sesión con GPS
  • Métricas: duración, FC, calorías
  • Mapa de ruta (RouteCanvas)
ViewModel: ActivityViewModel

HealthSectionContent

HealthScreen.kt · internal
  • Componente compartido
  • Usado en Dashboard Y Health
  • Recibe DashboardState
  • Renderiza las 7 cards de salud
Para modificar cualquier card de salud, editar en HealthScreen.kt. El cambio se refleja automáticamente en ambas pantallas.
03 — Hito Central
TrueAge™ Widget
★
TrueAgeWidget
ui/component/TrueAgeWidget.kt · Visible en: DashboardScreen
Canvas Animación 3 actos TrueAge™ Engine
⟳ · 🔋82%
TRUE AGE™  ·  EDAD BIOLÓGICA
34.2
años
▼ 2.3 años más joven · 9 días de datos
Tu resiliencia cardiovascular te sostiene — HRV de 48ms absorbiendo el déficit de sueño.
Estados de revelación
CALCULATING · Órbita celular + scanner
REVEALING · Flash radial
REVEALED · Número contando
Animaciones activas (REVEALED)
  • displayAge — count-up 0→trueAge en 1 800 ms
  • ringAlpha — fade-in del anillo en 800 ms
  • ringPulse — respiración suave 1.0→1.04 / 3 000 ms (∞)
  • flashAlpha/Scale — destello de 700 ms en transición
Parámetros de entrada
  • trueAge Double? — edad biológica calculada
  • daysRecorded Int — días con datos (barra de progreso)
  • requiredDays Int = 30 — días para cálculo completo
  • insight TrueAgeInsight? — resultado del motor 10 días
Contenido del insight (si disponible)
  • trend10d — flecha ▼ verde / ▲ rojo / ─ estable (umbral ±0.3)
  • offset — "<X> años más joven/mayor"
  • daysWithData — badge de datos disponibles
  • insightKey — texto humanizado según 8 claves
8 Claves de insight (TrueAgeInsightKey)
Clave Condición
HABITS_HOLDING_BACK habitsDriver es el factor dominante negativo
TREND_DECLINING trend10d > 0.5
TREND_IMPROVING trend10d < −0.5
HRV_SUSTAINS_COMPENSATES hrv alto compensa sueño bajo
SLEEP_DRIVES_RECOVERY sleepDriver dominante positivo
ACTIVITY_BOOSTS_VITALITY activityDriver dominante positivo
BALANCED todos los factores equilibrados
NEUTRAL_INSUFFICIENT_DATA daysWithData < 3
Referencia para modificar: editar TrueAgeWidget.kt. El anillo, el tamaño de número y los colores están en RevealedView. El estado de cálculo en CalculatingView.
04 — Motores de IA
Motores de Cálculo Local
T
TrueAgeEngine · calculateWindowed()
shared/domain/engine/TrueAgeEngine.kt
Motor IA 100% Local
Algoritmo — ventana deslizante 10 días
offset_final = recentOffset × 0.60 + historicalOffset × 0.40 // recentOffset = media ponderada días 1-3 (últimos 3) // historicalOffset = media ponderada días 4-10 biologicalAge = chronologicalAge + offset_final trend10d = avg(segunda_mitad_offsets) - avg(primera_mitad_offsets) // negativo = mejorando (edad bajando)
4 factores de driver
sleepDriver = (avgSleepScore / 100 - 0.5) × 2 hrvDriver = (avgHrvMs / 60 - 0.5) × 2 activityDriver = (avgActivityScore / 100 - 0.5) × 2 habitsDriver = -(bmi_offset + sedentary_penalty)
Entrada requerida
  • profile UserProfile — birthYear, weightKg, heightCm, sex
  • habits UserHabits — smoker, alcoholUnits, exerciseDays
  • tenDayMetrics List<DailyMetrics> — últimos 10 días con datos
  • currentYear Int — para calcular edad cronológica
Salida — TrueAgeInsight
data class TrueAgeInsight(
  biologicalAge:   Double,  // edad calculada
  chronologicalAge: Int,
  offset:          Double,  // + = mayor, - = menor
  trend10d:        Double,  // + = empeorando
  dominantFactor:  TrueAgeFactor,
  insightKey:      TrueAgeInsightKey,
  sleepDriver:     Double,
  hrvDriver:       Double,
  activityDriver:  Double,
  habitsDriver:    Double,
  daysWithData:    Int,
  avgHrvMs:        Double,
  avgSleepScore:   Double,
  avgActivityScore: Double,
)
¿Cuándo se calcula? Tras cada sync y al cargar el Dashboard. El ViewModel hace un bucle de 0..9 días atrás, obtiene DailyMetrics por día y llama a calculateWindowed().
P
HeartRateIntelligenceEngine
shared/domain/engine/ · Salida: PhysiologyInsight
Motor IA 100% Local
Métricas calculadas
rhr = percentil_10(hrHistory_duringSleep) rmssd ≈ sqrt(mean(diff(bpm_sucesivos)²)) // estimación recoveryScore = sleepScore × 0.65 + cardiacEfficiency × 0.35 × hrvMultiplier cardiacEfficiency = 100 - (rhr - 40) × 1.6 sedentaryStressAlert = true si hay picos FC sin variación de movimiento detectados
Salida — PhysiologyInsight
  • rhr — FC mínima estable (reposo)
  • rmssd — variabilidad entre latidos (ms)
  • recoveryScore — 0-100 recuperación
  • recoveryStatus — OPTIMAL / GOOD / MODERATE / LOW
  • rhrTrend — hoy vs media 7 días
  • sedentaryStressAlert — booleano estrés
05 — Dashboard
Índice de Resiliencia (Radar)
R
ResilienceRadarChart
ui/component/ResilienceRadarChart.kt · Visible en: DashboardScreen
Canvas Animación 1200ms
ÍNDICE DE RESILIENCIA BUENO
HRV SpO₂ SUEÑO REC
Parámetros de entrada
  • hrvMs Double — desde physiologyInsight.rmssd
  • spo2Pct Int — desde spo2History.lastOrNull()?.percent
  • sleepScore Int — desde trueAgeInsight.avgSleepScore
  • recoveryScore Int — desde physiologyInsight.recoveryScore
Normalización (0.0 → 1.0)
HRV: min(hrvMs / 80.0, 1.0) SpO₂: (spo2Pct - 90) / 10 → [0.1, 1.0] Sleep: sleepScore / 100 Recovery: recoveryScore / 100
Badge de estado (recoveryScore)
Score Label Color
≥ 85 ÓPTIMO Cyan
65-84 BUENO Verde
40-64 BAJO Amber
< 40 CRÍTICO Rojo
Para modificar: ResilienceRadarChart.kt. Los 4 ejes están fijos (N/E/S/O = HRV/SpO2/Sueño/Recovery). Para añadir ejes, cambiar el loop de 0..3 y los ángulos a i * 360/n.
06 — Sección de Salud
HealthSectionContent · 7 Tarjetas
Componente compartido: internal fun HealthSectionContent(state: DashboardState, modifier: Modifier) — vive en HealthScreen.kt y se usa en DashboardScreen y HealthScreen sin duplicación.
1
ActivityRingsWidget
HealthScreen.kt · private · posición: tarjeta 1
Canvas Spring Bounce
Visual
7.842 PASOS
Origen de datos
steps = selectedDaySteps.totalSteps ?: latestVitals.steps // si es hoy calories = selectedDaySteps.totalCalories distKm = distanceMeters / 1000
3 Anillos — objetivos
Anillo Color Objetivo Fuente
Pasos Cyan 10 000 pasos StepsRecord
Calorías Naranja 500 kcal StepsRecord
Distancia Violeta 5 km StepsRecord
Animación

Cada anillo usa animateFloatAsState con spring(DampingRatioMediumBouncy, StiffnessLow). El bounce es suave y da sensación de física real al cargar.

Para modificar objetivos: constantes en la parte superior de HealthScreen.kt: STEPS_GOAL_DEFAULT = 10_000, CALS_GOAL = 500, DIST_GOAL_KM = 5f.
2
BioAgeCard + LoadRecoveryCard
HealthScreen.kt · Row de 2 tarjetas · posición: tarjeta 2
State Spring
BioAgeCard
  • trueAgeYears — de state.trueAgeYears (Int, redondeado)
  • profile — para calcular edad cronológica
diff = chronologicalAge - trueAgeYears // diff > 0 → más joven de lo esperado ✓ // diff < 0 → por encima de su edad ✗
diff Color Mensaje
≥ 5 Cyan Muy joven para tu edad
1-4 Teal Más joven de lo esperado
0 Teal Acorde a tu edad
−1 a −4 Amber Ligeramente por encima
≤ −5 Rojo Por encima de tu edad
LoadRecoveryCard
  • load — state.activityScore (0-100)
  • recovery — physiologyInsight.recoveryScore o estimado local
  • insight — PhysiologyInsight?
Subcomponentes internos
  • ScoreGauge — arco de progreso para Carga
  • RecoveryWidget — semicírculo WHOOP-style para Recuperación (componente externo RecoveryWidget.kt)
Para modificar: LoadRecoveryCard en HealthScreen.kt. El RecoveryWidget vive en ui/component/RecoveryWidget.kt.
07 — Frecuencia Cardíaca
HeartRateCard + HeartRateVitalityMap
3
HeartRateCard → HeartRateVitalityMap
HealthScreen.kt (card) · Charts.kt (mapa) · posición: tarjeta 3
Canvas multicapa Draw-in 1400ms Fases circadianas
FRECUENCIA CARDÍACA
72 bpm
00h06h12h18h
■ Sueño
■ Activo
● Estrés
52
Mín
72
Prom
118
Máx
ESTADO DE FORMA
Óptimo
HRV 48ms · RHR 54
RECUPERACIÓN
87
5 capas del Canvas (HeartRateVitalityMap)
# Capa Color Fuente
1 Fases sueño #1A237E α22% SleepRecord (excluye AWAKE)
2 Fases actividad #E65100 α14% FC ≥ 90 bpm × ≥3 muestras consecutivas
3 Etiquetas tiempo Blanco α28% Calculadas sobre ventana 24h (cada 6h)
4 Línea HR #26C6DA / #E65100 HeartRateRecord.bpm
5 Puntos estrés #B71C1C (blink) sedentaryStressAlert=true + máximos locales 75-100 bpm
Animación de dibujo (draw-in)
visibleCount = (drawProgress × records.size) drawProgress: 0.0 → 1.0 en 1 400ms // La línea se dibuja de izquierda a derecha // Se anima cuando cambia records.size
Widgets inferiores — VitalityStatChip × 2
  • Estado de Forma — PhysiologyInsight.RecoveryStatus.label + subtítulo HRV/RHR
  • Recuperación — score 0-100 + barra animada spring
Parámetros de HeartRateCard
  • currentHr — latestVitals.heartRate o último bpm
  • hrHistory — state.hrHistory (lista completa 24h)
  • sleepRecords — state.sleepHistory para overlays
  • insight — state.physiologyInsight
  • recoveryScore — estimado local si no hay insight
Para modificar el mapa HR: fun HeartRateVitalityMap en ui/component/Charts.kt.
Para modificar los chips: HrVitalityInsights y VitalityStatChip en HealthScreen.kt.
Para cambiar el umbral de actividad (actualmente 90 bpm): buscar rec.bpm >= 90 en ambas ubicaciones.
4
SpO2Card + MetabolismCard
HealthScreen.kt · Row de 2 tarjetas · posición: tarjeta 4
StateBMR Mifflin-St Jeor
SpO2Card
  • latest — spo2History.lastOrNull()?.percent
  • avg — media del historial del día
Valor Estado Color
≥ 96% Normal Cyan
90-95% Aceptable Amber
< 90% Bajo — Consulta médico Rojo
MetabolismCard — Fórmula BMR
BMR = 10×peso + 6.25×altura − 5×edad + 5 // si MALE − 161 // si FEMALE // Mifflin-St Jeor (commonMain-safe) activeRatio = calories / BMR estado: activo ≥ 25% · moderado ≥ 10% sedentario < 10%
Requiere perfil completo (peso, altura, año de nacimiento). Sin datos → "Perfil incompleto".
08 — Sueño
SleepCard
5
SleepCard
HealthScreen.kt · posición: tarjeta 5
SpringSleepRecord
Visual — estado con datos
SUEÑO
7h 24m
/ 8h objetivo
1h38m
Profundo
1h29m
REM
4h17m
Ligero
Origen de datos
  • sleepMin — suma durationMin donde level != AWAKE
  • deepMin — suma donde level == DEEP
  • remMin — suma donde level == REM
  • lightMin — suma donde level == LIGHT
  • goalMin — userProfile.sleepGoalMin ?: 480 (8h por defecto)
Colores de fases
Fase Color Hex
Profundo #7986CB
REM #4DD0E1
Ligero #80CBC4
Color de la barra de progreso
% del objetivo Color
≥ 85% Cyan (bueno)
60-84% Teal (aceptable)
< 60% Amber (insuficiente)
Para modificar: SleepCard, SleepStagesBar, SleepStageItem en HealthScreen.kt. El estado vacío usa SleepEmptyState (ui/component/SleepEmptyState.kt).
09 — HRV, Estrés y Presión
HrvCard + StressCard + BloodPressureCard
6
HrvCard + StressCard
HealthScreen.kt · Row de 2 tarjetas · posición: tarjeta 6
Canvas GaugeEstimación local
HrvCard
  • hrv — latestVitals.hrv o estimateHrv(hrHistory)
// Estimación local (sin sensor dedicado): diffs = hrHistory.zipWithNext { a,b → b.bpm - a.bpm } rmssd_approx = sqrt(mean(diffs²)) // ⚠ Aproximación BPM-level, no RR-interval
HRV (ms) Estado Color
≥ 60 Excelente Cyan
35-59 Normal Teal
20-34 Bajo Amber
< 20 Muy bajo Rojo
StressCard — Gauge circular
// Si HRV disponible: stressScore = 100 − (hrv / 80 × 100) // Si solo historial FC: avgDiff = mean(|bpm[i] - bpm[i-1]|) stressScore = 100 − (avgDiff / 10 × 50)
Score Estado Color
0-25 Relajado Cyan
26-50 Moderado Teal
51-75 Elevado Amber
76-100 Alto Rojo
Nota: el gauge es un arco circular animado con spring(DampingRatioMediumBouncy).
7
BloodPressureCard
HealthScreen.kt · posición: tarjeta 7 (última)
Estimación
Entrada
  • restingHr — mínimo bpm del día (hrBpms.minOrNull())
  • currentHr — bpm en tiempo real
⚠ No es una medición real de presión arterial. Es una perspectiva inferida de la FC en reposo. Se muestra con disclaimer explícito en la UI.
Tabla de perspectivas
RHR Perspectiva Color
< 50 Presión óptima · Perfil atlético Cyan
50-59 Presión óptima · FC excelente Cyan
60-69 Presión normal Teal
70-79 Presión normal-alta Amber
80-89 Atención — FC elevada Naranja
≥ 90 Consulta médico Rojo
10 — Dashboard
RecentActivitiesWidget + ActivityRow
A
RecentActivitiesWidget
DashboardScreen.kt · private · posición: después de HealthSectionContent
ActivityViewModel
Actividades recientes
Ver todas →
🏃
Correr
28 mar
32:14
284 kcal
🚴
Ciclismo
26 mar
58:33
512 kcal
🏋️
Fuerza
25 mar
45:00
380 kcal
Parámetros
  • sessions List<ActivitySession> — de activityState.history
  • isLoading Boolean — de activityState.isLoadingHistory
  • onViewAll — navega a ActivityScreen
  • onSessionClick — navega al detalle
ActivityRow — datos mostrados
  • exerciseType.icon — emoji del tipo de ejercicio
  • exerciseType.label — nombre del ejercicio
  • startMs → fecha — "28 mar" (calculado con Instant.toLocalDateTime)
  • durationSec → "MM:SS" — formato con padStart(2,'0')
  • totalCalories — se muestra si > 0
Máximo visible

Muestra máximo 3 sesiones (sessions.take(3)). La pantalla completa se ve en ActivityScreen.


Resumen de Componentes
# Componente Archivo Pantallas Datos desde
★ TrueAgeWidget TrueAgeWidget.kt Dashboard state.trueAgeInsight / trueAgeYears
R ResilienceRadarChart ResilienceRadarChart.kt Dashboard physiologyInsight, spo2History, trueAgeInsight
1 ActivityRingsWidget HealthScreen.kt Dashboard + Health selectedDaySteps, latestVitals
2a BioAgeCard HealthScreen.kt Dashboard + Health state.trueAgeYears, userProfile
2b LoadRecoveryCard HealthScreen.kt Dashboard + Health activityScore, physiologyInsight
3 HeartRateCard + HeartRateVitalityMap HealthScreen.kt + Charts.kt Dashboard + Health hrHistory, sleepHistory, physiologyInsight
4a SpO2Card HealthScreen.kt Dashboard + Health spo2History
4b MetabolismCard HealthScreen.kt Dashboard + Health steps, calories, userProfile
5 SleepCard HealthScreen.kt Dashboard + Health sleepHistory, userProfile.sleepGoalMin
6a HrvCard HealthScreen.kt Dashboard + Health latestVitals.hrv o estimado
6b StressCard HealthScreen.kt Dashboard + Health HRV o variación FC
7 BloodPressureCard HealthScreen.kt Dashboard + Health hrHistory.minOrNull() (RHR)
A RecentActivitiesWidget DashboardScreen.kt Dashboard activityState.history
09 — Auditoría · 31/03/2026
Pipeline BLE → DB → Engine → UI

Auditoría de 4 capas del flujo de datos de Frecuencia Cardíaca. Se identificaron y corrigieron 6 bugs que impedían que la gráfica HeartRateVitalityMap recibiera datos.

Flujo de datos auditado

① SDK BLE

JStyle SDK 2208
FFF7 notify → DataListener2025
→ _parsedData SharedFlow

→

② SQLDelight

HeartRateRecord
insertHeartRate()
by timestampMs

→

③ Engines

HRIntelligenceEngine
TrueAgeEngine
ventana 7–10 días

→

④ Compose UI

HeartRateVitalityMap
DashboardHrSection
state.hrHistory

Bugs encontrados y corregidos
# Capa Bug Archivo Fix
1 SDK → DB _syncCompletion(replay=1) — la 2ª llamada a syncHeartRateForDate devolvía el valor cacheado del sync anterior y salía sin esperar los datos reales BleRepositoryAndroid.kt resetReplayCache() antes de cada enqueueWrite
2 SDK → Sleep handleSleepPayload: el paquete de fin 53 FF (2 bytes, sin array) hacía ?: return antes del check isEnd. El loop de paginación de sueño nunca terminaba, saturando la cola BLE e impidiendo que los comandos HR llegaran al dispositivo BleRepositoryAndroid.kt Mover isEnd → tryEmit(26) antes del ?: return + flag sleepSyncActive
3 ViewModel 4 operaciones de sync BLE lanzadas en paralelo (coroutineScope { launch{} × 4 }). El protocolo JStyle es estrictamente command-response: comandos concurrentes producen respuestas entremezcladas o silenciosas DashboardViewModel.kt Sync secuencial: HR → Steps → Sleep → SpO2
4 DB Query Rango de fechas calculado con date.toEpochDays() * 86400000L (medianoche UTC). parseSdkDate() usa SimpleDateFormat en timezone local. En UTC+N los registros nocturnos caían fuera del rango BleRepositoryAndroid.kt:949 date.atStartOfDayIn(TimeZone.currentSystemDefault())
5 SDK → HR GetDynamicHRWithMode("2026.03.31") devuelve vacío en firmware sin sesiones de ejercicio para esa fecha exacta. El dispositivo solo almacena HR dinámica durante actividad activa BleRepositoryAndroid.kt:841 Cambiar a "" (sin filtro de fecha) como steps/sleep para recuperar todo el historial disponible
6 SDK → DB El dispositivo no expone historial HR estático vía GetStaticHRWithMode en este firmware. Los vitales en tiempo real (dataType=23, hr=57 cada ~1s) no se persistían en HeartRateRecord BleRepositoryAndroid.kt Case 23 en _parsedData.collect{}: persiste HR en tiempo real con throttle de 5 min → 288 puntos/día
Estrategia de persistencia HR · Análisis de fases

Schema HeartRateRecord

timestampMs  INTEGER  PK  ← epoch ms exacto
bpm          INTEGER      ← latidos/min
type         TEXT         ← CONTINUOUS | STATIC

1 fila = 1 punto de medición. Granularidad: 5 min (288 pts/día).

Fases derivables del BPM

Reposo nocturnobpm < 60 · 00:00–07:00
Reposo diurno60–70 bpm
Actividad ligera70–100 bpm
Actividad intensa≥ 100 bpm
Estrés sedentario75–100 · máx local
HRV / recuperaciónΔbpm entre puntos

Queries disponibles

getHeartRateByDateRange(from, to)
getLatestHeartRate()
getAvailableDates()
getFirstDataDate()
updateAvgHrForDay(epochDay)

El engine HeartRateIntelligenceEngine ya consume ventana de 7 días para calcular RHR, HRV y recoveryScore.

Tags de diagnóstico activos
// Layer 1 — SDK
MEITT_DEBUG Raw HR Data: dataType=28 items=12 isEnd=false
MEITT_DEBUG Raw HR Data: date=2026.03.31 bpmValues(12)=[62,65,68,71...]
MEITT_DEBUG Raw HR Data: hrRaw=NULL — available keys=[date, heartRate_v2, ...]
// Layer 1 — Sync completion
MEITT_DEBUG syncHR DynamicHR(""): completion=28 (null=timeout)
MEITT_DEBUG syncHR StaticHR(""): completion=89 (null=timeout)
MEITT_DEBUG syncSleepHistory completed=26 (null=timeout)
MEITT_DEBUG handleSleepPayload: isEnd=true → stopping pagination
// Layer 2 — DB
MEITT_DEBUG getHeartRateRecords: date=2026-03-31 start=1743375600000 end=... tz=Europe/Madrid
MEITT_DEBUG Saved real-time HR to DB: 57 bpm at 1743418800000