Commit Graph

23 Commits

Author SHA1 Message Date
dw.jang a86fefe321 fix(ble-log): 앱 시작~종료를 한 파일에 통으로 자동 저장
Downloads 에 VesiScan_BLE_*.log 가 여러 개 생기고 그마저 내용이 비던 문제.
원인이 셋이었다.

1. 재연결마다 파일이 갈라짐
   connected() 가 liveLogFile=null 로 핸들을 버려서 BLE 가 끊겼다 붙을 때마다
   새 파일이 생겼다. 앱 시작~첫 연결 구간도 별도 파일로 빠졌다.
   → 프로세스 1회당 파일 1개. 재연결 시에는 구분선만 남긴다.

2. 파일 쓰기에 동기화가 없음
   rx() 는 BLE 콜백 스레드, 나머지는 UI 스레드에서 호출되는데 매번 파일을
   새로 열어 쓰며 잠금이 없었다. 동시 쓰기로 줄이 섞이거나 유실됐다.
   → synchronized + 핸들 유지, 줄마다 flush (강제 종료에도 그 시점까지 보존).

3. 저장 실패가 조용히 묻힘
   Downloads 쓰기가 막히면 예외를 삼켜 아무 데도 안 남았다.
   → 내부 저장소로 폴백하고 실제 경로를 파일 헤더에 기록.

추가로 두 가지:

- Export 버튼이 메모리 링버퍼(2000줄)를 덤프해 "마지막 몇 분"만 나오던 것을
  실시간 세션 파일을 그대로 내보내도록 변경.
- disconnected() 가 비정상 종료 경로에서만 호출돼, 사용자가 직접 끊으면
  아무 기록 없이 로그가 끊겼다. 이유(user/unexpected/gatt_error)를 구분해
  항상 남기도록 수정.

임상 모드 step 별 ble.log 는 부가 사본으로 그대로 유지한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:54:38 +09:00
dw.jang f19a56694b feat(dev): 기기 배터리 성능 테스트용 임시 부하 버튼 (mim 1s / mbb 10s)
프로브 배터리 소모율 실측을 위해 홈 화면(dev 모드)에 고정 트래픽 부하를
거는 임시 버튼 추가.

- BatteryDrainTester: Handler 기반 루프 소유자. mim? 1초 / mbb? 10초 주기.
  화면 이동·백그라운드와 무관하게 계속 돌고 "정지" 로만 종료.
  mim 은 isMtbBusy 앞에서 skip (imuCollector.reset() 이 mtb/mbb 의 rim 파괴
  방지 · BLE_PROTOCOL_REFERENCE §2.6). 미연결 구간은 송신만 skip 해 auto-
  reconnect 후 부하 자동 재개.
- BatteryDrainTestDialog/Button: 송수신 카운터 · 배터리 전압 delta · 온도 ·
  마지막 응답 경과 · mV/h 소모율 실시간 표시.
- BleManager.onResponseFrame: 계측 전용 수신 프레임 tap 추가 (기존 콜백
  clobber 없이 rim/ric/rbb/rsn 집계). 기능 로직은 이 콜백에 비의존.
- PiezoMonitoringView: 부하 테스트 중이면 자체 mim 폴링 skip (중복 트래픽 방지).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:02:55 +09:00
dw.jang 1ce19949c9 chore(cleanup): NIRS/VIVAMYO 세트 완전 제거 (Category A · 사용자 요청)
배경: NIRS 는 초기 iOS 앱에서 이식했으나 현재 완전 미사용. Piezo 만 실사용.

완전 제거 (6 파일):
  · managers/NirsManager.kt (94 L)
  · managers/NirsCalcEngine.kt (206 L)
  · models/NirsConstants.kt (49 L)
  · models/NirsData.kt (106 L · NirsMcjPacket · NirsMagResponse · NirsOxyResult)
  · ui/views/monitoring/VivamyoMonitoringView.kt (497 L)
  · ui/views/calibration/CalibrationView.kt (216 L) + calibration/ 디렉토리

