fix: critical code review — thread safety, state management

CRITICAL fixes:
- #1: lastMultiChannelResult 변경을 handler.post로 (메인 스레드 보장)
- #3: measureDoneSignal var=remember → val mutableStateOf (recomposition 안전)
- #34,35: BleManager bluetoothGatt/tx/rxCharacteristic/lastRxTimestamp @Volatile
- #66: GreenZoneConstants 모든 mutable var @Volatile
- #56: PiezoHW.distancePerSample @Volatile
- nested handler.post 제거 (불필요한 프레임 지연)
- maa throttle deadlock 방지 (Boolean 반환 + state 리셋)
- Plot/Log 불일치 수정 (channelLogs를 allWalls 기준으로 통일)
- Placement 센서 위치/화살표 조정, gradient dead zone, CV relaxation
- 개발자 모드 CV 표시, 가이드 상자 항상 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 16:25:06 +09:00
parent 0e0e04b6f5
commit 758930796f
5 changed files with 85 additions and 47 deletions
@@ -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() {
@@ -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).
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
@@ -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 */
@@ -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<Double>() }
var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred<Unit>() }
val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred<Unit>()) }
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(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()
// channels를 로컬 복사 (Thread safety)
val channelsCopy = channels.toList()
handler.post {
lastMultiChannelResult.clear()
lastMultiChannelResult.addAll(channels)
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) {}
}
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)
}
@@ -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<List<PiezoChannelData>>(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