From 258bad9a2697b4632d3777b07a415af670f937b2 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 4 May 2026 16:09:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Foreground=20Service=20+=20Detachment?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20+=20Placement=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EB=AA=A8=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreground Service (BleForegroundService.kt): - BLE 연결 시 자동 시작, 해제 시 자동 중단 - PARTIAL_WAKE_LOCK (최대 4시간) - Notification: "VesiScan - BLE 기기 연결 중..." - connectedDevice foregroundServiceType - Doze 모드에서도 BLE 통신 유지 - keepScreenOn도 유지 (추가 안전장치) Detachment 개선: - THR_MEAN/STD 100→30 (완전 탈착만 감지, 거리 약화 무시) - 6채널 모두 수신됐을 때만 체크 (BLE 거리 문제와 구분) - Placement: if(false) 제거 → 활성화 + "센서가 부착되지 않았습니다" - Monitoring: 정상 복귀 시 LED OFF Placement 가이드 모드: - Simple (기본): 3+채널 → 바로 좌우 (기존) - Boundary: 올라갔다 내려오기 패턴 (경계 감지 → 최적점 복귀) - 설정 톱니바퀴에 Simple/Boundary 스위치 BLE 로그: - 역순 표시 (최신이 위) — 스크롤 위치 유지 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/AndroidManifest.xml | 10 ++ .../medilightv2android/MainActivity.kt | 1 + .../medilightv2android/ble/BleManager.kt | 5 +- .../managers/DetachmentDetection.kt | 4 +- .../managers/GreenZoneConstants.kt | 2 + .../services/BleForegroundService.kt | 136 ++++++++++++++++++ .../views/monitoring/PiezoMonitoringView.kt | 27 ++-- .../ui/views/monitoring/PlacementGuideView.kt | 112 +++++++++++---- 8 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d6f163..1fc60b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,11 @@ + + + + + @@ -56,6 +61,11 @@ + + \ No newline at end of file diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 933c434..210fa0f 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -46,6 +46,7 @@ class MainActivity : ComponentActivity() { NotificationService.getInstance(this) enableEdgeToEdge() + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setContent { Medilightv2androidTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index 2e687b7..95103c1 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -245,6 +245,7 @@ class BleManager private constructor(private val context: Context) { connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer = null bluetoothGatt?.disconnect() + com.example.medilightv2android.services.BleForegroundService.stop(context) } fun disconnectAndUnbond() { @@ -252,6 +253,7 @@ class BleManager private constructor(private val context: Context) { cancelAutoReconnect() stopWatchdog() stopBatteryPolling() + com.example.medilightv2android.services.BleForegroundService.stop(context) connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer = null @@ -274,7 +276,7 @@ class BleManager private constructor(private val context: Context) { connectedDeviceName.value = "" batteryLevel.value = 0 lastConnectedAddress = null - + // Android 측 본딩 정보도 삭제 device?.let { dev -> try { @@ -790,6 +792,7 @@ class BleManager private constructor(private val context: Context) { if (txCharacteristic != null && rxCharacteristic != null) { isServiceReady.value = true com.example.medilightv2android.services.AdcCsvLogger.newSession() + com.example.medilightv2android.services.BleForegroundService.start(context) startBatteryPolling() startWatchdog() sendDeviceInfoQuery() diff --git a/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt b/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt index 93818bb..112dd1c 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt @@ -17,8 +17,8 @@ object DetachmentDetection { const val WINDOW_END = 80 const val BASELINE_WINDOW_START = 80 const val BASELINE_WINDOW_END = 100 - const val THR_MEAN = 100.0 - const val THR_STD = 100.0 + const val THR_MEAN = 30.0 + const val THR_STD = 30.0 const val N_CENTER_CH = 4 data class DetachmentResult( diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index 6539df0..3399b75 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -13,10 +13,12 @@ package com.example.medilightv2android.managers * 각 상수의 의미와 영향 범위를 아래 주석 참고. */ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C } +enum class PlacementGuideMode { SIMPLE, BOUNDARY } object GreenZoneConstants { var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C + var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE // ═══════════════════════════════════════════════════════════ // 신호 범위 diff --git a/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt b/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt new file mode 100644 index 0000000..73ab087 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt @@ -0,0 +1,136 @@ +package com.example.medilightv2android.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import com.example.medilightv2android.MainActivity +import com.example.medilightv2android.R + +/** + * BLE 연결 유지를 위한 Foreground Service. + * + * 화면이 꺼져도 Android Doze 모드에서 앱을 유지하여 + * BLE 통신(TX/RX), watchdog, Auto 측정이 중단되지 않도록 한다. + */ +class BleForegroundService : Service() { + + companion object { + const val CHANNEL_ID = "vesiscan_ble_channel" + const val NOTIFICATION_ID = 1001 + const val ACTION_START = "com.example.medilightv2android.BLE_START" + const val ACTION_STOP = "com.example.medilightv2android.BLE_STOP" + + fun start(context: Context) { + val intent = Intent(context, BleForegroundService::class.java).apply { + action = ACTION_START + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, BleForegroundService::class.java).apply { + action = ACTION_STOP + } + context.startService(intent) + } + } + + private var wakeLock: PowerManager.WakeLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + releaseWakeLock() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + else -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, buildNotification(), + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + startForeground(NOTIFICATION_ID, buildNotification()) + } + acquireWakeLock() + } + } + return START_STICKY + } + + override fun onDestroy() { + releaseWakeLock() + super.onDestroy() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "VesiScan BLE Connection", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "BLE 기기 연결 유지" + setShowBadge(false) + } + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(channel) + } + } + + private fun buildNotification(): Notification { + val pendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("VesiScan") + .setContentText("BLE 기기 연결 중...") + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setOngoing(true) + .setContentIntent(pendingIntent) + .build() + } + + private fun acquireWakeLock() { + if (wakeLock == null) { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "VesiScan::BleWakeLock" + ).apply { + acquire(4 * 60 * 60 * 1000L) // 최대 4시간 + } + } + } + + private fun releaseWakeLock() { + wakeLock?.let { + if (it.isHeld) it.release() + } + wakeLock = null + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index eafe00a..f51bbbd 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -123,13 +123,19 @@ fun PiezoMonitoringView(appState: AppState) { lastMultiChannelResult.clear() lastMultiChannelResult.addAll(channels) - // Detachment 체크 - val sortedBuffers = channels.sortedBy { it.channel }.map { it.buffer } - val detachResult = com.example.medilightv2android.managers.DetachmentDetection.detect(sortedBuffers) - if (detachResult.isDetached) { - Log.w("PiezoMonitor", "DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") - bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") - handler.post { bleManager.sendLedMode(4) } + // Detachment 체크 (6채널 모두 수신됐을 때만 — BLE 거리 문제와 구분) + val fullChannels = channels.filter { it.buffer.size >= 20 } + if (fullChannels.size >= 6) { + val sortedBuffers = channels.sortedBy { it.channel }.map { it.buffer } + val detachResult = com.example.medilightv2android.managers.DetachmentDetection.detect(sortedBuffers) + if (detachResult.isDetached) { + Log.w("PiezoMonitor", "DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") + bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") + handler.post { bleManager.sendLedMode(4) } + } else { + // 정상 복귀 → LED OFF + handler.post { bleManager.sendLedMode(0) } + } } var rawVolumeMl: Double? = null @@ -1375,7 +1381,7 @@ private fun BleDebugPanel(bleManager: BleManager) { } } - val entries = remember(refreshTick) { logger.getRecentEntries(50) } + val entries = remember(refreshTick) { logger.getRecentEntries(50).reversed() } val stats = remember(refreshTick) { logger.getStats() } Card( @@ -1439,12 +1445,13 @@ private fun BleDebugPanel(bleManager: BleManager) { if (timeout > 0) Text("TOUT:$timeout", fontSize = 10.sp, color = Color(0xFFFF8A65)) } - // Log entries + // Log entries (스크롤 위치 유지 — 자동 스크롤 안함) + val logScrollState = rememberScrollState() Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 200.dp) - .verticalScroll(rememberScrollState()) + .verticalScroll(logScrollState) ) { for (entry in entries) { val color = when (entry.level) { diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 6165277..47181e1 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -77,6 +77,10 @@ fun PlacementGuideView(appState: AppState) { // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } + // 상하 탐색: 상한 경계 감지 로직 + var maxCenterSeen by remember { mutableIntStateOf(0) } + var stableAtMax by remember { mutableIntStateOf(0) } + var boundaryHit by remember { mutableStateOf(false) } // 화면 진입 시 상태 리셋 LaunchedEffect(Unit) { @@ -511,20 +515,25 @@ fun PlacementGuideView(appState: AppState) { lastChannelData = channels lastWallResults = allWalls.toList() - // Detachment check (미부착 감지) + // Detachment check (6채널 모두 수신됐을 때만) + val fullCh = channels.filter { it.buffer.size >= 20 } val buffers = channels.sortedBy { it.channel }.map { it.buffer } - val detachResult = DetachmentDetection.detect(buffers) + val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null - if (false) { // detachment 비활성화 - directionHint = "Sensor detached — check contact" + if (detachResult != null && detachResult.isDetached) { + directionHint = "센서가 부착되지 않았습니다. 부착해주세요" directionIcon = "exclamationmark.triangle" placementScore = 0 - bleManager.sendLedMode(4) // DETACH_WARNING + if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 } bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") Log.w("PlacementGuide", "DETACHED mean=${ "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") } else { - val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase) + val vs = VerticalState(maxCenterSeen, stableAtMax, boundaryHit) + val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase, vs) + maxCenterSeen = vs.maxCenterSeen + stableAtMax = vs.stableAtMax + boundaryHit = vs.boundaryHit // 디바운스: 같은 phase 2회 연속 + 최소 3초 유지 if (guideResult.nextPhase == lastGuidePhase) { @@ -675,6 +684,24 @@ fun PlacementGuideView(appState: AppState) { HorizontalDivider() + // Guide mode + var useBoundary by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Guide", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (useBoundary) "Boundary" else "Simple", fontSize = 13.sp, fontWeight = FontWeight.Bold, + color = if (useBoundary) Color(0xFFFF5722) else MlPrimary) + Spacer(modifier = Modifier.weight(1f)) + Switch(checked = useBoundary, onCheckedChange = { + useBoundary = it + com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode = + if (it) com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY + else com.example.medilightv2android.managers.PlacementGuideMode.SIMPLE + }, colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFFFF5722))) + } + + HorizontalDivider() + // Otsu toggle var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -898,12 +925,19 @@ private const val DEFAULT_LR_DEV_THR = 0.25 * [3단계] 최종 Green: CV 확인 * - CV ≤ threshold → Green (LED 6) */ +data class VerticalState( + var maxCenterSeen: Int = 0, + var stableAtMax: Int = 0, + var boundaryHit: Boolean = false +) + private fun computePlacementGuide( detected: List, urineLens: List, walls: List?> = emptyList(), repeatCount: Int = 0, - currentPhase: PlacementPhase = PlacementPhase.VERTICAL + currentPhase: PlacementPhase = PlacementPhase.VERTICAL, + verticalState: VerticalState? = null ): PlacementGuideResult { val detectedCount = detected.count { it } @@ -912,7 +946,6 @@ private fun computePlacementGuide( phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - // Center 채널 정보 val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } val centerDetected = (0..3).count { detected.getOrElse(it) { false } } val ch0 = detected.getOrElse(0) { false } @@ -922,39 +955,60 @@ private fun computePlacementGuide( val len0 = centerLens[0]; val len1 = centerLens[1] val len2 = centerLens[2]; val len3 = centerLens[3] - // ═══ 1단계: 상하 탐색 (치골→위로→CH0 안잡히면 아래로) ═══ + // ═══ 1단계: 상하 탐색 ═══ if (currentPhase == PlacementPhase.VERTICAL) { + val guideMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode + val vs = verticalState ?: VerticalState() + + // ── BOUNDARY 모드: 올라갔다 내려오기 ── + if (guideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) { + if (centerDetected < 2) { + vs.stableAtMax = 0 + val hint: String; val icon: String + when { + (ch3 || ch2) && !ch0 && !ch1 -> { hint = "센서를 아래로 천천히 내리세요 ↓"; icon = "arrow.down" } + else -> { hint = "센서를 위로 천천히 올리세요 ↑"; icon = "arrow.up" } + } + return PlacementGuideResult(hint, icon, centerDetected * 10, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } + if (centerDetected >= vs.maxCenterSeen) { vs.maxCenterSeen = centerDetected; vs.stableAtMax++ } + if (!vs.boundaryHit && vs.stableAtMax >= 2 && centerDetected < vs.maxCenterSeen) { + vs.boundaryHit = true + return PlacementGuideResult("너무 올라갔습니다. 아래로 내리세요 ↓", "arrow.down", 30, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } + if (vs.boundaryHit) { + if (centerDetected < vs.maxCenterSeen) { + return PlacementGuideResult("조금 더 아래로 ↓", "arrow.down", 35, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } + vs.boundaryHit = false; vs.stableAtMax = 0 + val l4 = urineLens.getOrElse(4) { 0 }; val l5 = urineLens.getOrElse(5) { 0 } + val lrHint = when { l4 > 0 && l5 > 0 && l4 > l5 -> "최적 위치! 오른쪽으로 →"; l4 > 0 && l5 > 0 && l5 > l4 -> "최적 위치! 왼쪽으로 ←"; else -> "최적 위치! 좌우 조절하세요 ↔" } + val lrIcon = when { l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"; l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"; else -> "arrow.left.arrow.right" } + return PlacementGuideResult(lrHint, lrIcon, 40, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) + } + return PlacementGuideResult("센서를 위로 천천히 올리세요 ↑", "arrow.up", centerDetected * 10, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } + + // ── SIMPLE 모드: 3+채널 안정 → 바로 좌우 ── if (centerDetected < 3) { - val hint: String - val icon: String + val hint: String; val icon: String when { - // 위쪽만 잡힘 → 아래로 내리세요 - (ch3 || ch2) && !ch0 && !ch1 -> { - hint = "센서를 아래로 천천히 내리세요 ↓" - icon = "arrow.down" - } - // 아래쪽만 잡힘 → 위로 올리세요 - (ch0 || ch1) && !ch3 && !ch2 -> { - hint = "센서를 위로 천천히 올리세요 ↑" - icon = "arrow.up" - } - // 아무것도 또는 1개만 → 위로 올리세요 (치골에서 시작 기준) - else -> { - hint = "센서를 위로 천천히 올리세요 ↑" - icon = "arrow.up" - } + (ch3 || ch2) && !ch0 && !ch1 -> { hint = "센서를 아래로 천천히 내리세요 ↓"; icon = "arrow.down" } + (ch0 || ch1) && !ch3 && !ch2 -> { hint = "센서를 위로 천천히 올리세요 ↑"; icon = "arrow.up" } + else -> { hint = "센서를 위로 천천히 올리세요 ↑"; icon = "arrow.up" } } return PlacementGuideResult(hint, icon, centerDetected * 10, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - - // 3개 이상 검출 — CH0 유무 확인 if (!ch0 && ch3) { return PlacementGuideResult("CH0이 약합니다. 센서를 아래로 조금 내리세요 ↓", "arrow.down", 25, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - // 크기 패턴 확인 val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] } val maxLen = validLens.maxOrNull() ?: 0 val minLen = validLens.minOrNull() ?: 0