feat(guardian): Rev2 · Bus 확장 + AlarmAggregator + NotificationSubscriber

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).
This commit is contained in:
2026-08-04 09:52:15 +09:00
parent f05068002b
commit de6e1d1931
6 changed files with 447 additions and 6 deletions
@@ -57,6 +57,11 @@ class MainActivity : ComponentActivity() {
Rive.init(this) Rive.init(this)
KeychainService.init(this) KeychainService.init(this)
NotificationService.getInstance(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() enableEdgeToEdge()
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -116,6 +116,74 @@ class BleManager private constructor(private val context: Context) {
val batteryLevel = mutableStateOf(0) val batteryLevel = mutableStateOf(0)
/** 현재 연결된 디바이스 RSSI (dBm) · 미측정/미연결 시 null. dev-mode UI 오버레이용. */ /** 현재 연결된 디바이스 RSSI (dBm) · 미측정/미연결 시 null. dev-mode UI 오버레이용. */
val rssi = mutableStateOf<Int?>(null) val rssi = mutableStateOf<Int?>(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 bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
val isServiceReady = mutableStateOf(false) val isServiceReady = mutableStateOf(false)
@@ -881,6 +949,11 @@ class BleManager private constructor(private val context: Context) {
stopBatteryPolling() stopBatteryPolling()
stopRssiPolling() stopRssiPolling()
rssi.value = null rssi.value = null
commandQueue.clear("watchdog-disconnect")
emitConnectionEvent(
com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED,
reason = "watchdog zombie",
)
isConnected.value = false isConnected.value = false
isServiceReady.value = false isServiceReady.value = false
connectedDeviceName.value = "" connectedDeviceName.value = ""
@@ -1043,6 +1116,11 @@ class BleManager private constructor(private val context: Context) {
stopBatteryPolling() stopBatteryPolling()
stopRssiPolling() stopRssiPolling()
rssi.value = null rssi.value = null
commandQueue.clear("gatt-error")
emitConnectionEvent(
com.medithings.vesiscan.telemetry.ConnState.FAILED,
reason = "GATT_ERROR status=$status newState=$newState",
)
stopWatchdog() stopWatchdog()
// 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소. // 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
@@ -1092,6 +1170,7 @@ class BleManager private constructor(private val context: Context) {
reconnectAttempt = 0 reconnectAttempt = 0
cancelReconnectTimer() cancelReconnectTimer()
debugLogger.connected(connectedDeviceName.value, gatt.device.address) debugLogger.connected(connectedDeviceName.value, gatt.device.address)
emitConnectionEvent(com.medithings.vesiscan.telemetry.ConnState.CONNECTED)
// 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시 // 2026-07-10: clinical 흐름 조건 제거 — 어떤 진입 경로든 연결 성공 시
// KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등). // KnownDeviceStore 에 영속화. 재사용 편의 (재연결 대상 자동 감지 등).
@@ -1115,10 +1194,15 @@ class BleManager private constructor(private val context: Context) {
} }
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
logd { "Disconnected from ${gatt.device.address}" } logd { "Disconnected from ${gatt.device.address}" }
emitConnectionEvent(
com.medithings.vesiscan.telemetry.ConnState.DISCONNECTED,
reason = "gatt state disconnected",
)
isConnecting.value = false isConnecting.value = false
stopBatteryPolling() stopBatteryPolling()
stopRssiPolling() stopRssiPolling()
rssi.value = null rssi.value = null
commandQueue.clear("state-disconnected")
// 2026-07-07 fix: disconnect 시 대기 중인 timer 취소. // 2026-07-07 fix: disconnect 시 대기 중인 timer 취소.
fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null }
mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null }
@@ -1399,6 +1483,7 @@ class BleManager private constructor(private val context: Context) {
batteryLevel.value = pct batteryLevel.value = pct
} }
debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)") debugLogger.rx("rsn", data.size, "battery=${millivolts}mV (${pct}%)")
emitBatteryEvent(pct)
} }
} }
"rid:" -> { "rid:" -> {
@@ -20,9 +20,17 @@ class NotificationService private constructor(private val context: Context) {
private const val CHANNEL_ID = "bladder_urgency" private const val CHANNEL_ID = "bladder_urgency"
private const val CHANNEL_NAME = "Bladder Monitoring" 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 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 { init {
createNotificationChannel() createNotificationChannel()
@@ -30,16 +38,20 @@ class NotificationService private constructor(private val context: Context) {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
CHANNEL_ID, val bladder = NotificationChannel(
CHANNEL_NAME, CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH,
NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "Bladder level urgency notifications" description = "Bladder level urgency notifications"
enableVibration(true) enableVibration(true)
} }
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val device = NotificationChannel(
notificationManager.createNotificationChannel(channel) 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) { } } 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() { fun cancelAll() {
lastNotifiedUrgency = null lastNotifiedUrgency = null
lastBatteryBucket = -1
lastBleAlertMs = 0L
NotificationManagerCompat.from(context).cancelAll() NotificationManagerCompat.from(context).cancelAll()
} }
} }
@@ -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 에서 걸러지지만 이중 안전)
}
}
@@ -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<AlarmEvent>()
.onEach { alarm -> handle(alarm, notifSvc) }
.launchIn(scope)
// 재연결 성공 시 debounce 리셋 (다음 disconnect 시 즉시 알림 가능).
ClinicalEventBus.events
.filterIsInstance<BleConnectionEvent>()
.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}")
}
}
}
@@ -204,6 +204,14 @@ fun PiezoMonitoringView(appState: AppState) {
val batteryLvl by bleManager.batteryLevel val batteryLvl by bleManager.batteryLevel
val isConnected by bleManager.isConnected val isConnected by bleManager.isConnected
val liveRssi by bleManager.rssi val liveRssi by bleManager.rssi
// Dev-mode: ClinicalEventBus 최근 5개 이벤트 (rolling · guardian bus 상태 확인용).
val recentEvents = remember { mutableStateListOf<com.medithings.vesiscan.telemetry.ClinicalEvent>() }
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 val catheterCount by appState.catheterCount
// Auto 측정 윈도우 (10개 모아서 최대/최소 각 1개 제외 → 8개 평균) // Auto 측정 윈도우 (10개 모아서 최대/최소 각 1개 제외 → 8개 평균)
@@ -1437,6 +1445,51 @@ fun PiezoMonitoringView(appState: AppState) {
fontFamily = FontFamily.Monospace, 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/일반 통일. // Donut + Bladdy sizing — 회의 결정: dev/일반 통일.