refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan
사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId 둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요). ## 변경 범위 - Kotlin 148 파일: package + import 문 (714 occurrences) - 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan (main, test, androidTest 각각) - app/build.gradle.kts: namespace, applicationId - docs/FLAVOR_DEMO_STABLE.md: 참조 갱신 - V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix) ## 주의 - applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은 Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨. - Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* BLE 디버그 로거 — 펌웨어 디버깅용 상세 통신 로그.
|
||||
*
|
||||
* 모든 TX/RX 패킷, RSSI, 연결 상태, 에러를 기록하고
|
||||
* 파일로 내보내기 가능.
|
||||
*
|
||||
* 파일 위치: vesiscan_ble_logs/YYYY-MM-DD_HHmmss.log
|
||||
*/
|
||||
class BleDebugLogger private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: BleDebugLogger? = null
|
||||
fun getInstance(context: Context) = instance ?: synchronized(this) {
|
||||
instance ?: BleDebugLogger(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val MAX_MEMORY_ENTRIES = 2000
|
||||
}
|
||||
|
||||
enum class Level { TX, RX, INFO, WARN, ERROR, RSSI, CONN }
|
||||
|
||||
data class Entry(
|
||||
val timestamp: Long,
|
||||
val level: Level,
|
||||
val message: String,
|
||||
val dataSize: Int = 0,
|
||||
val rssi: Int? = null
|
||||
) {
|
||||
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
val timeStr: String get() = fmt.format(Date(timestamp))
|
||||
|
||||
fun toLogLine(): String {
|
||||
val tag = level.name.padEnd(5)
|
||||
val extra = when {
|
||||
dataSize > 0 -> " [${dataSize}B]"
|
||||
rssi != null -> " [${rssi}dBm]"
|
||||
else -> ""
|
||||
}
|
||||
return "$timeStr $tag $message$extra"
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("t", timestamp)
|
||||
put("level", level.name)
|
||||
put("msg", message)
|
||||
if (dataSize > 0) put("bytes", dataSize)
|
||||
if (rssi != null) put("rssi", rssi)
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory ring buffer
|
||||
private val entries = CopyOnWriteArrayList<Entry>()
|
||||
|
||||
// Session tracking
|
||||
private var sessionStart: Long = System.currentTimeMillis()
|
||||
var deviceName: String = ""
|
||||
private set
|
||||
private var txCount = 0
|
||||
private var rxCount = 0
|
||||
private var errorCount = 0
|
||||
private var lastRssi: Int = 0
|
||||
private val rssiHistory = mutableListOf<Pair<Long, Int>>()
|
||||
private var packetTimeouts = 0
|
||||
private var channelMissCount = 0
|
||||
|
||||
// File
|
||||
private val logDir = File(context.filesDir, "vesiscan_ble_logs").also { it.mkdirs() }
|
||||
private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
|
||||
// 실시간 파일 기록 (화면 잠금/앱 종료에도 보존)
|
||||
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
|
||||
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
|
||||
private var liveLogFile: File? = null
|
||||
private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지
|
||||
private fun ensureLiveLogFile(): File {
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
val currentLabel = ClinicalSessionStore.currentLabel()
|
||||
// 측정 단위가 바뀌었으면 새 파일로 전환
|
||||
if (liveLogFile == null || liveLogLabel != currentLabel) {
|
||||
liveLogFile = File(clinicalDir, "ble.log")
|
||||
liveLogLabel = currentLabel
|
||||
}
|
||||
} else if (liveLogFile == null || liveLogLabel.isNotEmpty()) {
|
||||
// 일반 모드 진입 또는 임상 모드 종료
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
||||
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
||||
liveLogLabel = ""
|
||||
}
|
||||
return liveLogFile!!
|
||||
}
|
||||
private fun appendToFile(line: String) {
|
||||
try {
|
||||
ensureLiveLogFile().appendText(line + "\n")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// ── Logging Methods ──
|
||||
|
||||
fun tx(command: String, dataSize: Int = 0) {
|
||||
txCount++
|
||||
add(Entry(System.currentTimeMillis(), Level.TX, command, dataSize))
|
||||
}
|
||||
|
||||
fun rx(prefix: String, dataSize: Int = 0, detail: String = "") {
|
||||
rxCount++
|
||||
val msg = if (detail.isNotEmpty()) "$prefix $detail" else prefix
|
||||
add(Entry(System.currentTimeMillis(), Level.RX, msg, dataSize))
|
||||
}
|
||||
|
||||
fun rssi(value: Int) {
|
||||
lastRssi = value
|
||||
rssiHistory.add(System.currentTimeMillis() to value)
|
||||
if (rssiHistory.size > 500) rssiHistory.removeAt(0)
|
||||
add(Entry(System.currentTimeMillis(), Level.RSSI, "RSSI=$value dBm", rssi = value))
|
||||
}
|
||||
|
||||
/** mid?/rid: 응답 받은 후 호출 — 펌웨어 정보를 ble.log에 보기 좋게 헤더 형태로 기록 */
|
||||
fun deviceInfo(fw: String, hw: String, sn: String) {
|
||||
appendToFile("─── Device Info ───")
|
||||
if (fw.isNotEmpty()) appendToFile("Firmware: $fw")
|
||||
if (hw.isNotEmpty()) appendToFile("Hardware: $hw")
|
||||
if (sn.isNotEmpty()) appendToFile("Serial: $sn")
|
||||
appendToFile("───────────────────")
|
||||
}
|
||||
|
||||
fun connected(name: String, address: String) {
|
||||
deviceName = name
|
||||
sessionStart = System.currentTimeMillis()
|
||||
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
|
||||
rssiHistory.clear()
|
||||
liveLogFile = null // 새 세션 → 새 파일
|
||||
appendToFile("=== VesiScan BLE Debug Log ===")
|
||||
appendToFile("Device: $name")
|
||||
appendToFile("Session: ${dateFmt.format(Date())}")
|
||||
appendToFile("============================================================")
|
||||
add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)"))
|
||||
}
|
||||
|
||||
fun disconnected(reason: String = "", gattStatus: Int = -1) {
|
||||
val msg = buildString {
|
||||
append("DISCONNECTED")
|
||||
if (reason.isNotEmpty()) append(" reason=$reason")
|
||||
if (gattStatus >= 0) append(" gatt_status=$gattStatus")
|
||||
append(" session=${sessionDurationSec()}s tx=$txCount rx=$rxCount err=$errorCount")
|
||||
}
|
||||
add(Entry(System.currentTimeMillis(), Level.CONN, msg))
|
||||
}
|
||||
|
||||
fun reconnecting(attempt: Int, maxAttempt: Int, delayMs: Long) {
|
||||
add(Entry(System.currentTimeMillis(), Level.CONN, "RECONNECTING attempt=$attempt/$maxAttempt delay=${delayMs}ms"))
|
||||
}
|
||||
|
||||
fun reconnectFailed() {
|
||||
errorCount++
|
||||
add(Entry(System.currentTimeMillis(), Level.ERROR, "RECONNECT_FAILED after all attempts"))
|
||||
}
|
||||
|
||||
fun info(message: String) {
|
||||
add(Entry(System.currentTimeMillis(), Level.INFO, message))
|
||||
}
|
||||
|
||||
fun warn(message: String) {
|
||||
add(Entry(System.currentTimeMillis(), Level.WARN, message))
|
||||
}
|
||||
|
||||
fun error(message: String) {
|
||||
errorCount++
|
||||
add(Entry(System.currentTimeMillis(), Level.ERROR, message))
|
||||
}
|
||||
|
||||
fun packetTimeout(channel: Int, retryCount: Int) {
|
||||
packetTimeouts++
|
||||
error("PACKET_TIMEOUT ch=$channel retry=$retryCount")
|
||||
}
|
||||
|
||||
fun channelMissing(expectedChannels: Int, receivedChannels: Int) {
|
||||
channelMissCount++
|
||||
warn("CHANNEL_MISS expected=$expectedChannels received=$receivedChannels")
|
||||
}
|
||||
|
||||
fun endianDetected(isBigEndian: Boolean, firstSampleLE: Int, firstSampleBE: Int) {
|
||||
info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)")
|
||||
}
|
||||
|
||||
fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>, otsuThreshold: Double = 0.0) {
|
||||
val scanId = AdcCsvLogger.lastScanId
|
||||
val msg = buildString {
|
||||
append("MEASUREMENT scan=$scanId")
|
||||
append(" vol=${volumeMl?.let { "%.1f".format(it) } ?: "null"}ml")
|
||||
append(" lr=${"%.2f".format(lrRatio)}")
|
||||
append(" valid=$validChannels/6")
|
||||
append(" urine=[${urineLines.joinToString(",")}]")
|
||||
if (otsuThreshold > 0) append(" otsu=${"%.0f".format(otsuThreshold)}")
|
||||
}
|
||||
add(Entry(System.currentTimeMillis(), Level.INFO, msg))
|
||||
}
|
||||
|
||||
// ── Access ──
|
||||
|
||||
private fun add(entry: Entry) {
|
||||
entries.add(entry)
|
||||
if (entries.size > MAX_MEMORY_ENTRIES) {
|
||||
entries.removeAt(0)
|
||||
}
|
||||
appendToFile(entry.toLogLine())
|
||||
}
|
||||
|
||||
fun getEntries(): List<Entry> = entries.toList()
|
||||
|
||||
fun getRecentEntries(count: Int = 100): List<Entry> {
|
||||
val all = entries.toList()
|
||||
return if (all.size > count) all.subList(all.size - count, all.size) else all
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
}
|
||||
|
||||
// ── Stats ──
|
||||
|
||||
private fun sessionDurationSec(): Long = (System.currentTimeMillis() - sessionStart) / 1000
|
||||
|
||||
fun getStats(): JSONObject = JSONObject().apply {
|
||||
put("session_duration_sec", sessionDurationSec())
|
||||
put("device", deviceName)
|
||||
put("tx_count", txCount)
|
||||
put("rx_count", rxCount)
|
||||
put("error_count", errorCount)
|
||||
put("packet_timeouts", packetTimeouts)
|
||||
put("channel_misses", channelMissCount)
|
||||
put("last_rssi", lastRssi)
|
||||
if (rssiHistory.isNotEmpty()) {
|
||||
put("avg_rssi", rssiHistory.map { it.second }.average().toInt())
|
||||
put("min_rssi", rssiHistory.minOf { it.second })
|
||||
put("max_rssi", rssiHistory.maxOf { it.second })
|
||||
}
|
||||
}
|
||||
|
||||
// ── File Export ──
|
||||
|
||||
private fun writeLogContent(writer: java.io.BufferedWriter) {
|
||||
writer.write("=== VesiScan BLE Debug Log ===\n")
|
||||
writer.write("Device: $deviceName\n")
|
||||
writer.write("Session: ${dateFmt.format(Date(sessionStart))}\n")
|
||||
writer.write("Duration: ${sessionDurationSec()}s\n")
|
||||
writer.write("TX: $txCount RX: $rxCount Errors: $errorCount Timeouts: $packetTimeouts\n")
|
||||
|
||||
if (rssiHistory.isNotEmpty()) {
|
||||
writer.write("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} ")
|
||||
writer.write("min=${rssiHistory.minOf { it.second }} max=${rssiHistory.maxOf { it.second }}\n")
|
||||
}
|
||||
|
||||
writer.write("=".repeat(60) + "\n\n")
|
||||
|
||||
for (entry in entries) {
|
||||
writer.write(entry.toLogLine() + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
fun exportToFile(): File {
|
||||
val fileName = "ble_debug_${dateFmt.format(Date())}.log"
|
||||
val file = File(logDir, fileName)
|
||||
file.bufferedWriter().use { writeLogContent(it) }
|
||||
return file
|
||||
}
|
||||
|
||||
/** Downloads 폴더에 저장 (파일 탐색기에서 바로 접근 가능) */
|
||||
fun exportToDownloads(): File? {
|
||||
return try {
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS
|
||||
)
|
||||
val fileName = "VesiScan_BLE_${dateFmt.format(Date())}.log"
|
||||
val file = File(downloadsDir, fileName)
|
||||
file.bufferedWriter().use { writeLogContent(it) }
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun exportToJsonFile(): File {
|
||||
val fileName = "ble_debug_${dateFmt.format(Date())}.jsonl"
|
||||
val file = File(logDir, fileName)
|
||||
|
||||
file.bufferedWriter().use { writer ->
|
||||
val header = JSONObject().apply {
|
||||
put("type", "header")
|
||||
put("device", deviceName)
|
||||
put("session_start", sessionStart)
|
||||
put("stats", getStats())
|
||||
}
|
||||
writer.write(header.toString() + "\n")
|
||||
|
||||
for (entry in entries) {
|
||||
writer.write(entry.toJson().toString() + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
fun getShareIntent(): Intent {
|
||||
val file = exportToFile()
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
return Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "VesiScan BLE Debug Log — $deviceName")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLogText(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("=== VesiScan BLE Debug ===")
|
||||
sb.appendLine("Device: $deviceName | TX:$txCount RX:$rxCount Err:$errorCount Timeout:$packetTimeouts")
|
||||
if (rssiHistory.isNotEmpty()) {
|
||||
sb.appendLine("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} min=${rssiHistory.minOf { it.second }}")
|
||||
}
|
||||
sb.appendLine("---")
|
||||
for (entry in entries.takeLast(200)) {
|
||||
sb.appendLine(entry.toLogLine())
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** 7일 이상 된 로그 삭제 */
|
||||
fun purgeOldLogs(keepDays: Int = 7) {
|
||||
val cutoff = System.currentTimeMillis() - keepDays * 24 * 60 * 60 * 1000L
|
||||
logDir.listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user