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.
This commit is contained in:
2026-08-05 14:04:09 +09:00
parent 4eb67e7450
commit 13ba88b72b
@@ -121,30 +121,75 @@ class BleManager private constructor(private val context: Context) {
return m.groupValues[1].toIntOrNull()
}
/** 펌웨어 버전이 MIN_FIRMWARE_BUILD 미만이면 true. 모르거나(아직 미수신) 형식 안 맞으면 false.
* legacy "VBTFWxxxx" 포맷 대상. 신규 semver ("1.0.0+5") 는 무시. */
/**
* 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
// 신규 semver 포맷 (1.0.0+5 등) 은 이 legacy build-number 비교 대상 아님.
if (fw.startsWith("1.") || fw.contains("+")) return false
val n = parseFirmwareBuild(fw) ?: return false
return n < MIN_FIRMWARE_BUILD
}
/**
* 앱에 번들된 DFU 펌웨어와 기기 실행 중 펌웨어 버전이 다르면 true (2026-08-04).
* → 사용자에게 "새 펌웨어 사용 가능 · 업데이트 하시겠습니까?" 배너 노출.
* 정확한 match (contains) 로 비교 · 미수신 (empty) 시 false (모르는 것과 필요없는 것 구분).
* 앱 번들 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
// fw 가 bundled 버전 문자열을 포함하면 최신 (예: "VesiScan 1.0.0+5" · "1.0.0+5" 등)
return !fw.contains(bundled)
val devTuple = parseFwSemver(fw) ?: return false
val bunTuple = parseFwSemver(bundled) ?: return false
return compareSemver(bunTuple, devTuple) > 0
}
val batteryLevel = mutableStateOf(0)