From 165df3c63d6e7cff91f60538e2bcf4d866fe2945 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 3 Aug 2026 17:22:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(telemetry):=20Phase=20B/C/D=20=C2=B7=20Cli?= =?UTF-8?q?nicalEventBus=20+=20emit=20(cloud-mvp=206856a7b=20=EC=9D=B4?= =?UTF-8?q?=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D · telemetry/ClinicalEvent + ClinicalEventBus 신규 (demo-final path): - sealed 계층 · schemaVersion · severity · isTransition · anonymized sessionId - MutableSharedFlow (replay=0, buffer=64, DROP_OLDEST · fire-and-forget) - PhiRedactor 없음 (demo-final) · TelemetryHash.sessionId (inline SHA-256) 사용 Phase A → BvEvent emit (AppState.updateLevelFromMeasurement): - 매 측정마다 emit · urgency 전이 시 isTransition=true - bvMethod 이름 · anonymized sessionId Phase B/C → PostureEvent/GaitEvent emit (PiezoMonitoringView): - composite posture 전이 시 PostureEvent (매 사이클 X · 트래픽 절약) - walkingTransitioned 시 GaitEvent (severity=WARN on WALKING 진입) - prevCompositePosture 상태 신규 추적 기존 SyncEvents/PostureEventLogger 와 병렬 · 서로 독립. Refs: GUARDIAN-01 §3-2 이벤트 스키마 · §5 Phase A~D Co-Authored-By: Claude Opus 4.7 --- .../java/com/medithings/vesiscan/AppState.kt | 27 ++++- .../vesiscan/telemetry/ClinicalEvent.kt | 103 ++++++++++++++++++ .../vesiscan/telemetry/ClinicalEventBus.kt | 35 ++++++ .../views/monitoring/PiezoMonitoringView.kt | 44 ++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEvent.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEventBus.kt diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 63cd97f..6f28a47 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -241,9 +241,34 @@ class AppState(private val context: Context) { } val newUrgency = bladderLevel.urgency - if (newUrgency != previousUrgency) { + val urgencyChanged = newUrgency != previousUrgency + if (urgencyChanged) { notificationService.sendUrgencyNotification(newUrgency, volumeMl.toInt()) } + + // Phase D (GUARDIAN-01 · 2026-08-03) — ClinicalEventBus emit. + runCatching { + com.medithings.vesiscan.telemetry.ClinicalEventBus.emit( + com.medithings.vesiscan.telemetry.BvEvent( + ts = System.currentTimeMillis(), + sessionId = com.medithings.vesiscan.telemetry.TelemetryHash.sessionId( + bleManager.connectedDeviceName.value.ifEmpty { "unknown" } + ), + severity = when (newUrgency) { + com.medithings.vesiscan.models.UrgencyLevel.URGENT -> + com.medithings.vesiscan.telemetry.Severity.CRITICAL + com.medithings.vesiscan.models.UrgencyLevel.WARNING -> + com.medithings.vesiscan.telemetry.Severity.WARN + else -> com.medithings.vesiscan.telemetry.Severity.INFO + }, + volumeMl = volumeMl.toInt(), + urgency = newUrgency, + bvMethod = com.medithings.vesiscan.managers.GreenZoneConstants.bvMethod.name, + isTransition = urgencyChanged, + prevUrgency = previousUrgency.takeIf { urgencyChanged }, + ) + ) + } } fun resetLevel() { diff --git a/app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEvent.kt b/app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEvent.kt new file mode 100644 index 0000000..897cc28 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEvent.kt @@ -0,0 +1,103 @@ +/* + * Clinical event schema — 보호자 공유 및 in-app subscriber 용 강타입 이벤트. + * (GUARDIAN-01 §3-2 · 2026-08-03 · cloud-mvp 6856a7b 이식) + */ +package com.medithings.vesiscan.telemetry + +import com.medithings.vesiscan.models.UrgencyLevel + +enum class Severity { INFO, WARN, CRITICAL } +enum class Posture { UNKNOWN, SUPINE, SITTING, STANDING, TILTED, WALKING } +enum class GaitState { STOPPED, WALKING } +enum class ConnState { CONNECTED, DISCONNECTED, RECONNECTING, FAILED } +enum class AlarmCategory { + BV_URGENT, BV_WARN, + POSTURE_TRANSITION, + GAIT_STARTED, GAIT_STOPPED, + SENSOR_DETACHED, BATTERY_LOW, BATTERY_CRITICAL, + BLE_LOST, BLE_RESTORED, + FIRMWARE_OUTDATED, +} + +sealed class ClinicalEvent { + abstract val ts: Long + abstract val sessionId: String + abstract val schemaVersion: Int + abstract val severity: Severity +} + +data class BvEvent( + override val ts: Long, + override val sessionId: String, + override val schemaVersion: Int = 1, + override val severity: Severity = Severity.INFO, + val volumeMl: Int, + val urgency: UrgencyLevel, + val bvMethod: String, + val isTransition: Boolean, + val prevUrgency: UrgencyLevel? = null, +) : ClinicalEvent() + +data class PostureEvent( + override val ts: Long, + override val sessionId: String, + override val schemaVersion: Int = 1, + override val severity: Severity = Severity.INFO, + val posture: Posture, + val tiltDeg: Double, + val shakeRmsDps: Double, + val isTransition: Boolean, + val fromPosture: Posture? = null, +) : ClinicalEvent() + +data class GaitEvent( + override val ts: Long, + override val sessionId: String, + override val schemaVersion: Int = 1, + override val severity: Severity = Severity.INFO, + val state: GaitState, + val stepRate: Double? = null, + val durationMs: Long? = null, +) : ClinicalEvent() + +data class AlarmEvent( + override val ts: Long, + override val sessionId: String, + override val schemaVersion: Int = 1, + override val severity: Severity, + val category: AlarmCategory, + val message: String, + val data: Map = emptyMap(), +) : ClinicalEvent() + +data class BleConnectionEvent( + override val ts: Long, + override val sessionId: String, + override val schemaVersion: Int = 1, + override val severity: Severity, + val state: ConnState, + val deviceNameHash: String? = null, + val reason: String? = null, + val rssi: Int? = null, +) : ClinicalEvent() + +data class BatteryEvent( + override val ts: Long, + override val sessionId: String, + override val schemaVersion: Int = 1, + override val severity: Severity, + val level: Int, + val isTransition: Boolean, +) : ClinicalEvent() + +/** + * demo-final 용 간단한 sessionId hash 유틸 · PhiRedactor 없음. + * cloud-mvp 는 shared/util/PhiRedactor.hash 사용. + */ +internal object TelemetryHash { + fun sessionId(sn: String): String { + val bytes = java.security.MessageDigest.getInstance("SHA-256") + .digest(sn.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) }.take(16) + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEventBus.kt b/app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEventBus.kt new file mode 100644 index 0000000..1eab7ce --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/telemetry/ClinicalEventBus.kt @@ -0,0 +1,35 @@ +/* + * ClinicalEventBus — 임상 이벤트 중앙 브로드캐스트 (SSOT). + * (GUARDIAN-01 §3-3 · 2026-08-03 · cloud-mvp 6856a7b 이식) + * + * fire-and-forget · emit 실패해도 필수기능 영향 X. + */ +package com.medithings.vesiscan.telemetry + +import android.util.Log +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +object ClinicalEventBus { + private const val TAG = "ClinicalEventBus" + + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = _events.asSharedFlow() + + fun emit(event: ClinicalEvent) { + try { + val delivered = _events.tryEmit(event) + if (!delivered) { + Log.w(TAG, "buffer full · dropped: ${event.javaClass.simpleName}") + } + } catch (e: Exception) { + Log.w(TAG, "emit failed: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index f885b92..4d3baeb 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -155,6 +155,8 @@ fun PiezoMonitoringView(appState: AppState) { } // 직전 walking 상태 추적 — false → true 전이 시 critical 알람 var prevIsWalking by remember { mutableStateOf(false) } + // Phase B (GUARDIAN-01 · 2026-08-03): composite posture 전이 감지용 + var prevCompositePosture by remember { mutableStateOf(null) } // Wakeup alarm (warning, 주황) — LYING → NON_LYING/WALKING 전이 // 2026-07-10: 3개 알람 Dialog 를 AlertModalDialog 로 통합 (ui/components/AlertModalDialog.kt). if (showWakeupAlarm) { @@ -781,6 +783,7 @@ fun PiezoMonitoringView(appState: AppState) { } // Walking 시작 전이 (NON_WALKING → WALKING) — critical 알람 (alarm enabled 시) + val walkingTransitioned = isWalkingNow != prevIsWalking if (isWalkingNow && !prevIsWalking) { if (com.medithings.vesiscan.services.WakeupAlarmPrefs.isEnabled(context)) { showWalkingAlarm = true @@ -790,6 +793,47 @@ fun PiezoMonitoringView(appState: AppState) { if (!isWalkingNow && prevIsWalking) { com.medithings.vesiscan.services.PostureEventLogger.logAlarm(nowMs, "walking_end") } + + // Phase B/C (GUARDIAN-01 · 2026-08-03) — ClinicalEventBus 로 posture/gait emit + // (prevIsWalking 갱신 전에 walkingTransitioned 사용). + runCatching { + val bus = com.medithings.vesiscan.telemetry.ClinicalEventBus + val sid = com.medithings.vesiscan.telemetry.TelemetryHash.sessionId( + bleManager.connectedDeviceName.value.ifEmpty { "unknown" } + ) + if (composite != prevCompositePosture) { + bus.emit( + com.medithings.vesiscan.telemetry.PostureEvent( + ts = nowMs, sessionId = sid, + severity = com.medithings.vesiscan.telemetry.Severity.INFO, + posture = com.medithings.vesiscan.telemetry.Posture.valueOf(composite.name), + tiltDeg = r.pitchDeg.toDouble(), + shakeRmsDps = r.gyroMagDps.toDouble(), + isTransition = true, + fromPosture = prevCompositePosture?.let { + com.medithings.vesiscan.telemetry.Posture.valueOf(it.name) + }, + ) + ) + } + if (walkingTransitioned) { + bus.emit( + com.medithings.vesiscan.telemetry.GaitEvent( + ts = nowMs, sessionId = sid, + severity = if (isWalkingNow) + com.medithings.vesiscan.telemetry.Severity.WARN + else com.medithings.vesiscan.telemetry.Severity.INFO, + state = if (isWalkingNow) + com.medithings.vesiscan.telemetry.GaitState.WALKING + else com.medithings.vesiscan.telemetry.GaitState.STOPPED, + stepRate = null, + durationMs = null, + ) + ) + } + prevCompositePosture = composite + } + prevIsWalking = isWalkingNow // CSV logging — 검증용