fix: 센서 비율 기반 + LED 깜빡임 + 디바운스 + gated 체크

[Placement]
- 센서 이미지 비율 기반 (너비 35%, BiasAlignment 0.44) — 태블릿 대응
- LED 깜빡임 수정: greenHeld/failCount<3 동안 LED 5 안 보냄
- 디바운스: phaseProtected 중 lastHintChangeMs 안 건드림 (화살표 안 나오던 버그)
- Step 2 전환 힌트 항상 "Checking left/right balance..."
- GREEN에서 ch4/ch5 개별 탈락 시 방향 안내 (lrDev NaN 처리)

[도넛차트]
- Fill 카드: 윗줄 65%, 아랫줄 325/500 mL
- ml → mL 통일
- Spot 완료 시 displayMaxVolumeMl 즉시 갱신
- Auto 윈도우 < 5일 때 Current Measurement "—"

[기타]
- V41 gated 체크 + 최소 len≥3 (ungated 피크 안 보임)
- CCCD 실패(status 133) 500ms 후 1회 재시도
- 기기 스캔 사전순 정렬
- 힌트 점 순환 (./.../...)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 10:07:15 +09:00
parent 5d687f7154
commit 6287f21a05
4 changed files with 51 additions and 25 deletions
@@ -828,8 +828,30 @@ class BleManager private constructor(private val context: Context) {
logd { "Service ready! (CCCD written)" } logd { "Service ready! (CCCD written)" }
} }
} else { } else {
loge { "CCCD write failed with status: $status" } loge { "CCCD write failed with status: $status — retrying in 500ms" }
connectionError.value = "Notification setup failed (status $status)" // GATT 133 등 일시적 오류 → 500ms 후 재시도
handler.postDelayed({
try {
val rx = rxCharacteristic
if (rx != null) {
gatt.setCharacteristicNotification(rx, true)
val cccd = rx.getDescriptor(CCCD_UUID)
if (cccd != null) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(cccd)
}
logd { "CCCD retry sent" }
}
}
} catch (e: Exception) {
connectionError.value = "Notification setup failed (status $status)"
}
}, 500)
} }
} }
} }
@@ -46,7 +46,7 @@ fun DeviceScanView(appState: AppState) {
val filteredDevices = bleManager.discoveredDevices val filteredDevices = bleManager.discoveredDevices
.filter { it.name.startsWith("2025") || it.name.startsWith("VBT") } .filter { it.name.startsWith("2025") || it.name.startsWith("VBT") }
.sortedByDescending { it.rssi } .sortedBy { it.name }
val isConnected by bleManager.isConnected val isConnected by bleManager.isConnected
val isServiceReady by bleManager.isServiceReady val isServiceReady by bleManager.isServiceReady
@@ -202,7 +202,9 @@ fun PiezoMonitoringView(appState: AppState) {
) )
val result = v41.detect(sweepInput) val result = v41.detect(sweepInput)
for (ch in result.perChannel) { for (ch in result.perChannel) {
if (ch.antIdx != null && ch.postIdx != null && ch.ch < 6) { val gated = ch.v41Diag?.gated == true
if (gated && ch.antIdx != null && ch.postIdx != null && ch.ch < 6
&& (ch.postIdx - ch.antIdx) >= 3) {
allWalls[ch.ch] = Pair(ch.antIdx, ch.postIdx) allWalls[ch.ch] = Pair(ch.antIdx, ch.postIdx)
} }
} }
@@ -360,20 +360,24 @@ fun PlacementGuideView(appState: AppState) {
} }
// 센서 기기 이미지 (방광 타원 중앙에 정렬) // 센서 기기 이미지 (방광 타원 중앙에 정렬)
// 타원 중심 = (navelY+pubisY)/2 ≈ 높이의 68%. Alignment.Center = 50%. 차이 ≈ 18% // 센서 — 화면 비율 기반 크기/위치 (태블릿 대응)
val boxHeightDp = with(androidx.compose.ui.platform.LocalDensity.current) { // 타원 중심 ≈ 높이 72%. BiasAlignment(0, 0.44) ≈ 72%
(this@Box).let { 110.dp } // fallback // 센서 너비 = 박스 너비의 35%
androidx.compose.foundation.layout.BoxWithConstraints(
modifier = Modifier.matchParentSize()
) {
val sensorW = maxWidth * 0.35f
val sensorH = sensorW * 0.625f
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device),
contentDescription = "Sensor",
modifier = Modifier
.size(width = sensorW, height = sensorH)
.align(androidx.compose.ui.BiasAlignment(0f, 0.44f)),
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
alpha = 0.95f
)
} }
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device),
contentDescription = "Sensor",
modifier = Modifier
.size(width = 192.dp, height = 120.dp)
.align(Alignment.Center)
.offset(x = 0.dp, y = 95.dp),
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
alpha = 0.95f
)
} }
@@ -666,22 +670,20 @@ fun PlacementGuideView(appState: AppState) {
// Start 후 5초간 같은 phase 내 힌트는 즉시, phase 전환은 3초 유지 // Start 후 5초간 같은 phase 내 힌트는 즉시, phase 전환은 3초 유지
val justStarted = (now - startPressedAtMs) < 5000 val justStarted = (now - startPressedAtMs) < 5000
val hintUpdate = justStarted && !phaseChanged && !phaseProtected val hintUpdate = justStarted && !phaseChanged && !phaseProtected
if (hintUpdate || greenChanged || (phaseChanged && !greenHeld && debounceCount >= 2 && elapsed >= 3000) || (!phaseChanged && !phaseProtected && !greenHeld && debounceCount >= 2 && elapsed >= 1500)) { if (!phaseProtected && (hintUpdate || greenChanged || (phaseChanged && !greenHeld && debounceCount >= 2 && elapsed >= 3000) || (!phaseChanged && !greenHeld && debounceCount >= 2 && elapsed >= 1500))) {
directionHint = guideResult.hint directionHint = guideResult.hint
directionIcon = guideResult.icon directionIcon = guideResult.icon
placementScore = guideResult.score placementScore = guideResult.score
if (phaseChanged) { if (phaseChanged) {
lastPhaseChangeMs = now lastPhaseChangeMs = now
phaseHoldScans = 3 phaseHoldScans = 3
phaseHoldHint = guideResult.hint // 전환 힌트 저장 phaseHoldHint = guideResult.hint
} }
placementPhase = guideResult.nextPhase placementPhase = guideResult.nextPhase
lastHintChangeMs = now lastHintChangeMs = now
if (!guideResult.cv.isNaN()) lastCv = guideResult.cv if (!guideResult.cv.isNaN()) lastCv = guideResult.cv
} } else if (phaseProtected && phaseHoldHint.isNotEmpty()) {
// 보호 중 → 전환 힌트 강제 유지, lastHintChangeMs 안 건드림
// phase 보호 중이면 전환 힌트 강제 유지 (무조건 덮어쓰기)
if (phaseProtected && phaseHoldHint.isNotEmpty()) {
directionHint = phaseHoldHint directionHint = phaseHoldHint
directionIcon = "checkmark.circle" directionIcon = "checkmark.circle"
} }
@@ -727,8 +729,8 @@ fun PlacementGuideView(appState: AppState) {
rTracker.reset() rTracker.reset()
bleManager.debugLogger.info("PLACEMENT_GREEN_EXIT after 3 consecutive fails") bleManager.debugLogger.info("PLACEMENT_GREEN_EXIT after 3 consecutive fails")
} }
} else { } else if (!greenHeld) {
// GREEN 아닌 상태에서 실패 // GREEN 아닌 상태 + hold 아닌 상태에서 실패
if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 } if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 }
bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} hint=${guideResult.hint}") bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} hint=${guideResult.hint}")
} }