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:
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user