From 815774e23c15d03695b399b25d4641a32b979f9e Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 3 Aug 2026 18:04:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(dev):=20dev-mode=20RSSI=20live=20overlay?= =?UTF-8?q?=20=C2=B7=20PiezoMonitoringView=20=EB=8F=84=EB=84=9B=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BleManager: - val rssi: MutableStateOf — 미연결 시 null - startRssiPolling() / stopRssiPolling() — 2초 주기 readRemoteRssi() - Service ready 3 경로에 startRssiPolling() wire - disconnect · gatt error · reconnect · forceDisconnect 5 경로에 stopRssiPolling() + rssi.value = null PiezoMonitoringView: - val liveRssi by bleManager.rssi - Donut 위에 dev-mode gate overlay (badge chip): · null → "-- dBm" gray · >= -60 dBm → green (strong) · >= -75 dBm → amber (ok) · < -75 dBm → red (weak) - 개발자모드 (3-tap Bladdy) 진입 시에만 표시 일반 사용자 UI 영향 없음 (isDevMode=false 기본). --- .../com/medithings/vesiscan/ble/BleManager.kt | 45 ++++++++++++++++++- .../views/monitoring/PiezoMonitoringView.kt | 35 +++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index f101331..feb3793 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -64,6 +64,8 @@ class BleManager private constructor(private val context: Context) { private val BATTERY_POLL_INTERVAL_MS = 30_000L // 정상 polling 간격 private val BATTERY_RETRY_DELAY_MS = 3_000L // 첫 응답 재시도 지연 private val BATTERY_RETRY_MAX = 2 // 최대 재시도 횟수 + private var rssiTimer: Runnable? = null + private val RSSI_POLL_INTERVAL_MS = 2_000L // dev-mode UI polling 간격 private var connectionTimer: Runnable? = null private var isUserDisconnect = false var lastConnectedAddress: String? = null @@ -112,6 +114,8 @@ class BleManager private constructor(private val context: Context) { } val batteryLevel = mutableStateOf(0) + /** 현재 연결된 디바이스 RSSI (dBm) · 미측정/미연결 시 null. dev-mode UI 오버레이용. */ + val rssi = mutableStateOf(null) val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true) val isServiceReady = mutableStateOf(false) @@ -364,6 +368,8 @@ class BleManager private constructor(private val context: Context) { cancelAutoReconnect() stopWatchdog() stopBatteryPolling() + stopRssiPolling() + rssi.value = null com.medithings.vesiscan.services.BleForegroundService.stop(context) connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer = null @@ -733,6 +739,29 @@ class BleManager private constructor(private val context: Context) { batteryRetryTimer = null } + /** + * RSSI polling — 2 초 주기로 `readRemoteRssi()` 요청 → `onReadRemoteRssi` 콜백이 + * `rssi.value` 업데이트. dev-mode UI 오버레이가 이를 관찰한다. + * 실패해도 필수기능 영향 없음 (UC-05). + */ + fun startRssiPolling() { + stopRssiPolling() + val pollRunnable = object : Runnable { + override fun run() { + if (!isConnected.value) return + try { bluetoothGatt?.readRemoteRssi() } catch (_: Exception) {} + handler.postDelayed(this, RSSI_POLL_INTERVAL_MS) + } + } + rssiTimer = pollRunnable + handler.post(pollRunnable) + } + + fun stopRssiPolling() { + rssiTimer?.let { handler.removeCallbacks(it) } + rssiTimer = null + } + // ── Watchdog (zombie connection detection, runs on separate thread) ── private var watchdogJob: Thread? = null @@ -780,6 +809,8 @@ class BleManager private constructor(private val context: Context) { mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } stopBatteryPolling() + stopRssiPolling() + rssi.value = null isConnected.value = false isServiceReady.value = false connectedDeviceName.value = "" @@ -800,6 +831,8 @@ class BleManager private constructor(private val context: Context) { fun forceDisconnectAndReconnect() { stopBatteryPolling() + stopRssiPolling() + rssi.value = null stopWatchdog() isConnected.value = false isServiceReady.value = false @@ -937,6 +970,8 @@ class BleManager private constructor(private val context: Context) { debugLogger.error("GATT_ERROR status=$status newState=$newState wasConnected=$wasConnected") stopBatteryPolling() + stopRssiPolling() + rssi.value = null stopWatchdog() // 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } @@ -1011,6 +1046,8 @@ class BleManager private constructor(private val context: Context) { logd { "Disconnected from ${gatt.device.address}" } isConnecting.value = false stopBatteryPolling() + stopRssiPolling() + rssi.value = null // 2026-07-07 fix: disconnect 시 대기 중인 timer 취소. fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } mtbTimeoutRunnable?.let { handler.removeCallbacks(it); mtbTimeoutRunnable = null } @@ -1085,6 +1122,7 @@ class BleManager private constructor(private val context: Context) { isServiceReady.value = true onConnectionStateChanged?.invoke(true) startBatteryPolling() + startRssiPolling() startWatchdog() logd { "Service ready (no CCCD)" } } @@ -1096,6 +1134,7 @@ class BleManager private constructor(private val context: Context) { isServiceReady.value = true onConnectionStateChanged?.invoke(true) startBatteryPolling() + startRssiPolling() startWatchdog() logd { "Service ready (no RX)" } } @@ -1124,6 +1163,7 @@ class BleManager private constructor(private val context: Context) { com.medithings.vesiscan.services.AdcCsvLogger.newSession() com.medithings.vesiscan.services.BleForegroundService.start(context) startBatteryPolling() + startRssiPolling() startWatchdog() sendDeviceInfoQuery() // 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성). @@ -1196,7 +1236,10 @@ class BleManager private constructor(private val context: Context) { override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { - handler.post { debugLogger.rssi(rssi) } + handler.post { + debugLogger.rssi(rssi) + this@BleManager.rssi.value = rssi + } } } } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 4d3baeb..a0b1bcb 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -203,6 +203,7 @@ fun PiezoMonitoringView(appState: AppState) { val urgency = appState.bladderLevel.urgency val batteryLvl by bleManager.batteryLevel val isConnected by bleManager.isConnected + val liveRssi by bleManager.rssi val catheterCount by appState.catheterCount // Auto 측정 윈도우 (10개 모아서 최대/최소 각 1개 제외 → 8개 평균) @@ -1404,6 +1405,40 @@ fun PiezoMonitoringView(appState: AppState) { } } + // Dev-mode: RSSI live overlay — 도넛 위. 미연결/미측정 시 "-- dBm". + if (appState.isDevMode) { + val rssiValue = liveRssi + val (rssiLabel, rssiTint) = when { + rssiValue == null -> "-- dBm" to Color.Gray + rssiValue >= -60 -> "$rssiValue dBm" to Color(0xFF2E7D32) // strong · green + rssiValue >= -75 -> "$rssiValue dBm" to Color(0xFFF9A825) // ok · amber + else -> "$rssiValue dBm" to Color(0xFFD32F2F) // weak · red + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .padding(bottom = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(rssiTint.copy(alpha = 0.12f)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + "RSSI", + fontSize = (11 * fontScale).sp, + fontWeight = FontWeight.SemiBold, + color = rssiTint, + ) + Text( + rssiLabel, + fontSize = (12 * fontScale).sp, + fontWeight = FontWeight.Bold, + color = rssiTint, + fontFamily = FontFamily.Monospace, + ) + } + } + // Donut + Bladdy sizing — 회의 결정: dev/일반 통일. val baseDonutSize = donutFitSize val donutSize = baseDonutSize * 0.95f