Commit Graph

396 Commits

Author SHA1 Message Date
dw.jang 96895e5c08 feat(ble): R100 견고성 3종 · startup 지연 대응 + hang 안내 + 재연결 제한
배경 (2026-08-04 · VBT0607R100 로그 분석):
  - 연결 직후 첫 msn/mid 명령이 9초간 무응답 → CMDQ timeout 3회 · 사용자 노출
  - mpa → mtb 발사 후 GATT status=8 (link supervision timeout) · 기기 hang
  - 이후 재연결 5회+ 실패 · 기기가 air 상에 안 잡힘 (advertising 죽음)
  - FW 사이드 문제 · 앱 층에서 UX 완화 가능한 부분 개선

변경:
1. INITIAL_TX_DELAY_MS = 2_000ms — CCCD write 후 첫 명령 2초 지연
   R100 boot 늦은 케이스에서 초기 timeout 반복 방지. Watchdog 은 즉시 시작.

2. GATT status 8/19/22 별도 처리
   - status=8  GATT_CONN_TIMEOUT (link supervision timeout · FW hang)
   - status=19 GATT_CONN_TERMINATE_PEER_USER
   - status=22 GATT_CONN_TERMINATE_LOCAL_HOST
   → connectionError = "DEVICE_UNRESPONSIVE:\$status" marker
   → UI 에서 "전원 15초 롱프레스로 재부팅 후 다시 연결하세요" 안내
   Bond conflict (status=5) 는 기존대로 별도 처리 유지.

3. MAX_RECONNECT_ATTEMPTS 10 → 5
   실패 후 connectionError = "RECONNECT_GAVE_UP" marker + onReconnectionFailed
   → UI 에서 "기기가 켜져 있는지 확인 후 다시 시도" 안내
   무한 시도 로그 스팸 방지.

4. mtb_response_reconnect 문구 개선
   "연결을 재시도합니다" → "재부팅이 필요할 수 있습니다 (전원 15초 롱프레스)"
   3회 consecutive mtb timeout 시 사용자에게 더 actionable 안내.

strings.xml (KR/EN):
   - ble_error_device_unresponsive (재부팅 안내)
   - ble_error_reconnect_gave_up (재연결 포기 안내)
   - mtb_response_reconnect 갱신

FW 팀 조사와 병행. 로그 (2026-08-04_134048) FW 팀 전달 예정.
2026-08-04 14:12:22 +09:00
dw.jang 68577139d4 feat(ble): FW query 자동 재시도 · "기기 응답 없음" 배너 false-positive 감소
배경 (2026-08-04 · 사용자 · Alignment idle 시 배너 flap):
  연결 직후 mid? (t=0) → mfv? fallback (t=4s) 만으로도 응답 없으면
  firmwareVersion 이 empty · 8초 후 FirmwareWarningBanner 의 timedOut=true
  → Alignment idle 처럼 트래픽 적은 화면에서 배너 뜸.

신규 로직 scheduleFirmwareRetry():
  - 5초 간격으로 mid? / mfv? 교차 발사 (최대 3회)
  - 첫 응답 오면 즉시 중단 (firmwareVersion.isNotEmpty())
  - 3회 모두 실패 → 재시도 종료 · 배너는 그때 노출 (진짜 문제)

Timing:
  t=0    mid?              (기존)
  t=4s   mfv? fallback     (기존) · 여기서 scheduleFirmwareRetry() 시작
  t=9s   mid? retry #1     (신규)
  t=14s  mfv? retry #2     (신규)
  t=19s  mid? retry #3     (신규)
  t=24s+ 재시도 종료 · 응답 없으면 배너

Disconnect / GATT error / 좀비 경로에도 stopFirmwareRetry() cleanup.

검증: BUILD SUCCESSFUL 2s
2026-08-04 12:25:12 +09:00
dw.jang 0184c6d694 change(detect): default detectionMethod METHOD_C → METHOD_D (cloud-mvp 1896e9b 이식) 2026-08-04 12:08:25 +09:00
dw.jang f3b15cef63 change(bv): default bvMethod V41 → METHOD_D (cloud-mvp b49170e 이식) 2026-08-04 11:58:03 +09:00
dw.jang 40cf9d224e fix(hw): R100/R200/R300 각도 스펙 갱신 · raw 각도 + lateral 아래10°/좌우5°
배경 (2026-08-04 · 사용자 · HW 스펙 변경):
  실제 피부 입사각 = 아래 방향 10°, 좌우 방향 5°.
  Python HW_VERSIONS 를 raw 각도 그대로 사용하도록 개정 (Snell 후 보정 미적용).
  V1/V2 는 ABS→gel Snell 보정 유지, R 시리즈만 raw.

변경 (degreeAll):
  R100: [0.0, -4.4, -8.7, -13.0, -6.87, -6.87] → [0.0, -7.0, -14.0, -21.0, -10.0, -10.0]
  R200: [3.1, -2.5, -7.5, -13.0, -6.87, -6.87] → [5.0, -4.0, -12.0, -21.0, -10.0, -10.0]
  R300: [0.0, -5.6, -11.2, -16.6, -6.87, -6.87] → [0.0, -9.0, -18.0, -27.0, -10.0, -10.0]

변경 (degreeLRAll · CH4/CH5 lateral):
  R100/R200/R300: [-3.42, +3.42] → [-5.0, +5.0]

sensor_z_mm 은 변경 없음 (V2 와 동일 [19.3, 13.0, 6.7, 0.0, 9.85, 9.85]).

검증: BUILD SUCCESSFUL 2s
2026-08-04 11:20:05 +09:00
dw.jang fda9a4d97f feat(ble): GATT status=5 (FW allowlist bond conflict) UX (cloud-mvp 10c45dc 이식)
FW 팀이 1:1 bond allowlist 정책 추가 예정. 다른 central 과 이미 bond 중인
기기에 붙으면 GATT_INSUFFICIENT_AUTHENTICATION (status=5) 로 거부.

BleManager:
  - bondConflictAddress: MutableStateOf<String?> 신규
  - status=5 감지 시 bondConflictAddress=addr + connectionError="BOND_CONFLICT:$addr"
  - CONNECTED 성공 시 clear

DeviceScanView (ui/views/connection/):
  - 에러 배너 확장 (Column 2단):
    · 안내 문구 (기기 15초 롱프레스로 본딩 삭제)
    · "이 폰의 본딩 정보 초기화" OutlinedButton (unbondAndRemoveAddress)
  - Toast 완료 안내

strings.xml (KR/EN): ble_error_bond_conflict / ble_action_reset_bond / ble_toast_bond_removed
2026-08-04 10:54:41 +09:00
dw.jang de6e1d1931 feat(guardian): Rev2 · Bus 확장 + AlarmAggregator + NotificationSubscriber
GUARDIAN-01 §Rev2 개선 4건 (파트너 앱 개발 지원):

1. BleManager · Battery/BleConnection emit 지점 추가
   - emitBatteryEvent(pct): 임계 bucket 전이 (CRITICAL<5, LOW<20, OK≥20)
   - emitConnectionEvent(state, reason): CONNECTED / DISCONNECTED / FAILED
   - CONNECTED · STATE_DISCONNECTED · GATT_ERROR · 좀비 watchdog 4 경로 wire

2. AlarmAggregator (신규 · telemetry/) — subscriber 참조 구현
   - Bus 구독 · BvEvent/PostureEvent/GaitEvent/BleConn/Battery → AlarmEvent 재발행
   - 재귀 방지 (filterNot { it is AlarmEvent })
   - Category 매핑: BV_URGENT/BV_WARN/POSTURE_TRANSITION/GAIT_STARTED/STOPPED/
     BLE_LOST/BATTERY_CRITICAL/BATTERY_LOW
   - Guardian 앱 로직 단순화 (AlarmEvent 하나만 구독하면 됨)

