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:
@@ -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
|
||||
|
||||
|
||||
+2
-5
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user