diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c9c190..1c7cec3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/assets/dfu_application_2+35.zip b/app/src/main/assets/dfu_application_2+35.zip new file mode 100644 index 0000000..0165e2e Binary files /dev/null and b/app/src/main/assets/dfu_application_2+35.zip differ diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 3792c52..ef5fcbe 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -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()) diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index 1299a5f..2e117cf 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -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 } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuBleScanner.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuBleScanner.kt new file mode 100644 index 0000000..a9f017c --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/firmware/DfuBleScanner.kt @@ -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 + * MEDiThings Inc. + */ +package com.medithings.vesiscan.firmware +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.location.LocationManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import androidx.core.location.LocationManagerCompat + +data class ScannedDevice( + val device: BluetoothDevice, + val name: String, + val rssi: Int, + /** True when the advertisement carries the SMP service, i.e. the device is waiting for DFU. */ + val inDfuMode: Boolean, +) { + val address: String get() = device.address +} + +/** + * Thin BLE scanner. + * + * No hardware scan filter is used: the SMP UUID is advertised in the *scan response*, and some + * controllers will not match an offloaded filter against it. Filtering in software costs a few + * extra callbacks and is reliable everywhere. + */ +@SuppressLint("MissingPermission") +class BleScanner(context: Context) { + + private val appContext = context.applicationContext + private val handler = Handler(Looper.getMainLooper()) + private val adapter: BluetoothAdapter? = + (appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + + private var callback: ScanCallback? = null + private var timeoutTask: Runnable? = null + + val isBluetoothEnabled: Boolean get() = adapter?.isEnabled == true + + var isScanning: Boolean = false + private set + + /** + * Raw advertisements delivered during the last scan, counted before the "has a name" filter. + * Zero while devices are clearly nearby points at a permission problem rather than at the + * filter, so it is worth logging. + */ + var advertisementsSeen: Int = 0 + private set + + /** + * Android 11 and below refuse to report scan results while location services are off, and do + * so silently. Returns a message describing the problem, or null when scanning can proceed. + */ + fun blockingCondition(): String? { + if (adapter == null) return "This device has no Bluetooth adapter" + if (!adapter.isEnabled) return "Bluetooth is off" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return null + val lm = appContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager + ?: return null + return if (LocationManagerCompat.isLocationEnabled(lm)) { + null + } else { + "Location services are off — Android 11 and below need them for BLE scanning" + } + } + + /** + * Starts a scan. [onResult] fires for every named advertisement (repeatedly for the same + * device as the RSSI updates); [onFinished] fires exactly once when the scan ends. + */ + fun start( + timeoutMs: Long, + onResult: (ScannedDevice) -> Unit, + onFinished: (error: String?) -> Unit, + ) { + stop() + advertisementsSeen = 0 + blockingCondition()?.let { + onFinished(it) + return + } + val scanner = adapter?.bluetoothLeScanner + if (scanner == null) { + onFinished("Bluetooth LE scanner unavailable") + return + } + + val smpUuid = ParcelUuid(VesiSeekUuids.SMP_SERVICE) + val cb = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + advertisementsSeen++ + val name = try { + result.scanRecord?.deviceName ?: result.device.name + } catch (_: SecurityException) { + null + } ?: return + if (name.isEmpty()) return + val inDfu = result.scanRecord?.serviceUuids?.contains(smpUuid) == true + handler.post { onResult(ScannedDevice(result.device, name, result.rssi, inDfu)) } + } + + override fun onScanFailed(errorCode: Int) { + handler.post { + isScanning = false + callback = null + onFinished("Scan failed (error $errorCode)") + } + } + } + + callback = cb + isScanning = true + try { + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + scanner.startScan(null, settings, cb) + } catch (e: Exception) { + isScanning = false + callback = null + onFinished("Scan could not be started: ${e.message}") + return + } + + val task = Runnable { + stop() + onFinished(null) + } + timeoutTask = task + handler.postDelayed(task, timeoutMs) + } + + fun stop() { + timeoutTask?.let { handler.removeCallbacks(it) } + timeoutTask = null + callback?.let { cb -> + try { + adapter?.bluetoothLeScanner?.stopScan(cb) + } catch (_: Exception) { + // Adapter may have been turned off while scanning. + } + } + callback = null + isScanning = false + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuManager.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuManager.kt new file mode 100644 index 0000000..cae7a0e --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/firmware/DfuManager.kt @@ -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 + * MEDiThings Inc. + */ +package com.medithings.vesiscan.firmware +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import com.medithings.vesiscan.firmware.BleScanner +import com.medithings.vesiscan.firmware.NusConnection +import com.medithings.vesiscan.firmware.NusPacket +import com.medithings.vesiscan.firmware.ScannedDevice +import io.runtime.mcumgr.ble.McuMgrBleTransport +import io.runtime.mcumgr.dfu.FirmwareUpgradeCallback +import io.runtime.mcumgr.dfu.FirmwareUpgradeController +import io.runtime.mcumgr.dfu.mcuboot.FirmwareUpgradeManager +import io.runtime.mcumgr.dfu.mcuboot.model.ImageSet +import io.runtime.mcumgr.exception.McuMgrException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +enum class DfuPhase { + IDLE, + SCANNING, + CONNECTING, + CONNECTED, + ENTERING_DFU, + SEARCHING_DFU, + UPLOADING, + COMPLETED, + FAILED, +} + +/** + * Drives the full VesiSeek firmware update, ported from the Go host tool (`vesiseek_ble_dfu`): + * + * 1. scan and connect over NUS, + * 2. send `mdf?` — the device answers `rdf:`, drops the link and re-advertises with the SMP + * service, + * 3. re-scan and hand the device to Nordic's [FirmwareUpgradeManager], which uploads the signed + * image, marks it for test and resets. + * + * All state is Compose-observable and is only mutated on the main thread. + */ +@SuppressLint("MissingPermission") +class DfuManager(context: Context) { + + private companion object { + const val SCAN_TIMEOUT_MS = 15_000L + const val DFU_SCAN_TIMEOUT_MS = 30_000L + /** The firmware sends `rdf:` then disconnects ~100 ms later; don't wait much longer. */ + const val RDF_TIMEOUT_MS = 5_000L + /** Let the device finish re-advertising and the local stack settle before re-scanning. */ + const val RESCAN_DELAY_MS = 1_500L + const val MAX_LOG_LINES = 300 + /** nRF52 flash writes are word aligned. */ + const val MEMORY_ALIGNMENT = 4 + } + + private val appContext = context.applicationContext + private val handler = Handler(Looper.getMainLooper()) + private val scanner = BleScanner(appContext) + private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) + + private var nus: NusConnection? = null + private var transport: McuMgrBleTransport? = null + private var upgradeManager: FirmwareUpgradeManager? = null + + private var dfuEntryHandled = false + private var rdfTimeout: Runnable? = null + private val dfuCandidates = LinkedHashMap() + + // ── Observable state ──────────────────────────────────────────────────────────────────── + + val phase = mutableStateOf(DfuPhase.IDLE) + val status = mutableStateOf("Ready") + val devices = mutableStateListOf() + val logLines = mutableStateListOf() + val firmware = mutableStateOf(null) + val firmwareError = mutableStateOf(null) + val target = mutableStateOf(null) + /** Upload progress in 0f..1f. */ + val progress = mutableStateOf(0f) + val bytesSent = mutableStateOf(0) + + val isBluetoothEnabled: Boolean get() = scanner.isBluetoothEnabled + + val canStartDfu: Boolean + get() = phase.value == DfuPhase.CONNECTED && firmware.value != null + + val isBusy: Boolean + get() = phase.value in setOf( + DfuPhase.CONNECTING, DfuPhase.ENTERING_DFU, DfuPhase.SEARCHING_DFU, DfuPhase.UPLOADING, + ) + + // ── Firmware ──────────────────────────────────────────────────────────────────────────── + + fun loadFirmware() { + if (firmware.value != null) return + try { + val pkg = FirmwareLoader.loadFromAssets(appContext) + firmware.value = pkg + firmwareError.value = null + log("firmware ${pkg.imageFileName} v${pkg.version} (${pkg.sizeBytes} bytes)") + } catch (e: Exception) { + firmwareError.value = e.message ?: e.toString() + log("firmware load FAILED: ${e.message}") + } + } + + // ── Scan / connect ────────────────────────────────────────────────────────────────────── + + fun startScan() { + if (isBusy) return + devices.clear() + phase.value = DfuPhase.SCANNING + status.value = "Scanning…" + log("scanning ${SCAN_TIMEOUT_MS / 1000}s…") + scanner.start( + timeoutMs = SCAN_TIMEOUT_MS, + onResult = { found -> + val index = devices.indexOfFirst { it.address == found.address } + if (index >= 0) devices[index] = found else devices.add(found) + }, + onFinished = { error -> + if (phase.value != DfuPhase.SCANNING) return@start + phase.value = DfuPhase.IDLE + if (error != null) { + status.value = error + log("scan error: $error") + } else { + status.value = "Scan finished — ${devices.size} device(s)" + log("scan finished: ${devices.size} named device(s), " + + "${scanner.advertisementsSeen} advertisement(s) seen") + if (scanner.advertisementsSeen == 0) { + log("no advertisements at all — check the Nearby devices permission") + } + } + }, + ) + } + + /** Non-null when something outside the app prevents scanning (adapter off, location off). */ + fun bleBlockingCondition(): String? = scanner.blockingCondition() + + fun stopScan() { + scanner.stop() + if (phase.value == DfuPhase.SCANNING) { + phase.value = DfuPhase.IDLE + status.value = "Scan stopped" + } + } + + fun connect(device: ScannedDevice) { + if (isBusy) return + scanner.stop() + closeNus() + + target.value = device + phase.value = DfuPhase.CONNECTING + status.value = "Connecting to ${device.name}…" + log("connecting to ${device.name} (${device.address})") + + nus = NusConnection( + context = appContext, + onReady = { + if (phase.value != DfuPhase.CONNECTING) return@NusConnection + phase.value = DfuPhase.CONNECTED + status.value = "Connected to ${device.name}" + // Drop the scan results: the connected device is shown in the status card, and + // leaving the stale entries would offer a Connect button for other devices while + // a link is already up. + devices.clear() + log("NUS ready") + }, + onNotify = ::onNusNotification, + onDisconnected = ::onNusDisconnected, + onError = { message -> + if (phase.value == DfuPhase.CONNECTING || phase.value == DfuPhase.CONNECTED) { + fail(message) + } else { + log("BLE: $message") + } + }, + ).also { it.connect(device.device) } + } + + fun disconnect() { + closeNus() + if (phase.value == DfuPhase.CONNECTED || phase.value == DfuPhase.CONNECTING) { + phase.value = DfuPhase.IDLE + status.value = "Disconnected" + log("disconnected") + } + } + + // ── DFU ───────────────────────────────────────────────────────────────────────────────── + + fun startDfu() { + val pkg = firmware.value + if (phase.value != DfuPhase.CONNECTED || pkg == null) return + val connection = nus ?: return + + progress.value = 0f + bytesSent.value = 0 + dfuEntryHandled = false + dfuCandidates.clear() + phase.value = DfuPhase.ENTERING_DFU + status.value = "Entering DFU mode…" + log("sending mdf? (enter DFU / SMP advertising)") + + if (!connection.send(NusPacket.enterDfu())) { + fail("mdf? write failed") + return + } + + // The device answers rdf: and then drops the link on its own. Either event moves us on; + // the timeout covers the case where the disconnect beats the notification. + val timeout = Runnable { + if (dfuEntryHandled) return@Runnable + log("no rdf: response within ${RDF_TIMEOUT_MS / 1000}s — continuing anyway") + proceedToDfuScan() + } + rdfTimeout = timeout + handler.postDelayed(timeout, RDF_TIMEOUT_MS) + } + + private fun onNusNotification(packet: ByteArray) { + val tag = NusPacket.tagOf(packet) + if (tag == null) { + log("rx: bad CRC (${packet.size} bytes)") + return + } + if (phase.value == DfuPhase.ENTERING_DFU && tag == "rdf:") { + // Payload is a u16 status: 0x0000 accepted, 0xFFFF already in DFU advertising. + val already = packet.size >= 6 && + packet[4] == 0xFF.toByte() && packet[5] == 0xFF.toByte() + log(if (already) "rdf: device was already in DFU mode" else "rdf: DFU mode accepted") + handler.postDelayed({ proceedToDfuScan() }, 500) + } else { + log("rx: $tag") + } + } + + private fun onNusDisconnected() { + when (phase.value) { + DfuPhase.ENTERING_DFU -> { + log("device disconnected (switching to SMP advertising)") + proceedToDfuScan() + } + + DfuPhase.CONNECTING, DfuPhase.CONNECTED -> fail("device disconnected") + else -> Unit + } + } + + /** Tears down the NUS link and starts looking for the device in DFU mode. Runs once. */ + private fun proceedToDfuScan() { + if (dfuEntryHandled) return + dfuEntryHandled = true + rdfTimeout?.let { handler.removeCallbacks(it) } + rdfTimeout = null + closeNus() + + phase.value = DfuPhase.SEARCHING_DFU + status.value = "Looking for the device in DFU mode…" + handler.postDelayed({ scanForDfuDevice() }, RESCAN_DELAY_MS) + } + + private fun scanForDfuDevice() { + if (phase.value != DfuPhase.SEARCHING_DFU) return + val wanted = target.value + log("scanning for SMP advertising (up to ${DFU_SCAN_TIMEOUT_MS / 1000}s)…") + + scanner.start( + timeoutMs = DFU_SCAN_TIMEOUT_MS, + onResult = { found -> + if (!found.inDfuMode || phase.value != DfuPhase.SEARCHING_DFU) return@start + dfuCandidates[found.address] = found + if (wanted == null || found.name == wanted.name || found.address == wanted.address) { + scanner.stop() + log("found ${found.name} (${found.address}) in DFU mode") + startUpload(found.device) + } + }, + onFinished = { error -> + if (phase.value != DfuPhase.SEARCHING_DFU) return@start + // The device may advertise under a different address after the reset. If exactly + // one SMP device showed up, it is unambiguous — use it. + val onlyCandidate = dfuCandidates.values.singleOrNull() + when { + error != null -> fail(error) + onlyCandidate != null -> { + log("no name match; using the only SMP device found: ${onlyCandidate.name}") + startUpload(onlyCandidate.device) + } + + else -> fail("device not found in DFU mode — is it still advertising?") + } + }, + ) + } + + private fun startUpload(device: BluetoothDevice) { + val pkg = firmware.value ?: run { + fail("firmware not loaded") + return + } + + phase.value = DfuPhase.UPLOADING + status.value = "Uploading…" + log("uploading ${pkg.sizeBytes} bytes over SMP") + + val bleTransport = McuMgrBleTransport(appContext, device).apply { + setLoggingEnabled(true) + } + transport = bleTransport + + val manager = FirmwareUpgradeManager(bleTransport, upgradeCallback).apply { + // TEST_ONLY matches the Go tool: mark the image for test, then reset. TEST_AND_CONFIRM + // is not usable here because the device advertises NUS again after rebooting, so the + // library could never reconnect over SMP to send the confirm. + setMode(FirmwareUpgradeManager.Mode.TEST_ONLY) + } + upgradeManager = manager + + val settings = FirmwareUpgradeManager.Settings.Builder() + .setMemoryAlignment(MEMORY_ALIGNMENT) + .setWindowCapacity(1) + .build() + + try { + manager.start(ImageSet().add(pkg.image), settings) + } catch (e: McuMgrException) { + fail("image rejected: ${e.message}") + } + } + + fun cancelDfu() { + upgradeManager?.takeIf { it.isInProgress }?.cancel() + } + + private val upgradeCallback = object : FirmwareUpgradeCallback { + + override fun onUpgradeStarted(controller: FirmwareUpgradeController?) { + log("SMP upgrade started") + } + + override fun onStateChanged( + prevState: FirmwareUpgradeManager.State?, + newState: FirmwareUpgradeManager.State?, + ) { + log("state: ${newState?.name ?: "?"}") + status.value = when (newState) { + FirmwareUpgradeManager.State.VALIDATE -> "Validating image…" + FirmwareUpgradeManager.State.UPLOAD -> "Uploading…" + FirmwareUpgradeManager.State.TEST -> "Marking image for test…" + FirmwareUpgradeManager.State.RESET -> "Resetting device…" + FirmwareUpgradeManager.State.CONFIRM -> "Confirming image…" + else -> status.value + } + } + + override fun onUploadProgressChanged(sent: Int, imageSize: Int, timestamp: Long) { + bytesSent.value = sent + progress.value = if (imageSize > 0) sent.toFloat() / imageSize else 0f + } + + override fun onUpgradeCompleted() { + progress.value = 1f + phase.value = DfuPhase.COMPLETED + status.value = "Done — device is rebooting into the new image" + log("DFU complete; MCUboot will swap on boot") + releaseTransport() + } + + override fun onUpgradeFailed( + state: FirmwareUpgradeManager.State?, + error: McuMgrException?, + ) { + fail("DFU failed in ${state?.name ?: "?"}: ${error?.message ?: error.toString()}") + } + + override fun onUpgradeCanceled(state: FirmwareUpgradeManager.State?) { + phase.value = DfuPhase.FAILED + status.value = "DFU canceled" + log("DFU canceled in ${state?.name ?: "?"}") + releaseTransport() + } + } + + // ── Lifecycle / helpers ───────────────────────────────────────────────────────────────── + + fun reset() { + scanner.stop() + closeNus() + releaseTransport() + rdfTimeout?.let { handler.removeCallbacks(it) } + rdfTimeout = null + dfuEntryHandled = false + dfuCandidates.clear() + devices.clear() + target.value = null + progress.value = 0f + bytesSent.value = 0 + phase.value = DfuPhase.IDLE + status.value = "Ready" + } + + fun shutdown() { + scanner.stop() + closeNus() + releaseTransport() + rdfTimeout?.let { handler.removeCallbacks(it) } + rdfTimeout = null + } + + private fun closeNus() { + nus?.close() + nus = null + } + + private fun releaseTransport() { + upgradeManager = null + transport?.let { + try { + it.release() + } catch (_: Exception) { + // Already released or never connected. + } + } + transport = null + } + + private fun fail(message: String) { + phase.value = DfuPhase.FAILED + status.value = message + log("FAILED: $message") + scanner.stop() + closeNus() + releaseTransport() + } + + private fun log(message: String) { + logLines.add("${timeFormat.format(Date())} $message") + while (logLines.size > MAX_LOG_LINES) logLines.removeAt(0) + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuNus.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuNus.kt new file mode 100644 index 0000000..f02807d --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/firmware/DfuNus.kt @@ -0,0 +1,77 @@ +/* + * MEDiDFU — VesiSeek firmware updater + * + * VesiSeek BLE identifiers and the NUS command framing shared with the firmware. + * + * Author: Charles KWON OhJun + * MEDiThings Inc. + */ +package com.medithings.vesiscan.firmware +import java.util.UUID + +/** + * VesiSeek BLE identifiers. + * + * During normal operation the device advertises the Nordic UART Service. After the `mdf?` command + * it disconnects and re-advertises with the Zephyr SMP service instead, keeping the same + * advertised name (its serial number). + */ +object VesiSeekUuids { + val NUS_SERVICE: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") + /** host -> device (write) */ + val NUS_WRITE: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") + /** device -> host (notify) */ + val NUS_NOTIFY: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") + val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") + /** Zephyr SMP-over-BLE service, advertised only in DFU mode. */ + val SMP_SERVICE: UUID = UUID.fromString("8D53DC1D-1DB7-4CD3-868B-8A527460AA84") +} + +/** + * CRC-16/CCITT (poly 0x1021, init 0xFFFF) — byte-for-byte identical to the firmware's + * `cmd_crc16_compute` (Nordic SDK `crc16_compute`). + */ +object Crc16 { + fun compute(data: ByteArray): Int { + var crc = 0xFFFF + for (byte in data) { + crc = crc xor ((byte.toInt() and 0xFF) shl 8) + repeat(8) { + crc = if ((crc and 0x8000) != 0) (crc shl 1) xor 0x1021 else crc shl 1 + crc = crc and 0xFFFF + } + } + return crc + } + + /** Appends the CRC little-endian, matching the firmware's packet layout. */ + fun appendTo(data: ByteArray): ByteArray { + val crc = compute(data) + return data + byteArrayOf((crc and 0xFF).toByte(), ((crc shr 8) and 0xFF).toByte()) + } +} + +/** NUS packet framing: `[TAG 4B][DATA N][CRC16 2B little-endian]`. */ +object NusPacket { + + fun build(tag: String, data: ByteArray = ByteArray(0)): ByteArray { + require(tag.length == 4) { "tag must be 4 characters, got '$tag'" } + return Crc16.appendTo(tag.toByteArray(Charsets.US_ASCII) + data) + } + + /** + * Enter-DFU command. The firmware ignores the payload but the command dispatcher needs at + * least one data byte, so a single 0x00 is sent — the same packet the Go host tool uses. + */ + fun enterDfu(): ByteArray = build("mdf?", byteArrayOf(0x00)) + + /** Returns the 4-character response tag, or null if the frame is too short or the CRC fails. */ + fun tagOf(packet: ByteArray): String? { + if (packet.size < 6) return null + val body = packet.copyOfRange(0, packet.size - 2) + val received = (packet[packet.size - 2].toInt() and 0xFF) or + ((packet[packet.size - 1].toInt() and 0xFF) shl 8) + if (Crc16.compute(body) != received) return null + return String(packet, 0, 4, Charsets.US_ASCII) + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/DfuNusConnection.kt b/app/src/main/java/com/medithings/vesiscan/firmware/DfuNusConnection.kt new file mode 100644 index 0000000..9a5f4dc --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/firmware/DfuNusConnection.kt @@ -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 + * MEDiThings Inc. + */ +package com.medithings.vesiscan.firmware +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper + +/** + * GATT client for the VesiSeek NUS command channel. + * + * Mirrors the connection sequence of the VesiScan Basic app: connect, negotiate MTU 247, discover + * services, then enable notifications on the TX characteristic. [onReady] fires once the CCCD + * write completes, at which point [send] may be used. + * + * All callbacks are delivered on the main thread. + */ +@SuppressLint("MissingPermission") +class NusConnection( + context: Context, + private val onReady: () -> Unit, + private val onNotify: (ByteArray) -> Unit, + private val onDisconnected: () -> Unit, + private val onError: (String) -> Unit, +) { + private val appContext = context.applicationContext + private val handler = Handler(Looper.getMainLooper()) + + @Volatile private var gatt: BluetoothGatt? = null + @Volatile private var writeChar: BluetoothGattCharacteristic? = null + @Volatile private var released = false + + val isReady: Boolean get() = writeChar != null + + fun connect(device: BluetoothDevice) { + released = false + gatt = device.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + if (gatt == null) onError("connectGatt() returned null") + } + + fun send(data: ByteArray): Boolean { + val g = gatt ?: return false + val ch = writeChar ?: return false + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(ch, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) == + BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + run { + ch.value = data + ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + g.writeCharacteristic(ch) + } + } + } + + /** + * Tears the connection down. Further GATT callbacks are ignored, so [onDisconnected] will not + * fire for a close initiated here. Safe to call more than once. + */ + fun close() { + released = true + writeChar = null + gatt?.let { g -> + try { + g.disconnect() + g.close() + } catch (_: Exception) { + // Nothing useful to do if the stack already dropped the client. + } + } + gatt = null + } + + private val gattCallback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + if (released) return + handler.post { + if (released) return@post + when { + newState == BluetoothProfile.STATE_CONNECTED && + status == BluetoothGatt.GATT_SUCCESS -> { + // Service discovery continues from onMtuChanged. + if (!g.requestMtu(247)) g.discoverServices() + } + + newState == BluetoothProfile.STATE_DISCONNECTED -> { + writeChar = null + onDisconnected() + } + + else -> onError("GATT error (status $status)") + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + if (released) return + handler.post { if (!released) g.discoverServices() } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + if (released) return + handler.post { + if (released) return@post + if (status != BluetoothGatt.GATT_SUCCESS) { + onError("Service discovery failed (status $status)") + return@post + } + val service = g.getService(VesiSeekUuids.NUS_SERVICE) + if (service == null) { + onError("NUS service not found on this device") + return@post + } + writeChar = service.getCharacteristic(VesiSeekUuids.NUS_WRITE) + val notifyChar = service.getCharacteristic(VesiSeekUuids.NUS_NOTIFY) + if (writeChar == null || notifyChar == null) { + onError("NUS characteristics not found") + return@post + } + g.setCharacteristicNotification(notifyChar, true) + val cccd = notifyChar.getDescriptor(VesiSeekUuids.CCCD) + if (cccd == null) { + // No CCCD to write — notifications are already enabled locally. + onReady() + return@post + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + @Suppress("DEPRECATION") + run { + cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(cccd) + } + } + } + } + + override fun onDescriptorWrite( + g: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + ) { + if (released || descriptor.uuid != VesiSeekUuids.CCCD) return + handler.post { + if (released) return@post + if (status == BluetoothGatt.GATT_SUCCESS) { + onReady() + } else { + onError("Enabling notifications failed (status $status)") + } + } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (released || characteristic.uuid != VesiSeekUuids.NUS_NOTIFY) return + handler.post { if (!released) onNotify(value) } + } + + @Deprecated("Superseded by the API 33 overload") + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return + if (released || characteristic.uuid != VesiSeekUuids.NUS_NOTIFY) return + @Suppress("DEPRECATION") + val value = characteristic.value ?: return + handler.post { if (!released) onNotify(value) } + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt b/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt new file mode 100644 index 0000000..f73e31f --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/firmware/FirmwarePackage.kt @@ -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 + * 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 { + val entries = LinkedHashMap() + ZipInputStream(context.assets.open(assetName)).use { zip -> + val buffer = ByteArray(16 * 1024) + while (true) { + val entry = zip.nextEntry ?: break + if (entry.isDirectory) { + zip.closeEntry() + continue + } + val out = ByteArrayOutputStream() + while (true) { + val read = zip.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + } + entries[entry.name] = out.toByteArray() + zip.closeEntry() + } + } + return entries + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt b/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt new file mode 100644 index 0000000..51a7a5a --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt @@ -0,0 +1,349 @@ +/* + * VesiScan firmware updater — MEDiDFU 이식 (2026-08-03). + * Author: Charles KWON OhJun · adapt: dw.jang + * + * VesiScan 앱 통합: 설정 > 앱정보 > "펌웨어 업데이트" 진입점. + * FirmwareUpdateScreen() = 권한 · manager 라이프사이클 처리하는 host composable. + * DfuScreen() = 원본 UI (scan · connect · upload · log). + */ +package com.medithings.vesiscan.firmware.ui +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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 = 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, 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/BatteryWarningBanner.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/BatteryWarningBanner.kt index f4bb9f1..37cc70a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/components/BatteryWarningBanner.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/BatteryWarningBanner.kt @@ -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), + ) } } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt index fc4fbbc..c39fadf 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b9497f..03c9638 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ securityCrypto = "1.1.0-alpha06" rive = "9.4.2" startupRuntime = "1.2.0" lifecycleViewmodelCompose = "2.10.0" +# 2026-08-03 (cherry-pick from cloud-mvp): Firmware DFU +mcumgr = "2.5.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -20,6 +22,9 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +# 2026-08-03: LocalLifecycleOwner (Compose) 및 mcumgr-ble +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +mcumgr-ble = { group = "no.nordicsemi.android", name = "mcumgr-ble", version.ref = "mcumgr" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }