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