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:
@@ -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/일반 통일.
|
||||||
|
|||||||
Reference in New Issue
Block a user