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