diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70e391d..90b6d58 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 은 안드로이드 단위테스트에서 **모든 메서드가 스텁**이라 호출하면 예외가 diff --git a/app/src/main/assets/dfu_application_1.0.0+5.zip b/app/src/main/assets/dfu_application_1.0.0+5.zip deleted file mode 100644 index b9d4790..0000000 Binary files a/app/src/main/assets/dfu_application_1.0.0+5.zip and /dev/null differ diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index ddee70a..0166a3b 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -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) { diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index c2ac711..76517eb 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -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 } - ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 04707d9..1ad178d 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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? { - // 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, b: Triple): 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 diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuBleScanner.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuBleScanner.kt deleted file mode 100644 index a9f017c..0000000 --- a/app/src/main/java/com/medithings/vesiscan/firmware/DfuBleScanner.kt +++ /dev/null @@ -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 - * 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 - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuManager.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuManager.kt deleted file mode 100644 index bf15100..0000000 --- a/app/src/main/java/com/medithings/vesiscan/firmware/DfuManager.kt +++ /dev/null @@ -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 - * 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() - - // ── Observable state ──────────────────────────────────────────────────────────────────── - - val phase = mutableStateOf(DfuPhase.IDLE) - val status = mutableStateOf("Ready") - val devices = mutableStateListOf() - val logLines = mutableStateListOf() - val firmware = mutableStateOf(null) - val firmwareError = mutableStateOf(null) - val target = mutableStateOf(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 { - - 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) - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuNus.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuNus.kt deleted file mode 100644 index f02807d..0000000 --- a/app/src/main/java/com/medithings/vesiscan/firmware/DfuNus.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * MEDiDFU — VesiSeek firmware updater - * - * VesiSeek BLE identifiers and the NUS command framing shared with the firmware. - * - * Author: Charles KWON OhJun - * 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) - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuNusConnection.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuNusConnection.kt deleted file mode 100644 index 9a5f4dc..0000000 --- a/app/src/main/java/com/medithings/vesiscan/firmware/DfuNusConnection.kt +++ /dev/null @@ -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 - * 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) } - } - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt b/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt deleted file mode 100644 index 0cf7aca..0000000 --- a/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt +++ /dev/null @@ -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 - * 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 { - val entries = LinkedHashMap() - 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 - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt b/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt deleted file mode 100644 index 4e189d9..0000000 --- a/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt +++ /dev/null @@ -1,509 +0,0 @@ -/* - * VesiScan firmware updater — MEDiDFU 이식 (2026-08-03). - * Author: Charles KWON OhJun · 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 = 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 = 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, 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, - ) - } - } - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/FirmwareWarningBanner.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/FirmwareWarningBanner.kt index 6bc8d60..e45b5a8 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/components/FirmwareWarningBanner.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/FirmwareWarningBanner.kt @@ -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, + ) } } } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt index 080c316..cd5b7c2 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt @@ -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 diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 5512593..d20da17 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -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() diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt index 2923204..18517ec 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt @@ -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) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b65559f..06ca848 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -158,12 +158,7 @@ 기기 배터리 부족 현재 %d%% · 측정이 갑자기 중단될 수 있습니다. 즉시 충전하세요. 현재 %d%% · 곧 충전이 필요합니다. - 펌웨어 업데이트 필요 기기 응답 없음 - 현재: %1$s · 필요: VBTFW%2$04d 이상. 측정이 정상 동작하지 않을 수 있습니다. - - 새 펌웨어가 있습니다 - 기기: %1$s · 신규: %2$s. 눌러서 DFU 업데이트를 진행하세요. 잠금 해제됨 — 다시 탭하여 잠금 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7860dac..ea47e02 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,12 +158,7 @@ Low battery %d%% · Measurement may stop suddenly. Charge immediately. %d%% · Please charge soon. - Firmware update required No response from device - Current: %1$s · Required: VBTFW%2$04d or higher. Measurement may not work properly. - - New firmware available - Device: %1$s · New: %2$s. Tap to update via DFU. Unlocked — tap again to lock diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03c9638..da93dd8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,6 @@ rive = "9.4.2" startupRuntime = "1.2.0" lifecycleViewmodelCompose = "2.10.0" # 2026-08-03 (cherry-pick from cloud-mvp): Firmware DFU -mcumgr = "2.5.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -22,9 +21,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -# 2026-08-03: LocalLifecycleOwner (Compose) 및 mcumgr-ble +# 2026-08-03: LocalLifecycleOwner (Compose) — mcumgr-ble 는 2026-10-13 DFU 제거와 함께 뺐다 androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } -mcumgr-ble = { group = "no.nordicsemi.android", name = "mcumgr-ble", version.ref = "mcumgr" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }