refactor(firmware): 임상앱에서 프로브 DFU 와 펌웨어 버전 판정을 뺀다 — 펌웨어 갱신은 사용자 앱이 맡는다

사용자 결정(2026-10-13). 임상 폰 두 대 모두 사용자 앱이 같이 깔려 있고 거기서 양산 펌웨어
VB0FW0100 으로 올리므로, 임상앱이 번들 zip 과 버전 판정을 따로 들고 있을 이유가 없다.
실제로 임상 번들은 1.0.0+5 로 VBTFW0207 보다 낡아 "재설치"가 다운그레이드였고, 최소빌드
118 규칙은 0100 부터 다시 세는 양산 펌웨어에 "구버전" 거짓 경고를 낼 참이었다.

뺀 것
- firmware/ 패키지 전부(DfuManager·DfuNus·DfuNusConnection·DfuBleScanner·FirmwarePackage·
  FirmwareUpdateView)와 assets/dfu_application_1.0.0+5.zip, mcumgr-ble 의존성.
- AppScreen.FIRMWARE_UPDATE 라우트·뒤로가기 매핑, 홈 화면 "펌웨어 업데이트" 버튼(여백만 남김).
- BleManager 의 MIN_FIRMWARE_BUILD · parseFirmwareBuild · parseFwSemver · isFirmwareOutdated ·
  isFirmwareUpdateAvailable · currentBluetoothDevice(DFU 전용이었다).
- 배너의 주황(구버전)·파랑(새 펌웨어) 분기와 그 문자열. 빨강 "기기 응답 없음"은 생존 신호라 남긴다.

남긴 것
- 펌웨어 버전 표시(설정)와 세션 JSON·CSV 의 firmware_version 기록 — 어떤 펌웨어로 잰 세션인지는
  계속 추적된다.
- ClinicalEvent.FIRMWARE_OUTDATED 카테고리는 스키마 호환을 위해 두되, 올리는 곳은 없다.