부분 편집:
  · AppState.kt      — NirsManager import · NIRS_CALIBRATION/VIVAMYO_MONITORING enum ·
                       calibrationComplete() · sensor 분기 3곳 (goHome/enterDemoMode/
                       deviceConnected/backToMonitoring) 모두 PIEZO 로 단순화
  · MainActivity.kt  — CalibrationView/VivamyoMonitoringView import + Crossfade branch +
                       back-handler branch 삭제
  · BleManager.kt    — 콜백 4개 (onNirsPowerOn/SensorActivated/MagReceived/McjReceived) ·
                       sendRawWrite when-branch 4개 · NIRS Commands 섹션 5 함수 ·
                       RX branch 4개 삭제
  · MeasurementService.kt — performNirsMeasurement 제거 · performMeasurement 단순화 ·
                       stop() 콜백 정리 · SensorMode 유지 (센서 추가 대비)
  · SensorMode.kt    — NIRS enum value 삭제 · PIEZO 만 남음 · UserStorage 는
                       valueOf 실패 시 PIEZO fallback 이라 마이그레이션 불필요
  · SensorSelectView.kt — NIRS SensorCard 제거
  · strings.xml × 2  — sensor_nirs_desc · calibration_* 10개 · vivamyo_* 17개 삭제
                       (calibration_continue 는 PiezoPersonalization 사용 → 유지)

합계: 약 1,300 LOC 감소.
빌드: BUILD SUCCESSFUL 17s.
2026-08-12 15:38:59 +09:00
dw.jang 6dfe5e6281 fix(ble): disconnect() 시 isConnected.value 즉시 false 반영 (홈 "연결됨" stuck)
증상 (사용자 관찰 2026-08-11):
  · 설정 → Disconnect 눌러 연결 해제 후 goHome() → 홈 화면에 여전히 "● 연결됨"
    chip 표시. 실제 BLE 는 끊긴 상태.

원인:
  · BleManager.disconnect() 가 gatt.disconnect() 만 호출 · isConnected.value 는
    시스템 콜백 (onConnectionStateChange STATE_DISCONNECTED) 이 도착해야 false 로
    갱신됨. 콜백은 비동기 (수 초 지연 가능).
  · 그 사이 goHome() 실행 → HomeView 는 getter (isDemoMode || bleManager.isConnected.value)
    로 값 읽음 · 아직 true → "연결됨" 잘못 표시.
  · PiezoMonitoringView:2385 의 `appState.isDeviceConnected = false` 는 setter 가
    no-op (2026-08-03 fix 이후 getter-only) 라 무효.

수정:
  · disconnect() 첫머리에서 isConnected.value = false 즉시 세팅 (UI 층 즉시 반영).
  · 실제 GATT teardown 은 콜백에서 정상 진행 (idempotent). 재연결 시엔 그때
    콜백으로 다시 true 되므로 로직 훼손 없음.
  · disconnectAndUnbond() 는 이미 postDelayed 안에서 false 세팅 하고 있으나
    이건 사용자 요청 시엔 300ms 정도 지연이 커도 UI 오동작 없음 (즉시 goHome 없음).

빌드: BUILD SUCCESSFUL 20s. installDemoDebug 완료.
2026-08-11 16:08:25 +09:00
dw.jang 13ba88b72b fix(firmware): VBTFW ↔ MCUboot semver 매핑 · update-available 정확 판정 (FW 팀 스펙 2026-08-05)
증상 (사용자 보고):
  기기 fw = VBTFW0200 · 번들 DFU = 1.0.0+5 · 앱이 "새 펌웨어 사용 가능" 배너 노출.
  실제로는 기기 (2.0.0) > 번들 (1.0.0) 이라 업데이트 불필요.

원인:
  기존 isFirmwareUpdateAvailable 은 단순 String contains 비교 (`!fw.contains(bundled)`)
  → "VBTFW0200" 은 "1.0.0+5" 를 포함 안 하므로 무조건 true. 방향성 없음.

