feat(dev): dev-mode RSSI live overlay · PiezoMonitoringView 도넛차트 위

BleManager:
- val rssi: MutableStateOf<Int?> — 미연결 시 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 기본).
This commit is contained in:
2026-08-03 18:04:51 +09:00
parent 165df3c63d
commit 815774e23c
2 changed files with 79 additions and 1 deletions
@@ -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<Int?>(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
}
}
}
}
@@ -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