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:
@@ -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 — 검증용
|
||||
|
||||
Reference in New Issue
Block a user