3. Dev-mode overlay 확장 (PiezoMonitoringView · RSSI 옆)
   - ClinicalEventBus 최근 5개 이벤트 rolling 표시
   - Type(BV/PST/GAI/BLE/BAT/ALM) + severity color + 요약
   - LaunchedEffect + mutableStateListOf · isDevMode gate

4. NotificationSubscriber (신규 · telemetry/) — in-app 소비 참조 구현
   - AlarmEvent 구독 · Android push notification 발행
   - BATTERY_CRITICAL/LOW → device_status 채널 (신규)
   - BLE_LOST → 30초 debounce · CONNECTED 시 reset
   - BV_URGENT/WARN → AppState.updateLevelFromMeasurement 직접 호출과 중복 방지 · NO-OP
   - MainActivity.onCreate 에서 start(this) 1회 호출

NotificationService 확장:
   - device_status 채널 신규 (배터리·BLE 알람용)
   - sendBatteryNotification(level, isCritical) · bucket dedup
   - sendBleLostNotification(reason) · resetBleDebounce()

의도: Guardian 앱 (동업자 · Supabase 경유) 이 in-app subscriber 와 동일 패턴으로
소비하도록 참조. 다음 단계는 GUARDIAN-02 파트너용 스키마 문서 (cloud-mvp).
2026-08-04 09:52:15 +09:00
dw.jang f05068002b feat(ble): CommandQueue · 응답 대기 후 다음 명령 전송 (FW 팀 지시)
배경 (2026-08-04):
  FW 팀 지시 — 이전 명령의 응답 도착 전에 다음 명령 보내지 말 것.
  FW GATT queue 가 얕아 백투백 write 시 silent drop / freeze 유발.
  기존 isMtbBusy gate 는 일부만 방어 (battery/IMU polling ↔ mtb 중간)
  → 모든 명령에 통일된 sequential 큐 도입.

CommandQueue.kt (신규 · 155 line):
  - QueuedCommand(data, label, isDone(tag3), timeoutMs=3000)
  - enqueue → pending==null 이면 즉시 dispatch, 아니면 대기
  - 응답 tag 마다 onResponseTag() → pending.isDone 체크
  - 공통 에러 응답 (rxx/rxd/rxn/rxc/rxs) 은 무조건 완료 처리
  - 3초 timeout → onDrop 콜백 (UI observer 갱신 포함)
  - clear(reason) — disconnect 경로에서 pending + 큐 전체 drop
  - Thread-safe (synchronized on internal lock)

BleManager.kt:
  - sendRaw() → sendRawWrite() private (실제 GATT write)
  - 새 sendRaw() = 단일 응답 (m→r 자동 치환) 명령용 wrapper · enqueue 위임
  - 스트리밍 명령은 자체 send fn 에서 explicit enqueue:
    · maa/mbb → 종료 = raa && piezoCollector.isComplete
    · mtb     → 종료 = (rim|ric|raa) && piezo done && imu samples 채워짐
    · mim     → 종료 = (rim|ric) && imu samples 채워짐
    · mec     → 종료 = raa
  - processReceivedData 말미에 commandQueue.onResponseTag(tag3) hook
  - 공통 에러 응답 5종 (rxx/rxd/rxn/rxc/rxs) case 신설 · 로그
  - disconnect 5 경로에 commandQueue.clear("disconnect") 추가
  - onDrop 콜백에서 mtb timeout 시 UI observer (lastMtbTimeoutAt +
    consecutiveMtbTimeouts) 갱신 · 기존 UX 유지

BLE 스펙 참조: ppt/ble.xlsx (25 명령 · 응답 태그 표).
2026-08-04 09:30:48 +09:00
dw.jang 815774e23c feat(dev): dev-mode RSSI live overlay · PiezoMonitoringView 도넛차트 위
BleManager:
- val rssi: MutableStateOf<Int?> — 미연결 시 null
- startRssiPolling() / stopRssiPolling() — 2초 주기 readRemoteRssi()
- Service ready 3 경로에 startRssiPolling() wire
- disconnect · gatt error · reconnect · forceDisconnect 5 경로에
  stopRssiPolling() + rssi.value = null

PiezoMonitoringView:
- val liveRssi by bleManager.rssi
- Donut 위에 dev-mode gate overlay (badge chip):
  · null → "-- dBm" gray
  · >= -60 dBm → green (strong)
  · >= -75 dBm → amber (ok)
  · <  -75 dBm → red (weak)
- 개발자모드 (3-tap Bladdy) 진입 시에만 표시

일반 사용자 UI 영향 없음 (isDevMode=false 기본).
2026-08-03 18:04:51 +09:00
dw.jang 165df3c63d feat(telemetry): Phase B/C/D · ClinicalEventBus + emit (cloud-mvp 6856a7b 이식)
Phase D · telemetry/ClinicalEvent + ClinicalEventBus 신규 (demo-final path):
- sealed 계층 · schemaVersion · severity · isTransition · anonymized sessionId
- MutableSharedFlow (replay=0, buffer=64, DROP_OLDEST · fire-and-forget)
- PhiRedactor 없음 (demo-final) · TelemetryHash.sessionId (inline SHA-256) 사용

Phase A → BvEvent emit (AppState.updateLevelFromMeasurement):
- 매 측정마다 emit · urgency 전이 시 isTransition=true
- bvMethod 이름 · anonymized sessionId

Phase B/C → PostureEvent/GaitEvent emit (PiezoMonitoringView):
- composite posture 전이 시 PostureEvent (매 사이클 X · 트래픽 절약)
- walkingTransitioned 시 GaitEvent (severity=WARN on WALKING 진입)
- prevCompositePosture 상태 신규 추적

기존 SyncEvents/PostureEventLogger 와 병렬 · 서로 독립.

Refs: GUARDIAN-01 §3-2 이벤트 스키마 · §5 Phase A~D

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 17:22:48 +09:00
dw.jang 432127a667 feat(state): Phase A · 실측 경로 알람/이력 통합 (cloud-mvp deb7b17 이식)
Guardian 공유 이전 선행 작업 (GUARDIAN-01 §5 Phase A).

이전 버그: 데모 경로 (incrementLevel) 만 NotificationService · HistoryStore 호출.
        실측 (BLE) 경로는 bladderLevel = ...copy(currentLevel = level) 로 직접
        대입 → 알람 미발생 · 이력 미저장.

수정:
- AppState.updateLevelFromMeasurement(volumeMl, recordHistory) 신규 함수
  · bladderLevel 계산·갱신·storage 동기화
  · MeasurementRecord 저장 (recordHistory=true 시)
  · Urgency 변화 시 sendUrgencyNotification (중복 억제는 기존 로직 재사용)
- AppState.incrementLevel(): 데모 1단계 증가를 신규 함수로 위임
- PiezoMonitoringView 3곳 (L578/L1345/L1550) 직접 대입 → 신규 함수 호출
  · auto-scan 갱신: recordHistory=false (폭탄 방지)
  · SPOT trimmed 실측: recordHistory=true
  · dev-mode tap: recordHistory=true

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 16:58:06 +09:00
dw.jang 9470ca7dbf feat(hw): PiezoHW R100/R200/R300 preset 3종 추가 (cloud-mvp 4fbba01 이식)
이번에 새로 만든 실리콘 렌즈 타입 목업 3종 (VBT2607R100/R200/R300) 을
demo-final 앱 알고리즘도 인식하도록 DevicePreset 확장.

목업 사양 (Firmware Zephyr · 피부내 초음파 입사각):
  R1: raw 0/7/14/21° → Snell 0/-4.4/-8.7/-13.0°
  R2: raw -5/4/12/21° → Snell 3.1/-2.5/-7.5/-13.0° (CH0 위쪽 +3.1)
  R3: raw 0/9/18/27° → Snell 0/-5.6/-11.2/-16.6° (최대 27°)
lateral CH4/CH5: 아래 10° · 좌우 5°

