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:
@@ -66,6 +66,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
@@ -88,6 +89,7 @@ dependencies {
|
|||||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||||
implementation("androidx.camera:camera-view:1.3.4")
|
implementation("androidx.camera:camera-view:1.3.4")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API
|
implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API
|
||||||
|
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
Binary file not shown.
@@ -17,7 +17,8 @@ enum class AppScreen {
|
|||||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||||
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
||||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
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) {
|
class AppState(private val context: Context) {
|
||||||
@@ -25,7 +26,19 @@ class AppState(private val context: Context) {
|
|||||||
var user by mutableStateOf(User())
|
var user by mutableStateOf(User())
|
||||||
var sensorMode by mutableStateOf(SensorMode.PIEZO)
|
var sensorMode by mutableStateOf(SensorMode.PIEZO)
|
||||||
var bladderLevel by mutableStateOf(BladderLevel())
|
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 isDemoMode by mutableStateOf(false)
|
||||||
var isDevMode by mutableStateOf(false)
|
var isDevMode by mutableStateOf(false)
|
||||||
var piezoSettings by mutableStateOf(PiezoSettings())
|
var piezoSettings by mutableStateOf(PiezoSettings())
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.URINE_CAMERA -> 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.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
|
||||||
AppScreen.VIVAMYO_MONITORING -> 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_HOME -> ClinicalHomeView(appState)
|
||||||
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(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
|
package com.medithings.vesiscan.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.BatteryAlert
|
import androidx.compose.material.icons.filled.BatteryAlert
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -32,16 +39,27 @@ import com.medithings.vesiscan.ble.BleManager
|
|||||||
* - 15% 이하 → 주황 경고 ("기기 배터리 부족 — 곧 충전하세요")
|
* - 15% 이하 → 주황 경고 ("기기 배터리 부족 — 곧 충전하세요")
|
||||||
* - 5% 이하 → 빨강 위급 ("기기 배터리 위급 — 측정이 갑자기 중단될 수 있음")
|
* - 5% 이하 → 빨강 위급 ("기기 배터리 위급 — 측정이 갑자기 중단될 수 있음")
|
||||||
* - 그 외 / 미연결 / 0% (아직 미수신) → 숨김
|
* - 그 외 / 미연결 / 0% (아직 미수신) → 숨김
|
||||||
|
*
|
||||||
|
* 상호작용 (2026-08-03 요청 · cloud-mvp aa6cc1f 이식):
|
||||||
|
* - 배너 탭 또는 X 아이콘 → 사용자가 dismiss 가능 (숨김)
|
||||||
|
* - WARNING → CRITICAL 악화 시 자동 재표시 (놓치면 위험)
|
||||||
|
* - 재연결 시 dismiss 초기화 (새 세션에는 다시 표시)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
|
fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
|
||||||
val isConnected by bleManager.isConnected
|
val isConnected by bleManager.isConnected
|
||||||
val batt by bleManager.batteryLevel
|
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 (!isConnected) return
|
||||||
if (batt <= 0) return // 응답 받기 전엔 0 → false positive 방지
|
if (batt <= 0) return // 응답 받기 전엔 0 → false positive 방지
|
||||||
if (batt > LOW_BATTERY_THRESHOLD) return
|
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 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 title = if (critical) stringResource(R.string.battery_critical_title) else stringResource(R.string.battery_low_title)
|
||||||
val subtitle = if (critical)
|
val subtitle = if (critical)
|
||||||
@@ -55,6 +73,7 @@ fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier)
|
|||||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
|
.clickable { dismissed = true }
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 32.dp)
|
.padding(horizontal = 32.dp)
|
||||||
.padding(bottom = 48.dp)
|
.padding(bottom = 8.dp)
|
||||||
.height(48.dp),
|
.height(48.dp),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
|
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
|
} // Box (top-bar gear overlay) close
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ securityCrypto = "1.1.0-alpha06"
|
|||||||
rive = "9.4.2"
|
rive = "9.4.2"
|
||||||
startupRuntime = "1.2.0"
|
startupRuntime = "1.2.0"
|
||||||
lifecycleViewmodelCompose = "2.10.0"
|
lifecycleViewmodelCompose = "2.10.0"
|
||||||
|
# 2026-08-03 (cherry-pick from cloud-mvp): Firmware DFU
|
||||||
|
mcumgr = "2.5.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
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" }
|
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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
|||||||
Reference in New Issue
Block a user