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
|
val newUrgency = bladderLevel.urgency
|
||||||
if (newUrgency != previousUrgency) {
|
val urgencyChanged = newUrgency != previousUrgency
|
||||||
|
if (urgencyChanged) {
|
||||||
notificationService.sendUrgencyNotification(newUrgency, volumeMl.toInt())
|
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() {
|
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 알람
|
// 직전 walking 상태 추적 — false → true 전이 시 critical 알람
|
||||||
var prevIsWalking by remember { mutableStateOf(false) }
|
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 전이
|
// Wakeup alarm (warning, 주황) — LYING → NON_LYING/WALKING 전이
|
||||||
// 2026-07-10: 3개 알람 Dialog 를 AlertModalDialog 로 통합 (ui/components/AlertModalDialog.kt).
|
// 2026-07-10: 3개 알람 Dialog 를 AlertModalDialog 로 통합 (ui/components/AlertModalDialog.kt).
|
||||||
if (showWakeupAlarm) {
|
if (showWakeupAlarm) {
|
||||||
@@ -781,6 +783,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Walking 시작 전이 (NON_WALKING → WALKING) — critical 알람 (alarm enabled 시)
|
// Walking 시작 전이 (NON_WALKING → WALKING) — critical 알람 (alarm enabled 시)
|
||||||
|
val walkingTransitioned = isWalkingNow != prevIsWalking
|
||||||
if (isWalkingNow && !prevIsWalking) {
|
if (isWalkingNow && !prevIsWalking) {
|
||||||
if (com.medithings.vesiscan.services.WakeupAlarmPrefs.isEnabled(context)) {
|
if (com.medithings.vesiscan.services.WakeupAlarmPrefs.isEnabled(context)) {
|
||||||
showWalkingAlarm = true
|
showWalkingAlarm = true
|
||||||
@@ -790,6 +793,47 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
if (!isWalkingNow && prevIsWalking) {
|
if (!isWalkingNow && prevIsWalking) {
|
||||||
com.medithings.vesiscan.services.PostureEventLogger.logAlarm(nowMs, "walking_end")
|
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
|
prevIsWalking = isWalkingNow
|
||||||
|
|
||||||
// CSV logging — 검증용
|
// CSV logging — 검증용
|
||||||
|
|||||||
Reference in New Issue
Block a user