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
@@ -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<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 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:" -> {