feat(demo-final): cherry-pick — Firmware DFU + Battery dismiss + AppState fix (cloud-mvp 이식)

feature/cloud-mvp 에서 최근 만들어진 3가지 사용자용 기능을 demo-final 에도 적용.
demo-final 옛 폴더 구조 유지 (folder reorg 안 됨) · 각 파일 위치 수동 매핑.

이식 대상:

1. Firmware DFU (cloud-mvp 672e679):
   - firmware/ 폴더 신규 · MEDiDFU 6 파일 이식 (DfuManager 459 line 등)
   - assets/dfu_application_2+35.zip (243 KB · signed MCUboot)
   - libs.versions.toml: mcumgr = "2.5.0" (Kotlin 2.0 호환)
   - build.gradle.kts: mcumgr-ble + lifecycle-runtime-compose 추가
   - AppScreen.FIRMWARE_UPDATE enum 추가
   - MainActivity: 라우팅 + 뒤로가기 (→ HOME)
   - HomeView: "펌웨어 업데이트 (프로브 firmware)" TextButton 추가 (Dev clinical 버튼 밑)

2. BatteryWarningBanner tap-to-dismiss (cloud-mvp aa6cc1f):
   - clickable { dismissed = true } + 우측 X 아이콘
   - WARNING → CRITICAL 악화 시 자동 재표시
   - 재연결 시 dismissed 초기화

3. AppState.isDeviceConnected getter (cloud-mvp 9c82a50):
   - mutableStateOf → getter 로 변경 (BleManager.isConnected.value 실시간)
   - Compose 자동 재구성 · setter 는 no-op 로 호환 유지
   - 홈화면 "연결됨" · Start 후 "연결해제" 불일치 해결

주요 파일 매핑 (feature/cloud-mvp → demo-final):
- core/AppState.kt         → AppState.kt (root)
- core/MainActivity.kt     → MainActivity.kt (root)
- common/ui/components/BatteryWarningBanner.kt → ui/components/BatteryWarningBanner.kt
- firmware/**              → firmware/** (신규 · 동일)
- shell/ui/SettingsTabView.kt (진입점 원본) → HomeView 로 대체 (demo 는 SettingsTab 없음)

검증: BUILD SUCCESSFUL (demoDebug 1m 56s) · APK install PASS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 13:29:18 +09:00
parent 2e0ca889d9
commit 984fe2c939
13 changed files with 1416 additions and 6 deletions
@@ -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)
}
}