FW 팀 스펙 (2026-08-05 슬라이드 · Firmware 식별 코드):
  A. 공식 펌웨어 버전: VBTFW MMNN → M.0.N (major=MM, patch=NN, minor 항상 0)
     · VBTFW0100 = 1.0.0
     · VBTFW0200 = 2.0.0
     · VBTFW0201 = 2.0.1
  B. MCUboot/DFU 이미지: major.minor.patch+build (build 는 major/minor/patch 비교 무관)
  C. 두 포맷 1:1 매칭 관리 (VBTFW0100 = 1.0.0+0)

수정:
  BleManager.parseFwSemver(raw): Triple<Int, Int, Int>?
    - VBTFW / VB0FW / VBFW 정규식 (`VB\d?FW(\d{2})(\d{2})`) → (M, 0, N)
    - MCUboot 정규식 (`(\d+)\.(\d+)\.(\d+)`) → (major, minor, patch)
  BleManager.compareSemver(a, b): Int (튜플 3단계 비교)
  isFirmwareUpdateAvailable: bundled 튜플 > device 튜플 일 때만 true
    → 기기가 이미 최신이거나 동일하면 배너 미노출

검증 케이스:
  device VBTFW0200 · bundled 1.0.0+5 → (1,0,0) < (2,0,0) → false (no banner)
  device VBTFW0100 · bundled 2.0.0+0 → (2,0,0) > (1,0,0) → true (banner)
  device VBTFW0200 · bundled 2.0.1+0 → (2,0,1) > (2,0,0) → true (banner)

빌드 SUCCESSFUL 37s.
2026-08-05 14:04:09 +09:00
dw.jang 4eb67e7450 feat(firmware): 새 DFU zip (1.0.0+5) 번들 + 버전 불일치 시 업데이트 배너
DFU 파일:
  - dfu_application_2+35.zip (구) 삭제
  - dfu_application_1.0.0+5.zip (신규 238KB) 추가

FirmwarePackage:
  - DEFAULT_ASSET = "dfu_application_1.0.0+5.zip"
  - BUNDLED_VERSION = "1.0.0+5" 상수 신규 (manifest.json version_MCUBOOT 와 일치)

BleManager:
  - isFirmwareOutdated: 신규 semver 포맷 ("1.0.0+5") 은 legacy VBTFW build-number
    비교 제외 (false positive 방지 · parseFirmwareBuild 가 tail 숫자 5 를 118 미만
    으로 오판정 안 하도록).
  - isFirmwareUpdateAvailable (신규): 런타임 fw 문자열이 BUNDLED_VERSION 을 포함
    하지 않으면 true. 사용자에게 "새 펌웨어 사용 가능" 배너 노출.

FirmwareWarningBanner:
  - update-available 파랑 배너 신규 (정보성 · 우선순위 outdated > timedOut > update).
  - onUpdateAvailableTap 콜백 · 파랑 배너 탭 시 FIRMWARE_UPDATE 화면 이동.
  - PiezoMonitoringView · PlacementGuideView 호출부에 콜백 전달.

strings.xml (KR/EN):
  - firmware_update_available_title/desc

동작 흐름:
  1. 앱 연결 → mid?/rid: 로 fw 수신 (예: "VBTFW0200")
  2. isFirmwareUpdateAvailable → true (fw !contains "1.0.0+5")
  3. 상단 파랑 배너 "새 펌웨어가 있습니다 · 기기: VBTFW0200 · 신규: 1.0.0+5" 노출
  4. 탭 → FIRMWARE_UPDATE 화면 → DFU 진행 → 성공 시 fw 갱신 후 배너 자동 사라짐

향후 FW 팀이 버전 스키마 확정 시 BUNDLED_VERSION 만 갱신하면 됨.
2026-08-04 17:24:59 +09:00
dw.jang a992a88b86 feat(ble): unbondSmart() · 삭제 시 자동 재연결 + 오프라인 경고 팝업 (cloud-mvp 1982399 이식) 2026-08-04 17:03:38 +09:00
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 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 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 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 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 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 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 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
dw.jang bcbc7b440c refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan
사용자앱 (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>
2026-07-02 14:30:43 +09:00