실측 로그 (2026-07-07 10:43~10:44 세션) 재분석 결과 두 이슈 발견.
1) Duplicate startBatteryPolling
이전 fix (f4f5407) 로 onConnectionStateChanged(true) 가 CCCD write 완료
시점 (BleManager onDescriptorWrite 안 line 1059 startBatteryPolling
직전) 으로 이동. 그런데 PiezoMonitoringView 콜백도 startBatteryPolling
을 호출해 첫 msn battery TX 가 2번 발생 (10:43:46.008 + 10:43:46.040).
Idempotent 라 실제 부작용은 낮지만 노이즈. PiezoMonitoringView 콜백에서
startBatteryPolling 제거 — BleManager 가 이미 담당.
2) Watchdog 25초 (from 15초)
세션 1: 첫 연결 후 CCCD/MTU/CONN_PRIORITY/startBatteryPolling 모두 성공,
그런데 15초간 RX 0개 → forced reconnect. 세션 2: 재연결 후 14초 만에
첫 rim RX 도착 (10:44:00.475) → 스스로 회복. 즉 peripheral / OS BLE
스택의 좀비 회복 시간이 실측 14초. 기존 15초 timeout 은 회복 직전에
reconnect 발동 → 무한 재연결 루프. 25초로 연장하면 대부분 회복 케이스
살릴 수 있음.
세션 1 의 첫 handshake 응답 실종 (mid → rid 응답 없음) 은 앱 코드로
근본 해결 불가 (peripheral 또는 OS BLE 스택 문제). Watchdog 연장은
실질적 완화책.
병렬 진단 (BLE/thread + Compose lifecycle + null safety) 결과 HIGH
심각도 12건 중 crash 유발 가능성 높고 저비용 고효과인 것 우선 처리.
**H4 — coroutine leak**
- PiezoMonitoringView.kt BleDebugPanel: `while(true) { delay/refresh }` →
`while(isActive)`. LaunchedEffect 취소 후에도 refreshTick++ 이 계속
돌아 GC 방해.
- BladdyRiveView.kt 2곳: 동일 패턴 → isActive 로 통일.
**H1 — BleManager postDelayed 미취소**
- `fwFallbackTimer` (4초 mfv? fallback) + `cccdRetryTimer` (500ms CCCD
재시도) 를 Runnable 참조로 저장. disconnect / GATT_ERROR / watchdog
timeout 3곳에서 handler.removeCallbacks 로 명시적 취소.
- 이전에는 disconnect 후에도 4초 후 sendFirmwareVersionQuery() 가 발동
→ 이미 close 된 GATT 에 write → silent exception → state 오염.
**H2 — MeasurementService 콜백 leak**
- performNirsMeasurement/performPiezoMeasurement 는 singleton 에서
콜백 대입만 하고 정리 안 함. Self-clearing lambda 로 응답 1회 처리
후 자동 null. 시작 시 이전 stale 콜백도 clear.
- stop() 에서도 대기 중 콜백 취소.
**H3 — Watchdog race condition**
- watchdog thread 가 GATT disconnect/close + characteristic null 을
binder thread 에서 직접 실행 → UI thread 의 sendRaw 와 race.
- 정리 전부를 handler.post 로 UI thread 에 위임 → single-threaded.
- fwFallback/cccdRetry timer 도 여기서 함께 취소.
**H6 — PiezoMonitoringView measure() closure leak**
- DisposableEffect 에 piezoCollector.onMultiChannelComplete 정리 추가.
measure() 함수 안에서 이 콜백에 measureScope + channels + outer
state 를 다수 capture → 화면 이탈 후에도 GC 방해 + 재진입 시 stale
closure 가 새 상태 오염 위험.
**H9 — UrineCameraScreen NPE 위험**
- LaunchedEffect 안 while 루프에서 `currentDetection!!` 이 다른 recompose
가 detection 을 null 로 만들면 NPE. Local val snapshot 으로 fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상: 앱 실행 후 alignment → 도넛차트 → 개발자모드 → alignment 이동 시
BLE 가 끊긴 것처럼 응답 실종 → 15초 후 watchdog forced reconnect.
심하면 재연결도 좀비 상태로 나와 3번째 재연결에서야 정상 동작.
원인 1: BleManager.onConnectionStateChange 에서 STATE_CONNECTED 직후
onConnectionStateChanged?.invoke(true) 호출 → 이 시점에는 아직 MTU/
service discovery/CCCD write 이 완료되지 않아 txCharacteristic 은 null.
상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시도하면
sendRaw 내부 null-check 에 걸려 silent drop → peripheral 이 응답을
보내지 않음 → watchdog timeout.
원인 2: PiezoMonitoringView / PlacementGuideView 의 DisposableEffect
에서 BleManager 콜백 (onConnectionStateChanged, onUnexpectedDisconnect,
onReconnectionFailed, piezoCollector.onMultiChannelComplete 등) 을
정리하지 않음. 화면 전환 시 이전 화면의 stale 콜백이 남아 새 화면 진입
후에도 발동 → 상태 충돌.
수정:
- BleManager: onConnectionStateChanged(true) 호출을 CCCD write 완료
(isServiceReady=true) 시점으로 이동. CCCD 없음 / RX 없음 edge case 도
동일하게 처리.
- PiezoMonitoringView / PlacementGuideView: DisposableEffect 에서 등록한
BleManager 콜백 전부 null 로 정리.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
V1 은 이미 greenHoldMs=7000 로 hold 로직 있으나 V2 (v2Aligner) 는
매 프레임마다 finalStop 을 재판정하고 false 되는 순간 즉시 unlock 되어
"측정 시작" 버튼이 disable 되는 UX 문제. 사용자가 GREEN 뜬 순간
버튼 누를 시간이 부족.
수정: V2 쪽에도 5초 hold 추가. 진입 후 5초 이내에는 finalStop 이
false 로 바뀌어도 isLocked=true, LED mode 6 (GREEN) 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
기존 finalStop 조건이 `phase == LR_BALANCE` 만 인정했는데, 실제로는
LR_BALANCE 에서 imbal 통과 즉시 `phase = FINAL_CONFIRM` 으로 바뀌어
반환되므로 이 조건에 걸리지 않았음. FINAL_CONFIRM 5프레임 도달 후
STOP + align_complete 반환 시점에도 phase 는 FINAL_CONFIRM 이라
finalStop=false → isLocked 유지 안 됨 → 버튼 disabled.
수정: state=="commit" && phase==FINAL_CONFIRM && action==STOP 을
진짜 정렬 완료로 인정. Accum 중은 이미 앞 branch 로 빠지므로 안전.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
인체 데이터 (data123/) 분석 결과 CH3 (-13.66° down-tilt) 검출률이
Phantom 95.2% → Human 31.8% 로 급락하며 YYNNYY flick 패턴 관측.
기존 "3연속 hit" 진입 조건이 무한 대기에 빠져 사용자에게
"↑ 위로 조금씩" 문구만 반복 노출되는 문제를 해결.
- VERTICAL_CLIMB 진입: 3연속 → 최근 6프레임 중 3회 (majority)
- CH3_STABILIZE / CENTER_OPTIMIZE lost 판정: 4프레임 중 3회 (majority)
- Stuck detection (20프레임 ≈ 5초 대기 시): 상단 3채널 relaxed mode
진입 → CH3 없이도 정렬 완료 진행
- Soft hint (12프레임 ≈ 3초 후): "CH3 확인 중 · 위치 유지/미세 조정"
으로 문구 완화 (사용자 답답함 완화)
- AdvisorState.relaxedMode 플래그 추가 (후속 저장 로직 대비)
Python replay (VBT26050202_0CM 22프레임) 로 검증:
- LEGACY: VERTICAL 무한 대기 → 정렬 실패
- NEW: soft-hint → threshold 도달 시 relaxed → 3프레임 만에 LR_BAL 도달
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CENTER_OPTIMIZE 단계에서 CH0 만 놓친 상태로 "↑ 조금 더 위로" 안내가 계속 나옴.
Probe 를 더 올릴수록 CH0 beam 이 방광 dome 위로 더 벗어남 → 무한 상승 loop.
CH3 를 잃었을 때도 마찬가지: MOVE_UP 으로 회복 시도하지만 CH3 잃은 원인이 이미
probe 가 너무 올라간 것이라 상황 악화.
V1 preset 기하:
- CH0 +6.89° (위로 tilt, 상단 channel) — 방광 dome 담당
- CH3 −13.66° (아래로 tilt, 하단 channel) — 방광 inferior edge 담당
Probe 위치 vs 미검출 채널:
- probe 너무 낮음 → CH3 miss, CH0 catch → MOVE_UP 정답
- probe 너무 높음 → CH0 miss, CH3 catch → MOVE_DOWN 정답
기존 코드는 CH0~2 미검출을 모두 "너무 낮음" 으로 가정 → MOVE_UP.
CENTER_OPTIMIZE 미검출 처리 방향 결정 로직:
- CH3 잃음 (CENTER_OPTIMIZE 진입 후) = probe 가 위로 올라감 → MOVE_DOWN 으로 회복
- 상단만 미검출 (CH0 or CH0+CH1) = probe too high → MOVE_DOWN
- 하단 포함 미검출 (CH2/CH3 포함) = probe too low → MOVE_UP (기존 default)
- 정상 서있음 자세로 배꼽 근처에서 시작 → CENTER_OPTIMIZE 진입 시 "↓ 조금 아래로" 안내
- 치골 근처에서 시작 → 이전과 동일하게 "↑ 조금 더 위로"
- CH3 검출 후 위로 살짝 지나쳤을 때 "↓ ch3 재확인" 나오는지
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 이식 대상
알고리즘팀 main : 6229825 → ea3f15c (fast-forward)
- b252de0 : post wall 후보는 peak-only 로 탐색 (shoulder 제외)
- 4afda38 : method_d(apply_cross) 옵션 추가
## MethodDWallSelect 변경
1. BOUNDARY_PEAK_MARGIN = 2 (post 전용)
- post 탐색창 hi 를 2 샘플 밖으로 넓혀 view 확보 → 경계에 걸린 peak 이
shoulder 로 오분류되던 케이스 해결. 수용 범위 [lo, hi] 유지 → farther
peak 새로 안 받음 (d_max 전역 확대 부작용 회피).
2. side=POST 는 peak-only
- shoulder(d2 변곡) 는 후벽을 깊은쪽으로 과확장 → BV 과대 (특히 30° 채널).
- ant 는 shoulder 유지 (near-field 전벽은 shoulder 로 잡히는 게 정상).
## MethodDRunner 변경
- detectMultichannel(applyCross: Boolean = true) 파라미터 추가.
false 면 교차채널 3종 (ant_tiebreak / neighbor_top_validate / inward_post) 건너뜀.
## AlignmentAdvisorV2 (RollingAligner) 변경
- 정렬 위치 선택은 applyCross=false 로 base 검출 사용.
이유: 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단.
BV 산출은 PiezoMonitoringView 경로에서 별도로 applyCross=true 로 진행.
## 컴파일
:app:compileDevDebugKotlin / :compileDemoDebugKotlin / UnitTest 모두 통과.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 걷기 시작 후 4초에 chip "걷는중" 전이 (기존 6초)
- 이중 안전장치 유지:
* walkingStartWmean=10dps 로 stand-up motion (5~10dps) 이미 필터
* walkingMinPitchDeg=45° 로 upright 자세만 candidate
- Stand-up 자체가 4초 이내 대개 안정화되므로 false positive 여지 낮음.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 증상
걷기 시작 → 몇 초간 chip "감지중" 유지 → 그 후 "걷는중" 으로 전이.
## 원인
1. 걷기 발 임팩트로 순간 acc mag > 1.2g → classifier acc gate 실패 → UNKNOWN
2. Composite = UNKNOWN → 3연속 후 chip "감지중" 로 넘어감
3. Walking detector 는 6초 hold 진행 중이라 아직 IDLE
4. 6초 뒤 walking 발화 → chip "걷는중"
즉 6초 walking build-up 내내 chip "감지중" 이 뜨고 갑자기 "걷는중" 으로 점프.
## 수정
Composite 로직에서 UNKNOWN 발생 시 tilt(=r.pitchDeg) 로 fallback 판정.
- tilt > 25° (pitchLyingThreshold) → NON_LYING (upright 로 판정)
- tilt ≤ 25° → LYING
이유: tilt 는 acc gate 와 무관하게 항상 계산됨. 걷는 중 avg tilt 는 유효
(각 걸음의 upright 자세 반영). 정확한 pitch 는 어렵지만 lying vs upright
정도는 확신 가능.
## 결과 (예상)
걷기 시작 → chip 즉시 "일어서 있음" (NON_LYING) → 6초 walking hold 완료
후 "걷는중" 으로 전이. 감지중 flash 없음.
정지 자세 + 실제 오류 (acc mag 극단값 etc) 는 여전히 tilt 로 커버되므로
chip 은 최소한 LYING 또는 NON_LYING 을 표시. "감지중" 은 실질적으로 초기
1-2 cycle (buffer 부족) 에만 등장.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 문제
"5초 누워있다가 세워서 5초 움직였는데 다시 누움이 뜸"
= 세워 놓은 5초 동안 NON_LYING 이 뜨지 않음.
## 원인
기존 pitch = atan(ax / √(ay² + az²))
→ AX 축이 세로일 때만 큰 pitch, NON_LYING 인식.
→ AY 축이 세로면 (손으로 기기를 세로로 잡는 흔한 자세) pitch=0 → LYING.
정상 착용 (배 위 부착) 시엔 AX가 세로가 되도록 설계됐지만,
손 테스트에선 AY 축 세로가 되기 쉬움. 특히 흔들기 테스트할 때.
## 수정
tilt = atan(√(ax² + ay²) / |az|)
→ **Z축이 중력방향에서 얼마나 벗어났는지** 측정. 방향 독립적.
- 기기 평평 (AZ up) → tilt=0° → LYING ✓
- X축 세로 (AX up) → tilt=90° → NON_LYING ✓
- Y축 세로 (AY up) → tilt=90° → NON_LYING ✓ ★ 신규
- 완전 옆으로 세워짐 (az≈0) → tilt=90° → NON_LYING ✓
## 임계값
`pitchLyingThreshold = 25f` 유지 (Dev 슬라이더 그대로).
- 이전 의미: X축 tilt ≤ 25° = LYING
- 신규 의미: Z축 tilt ≤ 25° = LYING (기기 평평 여유 ±25°)
정상 착용에선 동일하게 동작. hand-test 시나리오 추가 커버.
## 다음
Walking detector 는 pitch 를 그대로 재활용. tilt=90° 근처면
walkingMinPitchDeg=45° 통과. 이전엔 손 세로 잡기로 walking 감지도 못
했지만 이제 가능.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 문제
기기를 흔들면 "감지중" (posture UNKNOWN) 후 "기기 응답 없음" 배너 flap.
## 원인
FirmwareWarningBanner 의 deviceAlive 기준이 5초로 너무 타이트:
1. 흔들면 BLE 안테나 wobbling → 짧은 disconnect
2. onConnectionStateChange → firmwareVersion.value = "" reset
3. 재연결 후 mid/mfv 재요청 응답 지연 → 8초 후 timedOut=true
4. 동시에 mim? 응답 몇 개 놓쳐 5초 초과 → deviceAlive=false
5. showTimedOut = timedOut && !deviceAlive → 배너 표시
## 수정
deviceAlive 5→12초. Watchdog 자체가 15초 timeout 이라, 12초 내 재개되는
transient 스톨은 배너로 알리지 않고 조용히 대기. 진짜 disconnect 는
watchdog 이 처리.
## 참고
"감지중" (posture UNKNOWN) 은 acc mag > 1.2g 정상 동작 (흔들 때 acc gate
초과 → classifier UNKNOWN). 이미 3연속 hysteresis 있어 순간 튐엔 반응 X.
수정 대상 아님.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).
## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
(main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)
## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>