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:
@@ -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 */
|
||||
|
||||
+27
-17
@@ -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)
|
||||
}
|
||||
|
||||
+35
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user