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 23ba6c1..4aa2911 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -48,9 +48,9 @@ class BleManager private constructor(private val context: Context) { private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private var bluetoothLeScanner: BluetoothLeScanner? = null - private var bluetoothGatt: BluetoothGatt? = null - private var txCharacteristic: BluetoothGattCharacteristic? = null - private var rxCharacteristic: BluetoothGattCharacteristic? = null + @Volatile private var bluetoothGatt: BluetoothGatt? = null + @Volatile private var txCharacteristic: BluetoothGattCharacteristic? = null + @Volatile private var rxCharacteristic: BluetoothGattCharacteristic? = null private val handler = Handler(Looper.getMainLooper()) val debugLogger = com.example.medilightv2android.services.BleDebugLogger.getInstance(context) private var scanTimer: Runnable? = null @@ -62,7 +62,7 @@ class BleManager private constructor(private val context: Context) { private var reconnectAttempt = 0 private val reconnectIntervalMs: Long = 3000 private var reconnectTimer: Runnable? = null - private var lastRxTimestamp: Long = 0 + @Volatile private var lastRxTimestamp: Long = 0 private var watchdogTimer: Runnable? = null private val watchdogTimeoutMs: Long = 15000 @@ -238,6 +238,7 @@ class BleManager private constructor(private val context: Context) { scheduleAutoReconnect() } + fun disconnect() { isUserDisconnect = true cancelAutoReconnect() @@ -395,16 +396,16 @@ class BleManager private constructor(private val context: Context) { sendRaw(CRC16.buildCommandBE("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh))) } - fun sendAllChannels(mode: Int = 0) { + fun sendAllChannels(mode: Int = 0): Boolean { val now = System.currentTimeMillis() - if (now - lastMaaSentMs < 1000) { + if (now - lastMaaSentMs < 800) { android.util.Log.w("BleManager", "maa THROTTLED — ${now - lastMaaSentMs}ms since last") - debugLogger.warn("maa THROTTLED ${now - lastMaaSentMs}ms") - return + return false } lastMaaSentMs = now piezoCollector.startMultiChannel(6) sendRaw(CRC16.buildCommandASCII("maa", " ")) + return true } // ── NIRS Commands ── @@ -453,18 +454,18 @@ class BleManager private constructor(private val context: Context) { sendRaw(CRC16.buildCommandASCII("mbb", " ")) } - /** 6채널 측정만 (maa) — 최소 1초 간격 throttle */ + /** 6채널 측정만 (maa) — 최소 800ms 간격 throttle */ private var lastMaaSentMs = 0L - fun sendChannelsOnly() { + fun sendChannelsOnly(): Boolean { val now = System.currentTimeMillis() - if (now - lastMaaSentMs < 1000) { + if (now - lastMaaSentMs < 800) { android.util.Log.w("BleManager", "maa THROTTLED — ${now - lastMaaSentMs}ms since last") - debugLogger.warn("maa THROTTLED ${now - lastMaaSentMs}ms") - return + return false } lastMaaSentMs = now piezoCollector.startMultiChannel(6) sendRaw(CRC16.buildCommandASCII("maa", " ")) + return true } fun startBatteryPolling() { 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 fb56521..0b8f475 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -18,11 +18,11 @@ enum class BvMethod { FRUSTUM, V41 } object GreenZoneConstants { - var postMaxIdx: Int = 80 // 후벽 탐색 최대 sample index (이 이상 peak 무시) + @Volatile var postMaxIdx: Int = 80 // 후벽 탐색 최대 sample index (이 이상 peak 무시) - var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C - var bvMethod: BvMethod = BvMethod.V41 - var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE + @Volatile var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C + @Volatile var bvMethod: BvMethod = BvMethod.V41 + @Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE // ═══════════════════════════════════════════════════════════ // 신호 범위 @@ -48,7 +48,7 @@ object GreenZoneConstants { * UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준. * Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전) * 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */ - var lowEchoAmp: Float = 1250f + @Volatile var lowEchoAmp: Float = 1250f /** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue). * 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대. diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt index cb31c49..bff5639 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -96,7 +96,7 @@ object PiezoHW { val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray() /** Acoustic calibration (런타임 조절 가능) */ - var distancePerSample: Double = 1.968 // mm/sample + @Volatile var distancePerSample: Double = 1.968 // mm/sample const val delayOffsetMm: Double = 6.85 /** Volume model */ 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 db55e43..2eaf41f 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 @@ -83,7 +83,7 @@ fun PiezoMonitoringView(appState: AppState) { var maxVolumeMl by remember { mutableStateOf(0.0) } var isSpotInProgress by remember { mutableStateOf(false) } val spotVolumes = remember { mutableListOf() } - var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred() } + val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred()) } val lastMultiChannelResult = remember { mutableStateListOf() } var lastAllWalls by remember { mutableStateOf?>>(List(6) { null }) } val analyzer = remember { PiezoEchoAnalyzer.shared } @@ -112,7 +112,7 @@ fun PiezoMonitoringView(appState: AppState) { return } isMeasuring = true - measureDoneSignal = kotlinx.coroutines.CompletableDeferred() + measureDoneSignal.value = kotlinx.coroutines.CompletableDeferred() // Simulation / demo mode — just increment level if (isDemoMode) { @@ -128,11 +128,15 @@ fun PiezoMonitoringView(appState: AppState) { bleManager.piezoCollector.onMultiChannelComplete = { channels -> Log.d("PiezoMonitor", "RECEIVED ${channels.size} channels: ${channels.map { "CH${it.channel}(${it.buffer.size})" }}") - // 분석을 백그라운드에서 수행 후 UI 업데이트만 메인으로 + // 분석을 백그라운드에서 수행 후 UI 업데이트는 메인 스레드로 Thread { val analysisStart = System.currentTimeMillis() - lastMultiChannelResult.clear() - lastMultiChannelResult.addAll(channels) + // channels를 로컬 복사 (Thread safety) + val channelsCopy = channels.toList() + handler.post { + lastMultiChannelResult.clear() + lastMultiChannelResult.addAll(channelsCopy) + } // Detachment 체크 (6채널 모두 수신됐을 때만 — BLE 거리 문제와 구분) val fullChannels = channels.filter { it.buffer.size >= 20 } @@ -232,15 +236,16 @@ fun PiezoMonitoringView(appState: AppState) { val centerWallCount = (0..3).count { allWalls[it] != null } totalWallCount = allWalls.count { it != null } - // ADC 로그 저장 (매 측정마다) + // ADC 로그 저장 — allWalls (선택된 Method) 기준으로 통일 val logService = com.example.medilightv2android.services.MeasurementLogService.getInstance(context) - val channelLogs = analysisResult.channels.map { ch -> + val channelLogs = (0..5).map { ch -> + val w = allWalls[ch] com.example.medilightv2android.services.ChannelLogEntry( - channel = ch.channel, - ant = ch.result?.ant, - post = ch.result?.post, - urineLen = ch.result?.urineLen, - score = ch.result?.score + channel = ch, + ant = w?.first, + post = w?.second, + urineLen = w?.let { it.second - it.first }, + score = analysisResult.channels.firstOrNull { it.channel == ch }?.result?.score ) } val rawADC = channels.sortedBy { it.channel }.map { it.buffer } @@ -337,17 +342,22 @@ fun PiezoMonitoringView(appState: AppState) { val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) } - handler.post { - isMeasuring = false - measureRetryCount = 0 - try { measureDoneSignal.complete(Unit) } catch (_: Exception) {} - } + isMeasuring = false + measureRetryCount = 0 + try { measureDoneSignal.value.complete(Unit) } catch (_: Exception) {} } }.start() } bleManager.piezoCollector.onLog = { msg -> Log.d("PiezoCollector", msg) } - bleManager.sendChannelsOnly() + if (!bleManager.sendChannelsOnly()) { + // Throttle됨 — isMeasuring 리셋 (deadlock 방지) + handler.post { + isMeasuring = false + try { measureDoneSignal.value.complete(Unit) } catch (_: Exception) {} + } + return + } // Timeout with auto-retry (15s) handler.postDelayed({ @@ -363,13 +373,13 @@ fun PiezoMonitoringView(appState: AppState) { Log.w("PiezoMonitor", "Retry $measureRetryCount also timed out") isMeasuring = false measureRetryCount = 0 - try { measureDoneSignal.complete(Unit) } catch (_: Exception) {} + try { measureDoneSignal.value.complete(Unit) } catch (_: Exception) {} }, 15_000) } else { Log.e("PiezoMonitor", "Measurement failed after $maxMeasureRetries retries") isMeasuring = false measureRetryCount = 0 - try { measureDoneSignal.complete(Unit) } catch (_: Exception) {} + try { measureDoneSignal.value.complete(Unit) } catch (_: Exception) {} } }, 15_000) } 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 a8efd10..998034a 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 @@ -70,6 +70,7 @@ fun PlacementGuideView(appState: AppState) { ) } var directionIcon by remember { mutableStateOf("") } var placementScore by remember { mutableIntStateOf(0) } + var lastCv by remember { mutableStateOf(Double.NaN) } var scanCount by remember { mutableIntStateOf(0) } var isLocked by remember { mutableStateOf(false) } var lastChannelData by remember { mutableStateOf>(emptyList()) } @@ -301,17 +302,19 @@ fun PlacementGuideView(appState: AppState) { style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))) ) - // ── 방향 화살표 (방광 타원 바깥) ── + // ── 방향 화살표 (센서와 안 겹치게) ── if (!greenZone) { val arrowColor = Color(0xFFFF5722) val arrowLen = 70f val arrowHead = 28f val sw = 9f val gap = 14f + // 위쪽 화살표는 나비 위쪽 (센서 안 겹치게) + val upArrowBase = navelY - h * 0.02f when { directionIcon.contains("up") -> { - val ax = cx; val ay = bladderCy - bladderRy - gap + val ax = cx; val ay = upArrowBase drawLine(arrowColor, Offset(ax, ay + arrowLen), Offset(ax, ay), strokeWidth = sw) drawLine(arrowColor, Offset(ax - arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = sw) drawLine(arrowColor, Offset(ax + arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = sw) @@ -357,7 +360,7 @@ fun PlacementGuideView(appState: AppState) { modifier = Modifier .size(width = 240.dp, height = 150.dp) .align(Alignment.Center) - .offset(x = 0.dp, y = 80.dp), + .offset(x = 0.dp, y = 95.dp), contentScale = androidx.compose.ui.layout.ContentScale.Fit, alpha = if (isGreenZone) 0.7f else 0.95f ) @@ -407,8 +410,14 @@ fun PlacementGuideView(appState: AppState) { val hintGreen = isGreenZone val hintBg = if (hintGreen) Color(0xFF4CAF50).copy(alpha = 0.12f) else Color(0xFFFFF3E0) val hintFg = if (hintGreen) Color(0xFF4CAF50) else Color(0xFFFF9800) + val displayHint = buildString { + append(directionHint.ifEmpty { " " }) + if (appState.isDevMode && !lastCv.isNaN()) { + append(" (CV=${"%.3f".format(lastCv)})") + } + } Text( - text = directionHint.ifEmpty { " " }, + text = displayHint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = hintFg, @@ -652,6 +661,7 @@ fun PlacementGuideView(appState: AppState) { placementScore = guideResult.score placementPhase = guideResult.nextPhase lastHintChangeMs = now + if (!guideResult.cv.isNaN()) lastCv = guideResult.cv } if (guideResult.isPass) { @@ -673,7 +683,10 @@ fun PlacementGuideView(appState: AppState) { ) } bleManager.piezoCollector.onLog = { msg -> Log.d("PlacementGuide", msg) } - bleManager.sendAllChannels() + if (!bleManager.sendAllChannels()) { + // Throttle됨 — isScanning 리셋 (deadlock 방지) + isScanning = false + } } fun performScan() { @@ -1253,9 +1266,23 @@ private fun computePlacementGuide( phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) } - // gradient 기반 방향 안내 + // gradient 기반 방향 안내 (dead zone: |gradient|<0.15 → LATERAL로) // ch0=위, ch3=아래. gradient>0 = ch3쪽 길다 = 방광 중심이 아래 = 내려야 - if (gradient > 0) { + if (kotlin.math.abs(gradient) < 0.15) { + // gradient 거의 0 — LATERAL로 진행 (위아래 깜빡임 방지) + val l4 = urineLens.getOrElse(4) { 0 } + val l5 = urineLens.getOrElse(5) { 0 } + val ch4ok = detected.getOrElse(4) { false } + val ch5ok = detected.getOrElse(5) { false } + if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) { + return PlacementGuideResult("Position OK! Final check...", + "checkmark.circle", 60, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) + } + return PlacementGuideResult("Checking lateral...", + "checkmark.circle", 50, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) + } else if (gradient > 0) { val intensity = if (gradient > 0.8) "Lower ↓" else "Lower slightly ↓" return PlacementGuideResult(intensity, "arrow.down", (20 + centerDetected * 5).coerceAtMost(35), @@ -1375,7 +1402,7 @@ private fun computePlacementGuide( val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average()) val cv = if (mean > 0) std / mean else Double.MAX_VALUE - val relaxStep = (repeatCount / 10).coerceAtMost(2) + val relaxStep = (repeatCount / 5).coerceAtMost(4) val cvThr = DEFAULT_CV_THR + relaxStep * 0.03 // LR deviation