From de6e1d193168cb101a42ec533162c1ddd7afa69f Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 4 Aug 2026 09:52:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(guardian):=20Rev2=20=C2=B7=20Bus=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20+=20AlarmAggregator=20+=20NotificationSubs?= =?UTF-8?q?criber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUARDIAN-01 §Rev2 개선 4건 (파트너 앱 개발 지원): 1. BleManager · Battery/BleConnection emit 지점 추가 - emitBatteryEvent(pct): 임계 bucket 전이 (CRITICAL<5, LOW<20, OK≥20) - emitConnectionEvent(state, reason): CONNECTED / DISCONNECTED / FAILED - CONNECTED · STATE_DISCONNECTED · GATT_ERROR · 좀비 watchdog 4 경로 wire 2. AlarmAggregator (신규 · telemetry/) — subscriber 참조 구현 - Bus 구독 · BvEvent/PostureEvent/GaitEvent/BleConn/Battery → AlarmEvent 재발행 - 재귀 방지 (filterNot { it is AlarmEvent }) - Category 매핑: BV_URGENT/BV_WARN/POSTURE_TRANSITION/GAIT_STARTED/STOPPED/ BLE_LOST/BATTERY_CRITICAL/BATTERY_LOW - Guardian 앱 로직 단순화 (AlarmEvent 하나만 구독하면 됨) 3. Dev-mode overlay 확장 (PiezoMonitoringView · RSSI 옆) - ClinicalEventBus 최근 5개 이벤트 rolling 표시 - Type(BV/PST/GAI/BLE/BAT/ALM) + severity color + 요약 - LaunchedEffect + mutableStateListOf · isDevMode gate 4. NotificationSubscriber (신규 · telemetry/) — in-app 소비 참조 구현 - AlarmEvent 구독 · Android push notification 발행 - BATTERY_CRITICAL/LOW → device_status 채널 (신규) - BLE_LOST → 30초 debounce · CONNECTED 시 reset - BV_URGENT/WARN → AppState.updateLevelFromMeasurement 직접 호출과 중복 방지 · NO-OP - MainActivity.onCreate 에서 start(this) 1회 호출 NotificationService 확장: - device_status 채널 신규 (배터리·BLE 알람용) - sendBatteryNotification(level, isCritical) · bucket dedup - sendBleLostNotification(reason) · resetBleDebounce() 의도: Guardian 앱 (동업자 · Supabase 경유) 이 in-app subscriber 와 동일 패턴으로 소비하도록 참조. 다음 단계는 GUARDIAN-02 파트너용 스키마 문서 (cloud-mvp). --- .../com/medithings/vesiscan/MainActivity.kt | 5 + .../com/medithings/vesiscan/ble/BleManager.kt | 85 +++++++++++ .../vesiscan/services/NotificationService.kt | 66 +++++++- .../vesiscan/telemetry/AlarmAggregator.kt | 141 ++++++++++++++++++ .../telemetry/NotificationSubscriber.kt | 103 +++++++++++++ .../views/monitoring/PiezoMonitoringView.kt | 53 +++++++ 6 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/telemetry/AlarmAggregator.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/telemetry/NotificationSubscriber.kt diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index 2e117cf..7e1f825 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -57,6 +57,11 @@ class MainActivity : ComponentActivity() { Rive.init(this) KeychainService.init(this) NotificationService.getInstance(this) + // GUARDIAN-01 Phase D · Bus 인프라 부팅. + // 1) AlarmAggregator: BV/Posture/Gait/BLE/Battery → AlarmEvent 재발행 + // 2) NotificationSubscriber: AlarmEvent → Android push (battery/BLE 신규) + com.medithings.vesiscan.telemetry.AlarmAggregator.start() + com.medithings.vesiscan.telemetry.NotificationSubscriber.start(this) enableEdgeToEdge() window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 3ebb14e..7bc534b 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -116,6 +116,74 @@ class BleManager private constructor(private val context: Context) { val batteryLevel = mutableStateOf(0) /** 현재 연결된 디바이스 RSSI (dBm) · 미측정/미연결 시 null. dev-mode UI 오버레이용. */ val rssi = mutableStateOf(null) + + // ── ClinicalEventBus emit helpers (Phase D · GUARDIAN-01) ── + // Battery / BLE 상태 변화 시 bus 로 broadcast. Subscriber (Notification · Guardian + // publisher 등) 가 관찰. 실패해도 필수기능 영향 없음 (UC-05 · fire-and-forget). + /** 마지막 배터리 bucket (0=critical<5, 1=low<20, 2=ok) · 임계 전이 감지용. -1=미측정. */ + @Volatile private var lastBatteryBucket: Int = -1 + private fun batteryBucketOf(pct: Int): Int = when { + pct < 5 -> 0 + pct < 20 -> 1 + else -> 2 + } + private fun deviceHash(): String = + com.medithings.vesiscan.telemetry.TelemetryHash.sessionId( + connectedDeviceName.value.ifEmpty { lastConnectedAddress ?: "unknown" } + ) + + /** 배터리 응답 (rsn) 파싱 후 호출. 임계 전이 시 CRITICAL/WARN, 아니면 INFO 로 emit. */ + private fun emitBatteryEvent(pct: Int) { + val newBucket = batteryBucketOf(pct) + val prev = lastBatteryBucket + val isTransition = prev != -1 && prev != newBucket + // 첫 측정은 반드시 emit (guardian 이 현재 상태 파악용), 이후는 전이 시에만. + val shouldEmit = prev == -1 || isTransition + if (!shouldEmit) return + lastBatteryBucket = newBucket + val sev = when (newBucket) { + 0 -> com.medithings.vesiscan.telemetry.Severity.CRITICAL + 1 -> com.medithings.vesiscan.telemetry.Severity.WARN + else -> com.medithings.vesiscan.telemetry.Severity.INFO + } + runCatching { + com.medithings.vesiscan.telemetry.ClinicalEventBus.emit( + com.medithings.vesiscan.telemetry.BatteryEvent( + ts = System.currentTimeMillis(), + sessionId = deviceHash(), + severity = sev, + level = pct, + isTransition = isTransition, + ) + ) + } + } + + /** BLE 연결 상태 전이 시 emit. CONNECTED=INFO, 나머지=WARN. */ + private fun emitConnectionEvent( + state: com.medithings.vesiscan.telemetry.ConnState, + reason: String? = null, + ) { + val sev = if (state == com.medithings.vesiscan.telemetry.ConnState.CONNECTED) + com.medithings.vesiscan.telemetry.Severity.INFO + else + com.medithings.vesiscan.telemetry.Severity.WARN + runCatching { + com.medithings.vesiscan.telemetry.ClinicalEventBus.emit( + com.medithings.vesiscan.telemetry.BleConnectionEvent( + ts = System.currentTimeMillis(), + sessionId = deviceHash(), + severity = sev, + state = state, + deviceNameHash = deviceHash(), + reason = reason, + rssi = rssi.value, + ) + ) + } + // 연결 해제 시 배터리 bucket 리셋 → 재연결 후 첫 rsn 이 다시 초기 emit 대상. + if (state != com.medithings.vesiscan.telemetry.ConnState.CONNECTED) lastBatteryBucket = -1 + } val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true) val isServiceReady = mutableStateOf(false) @@ -881,6 +949,11 @@ class BleManager private constructor(private val context: Context) { stopBatteryPolling() stopRssiPolling() rssi.value = null + commandQueue.clear("watchdog-disconnect") + emitConnectionEvent( + com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED, + reason = "watchdog zombie", + ) isConnected.value = false isServiceReady.value = false connectedDeviceName.value = "" @@ -1043,6 +1116,11 @@ class BleManager private constructor(private val context: Context) { stopBatteryPolling() stopRssiPolling() rssi.value = null + commandQueue.clear("gatt-error") + emitConnectionEvent( + com.medithings.vesiscan.telemetry.ConnState.FAILED, + reason = "GATT_ERROR status=$status newState=$newState", + ) stopWatchdog() // 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } @@ -1092,6 +1170,7 @@ class BleManager private constructor(private val context: Context) { reconnectAttempt = 0 cancelReconnectTimer() debugLogger.connected(connectedDeviceName.value, gatt.device.address) + emitConnectionEvent(com.medithings.vesiscan.telemetry.ConnState.CONNECTED) // 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시 // KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등). @@ -1115,10 +1194,15 @@ class BleManager private constructor(private val context: Context) { } BluetoothProfile.STATE_DISCONNECTED -> { logd { "Disconnected from ${gatt.device.address}" } + emitConnectionEvent( + com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED, + reason = "gatt state disconnected", + ) isConnecting.value = false stopBatteryPolling() stopRssiPolling() rssi.value = null + commandQueue.clear("state-disconnected") // 2026-07-07 fix: disconnect 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } @@ -1399,6 +1483,7 @@ class BleManager private constructor(private val context: Context) { batteryLevel.value = pct } debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)") + emitBatteryEvent(pct) } } "rid:" -> { diff --git a/app/src/main/java/com/medithings/vesiscan/services/NotificationService.kt b/app/src/main/java/com/medithings/vesiscan/services/NotificationService.kt index c9faea2..fab49d3 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/NotificationService.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/NotificationService.kt @@ -20,9 +20,17 @@ class NotificationService private constructor(private val context: Context) { private const val CHANNEL_ID = "bladder_urgency" private const val CHANNEL_NAME = "Bladder Monitoring" + private const val DEVICE_CHANNEL_ID = "device_status" + private const val BATTERY_NOTIF_ID = 1001 + private const val BLE_NOTIF_ID = 1002 + /** BLE 알람 최소 간격 (재연결/끊김 반복 시 스팸 방지) · ms */ + private const val BLE_DEBOUNCE_MS = 30_000L } private var lastNotifiedUrgency: String? = null + // GUARDIAN-01 Phase D · 신규 채널 dedup key (battery bucket, BLE state). + @Volatile private var lastBatteryBucket: Int = -1 + @Volatile private var lastBleAlertMs: Long = 0L init { createNotificationChannel() @@ -30,16 +38,20 @@ class NotificationService private constructor(private val context: Context) { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val bladder = NotificationChannel( + CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH, ).apply { description = "Bladder level urgency notifications" enableVibration(true) } - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) + val device = NotificationChannel( + DEVICE_CHANNEL_ID, "Device Status", NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Battery low / BLE disconnect notifications" + } + nm.createNotificationChannel(bladder) + nm.createNotificationChannel(device) } } @@ -70,8 +82,50 @@ class NotificationService private constructor(private val context: Context) { } catch (_: SecurityException) { } } + /** 배터리 임계 알림 · bucket 전이 시 1회만. isCritical=true → CRITICAL, false → LOW. */ + fun sendBatteryNotification(level: Int, isCritical: Boolean) { + val bucket = if (isCritical) 0 else 1 + if (bucket == lastBatteryBucket) return + lastBatteryBucket = bucket + val title = if (isCritical) "Battery Critical" else "Battery Low" + val body = "Device battery at $level%. Please charge soon." + val priority = if (isCritical) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT + val notif = NotificationCompat.Builder(context, DEVICE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setPriority(priority) + .setAutoCancel(true) + .build() + try { NotificationManagerCompat.from(context).notify(BATTERY_NOTIF_ID, notif) } + catch (_: SecurityException) { } + } + + /** BLE 연결 해제 알림 · 30초 내 중복 억제. */ + fun sendBleLostNotification(reason: String?) { + val now = System.currentTimeMillis() + if (now - lastBleAlertMs < BLE_DEBOUNCE_MS) return + lastBleAlertMs = now + val notif = NotificationCompat.Builder(context, DEVICE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Device Disconnected") + .setContentText(if (reason != null) "BLE connection lost · $reason" else "BLE connection lost") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + try { NotificationManagerCompat.from(context).notify(BLE_NOTIF_ID, notif) } + catch (_: SecurityException) { } + } + + /** BLE 재연결 성공 시 debounce 리셋. */ + fun resetBleDebounce() { + lastBleAlertMs = 0L + } + fun cancelAll() { lastNotifiedUrgency = null + lastBatteryBucket = -1 + lastBleAlertMs = 0L NotificationManagerCompat.from(context).cancelAll() } } diff --git a/app/src/main/java/com/medithings/vesiscan/telemetry/AlarmAggregator.kt b/app/src/main/java/com/medithings/vesiscan/telemetry/AlarmAggregator.kt new file mode 100644 index 0000000..b467e3e --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/telemetry/AlarmAggregator.kt @@ -0,0 +1,141 @@ +/* + * AlarmAggregator — 상위 severity 이벤트를 통합 AlarmEvent 로 재발행. + * + * 목적 (GUARDIAN-01 §Rev1 · 파트너 앱 로직 단순화): + * - Bus 에는 BvEvent / PostureEvent / GaitEvent / BleConnectionEvent / BatteryEvent 가 + * 개별로 흐름. Guardian 앱이 각각 필터/판정 로직 구현하면 부담 큼. + * - AlarmAggregator 는 "알람 등급 이상" (severity WARN 또는 CRITICAL) 만 뽑아 + * 통합 AlarmEvent 로 같은 bus 에 재발행 → Guardian 은 AlarmEvent 하나만 구독하면 됨. + * + * 필터 규칙 (2026-08-04 초안 · 임상 협의 후 튜닝): + * - BvEvent : severity in {WARN, CRITICAL} && isTransition + * → category = BV_URGENT (CRITICAL) / BV_WARN (WARN) + * - PostureEvent : severity == WARN && isTransition → POSTURE_TRANSITION + * - GaitEvent : state = WALKING (severity=WARN) → GAIT_STARTED + * state = STOPPED (severity=INFO) → GAIT_STOPPED + * - BleConnectionEvent : state in {DISCONNECTED, FAILED} → BLE_LOST + * state == CONNECTED (severity=INFO) → skip (알람 아님) + * state == RECONNECTING → skip (알람 아님) + * - BatteryEvent : severity == CRITICAL → BATTERY_CRITICAL + * severity == WARN → BATTERY_LOW + * - AlarmEvent : 자기 재귀 방지 → 무조건 skip + * + * 실패 시 필수기능 영향 없음 (UC-05 · fire-and-forget). + */ +package com.medithings.vesiscan.telemetry + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus + +/** + * 앱 시작 시 `AlarmAggregator.start()` 1회 호출. 정지는 `stop()` (테스트/재시작용). + * 프로세스 라이프사이클과 동일하게 살아야 하므로 GlobalScope 유사 · SupervisorJob 소유. + */ +object AlarmAggregator { + + private const val TAG = "AlarmAggregator" + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Default + job) + + @Volatile private var started = false + + fun start() { + if (started) return + started = true + ClinicalEventBus.events + .filterNot { it is AlarmEvent } // 재귀 방지 + .onEach { evt -> tryAggregate(evt) } + .launchIn(scope) + Log.i(TAG, "started · listening to ClinicalEventBus") + } + + /** 테스트/재시작용. 프로덕션에서는 호출 불필요. */ + fun stop() { + job.cancel() + started = false + } + + private fun tryAggregate(evt: ClinicalEvent) { + try { + val alarm = toAlarm(evt) ?: return + ClinicalEventBus.emit(alarm) + } catch (e: Exception) { + Log.w(TAG, "aggregate failed: ${e.message}") + } + } + + private fun toAlarm(evt: ClinicalEvent): AlarmEvent? = when (evt) { + is BvEvent -> if (evt.isTransition && evt.severity != Severity.INFO) { + val cat = if (evt.severity == Severity.CRITICAL) AlarmCategory.BV_URGENT else AlarmCategory.BV_WARN + AlarmEvent( + ts = evt.ts, sessionId = evt.sessionId, severity = evt.severity, + category = cat, + message = "BV ${evt.urgency.name} · ${evt.volumeMl} mL (${evt.bvMethod})", + data = mapOf( + "volumeMl" to evt.volumeMl.toString(), + "urgency" to evt.urgency.name, + "bvMethod" to evt.bvMethod, + "prevUrgency" to (evt.prevUrgency?.name ?: "null"), + ), + ) + } else null + + is PostureEvent -> if (evt.isTransition && evt.severity == Severity.WARN) { + AlarmEvent( + ts = evt.ts, sessionId = evt.sessionId, severity = evt.severity, + category = AlarmCategory.POSTURE_TRANSITION, + message = "Posture ${evt.fromPosture?.name ?: "?"} → ${evt.posture.name}", + data = mapOf( + "posture" to evt.posture.name, + "fromPosture" to (evt.fromPosture?.name ?: "null"), + "tiltDeg" to "%.1f".format(evt.tiltDeg), + ), + ) + } else null + + is GaitEvent -> AlarmEvent( + ts = evt.ts, sessionId = evt.sessionId, severity = evt.severity, + category = if (evt.state == GaitState.WALKING) AlarmCategory.GAIT_STARTED else AlarmCategory.GAIT_STOPPED, + message = if (evt.state == GaitState.WALKING) "보행 시작" else "보행 종료", + data = buildMap { + put("state", evt.state.name) + evt.stepRate?.let { put("stepRate", "%.1f".format(it)) } + evt.durationMs?.let { put("durationMs", it.toString()) } + }, + ) + + is BleConnectionEvent -> when (evt.state) { + ConnState.DISCONNECTED, ConnState.FAILED -> AlarmEvent( + ts = evt.ts, sessionId = evt.sessionId, severity = evt.severity, + category = AlarmCategory.BLE_LOST, + message = "BLE ${evt.state.name}${evt.reason?.let { " · $it" } ?: ""}", + data = buildMap { + put("state", evt.state.name) + evt.reason?.let { put("reason", it) } + evt.rssi?.let { put("rssi", it.toString()) } + }, + ) + else -> null // CONNECTED · RECONNECTING 은 알람 아님 + } + + is BatteryEvent -> if (evt.severity != Severity.INFO) { + val cat = if (evt.severity == Severity.CRITICAL) AlarmCategory.BATTERY_CRITICAL else AlarmCategory.BATTERY_LOW + AlarmEvent( + ts = evt.ts, sessionId = evt.sessionId, severity = evt.severity, + category = cat, + message = "배터리 ${evt.level}%", + data = mapOf("level" to evt.level.toString()), + ) + } else null + + is AlarmEvent -> null // 재귀 방지 (filterNot 에서 걸러지지만 이중 안전) + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/telemetry/NotificationSubscriber.kt b/app/src/main/java/com/medithings/vesiscan/telemetry/NotificationSubscriber.kt new file mode 100644 index 0000000..1035a5a --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/telemetry/NotificationSubscriber.kt @@ -0,0 +1,103 @@ +/* + * NotificationSubscriber — In-app AlarmEvent 소비자 · Android 알림 발행. + * + * 목적 (GUARDIAN-01 §Rev1 · Phase D 원래 설계 목표): + * - Bus subscriber 패턴의 in-app 참조 구현체. + * - Guardian 앱 (동업자 개발 예정 · 별도 프로젝트) 이 Supabase 로 이벤트 받은 뒤 + * 수행할 로직 (알림 발행) 을 앱 내에서 미리 시연 · 검증. + * + * 담당: + * - BATTERY_CRITICAL / BATTERY_LOW → 배터리 알림 (신규 채널 "device_status") + * - BLE_LOST → 기기 연결 끊김 알림 (30초 debounce) + * - BV_URGENT / BV_WARN → 기존 AppState.updateLevelFromMeasurement 직접 호출과 + * 중복되므로 여기서는 NO-OP · 로그만. + * (향후 direct 호출 제거 · subscriber 로 통일 예정) + * - POSTURE_TRANSITION / GAIT_* → 로그만 (필요 시 향후 push 확장) + * + * 안정성: + * - AlarmAggregator 와 동일 · SupervisorJob · fire-and-forget (UC-05) + * - context.applicationContext 사용 · leak 없음 + */ +package com.medithings.vesiscan.telemetry + +import android.content.Context +import android.util.Log +import com.medithings.vesiscan.services.NotificationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus + +object NotificationSubscriber { + + private const val TAG = "NotifSubscriber" + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Main + job) + + @Volatile private var started = false + + /** + * MainActivity.onCreate 등 앱 시작 1회 호출. + * AlarmAggregator.start() 이후에 호출해야 AlarmEvent 를 놓치지 않음. + */ + fun start(context: Context) { + if (started) return + started = true + val appCtx = context.applicationContext + val notifSvc = NotificationService.getInstance(appCtx) + ClinicalEventBus.events + .filterIsInstance() + .onEach { alarm -> handle(alarm, notifSvc) } + .launchIn(scope) + + // 재연결 성공 시 debounce 리셋 (다음 disconnect 시 즉시 알림 가능). + ClinicalEventBus.events + .filterIsInstance() + .onEach { evt -> + if (evt.state == ConnState.CONNECTED) notifSvc.resetBleDebounce() + } + .launchIn(scope) + + Log.i(TAG, "started · listening to AlarmEvent + BleConnectionEvent") + } + + fun stop() { + job.cancel() + started = false + } + + private fun handle(alarm: AlarmEvent, notif: NotificationService) { + try { + when (alarm.category) { + AlarmCategory.BATTERY_CRITICAL -> { + val level = alarm.data["level"]?.toIntOrNull() ?: 0 + notif.sendBatteryNotification(level, isCritical = true) + } + AlarmCategory.BATTERY_LOW -> { + val level = alarm.data["level"]?.toIntOrNull() ?: 0 + notif.sendBatteryNotification(level, isCritical = false) + } + AlarmCategory.BLE_LOST -> { + notif.sendBleLostNotification(alarm.data["reason"]) + } + AlarmCategory.BV_URGENT, AlarmCategory.BV_WARN -> { + // AppState.updateLevelFromMeasurement 에서 직접 호출 중 → 중복 방지 위해 NO-OP. + Log.d(TAG, "skip BV alarm (handled by AppState direct call): ${alarm.message}") + } + AlarmCategory.POSTURE_TRANSITION, + AlarmCategory.GAIT_STARTED, AlarmCategory.GAIT_STOPPED, + AlarmCategory.SENSOR_DETACHED, + AlarmCategory.BLE_RESTORED, + AlarmCategory.FIRMWARE_OUTDATED -> { + Log.d(TAG, "alarm (log only): ${alarm.category.name} · ${alarm.message}") + } + } + } catch (e: Exception) { + Log.w(TAG, "handle 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 a0b1bcb..7331870 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 @@ -204,6 +204,14 @@ fun PiezoMonitoringView(appState: AppState) { val batteryLvl by bleManager.batteryLevel val isConnected by bleManager.isConnected val liveRssi by bleManager.rssi + // Dev-mode: ClinicalEventBus 최근 5개 이벤트 (rolling · guardian bus 상태 확인용). + val recentEvents = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + com.medithings.vesiscan.telemetry.ClinicalEventBus.events.collect { evt -> + recentEvents.add(0, evt) + while (recentEvents.size > 5) recentEvents.removeAt(recentEvents.size - 1) + } + } val catheterCount by appState.catheterCount // Auto 측정 윈도우 (10개 모아서 최대/최소 각 1개 제외 → 8개 평균) @@ -1437,6 +1445,51 @@ fun PiezoMonitoringView(appState: AppState) { fontFamily = FontFamily.Monospace, ) } + // GUARDIAN-01 Phase D: Bus 이벤트 스트림 모니터 (최근 5개). + if (recentEvents.isNotEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(bottom = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF37474F).copy(alpha = 0.08f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + "BUS · last 5", + fontSize = (9 * fontScale).sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF546E7A), + ) + recentEvents.forEach { evt -> + val (type, summary) = when (evt) { + is com.medithings.vesiscan.telemetry.BvEvent -> + "BV " to "${evt.volumeMl}ml ${evt.urgency.name}${if (evt.isTransition) "*" else ""}" + is com.medithings.vesiscan.telemetry.PostureEvent -> + "PST" to "${evt.fromPosture?.name?.take(3) ?: "-"}→${evt.posture.name.take(3)}" + is com.medithings.vesiscan.telemetry.GaitEvent -> + "GAI" to evt.state.name + is com.medithings.vesiscan.telemetry.BleConnectionEvent -> + "BLE" to evt.state.name + is com.medithings.vesiscan.telemetry.BatteryEvent -> + "BAT" to "${evt.level}%${if (evt.isTransition) "*" else ""}" + is com.medithings.vesiscan.telemetry.AlarmEvent -> + "ALM" to "${evt.category.name.take(10)}" + } + val sevColor = when (evt.severity) { + com.medithings.vesiscan.telemetry.Severity.CRITICAL -> Color(0xFFD32F2F) + com.medithings.vesiscan.telemetry.Severity.WARN -> Color(0xFFF9A825) + com.medithings.vesiscan.telemetry.Severity.INFO -> Color(0xFF37474F) + } + Text( + "$type $summary", + fontSize = (10 * fontScale).sp, + color = sevColor, + fontFamily = FontFamily.Monospace, + ) + } + } + } } // Donut + Bladdy sizing — 회의 결정: dev/일반 통일.