feat(demo-final): cherry-pick — Firmware DFU + Battery dismiss + AppState fix (cloud-mvp 이식)
feature/cloud-mvp 에서 최근 만들어진 3가지 사용자용 기능을 demo-final 에도 적용.
demo-final 옛 폴더 구조 유지 (folder reorg 안 됨) · 각 파일 위치 수동 매핑.
이식 대상:
1. Firmware DFU (cloud-mvp 672e679):
- firmware/ 폴더 신규 · MEDiDFU 6 파일 이식 (DfuManager 459 line 등)
- assets/dfu_application_2+35.zip (243 KB · signed MCUboot)
- libs.versions.toml: mcumgr = "2.5.0" (Kotlin 2.0 호환)
- build.gradle.kts: mcumgr-ble + lifecycle-runtime-compose 추가
- AppScreen.FIRMWARE_UPDATE enum 추가
- MainActivity: 라우팅 + 뒤로가기 (→ HOME)
- HomeView: "펌웨어 업데이트 (프로브 firmware)" TextButton 추가 (Dev clinical 버튼 밑)
2. BatteryWarningBanner tap-to-dismiss (cloud-mvp aa6cc1f):
- clickable { dismissed = true } + 우측 X 아이콘
- WARNING → CRITICAL 악화 시 자동 재표시
- 재연결 시 dismissed 초기화
3. AppState.isDeviceConnected getter (cloud-mvp 9c82a50):
- mutableStateOf → getter 로 변경 (BleManager.isConnected.value 실시간)
- Compose 자동 재구성 · setter 는 no-op 로 호환 유지
- 홈화면 "연결됨" · Start 후 "연결해제" 불일치 해결
주요 파일 매핑 (feature/cloud-mvp → demo-final):
- core/AppState.kt → AppState.kt (root)
- core/MainActivity.kt → MainActivity.kt (root)
- common/ui/components/BatteryWarningBanner.kt → ui/components/BatteryWarningBanner.kt
- firmware/** → firmware/** (신규 · 동일)
- shell/ui/SettingsTabView.kt (진입점 원본) → HomeView 로 대체 (demo 는 SettingsTab 없음)
검증: BUILD SUCCESSFUL (demoDebug 1m 56s) · APK install PASS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,8 @@ enum class AppScreen {
|
||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||
CLINICAL_LIVE // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
|
||||
}
|
||||
|
||||
class AppState(private val context: Context) {
|
||||
@@ -25,7 +26,19 @@ class AppState(private val context: Context) {
|
||||
var user by mutableStateOf(User())
|
||||
var sensorMode by mutableStateOf(SensorMode.PIEZO)
|
||||
var bladderLevel by mutableStateOf(BladderLevel())
|
||||
var isDeviceConnected by mutableStateOf(false)
|
||||
/**
|
||||
* BLE 연결 상태 — **BleManager 실시간 값을 즉시 반영** (2026-08-03 fix · cloud-mvp 9c82a50).
|
||||
*
|
||||
* 이전: `mutableStateOf(false)` 로 stale snapshot 저장 → BLE 가 끊겨도 홈 화면이
|
||||
* "연결됨" 으로 표시되고, Start 누르면 실제 PIEZO_MONITORING 에서
|
||||
* "연결 해제" 로 표시되는 불일치 문제.
|
||||
*
|
||||
* 현재: getter 가 BleManager.isConnected 를 읽음 → Compose 가 자동 재구성.
|
||||
* 기존 코드의 setter 는 no-op 로 유지 (호환).
|
||||
*/
|
||||
var isDeviceConnected: Boolean
|
||||
get() = isDemoMode || bleManager.isConnected.value
|
||||
set(_) { /* no-op · 실제 상태는 BleManager 가 관리 */ }
|
||||
var isDemoMode by mutableStateOf(false)
|
||||
var isDevMode by mutableStateOf(false)
|
||||
var piezoSettings by mutableStateOf(PiezoSettings())
|
||||
|
||||
@@ -135,6 +135,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.URINE_CAMERA -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
|
||||
// 기타
|
||||
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
|
||||
AppScreen.VIVAMYO_MONITORING -> appState.currentScreen = AppScreen.HOME
|
||||
@@ -178,6 +179,9 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
)
|
||||
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
|
||||
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
|
||||
AppScreen.FIRMWARE_UPDATE -> com.medithings.vesiscan.firmware.ui.FirmwareUpdateScreen(
|
||||
onBack = { appState.currentScreen = AppScreen.HOME }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* 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) }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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_2+35.zip"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* 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.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.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)
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { manager.shutdown() } }
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로")
|
||||
}
|
||||
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,
|
||||
)
|
||||
Button(onClick = { launcher.launch(perms) }) { Text("권한 허용") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("MEDiDFU", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
// ── Anything outside the app that would silently break scanning ───────────────────
|
||||
val blocker = manager.bleBlockingCondition()
|
||||
if (blocker != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
blocker,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Firmware bundled in the APK ────────────────────────────────────────────────────
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("Firmware (bundled)", style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val fw = firmware
|
||||
when {
|
||||
fw != null -> {
|
||||
Text(fw.assetName, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
"${fw.imageFileName} · v${fw.version.ifEmpty { "?" }} · ${fw.sizeBytes} bytes",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
firmwareError != null -> Text(
|
||||
firmwareError ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
else -> Text("Loading…", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status + progress ─────────────────────────────────────────────────────────────
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(phase.name, style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(status, style = MaterialTheme.typography.bodyMedium)
|
||||
target?.let {
|
||||
Text(
|
||||
"${it.name} · ${it.address}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (phase == DfuPhase.UPLOADING || phase == DfuPhase.COMPLETED) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val total = firmware?.sizeBytes ?: 0
|
||||
Text(
|
||||
"${(progress * 100).roundToInt()}% ($bytesSent / $total bytes)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────────
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Keyed off the phase, not the scanner: the DFU re-scan also runs the scanner, and
|
||||
// stopping that one from here would abort the update.
|
||||
if (phase == DfuPhase.SCANNING) {
|
||||
OutlinedButton(onClick = { manager.stopScan() }) { Text("Stop") }
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { manager.startScan() },
|
||||
enabled = !manager.isBusy,
|
||||
) { Text("Scan") }
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { manager.startDfu() },
|
||||
enabled = manager.canStartDfu,
|
||||
) { Text("Start DFU") }
|
||||
|
||||
if (phase == DfuPhase.CONNECTED) {
|
||||
OutlinedButton(onClick = { manager.disconnect() }) { Text("Disconnect") }
|
||||
}
|
||||
if (phase == DfuPhase.UPLOADING) {
|
||||
OutlinedButton(onClick = { manager.cancelDfu() }) { Text("Cancel") }
|
||||
}
|
||||
if (phase == DfuPhase.COMPLETED || phase == DfuPhase.FAILED) {
|
||||
OutlinedButton(onClick = { manager.reset() }) { Text("Reset") }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discovered devices ────────────────────────────────────────────────────────────
|
||||
Text(
|
||||
"Devices (${manager.devices.size})",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Card(modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp, max = 220.dp)) {
|
||||
if (manager.devices.isEmpty()) {
|
||||
Text(
|
||||
when (phase) {
|
||||
DfuPhase.CONNECTED -> "Connected — disconnect to scan again."
|
||||
DfuPhase.SCANNING -> "Scanning…"
|
||||
else -> "No devices yet — tap Scan."
|
||||
},
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(manager.devices, key = { it.address }) { device ->
|
||||
DeviceRow(
|
||||
device = device,
|
||||
enabled = !manager.isBusy,
|
||||
onClick = { manager.connect(device) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Log ───────────────────────────────────────────────────────────────────────────
|
||||
Text("Log", style = MaterialTheme.typography.labelLarge)
|
||||
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 = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(device.name, style = MaterialTheme.typography.bodyMedium)
|
||||
if (device.inDfuMode) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"DFU",
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"${device.address} · ${device.rssi} dBm",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(onClick = onClick, enabled = enabled) { Text("Connect") }
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
itemsIndexed(lines) { _, line ->
|
||||
Text(
|
||||
line,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryAlert
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -32,16 +39,27 @@ import com.medithings.vesiscan.ble.BleManager
|
||||
* - 15% 이하 → 주황 경고 ("기기 배터리 부족 — 곧 충전하세요")
|
||||
* - 5% 이하 → 빨강 위급 ("기기 배터리 위급 — 측정이 갑자기 중단될 수 있음")
|
||||
* - 그 외 / 미연결 / 0% (아직 미수신) → 숨김
|
||||
*
|
||||
* 상호작용 (2026-08-03 요청 · cloud-mvp aa6cc1f 이식):
|
||||
* - 배너 탭 또는 X 아이콘 → 사용자가 dismiss 가능 (숨김)
|
||||
* - WARNING → CRITICAL 악화 시 자동 재표시 (놓치면 위험)
|
||||
* - 재연결 시 dismiss 초기화 (새 세션에는 다시 표시)
|
||||
*/
|
||||
@Composable
|
||||
fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
|
||||
val isConnected by bleManager.isConnected
|
||||
val batt by bleManager.batteryLevel
|
||||
|
||||
var dismissed by rememberSaveable { mutableStateOf(false) }
|
||||
val critical = batt in 1..CRITICAL_BATTERY_THRESHOLD
|
||||
LaunchedEffect(critical) { if (critical) dismissed = false }
|
||||
LaunchedEffect(isConnected) { if (!isConnected) dismissed = false }
|
||||
|
||||
if (!isConnected) return
|
||||
if (batt <= 0) return // 응답 받기 전엔 0 → false positive 방지
|
||||
if (batt > LOW_BATTERY_THRESHOLD) return
|
||||
if (dismissed) return
|
||||
|
||||
val critical = batt <= CRITICAL_BATTERY_THRESHOLD
|
||||
val bgColor = if (critical) Color(0xFFD32F2F) else Color(0xFFFF9500)
|
||||
val title = if (critical) stringResource(R.string.battery_critical_title) else stringResource(R.string.battery_low_title)
|
||||
val subtitle = if (critical)
|
||||
@@ -55,6 +73,7 @@ fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.clickable { dismissed = true }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -64,6 +83,12 @@ fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier)
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
Icons.Default.Close, contentDescription = "닫기",
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,15 +151,25 @@ fun HomeView(appState: AppState) {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
|
||||
) {
|
||||
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
} // Box (top-bar gear overlay) close
|
||||
|
||||
Reference in New Issue
Block a user