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
-1
View File
@@ -109,7 +109,6 @@ dependencies {
// 2026-08-12: YOLO/CameraX/MLKit 제거 (소변컵 사진 인식 기능 폐기).
implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
testImplementation(libs.junit)
// org.json 은 안드로이드 단위테스트에서 **모든 메서드가 스텁**이라 호출하면 예외가
Binary file not shown.
@@ -18,8 +18,9 @@ enum class AppScreen {
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
HOSPITAL_MODE, // ★ Dev 모드 한정 — 병원 임상 측정 (주파수×cycle 자동 순회)
ANCHOR_ALIGN, // ★ Dev 모드 한정 — 부착 위치 정렬 (병원 임상 전 단계)
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
ANCHOR_ALIGN // ★ Dev 모드 한정 — 부착 위치 정렬 (병원 임상 전 단계)
// FIRMWARE_UPDATE(프로브 DFU)는 2026-10-13 에 뺐다 — 펌웨어 갱신은 같은 폰의 사용자 앱이 맡는다.
// 번들 zip·버전 판정을 두 앱에 이중으로 두면 어긋나기만 했다(임상 번들이 1.0.0+5 로 낡아 있었다).
}
class AppState(private val context: Context) {
@@ -177,7 +177,6 @@ fun MedilightApp(modifier: Modifier = Modifier) {
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
// 정렬은 병원 임상 모드에서 들어온다 — 돌아갈 곳도 거기다.
AppScreen.ANCHOR_ALIGN -> appState.currentScreen = AppScreen.HOSPITAL_MODE
// 병원 임상 모드의 진입점은 시작 화면이다. else 로 흘려도 HOME 이지만
@@ -219,9 +218,6 @@ fun MedilightApp(modifier: Modifier = Modifier) {
AppScreen.HOSPITAL_MODE -> com.medithings.vesiscan.ui.views.clinical.HospitalModeView(appState)
AppScreen.ANCHOR_ALIGN -> com.medithings.vesiscan.ui.views.clinical.AnchorAlignView(appState)
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
AppScreen.FIRMWARE_UPDATE -> com.medithings.vesiscan.firmware.ui.FirmwareUpdateScreen(
onBack = { appState.currentScreen = AppScreen.HOME }
)
}
}
}
@@ -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
@@ -1,163 +0,0 @@
/*
* MEDiDFU — VesiSeek firmware updater
*
* BLE discovery. Also detects the SMP service that marks a device already in DFU mode,
* and reports the conditions that make Android drop scan results silently.
*
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* MEDiThings Inc.
*/
package com.medithings.vesiscan.firmware
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import androidx.core.location.LocationManagerCompat
data class ScannedDevice(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
/** True when the advertisement carries the SMP service, i.e. the device is waiting for DFU. */
val inDfuMode: Boolean,
) {
val address: String get() = device.address
}
/**
* Thin BLE scanner.
*
* No hardware scan filter is used: the SMP UUID is advertised in the *scan response*, and some
* controllers will not match an offloaded filter against it. Filtering in software costs a few
* extra callbacks and is reliable everywhere.
*/
@SuppressLint("MissingPermission")
class BleScanner(context: Context) {
private val appContext = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val adapter: BluetoothAdapter? =
(appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
private var callback: ScanCallback? = null
private var timeoutTask: Runnable? = null
val isBluetoothEnabled: Boolean get() = adapter?.isEnabled == true
var isScanning: Boolean = false
private set
/**
* Raw advertisements delivered during the last scan, counted before the "has a name" filter.
* Zero while devices are clearly nearby points at a permission problem rather than at the
* filter, so it is worth logging.
*/
var advertisementsSeen: Int = 0
private set
/**
* Android 11 and below refuse to report scan results while location services are off, and do
* so silently. Returns a message describing the problem, or null when scanning can proceed.
*/
fun blockingCondition(): String? {
if (adapter == null) return "This device has no Bluetooth adapter"
if (!adapter.isEnabled) return "Bluetooth is off"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return null
val lm = appContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: return null
return if (LocationManagerCompat.isLocationEnabled(lm)) {
null
} else {
"Location services are off — Android 11 and below need them for BLE scanning"
}
}
/**
* Starts a scan. [onResult] fires for every named advertisement (repeatedly for the same
* device as the RSSI updates); [onFinished] fires exactly once when the scan ends.
*/
fun start(
timeoutMs: Long,
onResult: (ScannedDevice) -> Unit,
onFinished: (error: String?) -> Unit,
) {
stop()
advertisementsSeen = 0
blockingCondition()?.let {
onFinished(it)
return
}
val scanner = adapter?.bluetoothLeScanner
if (scanner == null) {
onFinished("Bluetooth LE scanner unavailable")
return
}
val smpUuid = ParcelUuid(VesiSeekUuids.SMP_SERVICE)
val cb = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
advertisementsSeen++
val name = try {
result.scanRecord?.deviceName ?: result.device.name
} catch (_: SecurityException) {
null
} ?: return
if (name.isEmpty()) return
val inDfu = result.scanRecord?.serviceUuids?.contains(smpUuid) == true
handler.post { onResult(ScannedDevice(result.device, name, result.rssi, inDfu)) }
}
override fun onScanFailed(errorCode: Int) {
handler.post {
isScanning = false
callback = null
onFinished("Scan failed (error $errorCode)")
}
}
}
callback = cb
isScanning = true
try {
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
scanner.startScan(null, settings, cb)
} catch (e: Exception) {
isScanning = false
callback = null
onFinished("Scan could not be started: ${e.message}")
return
}
val task = Runnable {
stop()
onFinished(null)
}
timeoutTask = task
handler.postDelayed(task, timeoutMs)
}
fun stop() {
timeoutTask?.let { handler.removeCallbacks(it) }
timeoutTask = null
callback?.let { cb ->
try {
adapter?.bluetoothLeScanner?.stopScan(cb)
} catch (_: Exception) {
// Adapter may have been turned off while scanning.
}
}
callback = null
isScanning = false
}
}
@@ -1,468 +0,0 @@
/*
* MEDiDFU — VesiSeek firmware updater
*
* The update state machine: NUS connect -> mdf? -> re-scan for SMP -> MCUmgr upload.
* Ported from the Go host tool at D:\mt_project\vesiseek_ble_dfu.
*
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* MEDiThings Inc.
*/
package com.medithings.vesiscan.firmware
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.firmware.BleScanner
import com.medithings.vesiscan.firmware.NusConnection
import com.medithings.vesiscan.firmware.NusPacket
import com.medithings.vesiscan.firmware.ScannedDevice
import io.runtime.mcumgr.ble.McuMgrBleTransport
import io.runtime.mcumgr.dfu.FirmwareUpgradeCallback
import io.runtime.mcumgr.dfu.FirmwareUpgradeController
import io.runtime.mcumgr.dfu.mcuboot.FirmwareUpgradeManager
import io.runtime.mcumgr.dfu.mcuboot.model.ImageSet
import io.runtime.mcumgr.exception.McuMgrException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
enum class DfuPhase {
IDLE,
SCANNING,
CONNECTING,
CONNECTED,
ENTERING_DFU,
SEARCHING_DFU,
UPLOADING,
COMPLETED,
FAILED,
}
/**
* Drives the full VesiSeek firmware update, ported from the Go host tool (`vesiseek_ble_dfu`):
*
* 1. scan and connect over NUS,
* 2. send `mdf?` — the device answers `rdf:`, drops the link and re-advertises with the SMP
* service,
* 3. re-scan and hand the device to Nordic's [FirmwareUpgradeManager], which uploads the signed
* image, marks it for test and resets.
*
* All state is Compose-observable and is only mutated on the main thread.
*/
@SuppressLint("MissingPermission")
class DfuManager(context: Context) {
private companion object {
const val SCAN_TIMEOUT_MS = 15_000L
const val DFU_SCAN_TIMEOUT_MS = 30_000L
/** The firmware sends `rdf:` then disconnects ~100 ms later; don't wait much longer. */
const val RDF_TIMEOUT_MS = 5_000L
/** Let the device finish re-advertising and the local stack settle before re-scanning. */
const val RESCAN_DELAY_MS = 1_500L
const val MAX_LOG_LINES = 300
/** nRF52 flash writes are word aligned. */
const val MEMORY_ALIGNMENT = 4
}
private val appContext = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val scanner = BleScanner(appContext)
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
private var nus: NusConnection? = null
private var transport: McuMgrBleTransport? = null
private var upgradeManager: FirmwareUpgradeManager? = null
private var dfuEntryHandled = false
private var rdfTimeout: Runnable? = null
private val dfuCandidates = LinkedHashMap<String, ScannedDevice>()
// ── Observable state ────────────────────────────────────────────────────────────────────
val phase = mutableStateOf(DfuPhase.IDLE)
val status = mutableStateOf("Ready")
val devices = mutableStateListOf<ScannedDevice>()
val logLines = mutableStateListOf<String>()
val firmware = mutableStateOf<FirmwarePackage?>(null)
val firmwareError = mutableStateOf<String?>(null)
val target = mutableStateOf<ScannedDevice?>(null)
/** Upload progress in 0f..1f. */
val progress = mutableStateOf(0f)
val bytesSent = mutableStateOf(0)
val isBluetoothEnabled: Boolean get() = scanner.isBluetoothEnabled
val canStartDfu: Boolean
get() = phase.value == DfuPhase.CONNECTED && firmware.value != null
val isBusy: Boolean
get() = phase.value in setOf(
DfuPhase.CONNECTING, DfuPhase.ENTERING_DFU, DfuPhase.SEARCHING_DFU, DfuPhase.UPLOADING,
)
// ── Firmware ────────────────────────────────────────────────────────────────────────────
fun loadFirmware() {
if (firmware.value != null) return
try {
val pkg = FirmwareLoader.loadFromAssets(appContext)
firmware.value = pkg
firmwareError.value = null
log("firmware ${pkg.imageFileName} v${pkg.version} (${pkg.sizeBytes} bytes)")
} catch (e: Exception) {
firmwareError.value = e.message ?: e.toString()
log("firmware load FAILED: ${e.message}")
}
}
// ── Scan / connect ──────────────────────────────────────────────────────────────────────
fun startScan() {
if (isBusy) return
devices.clear()
phase.value = DfuPhase.SCANNING
status.value = "Scanning…"
log("scanning ${SCAN_TIMEOUT_MS / 1000}s…")
scanner.start(
timeoutMs = SCAN_TIMEOUT_MS,
onResult = { found ->
val index = devices.indexOfFirst { it.address == found.address }
if (index >= 0) devices[index] = found else devices.add(found)
},
onFinished = { error ->
if (phase.value != DfuPhase.SCANNING) return@start
phase.value = DfuPhase.IDLE
if (error != null) {
status.value = error
log("scan error: $error")
} else {
status.value = "Scan finished — ${devices.size} device(s)"
log("scan finished: ${devices.size} named device(s), " +
"${scanner.advertisementsSeen} advertisement(s) seen")
if (scanner.advertisementsSeen == 0) {
log("no advertisements at all — check the Nearby devices permission")
}
}
},
)
}
/** Non-null when something outside the app prevents scanning (adapter off, location off). */
fun bleBlockingCondition(): String? = scanner.blockingCondition()
fun stopScan() {
scanner.stop()
if (phase.value == DfuPhase.SCANNING) {
phase.value = DfuPhase.IDLE
status.value = "Scan stopped"
}
}
fun connect(device: ScannedDevice) {
if (isBusy) return
scanner.stop()
closeNus()
target.value = device
phase.value = DfuPhase.CONNECTING
status.value = "Connecting to ${device.name}…"
log("connecting to ${device.name} (${device.address})")
nus = NusConnection(
context = appContext,
onReady = {
if (phase.value != DfuPhase.CONNECTING) return@NusConnection
phase.value = DfuPhase.CONNECTED
status.value = "Connected to ${device.name}"
// Drop the scan results: the connected device is shown in the status card, and
// leaving the stale entries would offer a Connect button for other devices while
// a link is already up.
devices.clear()
log("NUS ready")
},
onNotify = ::onNusNotification,
onDisconnected = ::onNusDisconnected,
onError = { message ->
if (phase.value == DfuPhase.CONNECTING || phase.value == DfuPhase.CONNECTED) {
fail(message)
} else {
log("BLE: $message")
}
},
).also { it.connect(device.device) }
}
/**
* 스캔 생략 · 이미 알고 있는 기기에 바로 연결 (2026-08-03).
* 메인 앱이 이미 프로브에 연결됐던 상태에서 FirmwareUpdate 진입할 때 사용.
*/
fun connectDirectly(device: BluetoothDevice, name: String) {
val scanned = ScannedDevice(device = device, name = name, rssi = 0, inDfuMode = false)
log("skip scan — using previously connected device: $name")
connect(scanned)
}
fun disconnect() {
closeNus()
if (phase.value == DfuPhase.CONNECTED || phase.value == DfuPhase.CONNECTING) {
phase.value = DfuPhase.IDLE
status.value = "Disconnected"
log("disconnected")
}
}
// ── DFU ─────────────────────────────────────────────────────────────────────────────────
fun startDfu() {
val pkg = firmware.value
if (phase.value != DfuPhase.CONNECTED || pkg == null) return
val connection = nus ?: return
progress.value = 0f
bytesSent.value = 0
dfuEntryHandled = false
dfuCandidates.clear()
phase.value = DfuPhase.ENTERING_DFU
status.value = "Entering DFU mode…"
log("sending mdf? (enter DFU / SMP advertising)")
if (!connection.send(NusPacket.enterDfu())) {
fail("mdf? write failed")
return
}
// The device answers rdf: and then drops the link on its own. Either event moves us on;
// the timeout covers the case where the disconnect beats the notification.
val timeout = Runnable {
if (dfuEntryHandled) return@Runnable
log("no rdf: response within ${RDF_TIMEOUT_MS / 1000}s — continuing anyway")
proceedToDfuScan()
}
rdfTimeout = timeout
handler.postDelayed(timeout, RDF_TIMEOUT_MS)
}
private fun onNusNotification(packet: ByteArray) {
val tag = NusPacket.tagOf(packet)
if (tag == null) {
log("rx: bad CRC (${packet.size} bytes)")
return
}
if (phase.value == DfuPhase.ENTERING_DFU && tag == "rdf:") {
// Payload is a u16 status: 0x0000 accepted, 0xFFFF already in DFU advertising.
val already = packet.size >= 6 &&
packet[4] == 0xFF.toByte() && packet[5] == 0xFF.toByte()
log(if (already) "rdf: device was already in DFU mode" else "rdf: DFU mode accepted")
handler.postDelayed({ proceedToDfuScan() }, 500)
} else {
log("rx: $tag")
}
}
private fun onNusDisconnected() {
when (phase.value) {
DfuPhase.ENTERING_DFU -> {
log("device disconnected (switching to SMP advertising)")
proceedToDfuScan()
}
DfuPhase.CONNECTING, DfuPhase.CONNECTED -> fail("device disconnected")
else -> Unit
}
}
/** Tears down the NUS link and starts looking for the device in DFU mode. Runs once. */
private fun proceedToDfuScan() {
if (dfuEntryHandled) return
dfuEntryHandled = true
rdfTimeout?.let { handler.removeCallbacks(it) }
rdfTimeout = null
closeNus()
phase.value = DfuPhase.SEARCHING_DFU
status.value = "Looking for the device in DFU mode…"
handler.postDelayed({ scanForDfuDevice() }, RESCAN_DELAY_MS)
}
private fun scanForDfuDevice() {
if (phase.value != DfuPhase.SEARCHING_DFU) return
val wanted = target.value
log("scanning for SMP advertising (up to ${DFU_SCAN_TIMEOUT_MS / 1000}s)…")
scanner.start(
timeoutMs = DFU_SCAN_TIMEOUT_MS,
onResult = { found ->
if (!found.inDfuMode || phase.value != DfuPhase.SEARCHING_DFU) return@start
dfuCandidates[found.address] = found
if (wanted == null || found.name == wanted.name || found.address == wanted.address) {
scanner.stop()
log("found ${found.name} (${found.address}) in DFU mode")
startUpload(found.device)
}
},
onFinished = { error ->
if (phase.value != DfuPhase.SEARCHING_DFU) return@start
// The device may advertise under a different address after the reset. If exactly
// one SMP device showed up, it is unambiguous — use it.
val onlyCandidate = dfuCandidates.values.singleOrNull()
when {
error != null -> fail(error)
onlyCandidate != null -> {
log("no name match; using the only SMP device found: ${onlyCandidate.name}")
startUpload(onlyCandidate.device)
}
else -> fail("device not found in DFU mode — is it still advertising?")
}
},
)
}
private fun startUpload(device: BluetoothDevice) {
val pkg = firmware.value ?: run {
fail("firmware not loaded")
return
}
phase.value = DfuPhase.UPLOADING
status.value = "Uploading…"
log("uploading ${pkg.sizeBytes} bytes over SMP")
val bleTransport = McuMgrBleTransport(appContext, device).apply {
setLoggingEnabled(true)
}
transport = bleTransport
val manager = FirmwareUpgradeManager(bleTransport, upgradeCallback).apply {
// TEST_ONLY matches the Go tool: mark the image for test, then reset. TEST_AND_CONFIRM
// is not usable here because the device advertises NUS again after rebooting, so the
// library could never reconnect over SMP to send the confirm.
setMode(FirmwareUpgradeManager.Mode.TEST_ONLY)
}
upgradeManager = manager
val settings = FirmwareUpgradeManager.Settings.Builder()
.setMemoryAlignment(MEMORY_ALIGNMENT)
.setWindowCapacity(1)
.build()
try {
manager.start(ImageSet().add(pkg.image), settings)
} catch (e: McuMgrException) {
fail("image rejected: ${e.message}")
}
}
fun cancelDfu() {
upgradeManager?.takeIf { it.isInProgress }?.cancel()
}
private val upgradeCallback = object : FirmwareUpgradeCallback<FirmwareUpgradeManager.State> {
override fun onUpgradeStarted(controller: FirmwareUpgradeController?) {
log("SMP upgrade started")
}
override fun onStateChanged(
prevState: FirmwareUpgradeManager.State?,
newState: FirmwareUpgradeManager.State?,
) {
log("state: ${newState?.name ?: "?"}")
status.value = when (newState) {
FirmwareUpgradeManager.State.VALIDATE -> "Validating image…"
FirmwareUpgradeManager.State.UPLOAD -> "Uploading…"
FirmwareUpgradeManager.State.TEST -> "Marking image for test…"
FirmwareUpgradeManager.State.RESET -> "Resetting device…"
FirmwareUpgradeManager.State.CONFIRM -> "Confirming image…"
else -> status.value
}
}
override fun onUploadProgressChanged(sent: Int, imageSize: Int, timestamp: Long) {
bytesSent.value = sent
progress.value = if (imageSize > 0) sent.toFloat() / imageSize else 0f
}
override fun onUpgradeCompleted() {
progress.value = 1f
phase.value = DfuPhase.COMPLETED
status.value = "Done — device is rebooting into the new image"
log("DFU complete; MCUboot will swap on boot")
releaseTransport()
}
override fun onUpgradeFailed(
state: FirmwareUpgradeManager.State?,
error: McuMgrException?,
) {
fail("DFU failed in ${state?.name ?: "?"}: ${error?.message ?: error.toString()}")
}
override fun onUpgradeCanceled(state: FirmwareUpgradeManager.State?) {
phase.value = DfuPhase.FAILED
status.value = "DFU canceled"
log("DFU canceled in ${state?.name ?: "?"}")
releaseTransport()
}
}
// ── Lifecycle / helpers ─────────────────────────────────────────────────────────────────
fun reset() {
scanner.stop()
closeNus()
releaseTransport()
rdfTimeout?.let { handler.removeCallbacks(it) }
rdfTimeout = null
dfuEntryHandled = false
dfuCandidates.clear()
devices.clear()
target.value = null
progress.value = 0f
bytesSent.value = 0
phase.value = DfuPhase.IDLE
status.value = "Ready"
}
fun shutdown() {
scanner.stop()
closeNus()
releaseTransport()
rdfTimeout?.let { handler.removeCallbacks(it) }
rdfTimeout = null
}
private fun closeNus() {
nus?.close()
nus = null
}
private fun releaseTransport() {
upgradeManager = null
transport?.let {
try {
it.release()
} catch (_: Exception) {
// Already released or never connected.
}
}
transport = null
}
private fun fail(message: String) {
phase.value = DfuPhase.FAILED
status.value = message
log("FAILED: $message")
scanner.stop()
closeNus()
releaseTransport()
}
private fun log(message: String) {
logLines.add("${timeFormat.format(Date())} $message")
while (logLines.size > MAX_LOG_LINES) logLines.removeAt(0)
}
}
@@ -1,77 +0,0 @@
/*
* MEDiDFU — VesiSeek firmware updater
*
* VesiSeek BLE identifiers and the NUS command framing shared with the firmware.
*
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* MEDiThings Inc.
*/
package com.medithings.vesiscan.firmware
import java.util.UUID
/**
* VesiSeek BLE identifiers.
*
* During normal operation the device advertises the Nordic UART Service. After the `mdf?` command
* it disconnects and re-advertises with the Zephyr SMP service instead, keeping the same
* advertised name (its serial number).
*/
object VesiSeekUuids {
val NUS_SERVICE: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
/** host -> device (write) */
val NUS_WRITE: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
/** device -> host (notify) */
val NUS_NOTIFY: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
/** Zephyr SMP-over-BLE service, advertised only in DFU mode. */
val SMP_SERVICE: UUID = UUID.fromString("8D53DC1D-1DB7-4CD3-868B-8A527460AA84")
}
/**
* CRC-16/CCITT (poly 0x1021, init 0xFFFF) — byte-for-byte identical to the firmware's
* `cmd_crc16_compute` (Nordic SDK `crc16_compute`).
*/
object Crc16 {
fun compute(data: ByteArray): Int {
var crc = 0xFFFF
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
repeat(8) {
crc = if ((crc and 0x8000) != 0) (crc shl 1) xor 0x1021 else crc shl 1
crc = crc and 0xFFFF
}
}
return crc
}
/** Appends the CRC little-endian, matching the firmware's packet layout. */
fun appendTo(data: ByteArray): ByteArray {
val crc = compute(data)
return data + byteArrayOf((crc and 0xFF).toByte(), ((crc shr 8) and 0xFF).toByte())
}
}
/** NUS packet framing: `[TAG 4B][DATA N][CRC16 2B little-endian]`. */
object NusPacket {
fun build(tag: String, data: ByteArray = ByteArray(0)): ByteArray {
require(tag.length == 4) { "tag must be 4 characters, got '$tag'" }
return Crc16.appendTo(tag.toByteArray(Charsets.US_ASCII) + data)
}
/**
* Enter-DFU command. The firmware ignores the payload but the command dispatcher needs at
* least one data byte, so a single 0x00 is sent — the same packet the Go host tool uses.
*/
fun enterDfu(): ByteArray = build("mdf?", byteArrayOf(0x00))
/** Returns the 4-character response tag, or null if the frame is too short or the CRC fails. */
fun tagOf(packet: ByteArray): String? {
if (packet.size < 6) return null
val body = packet.copyOfRange(0, packet.size - 2)
val received = (packet[packet.size - 2].toInt() and 0xFF) or
((packet[packet.size - 1].toInt() and 0xFF) shl 8)
if (Crc16.compute(body) != received) return null
return String(packet, 0, 4, Charsets.US_ASCII)
}
}
@@ -1,193 +0,0 @@
/*
* MEDiDFU — VesiSeek firmware updater
*
* GATT client for the Nordic UART Service command channel, used only to hand the device
* into DFU mode. The firmware image itself travels over SMP, not over this link.
*
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* MEDiThings Inc.
*/
package com.medithings.vesiscan.firmware
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
/**
* GATT client for the VesiSeek NUS command channel.
*
* Mirrors the connection sequence of the VesiScan Basic app: connect, negotiate MTU 247, discover
* services, then enable notifications on the TX characteristic. [onReady] fires once the CCCD
* write completes, at which point [send] may be used.
*
* All callbacks are delivered on the main thread.
*/
@SuppressLint("MissingPermission")
class NusConnection(
context: Context,
private val onReady: () -> Unit,
private val onNotify: (ByteArray) -> Unit,
private val onDisconnected: () -> Unit,
private val onError: (String) -> Unit,
) {
private val appContext = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
@Volatile private var gatt: BluetoothGatt? = null
@Volatile private var writeChar: BluetoothGattCharacteristic? = null
@Volatile private var released = false
val isReady: Boolean get() = writeChar != null
fun connect(device: BluetoothDevice) {
released = false
gatt = device.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
if (gatt == null) onError("connectGatt() returned null")
}
fun send(data: ByteArray): Boolean {
val g = gatt ?: return false
val ch = writeChar ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(ch, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) ==
BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
ch.value = data
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
g.writeCharacteristic(ch)
}
}
}
/**
* Tears the connection down. Further GATT callbacks are ignored, so [onDisconnected] will not
* fire for a close initiated here. Safe to call more than once.
*/
fun close() {
released = true
writeChar = null
gatt?.let { g ->
try {
g.disconnect()
g.close()
} catch (_: Exception) {
// Nothing useful to do if the stack already dropped the client.
}
}
gatt = null
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (released) return
handler.post {
if (released) return@post
when {
newState == BluetoothProfile.STATE_CONNECTED &&
status == BluetoothGatt.GATT_SUCCESS -> {
// Service discovery continues from onMtuChanged.
if (!g.requestMtu(247)) g.discoverServices()
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
writeChar = null
onDisconnected()
}
else -> onError("GATT error (status $status)")
}
}
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
if (released) return
handler.post { if (!released) g.discoverServices() }
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (released) return
handler.post {
if (released) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
onError("Service discovery failed (status $status)")
return@post
}
val service = g.getService(VesiSeekUuids.NUS_SERVICE)
if (service == null) {
onError("NUS service not found on this device")
return@post
}
writeChar = service.getCharacteristic(VesiSeekUuids.NUS_WRITE)
val notifyChar = service.getCharacteristic(VesiSeekUuids.NUS_NOTIFY)
if (writeChar == null || notifyChar == null) {
onError("NUS characteristics not found")
return@post
}
g.setCharacteristicNotification(notifyChar, true)
val cccd = notifyChar.getDescriptor(VesiSeekUuids.CCCD)
if (cccd == null) {
// No CCCD to write — notifications are already enabled locally.
onReady()
return@post
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
run {
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(cccd)
}
}
}
}
override fun onDescriptorWrite(
g: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (released || descriptor.uuid != VesiSeekUuids.CCCD) return
handler.post {
if (released) return@post
if (status == BluetoothGatt.GATT_SUCCESS) {
onReady()
} else {
onError("Enabling notifications failed (status $status)")
}
}
}
override fun onCharacteristicChanged(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
if (released || characteristic.uuid != VesiSeekUuids.NUS_NOTIFY) return
handler.post { if (!released) onNotify(value) }
}
@Deprecated("Superseded by the API 33 overload")
override fun onCharacteristicChanged(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
if (released || characteristic.uuid != VesiSeekUuids.NUS_NOTIFY) return
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
handler.post { if (!released) onNotify(value) }
}
}
}
@@ -1,117 +0,0 @@
/*
* 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
}
}
@@ -1,509 +0,0 @@
/*
* VesiScan firmware updater — MEDiDFU 이식 (2026-08-03).
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr> · adapt: dw.jang
*
* VesiScan 앱 통합: 설정 > 앱정보 > "펌웨어 업데이트" 진입점.
* FirmwareUpdateScreen() = 권한 · manager 라이프사이클 처리하는 host composable.
* DfuScreen() = 원본 UI (scan · connect · upload · log).
*/
package com.medithings.vesiscan.firmware.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.graphics.Brush
import com.medithings.vesiscan.ui.theme.MlBackground
import com.medithings.vesiscan.ui.theme.MlCardBackground
import com.medithings.vesiscan.ui.theme.MlCritical
import com.medithings.vesiscan.ui.theme.MlPrimary
import com.medithings.vesiscan.ui.theme.MlSecondaryText
import com.medithings.vesiscan.ui.theme.MlSuccess
import com.medithings.vesiscan.ui.theme.MlTeal
import com.medithings.vesiscan.ui.theme.MlWarning
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.medithings.vesiscan.firmware.ScannedDevice
import com.medithings.vesiscan.firmware.DfuManager
import com.medithings.vesiscan.firmware.DfuPhase
import kotlin.math.roundToInt
/**
* VesiScan 통합용 host — 권한 · manager 생성/해제 · 뒤로가기 처리.
* MEDiDFU 의 MainActivity.DfuApp() 상당.
*/
@Composable
fun FirmwareUpdateScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
val manager = remember { DfuManager(context) }
val perms: Array<String> = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
var granted by remember {
mutableStateOf(perms.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
})
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result -> granted = result.values.all { it } }
LaunchedEffect(Unit) {
manager.loadFirmware()
if (!granted) {
launcher.launch(perms)
return@LaunchedEffect
}
// 이미 메인 앱이 연결된 기기가 있으면 스캔 생략 · 자동 재연결로 UX 단축 (2026-08-03).
val bleManager = com.medithings.vesiscan.ble.BleManager.getInstance(context)
val preConnectedDevice = if (bleManager.isConnected.value) bleManager.currentBluetoothDevice else null
val preConnectedName = bleManager.connectedDeviceName.value
// 메인 앱 BLE 연결 해제 — 프로브가 연결 중이면 광고 중단되어 스캔 안 보임.
runCatching { bleManager.disconnect() }
// 이미 알던 기기가 있으면 스캔 없이 바로 재연결 시도.
if (preConnectedDevice != null && preConnectedName.isNotEmpty()) {
kotlinx.coroutines.delay(1500) // BLE stack 정리 · 재광고 대기
manager.connectDirectly(preConnectedDevice, preConnectedName)
}
}
DisposableEffect(Unit) { onDispose { manager.shutdown() } }
Column(modifier = modifier.fillMaxSize().background(MlBackground)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로", tint = MlPrimary)
}
Text("펌웨어 업데이트", style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold)
}
if (granted) {
DfuScreen(manager = manager, modifier = Modifier.fillMaxSize())
} else {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"펌웨어 업데이트에는 Bluetooth 권한이 필요합니다.",
style = MaterialTheme.typography.bodyMedium,
color = MlSecondaryText,
)
PrimaryGradientButton(text = "권한 허용", onClick = { launcher.launch(perms) })
}
}
}
}
/** 앱 스타일 통일 primary 버튼 (MlTeal → MlPrimary gradient · rounded 14). */
@Composable
private fun PrimaryGradientButton(
text: String,
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
contentPadding = PaddingValues(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(
brush = if (enabled)
Brush.horizontalGradient(listOf(MlTeal, MlPrimary))
else Brush.horizontalGradient(listOf(Color(0xFFCCCCCC), Color(0xFFAAAAAA))),
shape = RoundedCornerShape(14.dp),
)
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(text, color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
/** Phase → 사용자용 한글 라벨 + 상태 색상. */
private fun phaseKo(phase: DfuPhase): Pair<String, Color> = when (phase) {
DfuPhase.IDLE -> "대기 중" to MlSecondaryText
DfuPhase.SCANNING -> "기기 검색 중…" to MlWarning
DfuPhase.CONNECTING -> "연결 중…" to MlWarning
DfuPhase.CONNECTED -> "연결됨 · 업데이트 준비 완료" to MlSuccess
DfuPhase.ENTERING_DFU -> "DFU 모드 진입 중…" to MlPrimary
DfuPhase.SEARCHING_DFU -> "DFU 모드 확인 중…" to MlPrimary
DfuPhase.UPLOADING -> "업데이트 진행 중…" to MlPrimary
DfuPhase.COMPLETED -> "완료 ✓" to MlSuccess
DfuPhase.FAILED -> "실패" to MlCritical
}
@Composable
fun DfuScreen(manager: DfuManager, modifier: Modifier = Modifier) {
val phase by manager.phase
val status by manager.status
val firmware by manager.firmware
val firmwareError by manager.firmwareError
val progress by manager.progress
val bytesSent by manager.bytesSent
val target by manager.target
val cardShape = RoundedCornerShape(16.dp)
val cardColors = CardDefaults.cardColors(containerColor = MlCardBackground)
Column(
modifier = modifier
.fillMaxSize()
.background(MlBackground)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// ── 스캔 실패 원인 안내 (블루투스 꺼짐 등) ────────────────────────────────────────
val blocker = manager.bleBlockingCondition()
if (blocker != null) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = cardShape,
colors = CardDefaults.cardColors(containerColor = MlCritical.copy(alpha = 0.10f)),
) {
Text(
blocker,
modifier = Modifier.padding(14.dp),
style = MaterialTheme.typography.bodyMedium,
color = MlCritical,
fontWeight = FontWeight.Medium,
)
}
}
// ── 펌웨어 정보 (APK 번들) ────────────────────────────────────────────────────────
Card(modifier = Modifier.fillMaxWidth(), shape = cardShape, colors = cardColors) {
Column(Modifier.padding(14.dp)) {
Text("펌웨어 정보", style = MaterialTheme.typography.labelLarge, color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
val fw = firmware
when {
fw != null -> {
val ver = if (fw.version.isNotEmpty()) "v${fw.version}" else "버전 정보 없음"
val sizeKb = fw.sizeBytes / 1024
Text("$ver · ${sizeKb} KB",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(2.dp))
Text(fw.imageFileName,
style = MaterialTheme.typography.bodySmall,
color = MlSecondaryText)
}
firmwareError != null -> Text(
firmwareError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MlCritical,
)
else -> Text("불러오는 중…",
style = MaterialTheme.typography.bodySmall,
color = MlSecondaryText)
}
}
}
// ── 상태 + 진행률 ─────────────────────────────────────────────────────────────────
Card(modifier = Modifier.fillMaxWidth(), shape = cardShape, colors = cardColors) {
Column(Modifier.padding(14.dp)) {
Text("기기 상태", style = MaterialTheme.typography.labelLarge, color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
val (phaseLabel, phaseColor) = phaseKo(phase)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.background(phaseColor, shape = RoundedCornerShape(50)),
)
Spacer(Modifier.width(8.dp))
Text(phaseLabel,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = phaseColor)
}
if (status.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(status, style = MaterialTheme.typography.bodySmall, color = MlSecondaryText)
}
target?.let {
Spacer(Modifier.height(4.dp))
Text("기기: ${it.name}",
style = MaterialTheme.typography.bodySmall,
color = MlSecondaryText)
}
if (phase == DfuPhase.UPLOADING || phase == DfuPhase.COMPLETED) {
Spacer(Modifier.height(10.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = MlPrimary,
trackColor = MlPrimary.copy(alpha = 0.15f),
)
Spacer(Modifier.height(6.dp))
val total = firmware?.sizeBytes ?: 0
val sentKb = bytesSent / 1024
val totalKb = total / 1024
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${(progress * 100).roundToInt()}%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MlPrimary)
Text("$sentKb KB / $totalKb KB",
style = MaterialTheme.typography.bodySmall,
color = MlSecondaryText)
}
}
}
}
// ── 액션 버튼 (primary 는 gradient · secondary 는 outline) ───────────────────────
// 2026-08-03: auto-connect 로 대부분 target 즉시 설정됨. target 없을 때만 Scan 노출.
val hasTarget = target != null
val outlineColors = ButtonDefaults.outlinedButtonColors(contentColor = MlPrimary)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// 주 액션: 업데이트 시작 (조건부 gradient primary)
if (phase != DfuPhase.UPLOADING && phase != DfuPhase.COMPLETED && phase != DfuPhase.FAILED) {
PrimaryGradientButton(
text = "업데이트 시작",
onClick = { manager.startDfu() },
enabled = manager.canStartDfu,
modifier = Modifier.fillMaxWidth(),
)
}
// 보조 액션 (조건부)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (!hasTarget || phase == DfuPhase.IDLE || phase == DfuPhase.SCANNING) {
if (phase == DfuPhase.SCANNING) {
OutlinedButton(
onClick = { manager.stopScan() },
shape = RoundedCornerShape(12.dp),
colors = outlineColors,
) { Text("검색 중지") }
} else {
OutlinedButton(
onClick = { manager.startScan() },
enabled = !manager.isBusy,
shape = RoundedCornerShape(12.dp),
colors = outlineColors,
) { Text("기기 검색") }
}
}
if (phase == DfuPhase.CONNECTED) {
OutlinedButton(
onClick = { manager.disconnect() },
shape = RoundedCornerShape(12.dp),
colors = outlineColors,
) { Text("연결 해제") }
}
if (phase == DfuPhase.UPLOADING) {
OutlinedButton(
onClick = { manager.cancelDfu() },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MlCritical),
) { Text("취소") }
}
if (phase == DfuPhase.COMPLETED || phase == DfuPhase.FAILED) {
OutlinedButton(
onClick = { manager.reset() },
shape = RoundedCornerShape(12.dp),
colors = outlineColors,
) { Text("초기화") }
}
}
}
// ── 발견된 기기 목록 (auto-connect 실패 · 첫 사용) ───────────────────────────────
if (!hasTarget) {
Text(
"발견된 기기 (${manager.devices.size})",
style = MaterialTheme.typography.labelLarge,
color = MlSecondaryText,
)
Card(
modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp, max = 220.dp),
shape = cardShape,
colors = cardColors,
) {
if (manager.devices.isEmpty()) {
Text(
when (phase) {
DfuPhase.SCANNING -> "검색 중…"
else -> "기기가 없습니다. 검색을 눌러주세요."
},
modifier = Modifier.padding(14.dp),
style = MaterialTheme.typography.bodySmall,
color = MlSecondaryText,
)
} else {
LazyColumn {
items(manager.devices, key = { it.address }) { device ->
DeviceRow(
device = device,
enabled = !manager.isBusy,
onClick = { manager.connect(device) },
)
HorizontalDivider(color = MlBackground)
}
}
}
}
}
// ── 상세 로그 (개발자용 · 그대로 유지) ────────────────────────────────────────────
Text("상세 로그", style = MaterialTheme.typography.labelLarge, color = MlSecondaryText)
LogView(
lines = manager.logLines,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
@Composable
private fun DeviceRow(device: ScannedDevice, enabled: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(device.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
if (device.inDfuMode) {
Spacer(Modifier.width(6.dp))
Surface(
color = MlTeal.copy(alpha = 0.15f),
shape = RoundedCornerShape(6.dp),
) {
Text(
"DFU 모드",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MlTeal,
fontWeight = FontWeight.SemiBold,
)
}
}
}
Spacer(Modifier.height(2.dp))
Text(
"${device.rssi} dBm",
style = MaterialTheme.typography.bodySmall,
color = MlSecondaryText,
)
}
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MlPrimary),
) { Text("연결", fontWeight = FontWeight.SemiBold) }
}
}
@Composable
private fun LogView(lines: List<String>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LaunchedEffect(lines.size) {
if (lines.isNotEmpty()) listState.animateScrollToItem(lines.size - 1)
}
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlSecondaryText.copy(alpha = 0.08f)),
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.padding(10.dp),
) {
itemsIndexed(lines) { _, line ->
Text(
line,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Normal,
color = MlSecondaryText,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@@ -1,7 +1,6 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -32,28 +31,30 @@ import com.medithings.vesiscan.ble.BleManager
import kotlinx.coroutines.delay
/**
* 펌웨어 상태 배너 — PlacementGuide / PiezoMonitoring 상단에 노출.
* 기기 응답 배너 — PlacementGuide / PiezoMonitoring 상단에 노출.
*
* 표시 케이스:
* (1) 펌웨어 구버전 (build < MIN_FIRMWARE_BUILD) — 주황 경고
* (2) 연결 후 8초 넘게 mid? 응답 미수신 — 빨강 경고 (펌웨어가 mid 미지원)
* (3) 정상 또는 미연결 — 안 보임
* (1) 연결 후 8초 넘게 mid? 응답 미수신이고 최근 12초간 수신도 없음 — 빨강 "기기 응답 없음"
* (2) 정상 또는 미연결 — 안 보임
*
* 2026-10-13: 펌웨어 버전 판정(최소빌드 118 "구버전" 주황 · 번들 DFU "새 펌웨어" 파랑)을 뺐다.
* 임상앱은 펌웨어 버전을 세션 기록에 남길 뿐 판정하지 않는다 — 판정과 DFU 는 같은 폰의
* 사용자 앱이 맡는다. 양산 펌웨어(VB0FW0100)는 0100 부터 다시 세므로 옛 규칙이 거짓 경고를 냈다.
*/
@Composable
fun FirmwareWarningBanner(
bleManager: BleManager,
modifier: Modifier = Modifier,
onUpdateAvailableTap: (() -> Unit)? = null, // 2026-08-04 · 파랑 배너 탭 시 DFU 이동
) {
val fw by bleManager.firmwareVersion
val isConnected by bleManager.isConnected
val lastRxAt by bleManager.lastBleRxAt
// 미연결 상태에선 펌웨어 배너 일절 노출 X. dev bypass / demo mode / 이전 세션
// 미연결 상태에선 배너 일절 노출 X. dev bypass / demo mode / 이전 세션
// 잔여 firmwareVersion 값으로 인한 false positive 방지.
if (!isConnected) return
// (2) 연결됐는데 일정 시간 동안 펌웨어 미수신 추적
// 연결됐는데 일정 시간 동안 펌웨어 미수신 추적
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(isConnected, fw) {
timedOut = false
@@ -80,53 +81,26 @@ fun FirmwareWarningBanner(
// Watchdog 15초 timeout 이 진짜 disconnect 판정하므로 12초까지는 조용히 대기.
val deviceAlive = lastRxAt > 0 && (System.currentTimeMillis() - lastRxAt) < 12_000
val outdated = bleManager.isFirmwareOutdated
val updateAvailable = bleManager.isFirmwareUpdateAvailable
val showTimedOut = timedOut && !deviceAlive
if (!outdated && !showTimedOut && !updateAvailable) return
if (!(timedOut && !deviceAlive)) return
// 우선순위: outdated (빨강) > timedOut (빨강) > updateAvailable (파랑 · 정보성).
val bgColor = when {
outdated -> Color(0xFFFF9500) // 주황 · 강한 경고
showTimedOut -> Color(0xFFD32F2F) // 빨강 · 응답 없음
else -> Color(0xFF1976D2) // 파랑 · 업데이트 사용 가능
}
val title = when {
outdated -> stringResource(R.string.firmware_outdated_title)
showTimedOut -> stringResource(R.string.firmware_no_response_title)
else -> stringResource(R.string.firmware_update_available_title)
}
val subtitle: String? = when {
outdated -> stringResource(R.string.firmware_outdated_desc, fw, BleManager.MIN_FIRMWARE_BUILD)
showTimedOut -> null
else -> stringResource(
R.string.firmware_update_available_desc,
fw,
com.medithings.vesiscan.firmware.FirmwareLoader.BUNDLED_VERSION,
)
}
// 파랑 배너 (update available) 는 탭 가능 · 나머지는 정보 표시만.
val clickableModifier = if (updateAvailable && onUpdateAvailableTap != null) {
Modifier.clickable { onUpdateAvailableTap() }
} else Modifier
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.then(clickableModifier)
.background(Color(0xFFD32F2F)) // 빨강 · 응답 없음
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, "Warning", tint = Color.White)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (subtitle != null) {
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
}
Text(
stringResource(R.string.firmware_no_response_title),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
}
@@ -173,18 +173,9 @@ fun HomeView(appState: AppState) {
Text("병원 임상 측정", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
// 2026-08-03 (cloud-mvp 672e679 이식): 프로브 firmware 업데이트 진입점.
TextButton(
onClick = { appState.currentScreen = com.medithings.vesiscan.AppScreen.FIRMWARE_UPDATE },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp),
) {
Text("펌웨어 업데이트 (프로브 firmware)",
color = com.medithings.vesiscan.ui.theme.MlPrimary,
fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
// 프로브 펌웨어 업데이트 진입점은 2026-10-13 에 뺐다 — 사용자 앱에서 올린다.
// 버튼이 차지하던 아래 여백(48dp)만 남긴다.
Box(modifier = Modifier.fillMaxWidth().padding(bottom = 48.dp)) {}
}
} // Box (top-bar gear overlay) close
@@ -1357,11 +1357,8 @@ fun PiezoMonitoringView(appState: AppState) {
// BLE 끊김 banner는 사내 테스트 피드백으로 제거됨 — 백그라운드 자동 재연결만 유지.
// 펌웨어 구버전 경고
com.medithings.vesiscan.ui.components.FirmwareWarningBanner(
bleManager,
onUpdateAvailableTap = { appState.currentScreen = com.medithings.vesiscan.AppScreen.FIRMWARE_UPDATE },
)
// 기기 응답 없음 경고 (버전 판정은 2026-10-13 에 뺐다 — 펌웨어는 사용자 앱이 관리)
com.medithings.vesiscan.ui.components.FirmwareWarningBanner(bleManager)
com.medithings.vesiscan.ui.components.BatteryWarningBanner(bleManager)
val scrollState = rememberScrollState()
@@ -314,11 +314,8 @@ fun PlacementGuideView(appState: AppState) {
)
}
// 펌웨어 구버전 경고
com.medithings.vesiscan.ui.components.FirmwareWarningBanner(
bleManager,
onUpdateAvailableTap = { appState.currentScreen = com.medithings.vesiscan.AppScreen.FIRMWARE_UPDATE },
)
// 기기 응답 없음 경고 (버전 판정은 2026-10-13 에 뺐다 — 펌웨어는 사용자 앱이 관리)
com.medithings.vesiscan.ui.components.FirmwareWarningBanner(bleManager)
// 배터리 잔량 경고 (<15%)
com.medithings.vesiscan.ui.components.BatteryWarningBanner(bleManager)
-5
View File
@@ -158,12 +158,7 @@
<string name="battery_low_title">기기 배터리 부족</string>
<string name="battery_critical_desc">현재 %d%% · 측정이 갑자기 중단될 수 있습니다. 즉시 충전하세요.</string>
<string name="battery_low_desc">현재 %d%% · 곧 충전이 필요합니다.</string>
<string name="firmware_outdated_title">펌웨어 업데이트 필요</string>
<string name="firmware_no_response_title">기기 응답 없음</string>
<string name="firmware_outdated_desc">현재: %1$s · 필요: VBTFW%2$04d 이상. 측정이 정상 동작하지 않을 수 있습니다.</string>
<!-- 2026-08-04 · 번들 FW 버전 불일치 시 업데이트 안내 (정보성 · 파랑 배너) -->
<string name="firmware_update_available_title">새 펌웨어가 있습니다</string>
<string name="firmware_update_available_desc">기기: %1$s · 신규: %2$s. 눌러서 DFU 업데이트를 진행하세요.</string>
<!-- Lockable Slider -->
<string name="slider_unlocked_desc">잠금 해제됨 — 다시 탭하여 잠금</string>
-5
View File
@@ -158,12 +158,7 @@
<string name="battery_low_title">Low battery</string>
<string name="battery_critical_desc">%d%% · Measurement may stop suddenly. Charge immediately.</string>
<string name="battery_low_desc">%d%% · Please charge soon.</string>
<string name="firmware_outdated_title">Firmware update required</string>
<string name="firmware_no_response_title">No response from device</string>
<string name="firmware_outdated_desc">Current: %1$s · Required: VBTFW%2$04d or higher. Measurement may not work properly.</string>
<!-- 2026-08-04 · 번들 FW 버전 불일치 시 업데이트 안내 (정보성 · 파랑 배너) -->
<string name="firmware_update_available_title">New firmware available</string>
<string name="firmware_update_available_desc">Device: %1$s · New: %2$s. Tap to update via DFU.</string>
<!-- Lockable Slider -->
<string name="slider_unlocked_desc">Unlocked — tap again to lock</string>