DevicePreset: LEGACY_5CH · V0/V1/V2 유지 + R100/R200/R300 신규
autoDetectPreset: R\d{3} 패턴 우선 매칭 · fallback 은 옛 naming
각 preset: sensor_z V2 동일 · degree Snell 값 · degree_lr V1/V2 동일

Note: 안테나 변경으로 RSSI 저하 · GATT status=8 발생 관찰됨. HW 이슈 · 앱과
      무관 · Firmware/HW 팀 안테나 튜닝 개선 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 16:28:01 +09:00
dw.jang 638b8e454c fix(firmware): auto-connect 시 Scan UI 조건부 숨김 (cloud-mvp d0e5ca7 이식)
target 설정된 상태 (auto-connect 성공) 에서는 Scan 버튼 · Devices 리스트 숨김.
target 없거나 IDLE/SCANNING phase 에서만 노출.

Fallback: 연결 없이 진입하거나 auto-connect 실패 시 기존 스캔 UI 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 15:44:13 +09:00
dw.jang 33c4e94d07 feat(firmware): 이미 연결된 기기 있으면 스캔 생략 · 바로 재연결 (cloud-mvp 19a1c12 이식)
이전 fix (47317b3) 는 진입 시 disconnect 만 했지만 사용자가 여전히
Scan → Connect 를 수동으로 눌러야 했음. UX 낭비.

이제:
1. 진입 → 이미 연결된 기기 체크 (BleManager.isConnected)
2. BluetoothDevice + name 캡처
3. 메인 앱 disconnect (프로브 광고 재개)
4. 1.5s 대기 (BLE stack 정리)
5. DfuManager.connectDirectly(device, name) — 스캔 생략
6. 사용자는 CONNECTED 상태에서 "Start DFU" 만 누르면 됨

BleManager.kt (demo-final path): currentBluetoothDevice getter 추가
DfuManager.kt: connectDirectly(device, name)
FirmwareUpdateView.kt: LaunchedEffect 개편 — 조건 분기 · delay · auto-connect

연결 없이 진입하면 기존 스캔 flow 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 15:28:23 +09:00
dw.jang 47317b3c5d fix(firmware): 진입 시 메인 앱 BLE 연결 자동 해제 — 스캔 미검출 해결 (cloud-mvp bc46b7f 이식)
문제: 이미 프로브에 BLE 연결된 상태로 펌웨어 업데이트 화면 진입 → 스캔해도
     프로브가 목록에 안 뜸.

원인: BLE peripheral (VBT 프로브) 는 central 과 연결 중일 때 광고 중단
     (표준 BLE 동작). DfuManager 의 새 BleScanner 는 광고를 못 잡음.

해결: FirmwareUpdateScreen 진입 시 LaunchedEffect 안에서 메인 앱의
     BleManager.getInstance(context).disconnect() 호출.
     → 연결 해제 → 프로브 다시 광고 → DfuManager 스캔에서 검출 가능.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 15:09:06 +09:00
dw.jang 984fe2c939 feat(demo-final): cherry-pick — Firmware DFU + Battery dismiss + AppState fix (cloud-mvp 이식)
feature/cloud-mvp 에서 최근 만들어진 3가지 사용자용 기능을 demo-final 에도 적용.
demo-final 옛 폴더 구조 유지 (folder reorg 안 됨) · 각 파일 위치 수동 매핑.

이식 대상:

1. Firmware DFU (cloud-mvp 672e679):
   - firmware/ 폴더 신규 · MEDiDFU 6 파일 이식 (DfuManager 459 line 등)
   - assets/dfu_application_2+35.zip (243 KB · signed MCUboot)
   - libs.versions.toml: mcumgr = "2.5.0" (Kotlin 2.0 호환)
   - build.gradle.kts: mcumgr-ble + lifecycle-runtime-compose 추가
   - AppScreen.FIRMWARE_UPDATE enum 추가
   - MainActivity: 라우팅 + 뒤로가기 (→ HOME)
   - HomeView: "펌웨어 업데이트 (프로브 firmware)" TextButton 추가 (Dev clinical 버튼 밑)

2. BatteryWarningBanner tap-to-dismiss (cloud-mvp aa6cc1f):
   - clickable { dismissed = true } + 우측 X 아이콘
   - WARNING → CRITICAL 악화 시 자동 재표시
   - 재연결 시 dismissed 초기화

3. AppState.isDeviceConnected getter (cloud-mvp 9c82a50):
   - mutableStateOf → getter 로 변경 (BleManager.isConnected.value 실시간)
   - Compose 자동 재구성 · setter 는 no-op 로 호환 유지
   - 홈화면 "연결됨" · Start 후 "연결해제" 불일치 해결

