diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index afd6a2d..507486e 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -52,6 +52,7 @@ class BleManager private constructor(private val context: Context) { private var txCharacteristic: BluetoothGattCharacteristic? = null private var rxCharacteristic: BluetoothGattCharacteristic? = null private val handler = Handler(Looper.getMainLooper()) + val debugLogger = com.example.medilightv2android.services.BleDebugLogger.getInstance(context) private var scanTimer: Runnable? = null private var batteryTimer: Runnable? = null private var connectionTimer: Runnable? = null @@ -265,8 +266,9 @@ class BleManager private constructor(private val context: Context) { loge { "sendRaw: bluetoothGatt is null!" } return } - val cmdPreview = if (data.size >= 4) String(data, 0, 4, Charsets.US_ASCII) else "?" + val cmdPreview = if (data.size >= 4) String(data, 0, 4, Charsets.US_ASCII).trimEnd('?') else "?" logd { "sendRaw: $cmdPreview (${data.size} bytes)" } + debugLogger.tx("$cmdPreview (${data.size}B)", data.size) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) logd { "sendRaw result: $result" } @@ -329,9 +331,11 @@ class BleManager private constructor(private val context: Context) { fun startBatteryPolling() { batteryTimer?.let { handler.removeCallbacks(it) } sendBatteryQuery() + bluetoothGatt?.readRemoteRssi() val poll = object : Runnable { override fun run() { sendBatteryQuery() + bluetoothGatt?.readRemoteRssi() handler.postDelayed(this, 5000) } } @@ -349,6 +353,7 @@ class BleManager private constructor(private val context: Context) { private fun scheduleAutoReconnect() { if (reconnectAttempt >= maxReconnectAttempts) { logw { "Max reconnect attempts ($maxReconnectAttempts) reached" } + debugLogger.reconnectFailed() isReconnecting.value = false onReconnectionFailed?.invoke() return @@ -356,6 +361,7 @@ class BleManager private constructor(private val context: Context) { val delay = reconnectDelays[reconnectAttempt.coerceAtMost(reconnectDelays.size - 1)] logd { "Auto-reconnect attempt ${reconnectAttempt + 1}/$maxReconnectAttempts in ${delay}ms" } + debugLogger.reconnecting(reconnectAttempt + 1, maxReconnectAttempts, delay) isReconnecting.value = true cancelReconnectTimer() @@ -442,6 +448,7 @@ class BleManager private constructor(private val context: Context) { lastConnectedAddress = gatt.device.address reconnectAttempt = 0 cancelReconnectTimer() + debugLogger.connected(connectedDeviceName.value, gatt.device.address) try { val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE) @@ -468,6 +475,7 @@ class BleManager private constructor(private val context: Context) { onConnectionStateChanged?.invoke(false) if (!isUserDisconnect && lastConnectedAddress != null) { + debugLogger.disconnected("unexpected", status) onUnexpectedDisconnect?.invoke() scheduleAutoReconnect() } @@ -562,19 +570,18 @@ class BleManager private constructor(private val context: Context) { handler.post { processReceivedData(data) } } } + + override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + handler.post { debugLogger.rssi(rssi) } + } + } } private fun processReceivedData(data: ByteArray) { val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII) - logd { "Received: $prefix (${data.size} bytes), callback=${ - when { - prefix.startsWith("rcj") -> "onNirsMcjReceived=${onNirsMcjReceived != null}" - prefix.startsWith("rta") -> "onNirsPowerOnReceived=${onNirsPowerOnReceived != null}" - prefix.startsWith("rqq") || prefix.startsWith("rsh") -> "onNirsSensorActivated=${onNirsSensorActivated != null}" - prefix.startsWith("rag") -> "onNirsMagReceived=${onNirsMagReceived != null}" - else -> "n/a" - } - }" } + debugLogger.rx(prefix.trimEnd(':', '!'), data.size) + logd { "Received: $prefix (${data.size} bytes)" } when (prefix) { "rta:", "rta!" -> onNirsPowerOnReceived?.invoke() "rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke() diff --git a/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt new file mode 100644 index 0000000..b9d4eae --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt @@ -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() + + // 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>() + 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) { + 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 = entries.toList() + + fun getRecentEntries(count: Int = 100): List { + 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() } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 081c416..8783e7e 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalConfiguration 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 @@ -743,11 +744,14 @@ fun PiezoMonitoringView(appState: AppState) { if (!isPlacementTesting && !isMeasuring) { isPlacementTesting = true measure() - // Reset placement flag when measurement completes handler.postDelayed({ isPlacementTesting = false }, 20_000) } } ) + + // BLE Debug Log + Spacer(modifier = Modifier.height(8.dp)) + BleDebugPanel(bleManager = bleManager) } } } @@ -1056,6 +1060,111 @@ private fun WaveformChart( } } +// ── BLE Debug Log Panel ── + +@Composable +private fun BleDebugPanel(bleManager: BleManager) { + val logger = bleManager.debugLogger + val context = LocalContext.current + var refreshTick by remember { mutableIntStateOf(0) } + + // Auto-refresh every 2 seconds + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(2000) + refreshTick++ + } + } + + val entries = remember(refreshTick) { logger.getRecentEntries(50) } + val stats = remember(refreshTick) { logger.getStats() } + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A2E)), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("BLE Debug Log", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF00FF88)) + Spacer(modifier = Modifier.weight(1f)) + + // Copy button + IconButton( + onClick = { + val cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + cm.setPrimaryClip(android.content.ClipData.newPlainText("BLE Log", logger.getLogText())) + android.widget.Toast.makeText(context, "Log copied to clipboard", android.widget.Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.ContentCopy, "Copy", tint = Color.Gray, modifier = Modifier.size(16.dp)) + } + + // Export file button + IconButton( + onClick = { + val file = logger.exportToFile() + android.widget.Toast.makeText(context, "Saved: ${file.name}", android.widget.Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.SaveAlt, "Export", tint = Color.Gray, modifier = Modifier.size(16.dp)) + } + + // Clear button + IconButton( + onClick = { logger.clear(); refreshTick++ }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Delete, "Clear", tint = Color.Red.copy(alpha = 0.6f), modifier = Modifier.size(16.dp)) + } + } + + // Stats bar + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val txC = stats.optInt("tx_count", 0) + val rxC = stats.optInt("rx_count", 0) + val errC = stats.optInt("error_count", 0) + val rssi = stats.optInt("avg_rssi", 0) + Text("TX:$txC", fontSize = 10.sp, color = Color(0xFF64B5F6)) + Text("RX:$rxC", fontSize = 10.sp, color = Color(0xFF81C784)) + Text("ERR:$errC", fontSize = 10.sp, color = if (errC > 0) Color.Red else Color.Gray) + if (rssi != 0) Text("RSSI:${rssi}dBm", fontSize = 10.sp, color = Color(0xFFFFD54F)) + val timeout = stats.optInt("packet_timeouts", 0) + if (timeout > 0) Text("TOUT:$timeout", fontSize = 10.sp, color = Color(0xFFFF8A65)) + } + + // Log entries + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + ) { + for (entry in entries) { + val color = when (entry.level) { + com.example.medilightv2android.services.BleDebugLogger.Level.TX -> Color(0xFF64B5F6) + com.example.medilightv2android.services.BleDebugLogger.Level.RX -> Color(0xFF81C784) + com.example.medilightv2android.services.BleDebugLogger.Level.ERROR -> Color.Red + com.example.medilightv2android.services.BleDebugLogger.Level.WARN -> Color(0xFFFFB74D) + com.example.medilightv2android.services.BleDebugLogger.Level.RSSI -> Color(0xFFFFD54F) + com.example.medilightv2android.services.BleDebugLogger.Level.CONN -> Color(0xFFCE93D8) + com.example.medilightv2android.services.BleDebugLogger.Level.INFO -> Color.White + } + Text( + entry.toLogLine(), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + color = color, + lineHeight = 11.sp + ) + } + } + } + } +} + @Composable private fun InfoCard(title: String, value: String, unit: String, color: Color, modifier: Modifier = Modifier) { Card(