Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt
T
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

118 lines
4.2 KiB
Kotlin

/*
* MEDiDFU — VesiSeek firmware updater
*
* Unpacks the DFU archive bundled in assets/ and extracts the signed MCUboot image.
*
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* MEDiThings Inc.
*/
package com.medithings.vesiscan.firmware
import android.content.Context
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.zip.ZipInputStream
/** A signed MCUboot application image unpacked from a `dfu_application_*.zip`. */
data class FirmwarePackage(
val assetName: String,
val imageFileName: String,
/** `version_MCUBOOT` from the manifest, e.g. "0.0.2+35". Empty when the manifest omits it. */
val version: String,
val image: ByteArray,
) {
val sizeBytes: Int get() = image.size
// Generated equals/hashCode would compare the ByteArray by reference; identify the package by
// its source instead.
override fun equals(other: Any?): Boolean =
this === other || (other is FirmwarePackage &&
assetName == other.assetName &&
imageFileName == other.imageFileName &&
version == other.version &&
image.size == other.image.size)
override fun hashCode(): Int =
(((assetName.hashCode() * 31 + imageFileName.hashCode()) * 31) + version.hashCode()) * 31 +
image.size
}
/**
* Reads a DFU zip bundled in `assets/`.
*
* The zip is the nRF Connect SDK / Zephyr layout: a `manifest.json` listing the images plus the
* signed binaries themselves. The application image (`image_index` 0, or `type` "application") is
* the one uploaded over SMP.
*/
object FirmwareLoader {
const val DEFAULT_ASSET = "dfu_application_1.0.0+5.zip"
/**
* 번들된 DFU 버전 (manifest.json 의 version_MCUBOOT 필드와 일치).
* 2026-08-04: BleManager 의 update-available 배너가 이 값 vs 런타임 FW 를 비교.
*/
const val BUNDLED_VERSION = "1.0.0+5"
private const val MANIFEST = "manifest.json"
@Throws(IllegalStateException::class)
fun loadFromAssets(context: Context, assetName: String = DEFAULT_ASSET): FirmwarePackage {
val entries = readZipEntries(context, assetName)
val manifestBytes = entries.entries
.firstOrNull { it.key.equals(MANIFEST, ignoreCase = true) }
?.value
?: error("$MANIFEST not found in $assetName")
val files = JSONObject(String(manifestBytes, Charsets.UTF_8)).optJSONArray("files")
?: error("$MANIFEST has no \"files\" array")
check(files.length() > 0) { "$MANIFEST lists no images" }
var chosen = files.getJSONObject(0)
for (i in 0 until files.length()) {
val entry = files.getJSONObject(i)
if (entry.optString("image_index") == "0" ||
entry.optString("type").equals("application", ignoreCase = true)
) {
chosen = entry
break
}
}
val fileName = chosen.optString("file")
check(fileName.isNotEmpty()) { "$MANIFEST entry has no \"file\" field" }
val image = entries[fileName] ?: error("$fileName listed in $MANIFEST but missing from the zip")
check(image.isNotEmpty()) { "$fileName is empty" }
return FirmwarePackage(
assetName = assetName,
imageFileName = fileName,
version = chosen.optString("version_MCUBOOT"),
image = image,
)
}
private fun readZipEntries(context: Context, assetName: String): Map<String, ByteArray> {
val entries = LinkedHashMap<String, ByteArray>()
ZipInputStream(context.assets.open(assetName)).use { zip ->
val buffer = ByteArray(16 * 1024)
while (true) {
val entry = zip.nextEntry ?: break
if (entry.isDirectory) {
zip.closeEntry()
continue
}
val out = ByteArrayOutputStream()
while (true) {
val read = zip.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
}
entries[entry.name] = out.toByteArray()
zip.closeEntry()
}
}
return entries
}
}