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:
2026-08-03 13:29:18 +09:00
parent 2e0ca889d9
commit 984fe2c939
13 changed files with 1416 additions and 6 deletions
+2
View File
@@ -66,6 +66,7 @@ android {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -88,6 +89,7 @@ dependencies {
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
Binary file not shown.
@@ -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