feat: BLE 디버그 로거 — 펌웨어 디버깅용 상세 통신 로그

BleDebugLogger 신규:
- TX/RX 패킷 기록 (타임스탬프 ms, 데이터 크기)
- RSSI 실시간 추적 (5초마다 readRemoteRssi)
- 연결/끊김/재연결 이벤트 + GATT status code
- 패킷 타임아웃, 채널 누락, Endian 감지 로그
- 측정 결과 (volumeMl, lrRatio, validChannels)
- 파일 내보내기 (.log / .jsonl) + 클립보드 복사
- 세션 통계 (TX/RX/ERR/RSSI avg/min/max)

BleManager 연동:
- sendRaw() → TX 로그
- processReceivedData() → RX 로그
- connected/disconnected/reconnecting/reconnectFailed 이벤트
- onReadRemoteRssi 콜백 → RSSI 기록

PiezoMonitoringView:
- Developer Tools 하단에 BLE Debug Panel 추가
- 2초 자동 갱신, 색상 코딩 (TX=파랑, RX=초록, ERR=빨강, RSSI=노랑, CONN=보라)
- 클립보드 복사 / 파일 저장 / 클리어 버튼

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 11:47:36 +09:00
parent 079774c869
commit f220cca187
3 changed files with 414 additions and 11 deletions
@@ -0,0 +1,287 @@
package com.example.medilightv2android.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)
// ── 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))
}
fun connected(name: String, address: String) {
deviceName = name
sessionStart = System.currentTimeMillis()
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
rssiHistory.clear()
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>) {
val msg = buildString {
append("MEASUREMENT vol=${volumeMl?.let { "%.1f".format(it) } ?: "null"}ml")
append(" lr=${"%.2f".format(lrRatio)}")
append(" valid=$validChannels/6")
append(" urine=[${urineLines.joinToString(",")}]")
}
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)
}
}
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 ──
fun exportToFile(): File {
val fileName = "ble_debug_${dateFmt.format(Date())}.log"
val file = File(logDir, fileName)
file.bufferedWriter().use { writer ->
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")
}
}
return file
}
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() }
}
}