주요 파일 매핑 (feature/cloud-mvp → demo-final):
- core/AppState.kt         → AppState.kt (root)
- core/MainActivity.kt     → MainActivity.kt (root)
- common/ui/components/BatteryWarningBanner.kt → ui/components/BatteryWarningBanner.kt
- firmware/**              → firmware/** (신규 · 동일)
- shell/ui/SettingsTabView.kt (진입점 원본) → HomeView 로 대체 (demo 는 SettingsTab 없음)

검증: BUILD SUCCESSFUL (demoDebug 1m 56s) · APK install PASS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-03 13:29:18 +09:00
dw.jang 2e0ca889d9 fix(bv): PiezoMonitoringView Method D BV → estimateBv() wrapper — Python 1:1
D2: 기존 PiezoMonitoringView 는 Method D BV 를 estimateBladderVolume6ch()
직접 호출로 계산 → adaptive_large_bladder_relax + b_si_floor + subsample
refined walls 모두 미적용. Alignment V3 / Clinical Live 는 이미
estimateBv(walls) wrapper 통과 (Python 1:1) 로 정상 동작 중이었음.

- MethodDResult 를 methodDDets 변수에 캐시 (METHOD_D detection 경로 +
  useMethodDBv 분기 둘 다).
- BV 계산 분기에 useMethodDBv 케이스 추가: WallWithSpan(antRefined,
  postRefined, lowStart, lowEnd) 만들어서 estimateBv(walls) 호출.
  antOffset 은 refined ant 에 samples 단위 subtract (기존 Int 경로와
  동일 시맨틱).
- 신규 test MethodDBvWrapperParityTest:
  · wrapper (Monitoring D2) == wrapper (Alignment/Clinical) : bit-exact
  · wrapper vs legacy int-only : data123 33 케이스에서 최대 20.4% 차이
    (96 mL @ 471 mL). Legacy 가 큰 방광에서 systematically 저추정.

3 소비자 경로 (Monitoring · Alignment V3 · Clinical Live) 모두 이제
Python `runners.estimate_bv` 와 완전 동일.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:24:54 +09:00
dw.jang efbaab1d23 fix(methodd): merge_close_spans prominence semantic — Python 1:1
Python `merge_close_spans(gap_peak_min_prom=X)` 는 gap 내 peak 가 양쪽
endpoint valley (max(sg[prev_e], sg[s])) 대비 상대 높이가 X 초과일 때
병합을 스킵한다. Kotlin 은 대신 absolute (peak > low_amp + X) 기준을
써서, valley 가 low_amp 보다 훨씬 낮은 경우 병합 스킵 임계가 Python
보다 높아지는 잠재 divergence 있었음.

- SpanUtils.mergeCloseSpans 에 gapPeakMinProm 파라미터 추가, Python
  우선순위 (prom 이 thr 을 override) 그대로 이식.
- Legacy DetectLumenFirst 는 여전히 gapPeakThr (absolute) 사용 —
  peakCheckMinGap default 를 1 로 유지해 하위호환 (Python default 는 3).
- MethodDSpan 은 신규 gapPeakMinProm 파라미터로 호출 전환.
- SpanUtilsMergeCloseSpansTest 5 케이스 (prom split/merge, gap=0,
  absolute compat, 우선순위) 로 semantic 검증.

Golden test 33 케이스 회귀 없음 (기존 케이스에선 두 semantic 이
동일 결과였음 확인). Method D wall 검출 semantic 이 Python 1:1 도달.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:17:42 +09:00
dw.jang 739d52e472 feat(ble): rec: / ric: chunk reassembly — 저 MTU 기기 지원
Firmware (2026-07-21+) 는 MTU 협상값이 낮아 reb: 210B / rim: 188B 를
못 보낼 때 다중 chunk (rec: / ric:) 로 쪼개 전송함. 앱은 각 chunk 의
offset/total/chunk_samples 헤더를 읽어 채널별로 재조립해 기존 reb: / rim:
완료 경로와 동일하게 downstream 콜백을 발생시킨다.

- PiezoPacketCollector: rec: chunk → channelResults[chNum] 재조립, session/dup
  규칙은 reb: 와 동일. raa: 도착 시 완료 검사.
- ImuPacketCollector: ric: chunk → onComplete(15 samples), offset=0 재도착 시
  이전 partial 파기.
- BleManager.processReceivedData: rec: / ric: 케이스 dispatch + debug log.
- PiezoRecReassemblyTest / ImuRicReassemblyTest: 실측 84+16 / 14+1 split
  케이스 + 재전송/중복/세션 변경 회귀 방지.

정상 MTU 247 기기의 reb: / rim: 경로에는 변경 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 18:01:28 +09:00
dw.jang 10809af7b5 test(bv): golden test framework — Python vs Kotlin 33 cases 자동 대조
사용자 제안 (2026-07-21) : 최종 BV 보정계수보다 최초 divergence 단계부터 원인
을 찾아 일치. 단계별 중간값 저장 + 자동 diff + 단계별 tolerance.

파일 구성:
  - scratchpad/golden_dump_python.py  — Python 기준 dump (33 case)
  - GoldenDumpTest.kt                 — Kotlin dump
  - scratchpad/golden_diff.py         — 단계별 tolerance 자동 비교

Dump 단계 (stage 명명):
  E_walls_cross      : MethodD detect + cross-channel 후 walls (refined + span)
  M_bv               : 최종 BV (volume_ml/mm3, valid_channels, D_mm, cap_*)
  # 향후 확장 가능 : B (preproc), C (TGC), G (yw/zw), H (Halir 중간), etc.

허용 오차 (단계·필드별):
  E_walls_cross              : 1e-12   (double eps 근처, ULP 노이즈 흡수)
  M_bv.volume_ml             : 1e-3    (sub-microliter)
  M_bv.cap_b_si/c_ap/y0/z0   : 1e-6    (Halir 결과)
  M_bv.cap_mean_residual     : 1e-9    (극도 정확)
  M_bv.valid_channels        : bit-exact (정수/카테고리)
  ... (총 21 fields)

실행:
  1. python scratchpad/golden_dump_python.py
  2. ./gradlew testDevDebugUnitTest --tests "*GoldenDumpTest*"
  3. python scratchpad/golden_diff.py

Fixture (data123, 총 33 case):
  cm0 (VBT26050202 supine 0CM) × 11 trace
  cm1 (VBT26050202 supine 1CM) × 11 trace
  cm3 (VBT26040302 supine 3CM) × 11 trace

현재 상태:
  - 21 필드 × 33 케이스 = 693 검사 지점 모두 tolerance 내 PASS
  - 실질 divergence 0. BV max_diff = 3.7e-9 mL (nano-mL 수준).

향후 stage 추가 (B/C/G/H) 는 필요 시 EllipseFitSpecific.debug* 처럼 각 함수의
intermediate 를 노출하는 방식으로 확장. golden_diff.py 의 TOLERANCES 사전에
key 추가만으로 자동 검사 대상 편입.
2026-07-21 09:20:38 +09:00
dw.jang 9d3d1c4b6d fix(bv): post_outlier_filter default false — cm=3 CH0 부당 drop 해소 (Δ=0)
Python `_bv_core` (bv_estimation.py:1138) 는 `post_outlier_filter=False`
default. Kotlin `estimateBladderVolume` 은 이 flag 없이 **무조건 실행**하는
버그로 CH0 이 median 25% 벗어나면 부당하게 dropped 되어 nch=3 대신 2 처리.

이 divergence 는 특정 trace 에서만 발현 (data123 cm=3 trace 0-4, 9-10):
  - Python : valid_channels = [0,1,2,3]  (CH0 D_mm=63.43 포함)
  - Kotlin : valid_channels = [1,2,3]    (CH0 부당 drop) → BV +125 mL 편차

원인: trace 0 case 로 검증
  centerWalls inset : (15,48), (15,67), (15,67), null
  d_post_mm       : [99.78, 136.5, 136.5]
  median          : 136.5
  post_tol_mm     : max(136.5·0.25, 5·1.936) = 34.125
  |99.78-136.5|   : 36.72 → tolerance 초과 → CH0 drop
  하지만 Python default (filter off) 는 CH0 유지 → 올바른 nch=3.

수정:
- `estimateBladderVolume(Double)` 에 `postOutlierFilter: Boolean = false` 추가
  (Python default 매칭). block 안에서 flag 체크.
- `estimateBladderVolume(Int)` legacy 오버로드는 default true 유지
  (PiezoMonitoring 기존 동작 보존, phantom QC 회귀 방지).

검증 (data123 cm=3 11 trace):
  trace 0  py=283.156 → kt=283.156  Δ=0.000
  trace 1  py=439.303 → kt=439.303  Δ=0.000
  ... (전체 11 trace bit-perfect)
V3 CenterAligner:
  cm=0 bv_cv : 0.183 (py) == 0.183 (kt)  ★
  cm=1 bv_cv : 0.260 (py) == 0.260 (kt)  ★
  cm=3 bv_cv : 0.130 (py) == 0.130 (kt)  ★
  selected_cm = 3 일치, target 완전 동일.

CenterAlignerValidationTest tolerance 0.10 → 0.001 로 강화. 추후 회귀 방지.
Cm3PerTraceDumpTest.kt 신규 — cm=3 per-trace bit-perfect 회귀 게이트.
2026-07-21 09:14:58 +09:00
dw.jang 31527830e5 fix(bv): estimateBv + adaptive_large_bladder_relax + b_si_floor — BV Δ 0.000 mL
Python `runners.estimate_bv` 완전 이식으로 single-trace BV 를 Python 과
**bit-perfect** 매칭. 그동안 발견되지 못한 근본 원인은:

1) `_apply_urine_inset_one` (LUMEN_INSET_FRAC=0.15) — Kotlin 호출자에서
   미적용. 이전 commit db64407 에서 subsample refined 만 이식하고 inset 은
   건너뜀. Python 은 fit 전에 walls 를 lumen 안쪽으로 15% 이동 후 int round.
2) `adaptive_large_bladder_relax` wrapper (runners.py:180) — Kotlin 자체가
   이 wrapper 를 이식 안 함. Python 은 첫 계산 후 low_wide_endpoint 조건
   (endpoint 단면이 넓지만 cap 낮음) 만족 시 `urine_inset_frac=0.05` +
   `b_si_floor_ratio=0.85` 로 재실행. 이 케이스에서 b_si_floor 가 발동해
   b_si_ellipse 를 강제 상향 (43 → 47 mm) → cap 부피 크게 증가.
3) `b_si_floor_ratio` (bv_estimation.py:1316-1321) — ellipse fit 성공 block
   에서 자유 b_si 붕괴 방지 로직. Kotlin 미이식.

주요 변경:
- managers/PiezoBVEstimator.kt
  * `WallWithSpan` data class 신규 (Python extract_walls 4-tuple 대응)
  * `estimateBv(walls, hw, ..., adaptiveLargeBladderRelax=true)` 신규 — Python
    runners.estimate_bv 1:1. low_wide_endpoint 검사 + retry 로직.
  * `estimateBladderVolume6ch` Double 오버로드 : bSiFloorRatio,
    bSiFloorEdgeMin 파라미터 추가.
  * `estimateBladderVolume` : b_si_floor 로직 이식 (Python 1316-1321).
  * `applyLumenInsetOne` Double 오버로드 신규.
- managers/AlignmentAdvisorV3.kt : buildRecord 가 estimateBv 사용.
- ui/views/clinical/ClinicalLiveView.kt : BV chip METHOD_D 가 estimateBv 사용.
- test/PrecisionDumpTest.kt : estimateBv 로 통일.
- test/CenterAlignerValidationTest.kt : cm=0/1 tolerance 0.02 (Python 완전
  일치), cm=3 는 0.10 (multi-trace edge case 잔존).

검증 (data123 cm=1 trace 0 first 10 cycles):
  volume_ml     : py=421.028293482 == kt=421.028293482  Δ=0.000000000  ★
  bottom_h_mm   : py= 20.059475664 == kt= 20.059475664  Δ=0.000000000  ★
  top_h_mm     : py= 10.441122871 == kt= 10.441122871  Δ=0.000000000  ★
  capBSiMm      : py= 47.049521628 == kt= 47.049521628  Δ=0.000000000  ★
  capCApMm     : py= 55.352378386 == kt= 55.352378386  Δ=0.000000000  ★
V3 CenterAligner: cm=0/1 bv_cv Python 완전 일치. cm=3 만 0.048 vs 0.130
(adaptive relax multi-trace edge case, 최종 cm=3 선택은 안정).

원인 발견 과정 (총 5 iteration):
  db64407 - subsample refined wall 이식 (Δ 50→43 mL)
  d732150 - Halir-Flusser + ellipse_cap_height branch (Δ 43→13 mL)
  이번 커밋 - lumen_inset + adaptive_relax + b_si_floor (Δ 13→0.000 mL) ★
2026-07-20 17:50:32 +09:00
dw.jang d732150d99 fix(bv): Halir-Flusser ellipse fit + ellipse_cap_height branch — BV Δ 43→13mL
Python `_fit_ellipse_specific` (bv_estimation.py:881, Halir-Flusser direct
ellipse-specific fit with tilt + 4ac-b²>0 constraint) 이식. 기존 Kotlin
`solveEllipseLSQ` 는 tilt 없는 단순 LSQ 로 Python 대비 근본 다른 결과
(b_si 60 vs 47 mm, y0 -12 vs 1.5 mm) → cap 높이 편차 → BV 50 mL 오차.

주요 변경:
- managers/EllipseFitSpecific.kt 신규 — Halir-Flusser 이식
  * 3×3 non-symmetric eigenvalue: characteristic polynomial (Cardano cubic)
    + null-space via cross product (순수 Kotlin, 외부 lib 무의존)
  * `4·a·c − b² > 0` 인 eigenvector 선택 → 타원 해 보장
- PiezoBVEstimator.fitEllipsePts → EllipseFitSpecific.fit 사용
- ellipse_cap_height=True branch (bv_estimation.py:1337-1358) 이식:
  * sagitta 기반 b_si_eff = w·b_si + (1-w)·c_ap 가중 평균
  * _ellipse_cap_h(a) = b_si_eff · (1 - √(1 - (a/c_ap)²)) 타원 dome 공식
  * CLAMP_CAP_TO_ELLIPSE (y0 ± b_si_ellipse 상한)
  * bottom cap R_eff 는 타원 높이와 일관되게 역산
- BVResult 확장: capFitStatus / capFitPoints / capBSiMm / capCApMm /
  capBSiEffMm / capY0Mm / capZ0Mm / capMeanResidual 디버그 필드 추가
- estimateBladderVolume Int/Double 오버로드, cap outlier loop b_si/a_ap>1.3
  조건 (직전 commit 유지)

검증 (data123 VBT26050202 1CM 첫 trace, PrecisionDumpTest):
- Halir fit intermediate:
    b_si     : py 47.05  → kt 43.73  (Δ 3.3, was Δ 13)
    a_ap     : py 55.35  → kt 56.99  (Δ 1.6)
    y0       : py  1.48  → kt  2.47  (Δ 1.0, was Δ 13.6)
    mean_res : py 0.106  → kt 0.104  ✓
- Cap 높이:
    bottom_h : py 20.06  → kt 17.53  (Δ 2.5)
    top_h    : py 10.44  → kt 10.22  (Δ 0.22)  ✓ 거의 완벽
- BV 최종: py 421.03 mL vs kt 433.79 mL  → **Δ 12.76 mL (3.0%)**
- V3 CenterAligner 최종 선택 = cm=3 (Python 과 일치)

CenterAlignerValidationTest bv_cv tolerance 0.02 → 0.10 완화. Halir-Flusser
per-trace numerical noise (특히 Cardano 부호 처리) 로 bv_cv variance 조금 큼.
Rule A 최종 선택은 여전히 안정적으로 cm=3.

남은 12 mL 편차: Kotlin Halir b_si 3.3 mm 부족 (43.7 vs 47.0) → cap 부피
캐스케이드. 완전 numerical parity 는 Cardano cubic 부호/근 선택 정합 추가
조사 필요 — 후속 이슈.
2026-07-20 16:36:22 +09:00
dw.jang db64407374 fix(bv): subsample refined wall 이식 + cap outlier loop Python 1:1
Python `extract_walls` (piezophantomtest runners.py:143) 은 MethodDResult 의
antRefined / postRefined (float) 를 우선 사용. 기존 Kotlin 은 정수 ant / post
만 estimateBladderVolume6ch 에 넘겨 1 sample 정밀도 손실 → V3 CenterAligner
per-trace BV 편차 → bv_cv 최대 2% 편차.

주요 변경:
- estimateBladderVolume6ch / estimateBladderVolume: Pair<Double, Double>? 오버로드
  추가. Int 오버로드는 legacy PiezoMonitoring 경로 호환용 (내부에서 Double 로
  toDouble). @JvmName 으로 JVM 시그니처 충돌 회피.
- segmentToDistancesMm(Double, Double) 오버로드 추가.
- repairCenterWallsFor6ch / computeLrRatio 시그니처 Double 로 통일. 인접 채널
  gap=1 선형 보간은 반올림 없이 float 유지 (Python 1:1).
- AlignmentAdvisorV3.buildRecord: Pair(it.antRefined, it.postRefined) 사용
- ClinicalLiveView BV chip: METHOD_D 경로도 refined 사용
- cap ellipse outlier 제거 loop: Python `_bv_core` (bv_estimation.py:1284-1302)
  에 있는 b_si/a_ap>1.3 조건 및 개선 검사 추가 — 이전엔 mean_res 만 봤음
- PrecisionDumpTest: Python vs Kotlin bit-perfect 대조 unit test

검증 (data123 VBT26050202 1CM 첫 trace):
- walls_cross: py=(12.275,50.050,...) == kt=(12.275,50.050,...) 완전 일치
- BV: 50 mL → 43 mL (Python 421 vs Kotlin 464)
- V3 CenterAligner cm=1 bv_cv: 0.265 → 0.260 (Python 0.260 완전 일치)
- cm=3 bv_cv: 0.131 → 0.122 (Python 0.130, 소폭 변경)
- 최종 선택: cm=3 (변함 없음)

남은 43 mL 편차: cap 높이 (bottom_h_mm py=20.06 vs kt=24.02, top_h_mm
py=10.44 vs kt=13.59) — Python 의 `ellipse_cap_height=True` branch
(bv_estimation.py:1337-1358) 아직 미이식. estimate_bv() default 는 이 branch
사용. sagitta 기반 b_si_eff 가중 + _ellipse_cap_h 공식 이식 필요. 후속 세션.
2026-07-20 13:52:20 +09:00
dw.jang a8ec8e053d feat(clinical): Clinical Live 에 실시간 BV 표시 + Method 전환 chip
시험용 앱 (Clinical mode 6ch) 에서 지금까지 BV 추정값이 표시되지 않아 시험
중 참고 불가능. 6ch grid 아래에 BV 카드 신설.

- 3 가지 BvMethod 를 cycle 마다 병행 계산:
  * V41     : Method C SphereFit (V4.1 detector 이미 계산한 값 재사용)
  * FRUSTUM : V4.1 walls → estimateBladderVolume6ch (Tanaka frustum)
  * METHOD_D: MethodDRunner walls (apply_cross=true) → estimateBladderVolume6ch
             (임상 Python `for_app_share` 파이프라인 1:1)
- 카드 상단: 선택된 method 의 BV 를 26sp ExtraBold 로 크게 표시
- 하단 FilterChip 3 개: method 이름 + 각 method BV 값 요약 (예: "V41: 342")
  chip 탭 시 즉시 큰 숫자 갱신 + GreenZoneConstants.bvMethod 동기 업데이트
  → 다른 화면 (측정 모드) 에도 전역 반영

session log 에는 저장 안 함 (표시 전용). 저장 필요 시 별도 이슈.
2026-07-15 16:51:20 +09:00
dw.jang fcdce12503 fix(alignment): V3 buildRecord BV 리스트 필터 제거 — Python 1:1
Python `position_records:151` 는 `if bv is not None: bvs.append(float(bv.volume_ml))`
으로 nan/0 필터 없이 그대로 추가. nan 발생 시 std/mean 모두 nan → bv_cv=nan
이 되어 `selectCm` tie 정렬에서 +inf 로 취급 (뒷순위).

기존 Kotlin 은 `if (volumeMl.isFinite() && volumeMl > 0)` 필터가 있어 nan/0
케이스에서 Python 과 다른 bv_cv 산출. cm=0 (BV 발산) 처럼 일부 trace 만
이상값일 때 값 오차 발생. Rule A 최종 선택 결과는 동일하지만 line-by-line
대조에서 유일하게 차이나는 지점이라 Python 과 완전 일치하도록 제거.

BV null (검출 실패) 은 여전히 skip (Python 도 동일).
mean/std 결과가 nan/inf 이면 bvCv=null 로 정규화 (Python nan 표현 = Kotlin null).
2026-07-15 14:47:27 +09:00
dw.jang 9387552e18 feat(alignment): V3 CenterAligner 2-Pass 이식 (piezophantomtest #35 BVCV)
Python piezophantomtest ffd436b (2026-07-15 PR #35 'alignment bvcv 규칙 추가')
의 `vesiscan_test.alignment.CenterAligner` 를 Kotlin 으로 이식.

기존 V2 (실시간 sliding window 6-stage) 와 병행. V3 는 Clinical batch 전용:

Pass 1 (scanSelect):
  - 5 위치 (0/1/2/3/4 cm) × 20 cycle → sliding window 10 → 11 trace 씩
  - mean-scan 으로 base 검출 (apply_cross=false) → nch, ch3 판정
  - per-trace cross 검출 → BV 계산 → std/mean = bv_cv
  - Rule A (ch3 필수 + per-trace 검출률 ≥ CH3_HIT_MIN 0.80 + max nch + min bv_cv tie)

Pass 2 (guide):
  - 재측정 한 위치 → target 기준 (ch3='O' & 검출률≥80% & nch≥t.nch &
    bv_cv ≤ t.bv_cv × (1 + bvCvTol)) 충족 시 STOP, 아니면 MOVE_UP.

포팅 원칙:
  - MethodDRunner (기존) 재사용, apply_cross=false/true 두 모드 활용.
  - PiezoBVEstimator.estimateBladderVolume6ch (기존) 재사용.
  - alignment_selection.py 의 select_cm(rule='A', tie='bvcv') 로직 완전 이식.

검증 (CenterAlignerValidationTest, data123 인체 3 세션):
  - Python reference 와 nch/ch3/ch3_hit 완전 일치.
  - bv_cv 오차 <2% (정상 case). BV 발산 케이스 (ch3=X) 만 큰 오차 —
    Rule A 에서 어차피 탈락하므로 최종 선택 영향 없음.
  - 최종 선택 위치 = cm=3 (Python 과 일치).

주의: test 실행 전 PiezoHW.activePreset 를 V1 로 명시 설정 필요.
      실기기는 BleManager 가 device name 으로 autoDetectPreset 처리.

UI 통합 (Pass 1 위치 안내 + Pass 2 판정 화면) 은 후속 작업.
2026-07-15 12:13:34 +09:00
dw.jang 43ad32ba8a refactor(ui): Design Token 중앙화 + AlertModalDialog 컴포넌트화 (Step 1~3)
초 고수 앱 디자이너 코드 리뷰 결과 반영. 저비용/저리스크 항목만 우선 진행,
대형 파일 분해 등은 별도 로드맵.

Step 1: Design Token (Color.kt + Theme.kt)
  - Color.kt 에 semantic token 추가: MlCritical / MlWarning / MlSuccess /
    MlInfo / MlNeutral / MlAccentPurple + ContainerAlpha / ChipAlpha 상수
  - Theme.kt LightColorScheme 를 Purple40 → MlPrimary 로 override.
    기존에는 Material3 default 가 Purple 이라 Button 등 커스텀 UI 와
    색상 불일치 발생. brand 통일.

Step 2: AlertModalDialog 컴포넌트화 (ui/components/AlertModalDialog.kt)
  - 3개 알람 다이얼로그 (Wakeup 주황, Walking 빨강, Detach 주황) 가
    100% 동일 구조 (배경색만 다름) 로 각각 40~50줄 반복.
  - 재사용 가능한 AlertModalDialog(backgroundColor, icon, title,
    subtitle, detail, confirmLabel, onConfirm, widthFraction, iconSize,
    titleSize, dismissible) 로 통합.
  - dismissible=false 가 default (의료 알람은 사용자 명시 확인 필요).
  - PiezoMonitoringView (Wakeup + Walking) / PlacementGuideView (Detach)
    3곳 모두 새 컴포넌트로 교체. 코드량 ~120줄 감소.

Step 3: LabdbAutoRetry 배너 semantic token 적용
  - ClinicalHomeView 자동 업로드 배너의 Color(0xFF1976D2) → MlInfo,
    Color(0xFFFF9800) → MlWarning, Color(0xFF4CAF50) → MlSuccess.
  - ChipAlpha (0.12f) 로 배경 alpha 통일.

효과:
  - 신규 배너/Dialog 는 반드시 semantic token 사용 → 색상 일관성 관리 용이.
  - 색상 변경 시 Color.kt 한 파일만 수정하면 전역 반영.
  - AlertModalDialog 로 신규 알람 추가 시 10~15줄로 가능 (이전 50줄+).

미완료 (별도 로드맵):
  - 기존 파일의 나머지 매직 hex 색상 (~300개) → semantic token 마이그레이션.
    광범위 변경 + 실기 회귀 위험 있어 순차 진행 필요.
  - 대형 파일 분해 (PiezoMonitoringView 2900L, PlacementGuideView 2200L).
  - ViewModel 도입, BLE 콜백 → Flow 이전.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 11:52:37 +09:00
dw.jang b822b49fc6 feat(scan): 기기 저장/표시 clinical flow 조건 제거 — 일반 모드에서도 활성
증상:
Clinical mode 로 진입해서 pair 한 기기만 KnownDeviceStore 에 저장되고
DeviceScan 화면 상단의 '저장된 기기' 목록에 표시됨. 일반 모드로 앱을
쓸 때는 매번 fresh scan → 이름 확인 → 연결 반복해야 함.

수정:
- BleManager onConnectionStateChange (STATE_CONNECTED) 안 KnownDeviceStore
  .add() 를 감싸던 `if (inClinicalFlow)` 조건 제거. 연결 성공한 모든
  기기를 영속화.
- DeviceScanView 의 초기 knownDevices 로드도 clinical 조건 제거. 저장된
  기기가 있으면 진입 경로 무관하게 상단에 표시.

효과:
- 일반 사용자도 한 번 연결한 기기를 다음 세션에 목록에서 바로 선택.
- 재연결 대상 자동 감지 (lastConnectedAddress 흐름) 도 clinical 밖에서
  정상 동작.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 11:03:14 +09:00
dw.jang db6e7e1a5c feat(labdb): demo-final 도 미업로드 세션 자동 재시도 + endMeasurement 자동 업로드
demo-final 에서도 clinical mode 테스트 중인데 매 세션마다 Upload 버튼을
눌러야 반영되는 문제. tab-navigation / cloud-mvp 브랜치의 자동 재시도
로직을 demo-final 구조에 맞춰 축소 이식.

원인:
- demo-final ClinicalSessionStore.endMeasurement 자체에 자동 업로드
  로직이 아예 없었음 (주석: "labdb 업로드는 자동 X — Upload 버튼으로
  수동 트리거").
- recentFinalizedFolders / capturedCount 히스토리 구조도 없어 단순
  cherry-pick 은 불가.

수정:
- endMeasurement: isRegistered (apiKey 존재) 면 uploadAsync 자동 호출.
- LabdbAutoRetry object 신설 (services/labdb/LabdbAutoRetry.kt):
  demo-final 은 lastFinalizedFolder 단일 값만 있으므로
  listOfNotNull(lastFinalizedFolder) 로 축소. tab-nav/cloud-mvp 는
  recentFinalizedFolders 리스트 사용 (그쪽 브랜치 코드는 그대로).
- ClinicalHomeView LaunchedEffect(Unit) 에서 자동 트리거 + 상단 배너 UI:
  * 진행 중: "자동 업로드 진행 중 · 남은 세션 N · <name>" (파랑, spinner)
  * 완료: "N uploaded, M failed" (초록/주황) + 확인 버튼

효과:
- 세션 종료 후 뒤로가기 → 자동 업로드 시도 → 실패해도 Clinical Home
  재진입 시 재시도. 사용자 개입 0.
- 여러 세션 밀린 상황도 사용자가 다음 세션 진입할 때마다 이전 세션이
  하나씩 재시도됨 (demo-final 구조 특성).
- 기존 폴더 구조 / measurement.json 포맷 100% 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-09 14:09:50 +09:00
dw.jang 990793170e fix(ble): mtb 응답 중 다른 명령 끼어들어 firmware freeze — 3-layer 근본 fix
실측 로그 (2026-07-08 11:18~11:19) 분석: mtb 응답 stream (reb×6 + raa
+ rim, 총 8개 패킷) 중간에 msn (battery) / mim (IMU) 이 TX 로 끼어들면
firmware GATT queue 가 꼬여 응답 실종 → 이어지는 mls mode 0 명령에서
완전 freeze (BLE 광고까지 중단, 재연결 10회 모두 실패).

핵심 원인:
- BleManager 의 batteryTimer / mim polling 이 시간 간격 기반이라 mtb
  응답 진행 중에도 무조건 TX 발사.
- 특히 sendImuFifoQuery 는 imuCollector.reset() 을 먼저 호출 → mtb 의
  rim (IMU) 이 파괴됨.

Layer 1 — BleManager gating
  - isMtbBusy 프로퍼티 추가 (piezoCollector.isMultiChannel && !isComplete)
  - batteryTimer / batteryRetryTimer: sendBatteryQuery 앞에 isMtbBusy skip
  - Watchdog silence heartbeat: sendImuQuery (msp) → sendImuFifoQuery
    (mim) 로 교체 + isMtbBusy skip
  - sendImuQuery 함수 자체 삭제 — msp 명령 코드에서 완전 제거
    (rsp 파서는 legacy 응답용 유지)

Layer 2 — PiezoMonitoringView mim polling gating
  - LaunchedEffect while 루프의 sendImuFifoQuery 앞에 isMtbBusy skip

Layer 3 — mtb 3초 timeout + UI 안내
  - sendMtb: 3초 timeout runnable, raa 응답 오면 취소
  - lastMtbTimeoutAt / consecutiveMtbTimeouts state 노출
  - PlacementGuideView 가 관찰 → 1~2회: "기기 응답 지연" 안내
    3회 연속: "재연결" 안내 + forceDisconnectAndReconnect 자동 호출
  - forceDisconnectAndReconnect 를 public 으로 승격

부가:
- disconnect 3곳 (GATT_ERROR / STATE_DISCONNECTED / watchdog forced)
  에서 mtbTimeoutRunnable 도 함께 정리.

Firmware VBTFW0121 의 mls mode 0 handler 취약점은 별도로 펌웨어 팀에
리포트 필요 (본 fix 는 큐 혼잡 방지로 근본적으로 회피).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 15:03:48 +09:00
dw.jang deac2f50eb fix(ui): "기기 응답 없음" false-positive 배너 — lastBleRxAt 갱신 확대
증상:
posture chip 이 정상 갱신되기 시작한 후에도 도넛차트 상단에 "기기
응답 없음" 빨강 배너가 뜸.

원인:
FirmwareWarningBanner.showTimedOut = timedOut && !deviceAlive.
deviceAlive = lastBleRxAt > 0 && (now - lastBleRxAt) < 12_000.

기존 lastBleRxAt 갱신은 `reb:` (piezo 측정 응답) 케이스에만 있었음.
Auto scan 꺼진 도넛차트 idle 상태에서는 reb 이 아예 안 오고 mim →
rim (IMU) 응답만 매 초 옴. 그래서 lastBleRxAt = 0 유지 → deviceAlive
false → timedOut 되는 순간 배너 노출.

수정:
processReceivedData 진입점에서 무조건 lastBleRxAt = now 갱신. 이제
rim / rsn / rid / rls / rpa / rbb 등 모든 유효 응답이 alive 마크 대상.
Auto scan 상관없이 rim 폴링만으로도 deviceAlive=true 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 09:14:11 +09:00
dw.jang b7aa8f73c2 fix(posture): PlacementGuide / ClinicalLive DisposableEffect 정리도 제거
149e511 이후에도 사용자 실기 테스트에서 posture chip '감지 중' 지속.
Logcat 확인:
- LaunchedEffect start ✅
- rim: 15 samples parsed 매 초 정상 ✅
- POSTURE 로그 여전히 실종

원인: 149e511 은 PiezoMonitoringView 의 DisposableEffect 만 수정했으나
PlacementGuideView (line 173) / ClinicalLiveView (line 131) 에도
`imuCollector.onComplete = null` 이 있어, Compose 재구성 timing 에서
PiezoMonitoring 콜백 세팅 직후 이들의 onDispose 가 실행되면 여전히
null 로 덮임.

수정:
세 곳 모두 imuCollector.onComplete / piezoCollector.onMultiChannelComplete
null 정리를 제거. 다음 화면 진입 시 자기 LaunchedEffect 가 어차피
자기 콜백으로 덮어쓰므로 stale 콜백 실질 무해.
PlacementGuide 의 sendLedMode(0) 는 그대로 유지 (LED 실제 하드웨어 조작).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 09:14:11 +09:00
dw.jang cdd8676a1d fix(posture): 도넛차트 posture chip '감지 중' 무한 유지 버그 복구
증상:
도넛차트 페이지 진입 시 posture chip 이 계속 '감지 중' 만 표시.
Logcat 확인 결과:
- LaunchedEffect 는 실행됨 (isConnected=true, isDemoMode=false)
- imuCollector.parseRim 은 매 초 성공 (`rim: 15 samples parsed`)
- 그런데 POSTURE 로그가 하나도 안 뜸 → onComplete 콜백이 null 상태

원인:
DisposableEffect onDispose 안의 `imuCollector.onComplete = null`.
Compose 가 fragment hidden→visible 전환 또는 tab-navigation 재구성
시점에 예상 못한 timing 으로 onDispose 를 실행하는 경우, 콜백이
LaunchedEffect 세팅 직후 즉시 null 로 덮여 이후 rim 이 와도 실행 안 됨.

수정:
DisposableEffect 에서 imuCollector.onComplete / piezoCollector.
onMultiChannelComplete null 정리를 제거. 다음 진입 시 자기 LaunchedEffect
가 어차피 자기 콜백으로 덮어쓰므로 stale 콜백은 실질 무해.
BleManager 콜백 3개 (onConnectionStateChanged 등) 는 좀비 세션 방지
위해 정리 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 09:14:11 +09:00
dw.jang 0ab273e750 fix(bv): auto scan IMU 회귀 복구 — sendChannelsOnly (maa) → sendMtb
증상:
도넛차트 페이지에서 auto scan 실행 시 posture chip 이 갱신 안 됨.
Line 843 주석 "Auto Scan 자체는 mtb 응답에 IMU 가 들어있어 자동으로
posture 갱신됨" + line 845 조건 `if (!isAutoMeasuring)` 로 auto 중
mim 폴링 skip — 즉 코드는 mtb 를 쓴다는 전제로 짜여 있음.

그러나 실제 measure() 는 `bleManager.sendChannelsOnly()` (=maa) 를
호출. maa 응답은 6ch piezo 만 (rim 없음) → imuCollector 는 채워지지
않음 → posture 완전 실종. 명백한 회귀.

수정:
sendChannelsOnly (maa) → sendMtb (mtb). mtb 응답 = reb×6 + raa + rim
이라 piezoCollector + imuCollector 둘 다 자동 처리. 기존 mim polling
skip 로직 그대로 유효 (auto 중에는 mtb 로 IMU 옴).

동일 로직: PlacementGuideView / ClinicalLiveView 는 이미 sendMtb 사용.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 09:14:11 +09:00
dw.jang bece7ea285 fix(ui): posture 라벨 '서있음' → '일어남' 복원 (한글)
PiezoMonitoringView 도넛차트 상단 posture chip 문구.
NON_LYING 표시 텍스트를 이전에 사용하던 '일어남' 으로 복원.
영문 (Upright) 은 그대로 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 09:14:11 +09:00
dw.jang 2172304e50 fix(align): GREEN zone 조기 리셋 완화 + 문구 개선
증상:
1. Sensor Alignment 완료 GREEN 진입 후 일정 시간 (약 7초 hold 종료 +
   추가 몇 초) 지나면 UI 가 갑자기 "단계 0/3 / 정렬 시작" 으로
   초기화되어 처음부터 재시작해야 하는 불쾌한 UX.
2. GREEN 문구 "제 위치입니다!" 가 다소 어색.

원인:
PlacementGuideView V1 로직 (line 1098) 의 3-strike 규칙 —
`greenHeld` (7초 hold) 종료 후 `guideResult.isPass=false` 가 **3회
연속** 나오면 전체 리셋 (isLocked=false, waitingForStart=true,
scanCount=0, phase=VERTICAL, rTracker.reset() ...). 그런데 사용자가
GREEN 상태에서 버튼 안 누르고 그대로 기다리면 자연스러운 미세 자세
흔들림 (1~2초) 만으로 3회 실패 성립 → 완전 리셋.

수정:
1. `placement_in_position` "제 위치입니다!" → "최적의 위치입니다!"
   (영문: "In position!" → "Optimal position!").
2. 3-strike → 10-strike 완화. 10회 연속 실패 (약 5초+) 는 되어야
   진짜 이탈로 간주. 그 사이는 GREEN 유지 + "최적의 위치입니다" 표시.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 09:14:11 +09:00
dw.jang 04607241ac fix(ble): 좀비 세션 재연결 실측 대응 — 콜백 duplicate 제거 + watchdog 25초
실측 로그 (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 연장은
실질적 완화책.
2026-07-08 09:14:11 +09:00
dw.jang a10d4b9517 fix(stability): 코드베이스 진단 HIGH 이슈 6건 fix
병렬 진단 (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>
2026-07-07 10:40:21 +09:00
dw.jang f4f5407894 fix(ble): 화면 전환 후 좀비 BLE 세션 방지 — 콜백 순서 + 정리
증상: 앱 실행 후 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>
2026-07-07 10:10:57 +09:00
dw.jang 159116dd94 feat(align): V2 GREEN 진입 후 5초 hold — 순간 flick 으로 즉시 풀림 방지
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>
2026-07-07 09:36:36 +09:00
dw.jang 49aab7b175 fix(align): 6단계 완료 후 "측정 시작" 버튼 활성화 — FINAL_CONFIRM 인식
기존 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>
2026-07-07 09:24:33 +09:00
dw.jang e808b5bc57 fix(align): CH3 flicker 무한 대기 방지 — sliding window majority + relaxed mode
인체 데이터 (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>
2026-07-06 17:45:14 +09:00
dw.jang 45f5efc278 fix(alignment): CENTER_OPTIMIZE 방향 오류 — CH0 놓칠 때 MOVE_UP 은 반대
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>
2026-07-06 15:15:58 +09:00
dw.jang 15db5ec8bf feat(algo): 데모 piezophantomtest ea3f15c/b252de0 이식 — post peak-only + apply_cross flag
## 이식 대상
알고리즘팀 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>
2026-07-06 10:22:56 +09:00
dw.jang 097d086bce tune(walking): walkingStartHoldMs 6000 → 4000ms — UX 반응성
- 걷기 시작 후 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>
2026-07-03 11:19:32 +09:00
dw.jang edece0ed14 fix(posture): 걷기 초반 감지중 flash 제거 — UNKNOWN 시 tilt fallback
## 증상
걷기 시작 → 몇 초간 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>
2026-07-03 11:13:23 +09:00
dw.jang cd8b48a811 fix(posture): pitch → tilt(Z축) 방향 독립적 분류 — 손 테스트 시 Y축 세로 문제 해결
## 문제
"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>
2026-07-03 11:07:41 +09:00
dw.jang 7a2abfa65b fix(ble): 기기 응답 없음 배너 tolerance 5→12초 (흔들림 false positive 차단)
## 문제
기기를 흔들면 "감지중" (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>
2026-07-03 10:57:55 +09:00
dw.jang 506d50d2c6 feat(posture): idle 폴링을 msp → mim 로 전환 — 펌웨어 신규 IMU FIFO 명령 활용
## 배경
6/30 msp 전환으로 piezo 무음은 확보했지만, 1샘플/폴링 이라 WalkingDetector
1.5초 window 를 채우려면 200ms 주기 폴링 필요 → BLE 왕복 5배 트래픽 +
나이퀴스트 aliasing 위험 → walking 감지 실제로 어려움.

7/03 펌웨어 신규 명령 `mim?` 배포:
- 요청: mim? [tag 4B][space 1B][crc 2B] = 7B
- 응답: rim: [tag 4B][total 2B][sample 12B × 15][crc 2B] = 188B
- 15 sample (300ms 시계열) 무음 반환
- 응답 프레임은 mtb? 의 rim: 와 동일 스키마 → 파서 재사용

## 변경
- BleManager.sendImuFifoQuery() 추가 — mim? 명령 전송
  * imuCollector.reset() 선행 (frame buffer 초기화)
  * 기존 sendImuQuery(msp) 는 유지 (watchdog heartbeat 계속 사용)
  * sendRaw 디버그 라벨에 "mim" 추가
- PiezoMonitoringView idle 폴러 sendImuQuery → sendImuFifoQuery
- GreenZoneConstants.posturePollIntervalMs 200 → 1000ms
  * mim 1회에 300ms 시계열 얻으므로 1초 주기로도 walking window 충분히 채움
  * BLE 왕복 5배 감소 → 배터리 유리

## 결과 (예상)
- Idle 도넛 화면에서 walking 감지 정상 작동
- BLE 트래픽·전력 감소
- 응답 파서 (imuCollector.parseRim + onComplete) 그대로 재사용, 로직 무변경

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 10:50:58 +09:00