검증: assembleDemoDebug · testDemoDebugUnitTest 통과. 수신 CRC 가드(사용자 앱 a0a61773)는 별건으로
남아 있다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 10:21:49 +09:00
parent 30aaf310e3
commit 25317282d7
18 changed files with 32 additions and 1696 deletions
@@ -54,8 +54,6 @@ class BleManager private constructor(private val context: Context) {
companion object {
private const val TAG = "BleManager"
/** 정상 측정에 필요한 최소 펌웨어 빌드 번호 (mtb? throttle/MTU 가정과 일치). */
const val MIN_FIRMWARE_BUILD = 118
private inline fun logd(msg: () -> String) { if (BuildConfig.DEBUG) Log.d(TAG, msg()) }
private inline fun logw(msg: () -> String) { if (BuildConfig.DEBUG) Log.w(TAG, msg()) }
private inline fun loge(msg: () -> String) { if (BuildConfig.DEBUG) Log.e(TAG, msg()) }
@@ -199,85 +197,13 @@ class BleManager private constructor(private val context: Context) {
val hardwareVersion = mutableStateOf("") // "VBTHW0100"
val serialNumber = mutableStateOf("") // "VBT26040001"
/** 마지막으로 의미있는 BLE RX (reb 등) 가 도착한 시각 ms.
* FirmwareWarningBanner false-positive 차단용. */
* FirmwareWarningBanner("기기 응답 없음") false-positive 차단용. */
val lastBleRxAt = mutableStateOf(0L)
/** "VBTFW0116" → 116, 형식 안 맞으면 null. */
fun parseFirmwareBuild(raw: String): Int? {
val m = Regex("(\\d+)$").find(raw) ?: return null
return m.groupValues[1].toIntOrNull()
}
// 펌웨어 버전 판정(최소빌드 118 경고 · 번들 DFU 비교)은 2026-10-13 에 뺐다. 임상앱은 버전을
// 세션 JSON·CSV 에 기록만 하고 판정하지 않는다 — 판정과 DFU 는 같은 폰의 사용자 앱이 맡는다.
// (양산 펌웨어 VB0FW0100 은 0100 부터 다시 세므로 옛 규칙이 "구버전" 거짓 경고를 냈다.)
/**
* 2026-08-05: FW 팀 스펙 (VBTFW 코드표) 반영. 두 포맷 통일 매핑.
*
* A. VBTFW MMNN → (M, 0, N)
* 예: VBTFW0100 = 1.0.0, VBTFW0200 = 2.0.0, VBTFW0201 = 2.0.1
* B. MCUboot major.minor.patch[+build] → (major, minor, patch)
* 예: 1.0.0+5 = 1.0.0 (build 5 는 major/minor/patch 비교와 무관)
*
* Returns Triple(major, minor, patch) · 파싱 실패 시 null.
*/
private fun parseFwSemver(raw: String): Triple<Int, Int, Int>? {
// VBTFW / VB0FW / VBFW MMNN
val vbt = Regex("""VB\d?FW(\d{2})(\d{2})""").find(raw)
if (vbt != null) {
val major = vbt.groupValues[1].toIntOrNull() ?: return null
val patch = vbt.groupValues[2].toIntOrNull() ?: return null
return Triple(major, 0, patch)
}
// MCUboot semver
val mcu = Regex("""(\d+)\.(\d+)\.(\d+)""").find(raw)
if (mcu != null) {
return Triple(
mcu.groupValues[1].toInt(),
mcu.groupValues[2].toInt(),
mcu.groupValues[3].toInt(),
)
}
return null
}
/** 튜플 비교: a>b 면 양수, 같으면 0, 작으면 음수. */
private fun compareSemver(a: Triple<Int, Int, Int>, b: Triple<Int, Int, Int>): Int {
if (a.first != b.first) return a.first - b.first
if (a.second != b.second) return a.second - b.second
return a.third - b.third
}
/** 펌웨어 버전이 MIN_FIRMWARE_BUILD 미만이면 true. legacy "VBTFWxxxx" 만 판정.
* 신규 semver 는 별도 update-available 로 처리. */
val isFirmwareOutdated: Boolean
get() {
val fw = firmwareVersion.value
if (fw.isEmpty()) return false
if (fw.startsWith("1.") || fw.contains("+")) return false
val n = parseFirmwareBuild(fw) ?: return false
return n < MIN_FIRMWARE_BUILD
}
/**
* 앱 번들 DFU 가 기기 fw 보다 **엄격히 최신** 이면 true (2026-08-05).
* → "새 펌웨어 사용 가능" 배너 노출.
*
* 매핑 규칙 (FW 팀 스펙 · VBTFW 코드표):
* VBTFW MMNN = M.0.N (major=MM, minor=0, patch=NN)
* MCUboot X.Y.Z+B = (X, Y, Z) · +B build 는 무시 (같은 major.minor.patch 는 동일 취급)
*
* 예:
* device VBTFW0200 (2.0.0) · bundled 1.0.0+5 (1.0.0) → bundled < device → **false (배너 X)**
* device VBTFW0100 (1.0.0) · bundled 2.0.0+0 (2.0.0) → bundled > device → **true (배너 O)**
* device VBTFW0200 (2.0.0) · bundled 2.0.1+0 (2.0.1) → bundled > device → true
*/
val isFirmwareUpdateAvailable: Boolean
get() {
val fw = firmwareVersion.value
if (fw.isEmpty()) return false
val bundled = com.medithings.vesiscan.firmware.FirmwareLoader.BUNDLED_VERSION
val devTuple = parseFwSemver(fw) ?: return false
val bunTuple = parseFwSemver(bundled) ?: return false
return compareSemver(bunTuple, devTuple) > 0
}
val batteryLevel = mutableStateOf(0)
/** 현재 연결된 디바이스 RSSI (dBm) · 미측정/미연결 시 null. dev-mode UI 오버레이용. */
@@ -353,12 +279,6 @@ class BleManager private constructor(private val context: Context) {
val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
val isServiceReady = mutableStateOf(false)
/** 현재 GATT 연결된 BluetoothDevice · 없으면 null. Firmware DFU 진입 시
* 스캔 생략하고 바로 이 기기로 재연결하는 경로에서 사용. */
val currentBluetoothDevice: android.bluetooth.BluetoothDevice?
@android.annotation.SuppressLint("MissingPermission")
get() = bluetoothGatt?.device
val isBonded: Boolean
@android.annotation.SuppressLint("MissingPermission")
get() = bluetoothGatt?.device?.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED