## ① 반쪽 연결 — 원인은 MTU 협상 타이밍이었다
성공과 실패를 가르는 것은 두 번째 `onMtuChanged` 가 **언제** 오는지 하나였다.
2026-09-15 Xiaomi 23021RAA2Y · VBT2607R300 · 9건에 예외가 없었다:
두 번째 콜백 +1~2ms → WATCHDOG_STARTED 3/3
두 번째 콜백 +297~342ms → HALF_CONNECTED 6/6
+1ms 는 탐색이 시작도 안 했을 때라 살아남고, +300ms 는 탐색 한가운데라 깨진다.
프로브가 연결 뒤 ~300ms 에 자기 쪽에서 MTU 협상을 걸고(폰 GATT 서버 로그의
`gatts_process_mtu_req: MTU 247 request from remote`), 그 ATT 트랜잭션이 진행 중인
서비스 탐색을 깨뜨려 `onServicesDiscovered` 가 오지 않는 것으로 보인다.
**중복 호출을 막는 것으로는 고쳐지지 않았다.** 직전 커밋(8b42996)에서 막아 봤고
`DISCOVER_SKIPPED` 가 발동한 5회 중 3회가 여전히 반쪽이었다 — 깨뜨리는 것은 우리
호출이 아니라 프로브의 ATT 요청이고 앱이 막을 수 없다. 그래서 **피한다**:
탐색을 500ms 뒤에 시작해 협상 창을 지나 보낸다.
실측: 수정 전 6/6 실패 → 수정 후 **4/4 성공**(전부 MTU 2회였다).
대가는 연결 완료가 0.5초 늦는 것뿐이고, 실패하면 20초 `armHalfConnectedGuard` 가
그대로 잡는다. 지연 중 끊기면 `cancelPendingDiscover()` 로 취소한다(정리 경로 10곳).
⚠ 근본 원인은 프로브가 연결 직후 MTU 협상을 거는 것이다. 펌웨어에서 없애거나 연결
직후 즉시 끝내면 이 지연은 필요 없어진다 — 문의 예정.
## ② "광고가 없습니다" 의 정체는 안드로이드 스캔 제한이었다
연결/해제를 연타한 뒤 앱이 기기를 못 찾았다. 그런데 **프로브 LED 는 광고 중이었고
다른 폰에서는 잡혔다.** 시스템 로그에 근거가 그대로 있었다:
E/BtGatt.GattService: App 'com.medithings.vesiscan.demo' is scanning too frequently
안드로이드는 30초에 5회를 넘기면 스캔을 **조용히** 막는다. 빈 결과가 오므로 앱은
"광고가 없습니다" 로 보고하고, 사용자는 기기를 의심하게 된다 — 실제로 그랬다.
OS 가 막기 전에 앱에서 먼저 막는다. 30초 창에 4회(한도 5 에서 하나 남김)를 넘으면
스캔하지 않고 `SCAN_THROTTLED:<남은 초>` 로 알린다. 화면에 남은 초와 함께 "기기 문제가
아닙니다" 를 적었다.
스캔 시작 세 경로 전부 가드를 거친다 — `startScan`, `waitForAdvertThenConnect`,
자동 재연결 스캔(이쪽은 재시도 경로라 건너뛰고 다음 차례로 넘긴다).
광고 확인(`ADVERT_WAIT`)을 생략하는 선택지는 택하지 않았다. 광고하지 않거나 먼 기기에
붙으려다 오류가 났던 이력이 있어 그 확인을 넣은 것이다 — 스캔을 줄이려고 그걸 빼면
예전 문제가 돌아온다(사용자 지적).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지시(2026-09-15, 병원 임상 당일). 실측에서 23초 주기로 영원히 반복했다.
## 무엇이 일어났나 (Xiaomi 23021RAA2Y · VBT2607R300 · RSSI −46)
11:08:41 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결
11:09:04 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결
11:09:27 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결
11:09:50 연결 → MTU×2 → (반복)
신호는 강했고(−46) 프로브는 매번 연결을 받았다. 그런데 GATT 서비스를 끝까지 내주지
않아 `isServiceReady` 가 한 번도 서지 않았다. **프로브가 갇힌 상태**이고, 같은 프로브가
같은 날 13회 정상 동작했으므로 도중에 그 상태로 빠진 것이다.
다시 붙어도 같은 결과가 나온다. 그리고 링크는 매번 붙으므로 재시도 카운터가 1 로
리셋돼(`attempt=1/-1`) escalation 도 없었다 — 화면에는 "재연결 중" 만 계속 뜨고
조작자는 무엇을 해야 하는지 알 수 없다. 임상에서 이게 제일 나쁘다.
## 바꾼 것
반쪽 연결 가드에서 `scheduleAutoReconnect()` 를 뺐다. 멈추고 `connectionError` 를
`HALF_CONNECTED` 로 남긴다 — 표시 경로는 이미 있었다(DeviceScanView).
문구도 사실에 맞게 고쳤다. "다시 연결합니다" 라고 적혀 있었는데 이제 재연결하지
않으므로 거짓이 된다. 대신 **해야 할 일**을 적었다 — 이 상태를 푸는 것은 프로브 전원
재투입이고 앱이 할 수 있는 일이 아니다.
연결이 끝까지 되지 않았습니다 — 기기가 응답했지만 준비를 마치지 못했습니다.
프로브 전원을 껐다 켠 뒤 다시 연결해 주세요. 다시 시도해도 같으면 다른 프로브를 쓰세요.
## 건드리지 않은 것
진짜 링크 유실(범위 이탈·간섭·`status 8`)의 자동 재연결은 그대로다. 그쪽은 다시 붙으면
실제로 복구되고 `MAX_RECONNECT_ATTEMPTS`(5) 로 escalation 이 있다. 반쪽 연결만 예외로
뺐다 — 재시도가 의미 없는 유일한 경우다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
병원 임상 당일 빌드의 추적성을 위해 커밋한다 — 설치된 APK 가 기록된 커밋과
일치해야 한다.
## 무엇을 넣었나
`onMtuChanged` 에서 무조건 `discoverServices()` 를 부르던 것을, 연결당 한 번만
부르도록 가드를 뒀다(`discoverServicesOnce`). 중복 호출 자체는 없어진다 —
로그에 `DISCOVER_SKIPPED` 로 남는다.
## ⚠ 이것이 반쪽 연결을 고치지 못한다 — 실측으로 확인
2026-09-15 Xiaomi 23021RAA2Y · 프로브 VBT2607R300:
10:52:56 MTU×2, 가드 미발동 → HALF_CONNECTED
10:53:19 MTU×2, DISCOVER_SKIPPED → 정상
10:57:01 MTU×2, DISCOVER_SKIPPED → HALF_CONNECTED
10:57:25 MTU×2, DISCOVER_SKIPPED → ADVERT_TIMEOUT
10:57:46 MTU×2, DISCOVER_SKIPPED → HALF_CONNECTED
10:58:10 MTU×2, DISCOVER_SKIPPED → 정상
가드가 발동한 5회 중 3회가 여전히 반쪽 연결이다. **중복 MTU 는 증상이고 원인이
아니다.** 464건 전수의 상관관계(1회 97.0% vs 2회 22.6%)를 인과로 읽은 것이 잘못이었다.
그래서 이 커밋은 수정이 아니라 **중복 호출 제거 + 진단 로그**로만 취급해야 한다.
`DISCOVER_SKIPPED` 는 중복 MTU 가 언제 오는지를 기록에 남겨, 임상 후 분석의 근거가
된다. 펌웨어팀에 "MTU 를 먼저 걸지 말라"는 요청은 **보내지 않는다** — 근거가 무너졌다.
## 관찰된 패턴 (가설, 표본 4건)
성공한 회차는 두 번째 콜백이 1~2ms 뒤에 오고, 실패한 회차는 ~300ms 뒤에 온다.
300ms 뒤의 두 번째 MTU 협상이 진행 중인 서비스 탐색을 깨뜨리는 것으로 보이는데,
그렇다면 탐색을 다시 부르지 않아도 실패하므로 앱에서 막을 수 없다. 미검증이다.
동작이 나빠질 경로는 없다(중복 호출을 건너뛰는 것뿐). 임상 로그로 표본을 늘린다.
⚠ 반쪽 연결 자체는 기존 `HALF_CONNECTED` 가드가 20초 안에 잡아 재연결한다.
다만 그 20초 동안 측정 명령(`mcf`/`mcs`)이 큐에 들어가 타임아웃까지 대기한다 —
사용자에게는 "0cm · 프로브가 응답이 없습니다" 로 보인다. 이 경로는 아직 손대지 않았다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 실기기 시험(IM-H091)에서 둘이 드러났다.
## ① 건너뛰기가 파일 로그에 안 남았다
`logd` 로 남겨서 logcat 에만 갔다. 현장에서는 `Download/VesiScan_BLE_*.log` 파일만 받아
보므로, 그러면 **"왜 배터리가 안 갱신되나"를 파일로 답할 수 없다.** `debugLogger.info` 로
올렸다 — `BATT_POLL_SKIP`. 최대 30초에 한 줄이라 부담이 없다.
## ② 문구가 틀린 원인을 가리켰다
시험 로그가 이랬다:
09:11:11.980 DISCONNECTED
09:11:12.863 ADVERT_WAIT 0.88초 뒤 연결 누름 — 광고를 못 봐서 대기
09:11:18.867 ADVERT_TIMEOUT 6초 기다려도 없음
09:11:26.988 ADVERT_TIMEOUT 또 없음
09:11:28.967 ADVERT_FOUND 16초 만에 광고 재개 → 연결 성공
**프로브가 빠른 연결/해제 반복 뒤 약 16초간 광고를 멈췄다.** 가드는 정확히 작동했다 —
연결을 시도하지 않아 반쪽 연결도, 무한 스피너도, 본드 비대칭도 없었다(HALF_CONNECTED ·
CONNECT_TIMEOUT · stale GATT 전부 안 찍힘).
그런데 문구가 "전원이 켜져 있는지 확인" 이었다. 기기는 켜져 있었다 — 광고를 아직 안
시작한 것이다. 사용자를 엉뚱한 곳으로 보낸다. 실제 원인과 할 일을 적었다:
기기 신호가 잡히지 않습니다. 방금 연결을 끊었다면 기기가 다시 신호를 보낼 때까지
10~20초 걸릴 수 있습니다 — 잠시 뒤 다시 눌러 주세요. 계속 안 되면 전원을 확인하세요.
## 펌웨어 쪽에 남길 것
광고 재개가 16초 걸린 것은 **펌웨어 동작**이다. 빠른 연결/해제를 7회 반복한 뒤였다. 앱은
기다리고 다시 시도하면 되지만, 그 지연 자체는 펌웨어팀이 볼 문제다.
demo-final 143 · user 79 · caregiver 33 테스트 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`msn?` 을 30초마다 보내고 있었다. 원래 목적은 **링크를 살려 두는 것**이었는데, 자동
측정(500ms 주기 `mtb`)이나 배터리 소모 시험(1Hz `mbb`)처럼 계속 통신하는 동안에는 링크가
그 트래픽으로 이미 살아 있다 — 보낼 이유가 없는데 측정 스트림 사이에 끼어든다.
## 기존 가드로는 부족했다
`isMtbBusy` 는 **한 스트림이 흐르는 동안**만 막는다(`startMultiChannel` ~ `isComplete`).
자동 측정은 cycle 사이에 완료 구간이 생기므로 그 틈에 `msn?` 이 나가고, 그 응답(`rsn`)이
다음 `mtb` 스트림과 겹친다. 끼어들면 펌웨어 GATT 큐가 꼬여 응답이 실종되고 freeze 로
이어진다 — 2026-07-08 주석이 그 증상을 적고 있다.
## TX 시각을 보고 판단한다
전송 관문(demo-final `sendRawWrite` · user `sendRaw`)에서 `lastTxAtMs` 를 남기고, 폴링은
직전 TX 로부터 5초가 지났을 때만 보낸다. 5초는 자동 측정 주기(500ms)와 배터리 시험
주기(1초)보다 충분히 길어 **측정 중에는 한 번도 나가지 않는다.** 쉬고 있으면 직전 TX 가
30초 전(지난 폴링)이라 정상적으로 나간다.
첫 응답 재시도(3초 간격 2회)에도 같은 기준을 걸었다 — 연결 직후 바로 측정을 시작하는
흐름이 있어(배터리 시험) 그 재시도가 스트림에 끼어들 수 있다.
## 부작용을 막았다 — rbb 에서 배터리를 읽는다
폴링을 멈추면 화면의 배터리 표시가 멈춘다. 그런데 **`mbb` 응답 헤더(`rbb`)에는 배터리가
이미 실려 온다** — 앱이 그걸 안 읽고 있었다. 읽게 했더니 배터리 소모 시험(1Hz `mbb`,
50시간) 내내 표시가 살아 있고 **추가 통신은 0**이다. 원래는 배터리가 그 시험의 측정값인데
화면이 멈추는 모양새였다.
변환식과 단조 감소 가드를 `applyBatteryMv()` 한 곳으로 모았다. 따로 구현하면 같은 전압이
경로에 따라 다른 %로 보인다. 단조 가드를 그대로 둔 이유는 부하가 걸리면 전압이 일시적으로
떨어졌다 회복하는데, 그때마다 %가 오르내리면 사용자가 배터리가 늘었다고 읽기 때문이다.
## 남는 한 가지
`mtb`(자동 측정) 응답에는 배터리가 없어 **자동 측정 중에는 표시가 멈춘다.** 피할 수 없고,
원래 의도(측정 중에는 끼어들지 않는다)의 대가다. 끝나면 다음 tick 에 갱신된다.
보호자 앱에는 BLE 가 없어(아이콘 import 뿐) 대상이 아니다.
demo-final 143 · user 79 · caregiver 33 테스트 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앞 커밋은 반쪽 연결을 **복구**했다. 이건 **생기지 않게** 한다.
## 전제가 틀렸다
`connectByAddress` 는 바로 connectGatt 했다. 주석이 "스캔 결과에 안 잡혀도 OS 본드가 있으면
바로 됨"이라고 적고 있었는데, **BLE 는 상대가 광고(connectable)하지 않으면 연결이 성립하지
않는다.** 광고 전/직후에 [저장된 기기] 를 누르면 링크만 붙고 서비스가 안 올라온다 —
현장 재연 ③의 "광고를 하기 전 또는 직후, 휴대폰이 인지하지 못한 상황" 이 정확히 그것이다.
## 방금 봤으면 기다리지 않는다
주소별로 광고를 마지막에 본 시각을 들고 있다가(`lastSeenAtMs`), 5초 안이면 바로 연결한다.
기기 목록 화면은 이미 스캔 중이라 **대부분 이 경로**다 — 평소 연결이 느려지지 않는다.
매번 스캔을 돌리면 안드로이드 스캔 횟수 제한(30초에 5회)에 걸린다.
못 봤으면 그 주소만 필터로 걸어 최대 6초 기다린다. 광고가 오면 그 ScanResult 의 device 로
연결하고, 안 오면 "기기를 찾을 수 없습니다. 전원이 켜져 있는지 확인한 뒤 다시 눌러
주세요." 로 끝낸다 — **연결을 시도하지 않으므로 반쪽 연결이 애초에 안 생긴다.**
기록은 **이름 검사보다 먼저** 한다. 광고에 이름이 안 실린 패킷도 "지금 광고 중"이라는
증거다 — 놓치면 멀쩡히 광고하는 기기를 6초 기다린다.
## 확인이 불가능할 때는 막지 않는다
스캐너가 없거나 스캔이 실패하면(권한·횟수 제한) 확인을 포기하고 그냥 시도한다. 여기서
멈추면 연결할 길이 아예 없어진다. 그 경우는 앞 커밋의 반쪽 연결 가드(20초)가 받는다.
## unbondSmart 타임아웃 5초 → 14초
그 함수는 `connectByAddress` 로 재연결한 뒤 msr? 를 보낸다. 광고 대기가 최대 6초라 5초면
스캔 단계에서 잘려 **항상 "기기 오프라인"** 으로 떨어진다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
현장 재연(2026-09-10) 10단계를 받아 원인 사슬을 끝까지 따라갔다. 결론은 **반쪽 연결**
(링크는 붙었는데 서비스 탐색·CCCD 미완)이고, 마지막 단계가 제일 나쁘다 — **기기를 15초
길게 눌러 물리적으로 초기화해야만** 복구됐다.
③ 광고 직전/직후에 [저장된 기기] → 링크만 붙고 txCharacteristic = null
④ isServiceReady 가 안 와서 무한 스피너
⑤⑥ 화면은 isConnected 만 봐서 "연결됨"
⑦ txCharacteristic null → sendRawWrite 가 조용히 return → 배터리·IMU 전무
⑧-2 [페어링 삭제] → msr? 가 **안 나갔는데** removeBond() 는 실행
폰: 본드 삭제 ✓ 프로브: 본드 그대로 ✗ ← 비대칭
⑨ 재연결 시 프로브가 옛 LTK 를 요구 → "PIN/passkey 가 올바르지 않다"
⑩ 기기 15초 길게 눌러 초기화해야 복구
## 고친 것 넷
**① 보낼 수 없으면 본드를 지우지 않는다** (⑧-2 → ⑨⑩ 차단)
`canSendCommands`(= isServiceReady && tx != null && gatt != null)를 만들어
`disconnectAndUnbond()` 맨 앞에서 본다. false 면 **아무것도 지우지 않고** 끊고, 기기
초기화를 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시
연결하면 된다.
설정탭·임상의 [페어링 삭제] 가 이 함수를 **직접** 부르고 있었다(unbondSmart 만
isServiceReady 를 확인했다). 그래서 방어를 함수 안에 뒀다.
**② "연결됨"의 뜻을 바꿨다** (⑤⑥)
`AppState.isDeviceConnected` 가 `isConnected` → **`isServiceReady`** 를 본다.
"붙었다"가 아니라 **"쓸 수 있다"** 가 사용자에게 의미 있는 상태다.
**③ 반쪽 연결 자가 복구** (④⑤)
링크가 붙은 시점부터 20초 상한을 건다(`armHalfConnectedGuard`). 못 넘기면 끊고, 사용자가
끊은 게 아니면 재연결을 잇는다. connect() 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가
시작한 경로만 덮고, 이쪽은 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. 실측 최악이
15초라 20초로 잡았다.
**④ 쓰기 실패가 보이게** `sendRawWrite` 가 Boolean 을 돌려주고, 실패하면
`TX_FAIL ...` 를 로그에 남긴다. 종전에는 logd 만 찍고 조용했다.
문구는 en/ko 둘 다. `HALF_CONNECTED` 는 "다시 연결합니다",
`UNBOND_UNREACHABLE` 은 15초 길게 누르기까지 구체적으로 안내한다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
연결 해제 후 **빠르게** [연결] 을 누르면 연결이 안 됐다. 병원 임상·정렬·설정탭의
[연결 해제] 는 전부 같은 `disconnect()` 이고, 문제는 그 뒤에 있었다.
## 원인 — gattCallback 이 주인을 확인하지 않았다
`gattCallback` 은 객체 하나를 모든 연결이 공유한다. `gatt.disconnect()` 는 비동기라 옛
연결의 콜백이 **새 연결이 들어선 뒤에** 도착한다:
① [연결 해제] gatt.disconnect() ← 콜백은 몇 초 뒤
② 바로 [연결] 옛 gatt close + bluetoothGatt = 새 GATT
③ 옛 GATT 의 STATE_DISCONNECTED 도착 →
bluetoothGatt = null ← 새 참조를 지운다
isConnected.value = false
③ 이후 새 GATT 가 STATE_CONNECTED 를 받아도 그 분기는 `bluetoothGatt` 를 대입하지 않아
**isConnected=true 인데 bluetoothGatt 가 null** 이 된다. sendRaw 가 그 필드를 쓰므로
명령이 하나도 안 나간다. 반대로 isConnected 가 false 로 덮이면 화면만 "연결 안 됨".
늦게 온 **알림(onCharacteristicChanged)** 이 섞이면 더 나쁘다 — 다른 기기의 파형이
화면에 뜬다.
## 고친 것 셋
**① 주인 확인** `isStaleGatt()` 를 만들어 콜백 일곱 개 전부에 걸었다
(onConnectionStateChange · onMtuChanged · onServicesDiscovered · onDescriptorWrite ·
onCharacteristicChanged ×2 · onReadRemoteRssi). 주인이 아니면 **닫고 버린다** — 안 닫으면
그 핸들이 누수다. onConnectionStateChange 는 `handler.post` 안에서 **한 번 더** 본다:
post 사이에 새 연결이 들어설 수 있다.
`bluetoothGatt == null` 이면 통과시킨다. 연결을 막 만들어 대입 전인 구간이 있고, 그때
막으면 STATE_CONNECTED 를 놓쳐 영원히 연결되지 않는다.
**② STATE_CONNECTED 에서 `bluetoothGatt = gatt`** 뒷북이 지워도 여기서 다시 잡힌다.
**③ 재연결 쿨다운 600ms** close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에
끝난다. 그 틈에 다시 열면 0x3E(status 62)나 반쪽 연결이 된다. 마지막 disconnect 로부터
충분히 지났으면 기다리지 않는다 — 평소 연결이 느려지면 안 된다.
예약은 하나만 둔다(두 번 열리면 하나가 고아가 된다). [연결 해제] 는 예약을 **취소**한다 —
안 그러면 끊은 뒤 600ms 만에 스스로 다시 연결된다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
연결 → 연결 해제 → 메인 → [기기 연결] → [저장된 기기] 를 누르면 스피너가 영원히 돌고,
다른 기기 행까지 전부 비활성이 됐다(enabled = connectingDeviceAddress == null).
## 원인
화면이 스피너를 끄는 신호는 **둘뿐**이다 — `isServiceReady` 또는 `connectionError`.
그런데 10초 타임아웃이 보던 것은 `isConnected` 였다:
if (!isConnected.value) { connectionError.value = "Connection timed out..." }
링크는 붙었는데(STATE_CONNECTED) 서비스 탐색·CCCD 구독이 끝나지 않으면 **타임아웃이
면제된다.** 에러도 성공도 안 오니 화면이 영원히 잠긴다.
그 상태가 실제로 생긴다. 2026-09-10 12:29 로그에서 연결 직후 **15초 동안** 모든 명령이
`CMDQ drop ... timeout` 이었다(RX 0). 그때는 풀렸지만 안 풀리면 그대로다 — 연결 해제 뒤
재연결에서 특히 잘 난다.
## 고친 것 넷
**① 타임아웃이 보는 신호** `!isConnected` → `!isServiceReady`. 화면이 기다리는 것과 같아야
한다. 같이 10초 → **20초**로 올렸다 — 위 실측이 15초라 10초로 자르면 정상 연결을 끊는다.
**② 타임아웃 후 정리** 종전에는 에러만 띄우고 GATT 를 살려 뒀다. 다시 누르면 같은 반쪽
연결을 물고 간다. disconnect + close + 상태 리셋까지 한다.
**③ 화면 쪽 보험** DeviceScanView 에 25초 상한. BleManager 가 신호를 못 주는 경로가 하나라도
남으면 이 화면은 영원히 잠기므로, 원인을 고쳤어도 상한은 둔다. BleManager 의 20초보다
**길게** 잡았다 — 짧으면 정상 연결 중인 시도를 다시 눌러 GATT 가 두 번 열린다.
**④ 재연결 경로에도 같은 구멍** 재연결의 connectGatt 에는 상한이 **아예 없었다.** 그리고
watchdog 은 `isServiceReady` 뒤에만 시작하고, 8초 스캔 타임아웃은 `isConnected` 를 보고
"붙었다"며 재시도를 멈춘다. 결과는 **아무도 보지 않는 반쪽 연결** — 앱은 연결됐다고
표시하는데 명령이 전부 timeout 난다. 같은 20초 상한을 걸고 실패하면 재연결을 잇는다.
문구는 en/ko 둘 다 추가했다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
알고리즘은 이미 이식돼 있었다(MethodDRunner + estimateBv, AnchorGuide 가 cap_frac 에
쓰고 있다). 없던 것은 그 결과를 화면에 꺼내는 부분이다.
**ClinicalBv** — 검출→BV 조합을 한 군데로 묶는다. 조합을 화면마다 다시 쓰면 반드시
갈린다: CCC on/off, missingOutside 전달 여부, ant vs antRefined 중 무엇을 넘기는가.
셋 다 결과를 바꾼다. AnchorGuide 의 cap_frac 경로를 그대로 쓰고, 테스트가 두 경로의
volumeMl 이 1e-9 안에서 같은지 실측 trace 로 고정한다 — 갈리면 정렬이 고른 위치의
근거와 화면의 용적이 다른 계산이 되어 이 기능의 목적(위치 검증)이 무너진다.
**실패를 값으로 돌려준다.** 이 화면들의 관심사는 "왜 용적이 안 나오나"다. null 만
주면 화면은 "—" 밖에 못 쓴다. 검출 0개(부착 문제) / 2개 미만(위치 문제) / 검출은
됐는데 기하가 안 풀림을 각각 다른 문구로 낸다 — 대응이 다르기 때문이다.
**BvPanel** — 용적 + 채널별 전벽·후벽 표. 샘플 인덱스(파형에서 보이는 것)·mm·직경
(BV 가 실제로 쓴 값)을 나란히 둔다. "파형엔 벽이 보이는데 용적이 이상하다"에서 어느
단계가 틀렸는지 갈리려면 셋이 같이 있어야 한다. 직경이 "제외"면 검출은 됐지만 BV 에서
빠진 채널이다. 실패해도 검출된 만큼은 그대로 보여준다 — 어느 채널이 빠졌는지가 답이다.
**BvMeasureSection** — Spot / Continuous. Spot 은 "지금 얼마인가", Continuous 는
**흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지는 한 번으로 알 수 없고,
정렬 위치가 최적인지 판단하려면 재현성이 필요하다. 그래서 연속 모드는 최근 20회의
평균·표준편차·CV·범위를 같이 낸다. 5 cycle 을 모아 mean-scan 후 1회 계산한다 —
1 cycle 로 재면 노이즈가 그대로 벽으로 잡힌다.
측정 조건은 정렬과 같게 고정(2.3MHz·cycle 3). 검출 문턱이 절대값 기준이라 조건이
바뀌면 nch 가 달라지고, 정렬이 고른 위치의 근거와 다른 조건의 BV 가 된다.
붙인 곳:
· 부착 위치 정렬 — 위치마다 판정과 **같은 mean-scan** 으로 BV. 따로 재면 그 위치의
지표와 용적이 다른 데이터가 되어 대조가 성립하지 않는다.
· 병원 임상 모드 — 대기 중 Spot/Continuous, 측정 중 직전 회차 BV.
· 두 화면의 파형에 전벽(초록)·후벽(주황) 세로선. 마커가 검출 인덱스와 같은지도 테스트.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50시간짜리 소모율 측정을 걸려고 보니 기록이 분석에 못 쓸 상태였다.
1) 테스트가 10초마다 굴리는 mbb? 의 응답(rbb)에 **전압·온도가 로그에 안 남았다.**
파싱은 해서 화면에는 띄우면서 파일에는 "full measurement header (battery+IMU+temp)"
라는 고정 문자열만 썼다. 즉 이 테스트의 유일한 10초 주기 표본이 파일에 없었다.
(30초 주기 rsn 만 전압을 남기고 있었다.)
2) 진행 중 기록이 없었다. 시작·정지 두 줄뿐이라, 50시간을 걸어 놓고 중간에 프로세스가
죽으면 정지 줄조차 안 남는다 — 어디까지가 유효한 구간인지 판별할 방법이 없다.
3) BLE 로그는 초당 수 줄이 섞여 들어간다(RSSI 가 68%를 차지). 50시간이면 수십만 줄에서
전압만 골라내야 한다.
그래서 소모율 분석에 필요한 것만 별도 CSV 로 남긴다:
Download/VesiScan_BattDrain_<시작시각>.csv
time,elapsed_s,source,batt_mv,temp_c,connected,imu_*,full_*
10초 주기면 50시간에 18,000행이라 그대로 스프레드시트에 올라간다. 줄마다 flush 하므로
앱이 죽어도 그 시점까지는 남는다.
**rbb 가 안 와도 1분마다 한 행(source=tick)을 남긴다.** 프로브가 죽거나 BLE 가 끊기면
rbb 가 멈추는데 그때 CSV 도 같이 멈추면 "언제 끊겼는지"를 파일만 보고는 알 수 없다.
같은 주기로 BLE 로그에도 요약 한 줄을 남겨 두 파일을 맞춰 볼 수 있게 했다.
다이얼로그에 기록 파일명을 띄운다 — 장시간 돌린 뒤 Downloads 에서 어느 파일을 꺼내야
하는지 화면에서 바로 알아야 하고, 생성 실패도 여기서 보인다.
실기기 검증(23021RAA2Y · VBT26080001 · 141초): CSV 생성 · rbb 행 14건(전압 3849~3854mV,
온도 24.3~24.4C) · 60초 tick 1건 · stop 행 · BLE 로그의 rbb 줄에 값 표시 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지금까지 프로브에 무엇이 저장돼 있는지 **알 방법이 없었다.** `mcs?`(쓰기)의 echo
`rcs:` 로만 알 수 있었는데, 그건 이미 값을 바꾼 뒤다.
· sendPiezoConfigQuery() — `mcf?` [tag][space][crc] 7B. 응답 `rcf:` 는 배치가
`rcs:` 와 같아 파서를 그대로 옮겼다(실패 시 freq=0xFFFF 도 동일).
· 응답은 piezoConfigRead 로 받는다. piezoConfigEcho 에 섞으면 안 된다 — 그쪽은
"내가 방금 쓴 게 먹혔나"를 확인하는 자리라 쓰기 직전에 null 로 비우고 기다린다.
조회 응답이 같은 곳에 들어오면 쓰기 검증이 엉뚱한 값을 보고 통과한다.
ProbeConfigGuard — 임상 측정 전후로 파라미터를 보존한다.
정렬과 병원 임상 모드는 자기 조건을 프로브 FDS 에 써 넣고 되돌리지 않는다. 일반 측정
화면은 mcs 를 아예 보내지 않고 프로브에 있는 값을 그대로 쓰므로, 임상을 한 번 돌리면
일반 측정의 취득 조건이 조용히 바뀐 채 남는다 — 주파수가 바뀌면 파형이 달라져 BV 에도
영향이 간다.
기본값을 박아 두지 않았다. "일반 측정용 기본값"이 앱 어디에도 없고, 제품이 어떤
조건으로 검증됐는지는 펌웨어·알고리즘 쪽 값이라 여기서 정할 수 없다. 정해서 박으면
그 값이 틀렸을 때 모든 임상 종료 시점에 틀린 값을 심는다. 대신 시작 전 값을 읽어
두었다가 그대로 되돌린다 — 원래 무엇이었든 전후가 같아진다.
· 못 읽었으면 되돌리지 않는다. 짐작한 값을 쓰면 원래와 다른 것을 심어 놓고
"복원했다"고 믿게 된다 — 안 되돌리는 것보다 나쁘다.
· 쓰기 echo 까지 확인한다. 설정이 거부돼도 rcs: 는 오므로.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 앞선 커밋이 세 군데 틀렸다
2026-09-02 펌웨어팀 스펙을 받아 바로잡는다.
1. **인자가 2개가 아니라 5개다.**
mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B
avg·delay_us·samples 를 안 보내면 길이 부족으로 거부된다(freq=0xFFFF 응답).
2. **주파수 값이 1/2 가 아니라 0/5 다.**
0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz.
앞선 커밋의 가정(1.8→1, 2.3→2)은 둘 다 틀렸다 — 실제로는 2.0 과 2.3 을 재게 된다.
가정을 파일명에 남겨 둔 안전장치가 없었다면 못 알아챌 뻔했다.
3. **응답을 확인하지 않고 있었다.** 이게 가장 위험하다 — 아래 참고.
## 설정 실패는 조용하다 — 그래서 반드시 확인한다
실패해도 `rcs:` 는 온다. 구분은 freq 값이다:
· 0xFFFF — 파라미터 범위 초과 또는 데이터 길이 부족
· 0xFFFD — 검증은 통과했으나 NVS 저장 실패
거부돼도 프로브는 **옛 설정으로 측정을 계속한다.** 확인하지 않으면 파일에는 요청한
값이 적힌 채 다른 조건의 데이터가 쌓인다 — 잘못된 데이터가 정상처럼 보이는, 임상에서
가장 나쁜 결과다.
그래서 조합마다 echo 를 받아 **요청한 다섯 값과 전부 일치할 때만** 측정한다.
불일치·무응답이면 그 조합을 통째로 건너뛰고 run json 에 `skipped_reason` 을 남기며
화면에 빨갛게 띄운다. 비는 편이 틀린 것보다 낫다.
## 고정 파라미터
프로토콜이 바꾸는 것은 주파수·cycle 뿐이다. 나머지 셋은 모든 조합에서 같아야 비교가
성립하므로 `HospitalFixedParams` 한 곳에 둔다 — avg 10 · delay_us 10 · samples 100
(펌웨어팀 예시값, samples 는 앱 채널 버퍼 100 과도 일치).
1.8MHz·c3 6D 63 73 3F 00 00 00 03 00 0A 00 0A 00 64 3C 4A
2.3MHz·c7 6D 63 73 3F 00 05 00 07 00 0A 00 0A 00 64 36 FC
## ⚠ 설정이 프로브에 영구 저장된다 (NVS)
전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정 화면도 마지막
조합(2.3MHz·cycle 7)으로 동작한다.** run json 에 남기고 화면에도 명시했다.
임상 후 원래 값으로 되돌릴지는 별도 결정이 필요하다 — 공장 기본값을 모른다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 확인된 사실 (2026-09-02 펌웨어팀)
송신 주파수·cycle 설정 명령은 **`mcs?`** 다. `mpa?` 는 piezo power ON 이다.
이 앱은 여태 `mpa?` 에 [freq, cycles] 를 실어 보내며 그것이 주파수 설정이라고
가정하고 있었다(PlacementGuideView 의 `sendPiezoPowerOn()`, MeasurementService 의
`sendBurst(freqOption = 2, ...)`). `mcs` 는 코드에도 docs/BLE_PROTOCOL_REFERENCE.md
에도 **한 글자도 없다**.
즉 **지금까지 앱은 주파수·cycle 을 한 번도 바꾼 적이 없다.** 프로브 기본값으로만
측정해 온 셈이고, 그 사실을 아무도 몰랐다. 병원 임상 모드를 만들며 명령을 되짚다
드러났다.
## 고침
`BleManager.sendPiezoConfig(freqOption, cycles)` 를 추가하고 병원 임상 모드가 조합
진입마다 이것을 부른다. `sendPiezoPowerOn`(mpa)은 기존 호출부가 있어 그대로 둔다 —
power ON 은 그 나름의 역할이 있을 수 있고, 확인 전에 건드리면 정렬 화면이 깨진다.
응답 태그는 `sendRaw` 가 m→r 로 바꿔 `rcs` 를 기다린다. 파서의 when 에 rcs 분기가
없어도 태그 처리가 when 밖에 있어 큐는 정상 해제된다(BleManager L1783).
## ⚠ 아직 확인 안 된 것 — 인자 구성
명령 **이름만** 확인됐다. 인자의 개수·순서·인코딩은 듣지 못해, 이 프로토콜의 다른
수치 명령과 같은 규약을 따른다고 **가정**했다:
mcs? = 6D 63 73 3F | freq(BE 2B) | cycles(BE 2B) | CRC16-CCITT(LE 2B) 총 10 B
(1,3) 6D 63 73 3F 00 01 00 03 42 64
(2,5) 6D 63 73 3F 00 02 00 05 D4 5D
값↔MHz 대응도 여전히 모른다. 파일에 실제 전송 정수를 남기는 안전장치는 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
프로브 배터리 소모율 실측을 위해 홈 화면(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-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 완료.
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 · 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 팀 전달 예정.
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):
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 명령 · 응답 태그 표).
이전 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>
증상:
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-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>
증상:
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-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>
사용자앱 (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>