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
@@ -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}")
}
}
}