feat: Foreground Service + Detachment 개선 + Placement 가이드 모드

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:09:54 +09:00
parent 48df4370bc
commit 258bad9a26
8 changed files with 255 additions and 42 deletions
+10
View File
@@ -27,6 +27,11 @@
<!-- Internet (AI 음성 fallback) --> <!-- Internet (AI 음성 fallback) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Foreground Service (BLE 연결 유지) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- BLE feature --> <!-- BLE feature -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
@@ -56,6 +61,11 @@
<receiver <receiver
android:name=".services.ReminderReceiver" android:name=".services.ReminderReceiver"
android:exported="false" /> android:exported="false" />
<service
android:name=".services.BleForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application> </application>
</manifest> </manifest>
@@ -46,6 +46,7 @@ class MainActivity : ComponentActivity() {
NotificationService.getInstance(this) NotificationService.getInstance(this)
enableEdgeToEdge() enableEdgeToEdge()
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { setContent {
Medilightv2androidTheme { Medilightv2androidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
@@ -245,6 +245,7 @@ class BleManager private constructor(private val context: Context) {
connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null connectionTimer = null
bluetoothGatt?.disconnect() bluetoothGatt?.disconnect()
com.example.medilightv2android.services.BleForegroundService.stop(context)
} }
fun disconnectAndUnbond() { fun disconnectAndUnbond() {
@@ -252,6 +253,7 @@ class BleManager private constructor(private val context: Context) {
cancelAutoReconnect() cancelAutoReconnect()
stopWatchdog() stopWatchdog()
stopBatteryPolling() stopBatteryPolling()
com.example.medilightv2android.services.BleForegroundService.stop(context)
connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null connectionTimer = null
@@ -790,6 +792,7 @@ class BleManager private constructor(private val context: Context) {
if (txCharacteristic != null && rxCharacteristic != null) { if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true isServiceReady.value = true
com.example.medilightv2android.services.AdcCsvLogger.newSession() com.example.medilightv2android.services.AdcCsvLogger.newSession()
com.example.medilightv2android.services.BleForegroundService.start(context)
startBatteryPolling() startBatteryPolling()
startWatchdog() startWatchdog()
sendDeviceInfoQuery() sendDeviceInfoQuery()
@@ -17,8 +17,8 @@ object DetachmentDetection {
const val WINDOW_END = 80 const val WINDOW_END = 80
const val BASELINE_WINDOW_START = 80 const val BASELINE_WINDOW_START = 80
const val BASELINE_WINDOW_END = 100 const val BASELINE_WINDOW_END = 100
const val THR_MEAN = 100.0 const val THR_MEAN = 30.0
const val THR_STD = 100.0 const val THR_STD = 30.0
const val N_CENTER_CH = 4 const val N_CENTER_CH = 4
data class DetachmentResult( data class DetachmentResult(
@@ -13,10 +13,12 @@ package com.example.medilightv2android.managers
* 각 상수의 의미와 영향 범위를 아래 주석 참고. * 각 상수의 의미와 영향 범위를 아래 주석 참고.
*/ */
enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C } enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C }
enum class PlacementGuideMode { SIMPLE, BOUNDARY }
object GreenZoneConstants { object GreenZoneConstants {
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 신호 범위 // 신호 범위
@@ -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
}
}
@@ -123,13 +123,19 @@ fun PiezoMonitoringView(appState: AppState) {
lastMultiChannelResult.clear() lastMultiChannelResult.clear()
lastMultiChannelResult.addAll(channels) lastMultiChannelResult.addAll(channels)
// Detachment 체크 // 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 sortedBuffers = channels.sortedBy { it.channel }.map { it.buffer }
val detachResult = com.example.medilightv2android.managers.DetachmentDetection.detect(sortedBuffers) val detachResult = com.example.medilightv2android.managers.DetachmentDetection.detect(sortedBuffers)
if (detachResult.isDetached) { if (detachResult.isDetached) {
Log.w("PiezoMonitor", "DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") 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)}") bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
handler.post { bleManager.sendLedMode(4) } handler.post { bleManager.sendLedMode(4) }
} else {
// 정상 복귀 → LED OFF
handler.post { bleManager.sendLedMode(0) }
}
} }
var rawVolumeMl: Double? = null 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() } val stats = remember(refreshTick) { logger.getStats() }
Card( Card(
@@ -1439,12 +1445,13 @@ private fun BleDebugPanel(bleManager: BleManager) {
if (timeout > 0) Text("TOUT:$timeout", fontSize = 10.sp, color = Color(0xFFFF8A65)) if (timeout > 0) Text("TOUT:$timeout", fontSize = 10.sp, color = Color(0xFFFF8A65))
} }
// Log entries // Log entries (스크롤 위치 유지 — 자동 스크롤 안함)
val logScrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 200.dp) .heightIn(max = 200.dp)
.verticalScroll(rememberScrollState()) .verticalScroll(logScrollState)
) { ) {
for (entry in entries) { for (entry in entries) {
val color = when (entry.level) { val color = when (entry.level) {
@@ -77,6 +77,10 @@ fun PlacementGuideView(appState: AppState) {
// 2단계 순차 탐색: VERTICAL → LATERAL → GREEN // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN
var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } 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) { LaunchedEffect(Unit) {
@@ -511,20 +515,25 @@ fun PlacementGuideView(appState: AppState) {
lastChannelData = channels lastChannelData = channels
lastWallResults = allWalls.toList() 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 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 비활성화 if (detachResult != null && detachResult.isDetached) {
directionHint = "Sensor detached — check contact" directionHint = "센서가 부착되지 않았습니다. 부착해주세요"
directionIcon = "exclamationmark.triangle" directionIcon = "exclamationmark.triangle"
placementScore = 0 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)}") bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
Log.w("PlacementGuide", "DETACHED mean=${ Log.w("PlacementGuide", "DETACHED mean=${
"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
} else { } 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초 유지 // 디바운스: 같은 phase 2회 연속 + 최소 3초 유지
if (guideResult.nextPhase == lastGuidePhase) { if (guideResult.nextPhase == lastGuidePhase) {
@@ -675,6 +684,24 @@ fun PlacementGuideView(appState: AppState) {
HorizontalDivider() 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 // Otsu toggle
var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) } var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@@ -898,12 +925,19 @@ private const val DEFAULT_LR_DEV_THR = 0.25
* [3단계] 최종 Green: CV 확인 * [3단계] 최종 Green: CV 확인
* - CV ≤ threshold → Green (LED 6) * - CV ≤ threshold → Green (LED 6)
*/ */
data class VerticalState(
var maxCenterSeen: Int = 0,
var stableAtMax: Int = 0,
var boundaryHit: Boolean = false
)
private fun computePlacementGuide( private fun computePlacementGuide(
detected: List<Boolean>, detected: List<Boolean>,
urineLens: List<Int>, urineLens: List<Int>,
walls: List<Pair<Int, Int>?> = emptyList(), walls: List<Pair<Int, Int>?> = emptyList(),
repeatCount: Int = 0, repeatCount: Int = 0,
currentPhase: PlacementPhase = PlacementPhase.VERTICAL currentPhase: PlacementPhase = PlacementPhase.VERTICAL,
verticalState: VerticalState? = null
): PlacementGuideResult { ): PlacementGuideResult {
val detectedCount = detected.count { it } val detectedCount = detected.count { it }
@@ -912,7 +946,6 @@ private fun computePlacementGuide(
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
// Center 채널 정보
val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } }
val centerDetected = (0..3).count { detected.getOrElse(it) { false } } val centerDetected = (0..3).count { detected.getOrElse(it) { false } }
val ch0 = detected.getOrElse(0) { false } val ch0 = detected.getOrElse(0) { false }
@@ -922,39 +955,60 @@ private fun computePlacementGuide(
val len0 = centerLens[0]; val len1 = centerLens[1] val len0 = centerLens[0]; val len1 = centerLens[1]
val len2 = centerLens[2]; val len3 = centerLens[3] val len2 = centerLens[2]; val len3 = centerLens[3]
// ═══ 1단계: 상하 탐색 (치골→위로→CH0 안잡히면 아래로) ═══ // ═══ 1단계: 상하 탐색 ═══
if (currentPhase == PlacementPhase.VERTICAL) { if (currentPhase == PlacementPhase.VERTICAL) {
if (centerDetected < 3) { val guideMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode
val hint: String val vs = verticalState ?: VerticalState()
val icon: String
// ── BOUNDARY 모드: 올라갔다 내려오기 ──
if (guideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) {
if (centerDetected < 2) {
vs.stableAtMax = 0
val hint: String; val icon: String
when { when {
// 위쪽만 잡힘 → 아래로 내리세요 (ch3 || ch2) && !ch0 && !ch1 -> { hint = "센서를 아래로 천천히 내리세요 ↓"; icon = "arrow.down" }
(ch3 || ch2) && !ch0 && !ch1 -> { else -> { hint = "센서를 위로 천천히 올리세요 ↑"; icon = "arrow.up" }
hint = "센서를 아래로 천천히 내리세요 ↓"
icon = "arrow.down"
}
// 아래쪽만 잡힘 → 위로 올리세요
(ch0 || ch1) && !ch3 && !ch2 -> {
hint = "센서를 위로 천천히 올리세요 ↑"
icon = "arrow.up"
}
// 아무것도 또는 1개만 → 위로 올리세요 (치골에서 시작 기준)
else -> {
hint = "센서를 위로 천천히 올리세요 ↑"
icon = "arrow.up"
}
} }
return PlacementGuideResult(hint, icon, centerDetected * 10, return PlacementGuideResult(hint, icon, centerDetected * 10,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) 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)
}
// 3개 이상 검출 — CH0 유무 확인 // ── SIMPLE 모드: 3+채널 안정 → 바로 좌우 ──
if (centerDetected < 3) {
val hint: String; val icon: String
when {
(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)
}
if (!ch0 && ch3) { if (!ch0 && ch3) {
return PlacementGuideResult("CH0이 약합니다. 센서를 아래로 조금 내리세요 ↓", "arrow.down", 25, return PlacementGuideResult("CH0이 약합니다. 센서를 아래로 조금 내리세요 ↓", "arrow.down", 25,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
// 크기 패턴 확인
val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] } val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] }
val maxLen = validLens.maxOrNull() ?: 0 val maxLen = validLens.maxOrNull() ?: 0
val minLen = validLens.minOrNull() ?: 0 val minLen = validLens.minOrNull() ?: 0