feat(telemetry): Phase B/C/D · ClinicalEventBus + emit (cloud-mvp 6856a7b 이식)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 17:22:48 +09:00
parent 432127a667
commit 165df3c63d
4 changed files with 208 additions and 1 deletions
@@ -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() {
@@ -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<String, String> = 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)
}
}
@@ -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<ClinicalEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<ClinicalEvent> = _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}")
}
}
}
@@ -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<com.medithings.vesiscan.managers.Posture?>(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 — 검증용