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:
@@ -52,6 +52,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
private var txCharacteristic: BluetoothGattCharacteristic? = null
|
private var txCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
private var rxCharacteristic: BluetoothGattCharacteristic? = null
|
private var rxCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
val debugLogger = com.example.medilightv2android.services.BleDebugLogger.getInstance(context)
|
||||||
private var scanTimer: Runnable? = null
|
private var scanTimer: Runnable? = null
|
||||||
private var batteryTimer: Runnable? = null
|
private var batteryTimer: Runnable? = null
|
||||||
private var connectionTimer: Runnable? = null
|
private var connectionTimer: Runnable? = null
|
||||||
@@ -265,8 +266,9 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
loge { "sendRaw: bluetoothGatt is null!" }
|
loge { "sendRaw: bluetoothGatt is null!" }
|
||||||
return
|
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)" }
|
logd { "sendRaw: $cmdPreview (${data.size} bytes)" }
|
||||||
|
debugLogger.tx("$cmdPreview (${data.size}B)", data.size)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||||
logd { "sendRaw result: $result" }
|
logd { "sendRaw result: $result" }
|
||||||
@@ -329,9 +331,11 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
fun startBatteryPolling() {
|
fun startBatteryPolling() {
|
||||||
batteryTimer?.let { handler.removeCallbacks(it) }
|
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||||
sendBatteryQuery()
|
sendBatteryQuery()
|
||||||
|
bluetoothGatt?.readRemoteRssi()
|
||||||
val poll = object : Runnable {
|
val poll = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
sendBatteryQuery()
|
sendBatteryQuery()
|
||||||
|
bluetoothGatt?.readRemoteRssi()
|
||||||
handler.postDelayed(this, 5000)
|
handler.postDelayed(this, 5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +353,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
private fun scheduleAutoReconnect() {
|
private fun scheduleAutoReconnect() {
|
||||||
if (reconnectAttempt >= maxReconnectAttempts) {
|
if (reconnectAttempt >= maxReconnectAttempts) {
|
||||||
logw { "Max reconnect attempts ($maxReconnectAttempts) reached" }
|
logw { "Max reconnect attempts ($maxReconnectAttempts) reached" }
|
||||||
|
debugLogger.reconnectFailed()
|
||||||
isReconnecting.value = false
|
isReconnecting.value = false
|
||||||
onReconnectionFailed?.invoke()
|
onReconnectionFailed?.invoke()
|
||||||
return
|
return
|
||||||
@@ -356,6 +361,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val delay = reconnectDelays[reconnectAttempt.coerceAtMost(reconnectDelays.size - 1)]
|
val delay = reconnectDelays[reconnectAttempt.coerceAtMost(reconnectDelays.size - 1)]
|
||||||
logd { "Auto-reconnect attempt ${reconnectAttempt + 1}/$maxReconnectAttempts in ${delay}ms" }
|
logd { "Auto-reconnect attempt ${reconnectAttempt + 1}/$maxReconnectAttempts in ${delay}ms" }
|
||||||
|
debugLogger.reconnecting(reconnectAttempt + 1, maxReconnectAttempts, delay)
|
||||||
isReconnecting.value = true
|
isReconnecting.value = true
|
||||||
|
|
||||||
cancelReconnectTimer()
|
cancelReconnectTimer()
|
||||||
@@ -442,6 +448,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
lastConnectedAddress = gatt.device.address
|
lastConnectedAddress = gatt.device.address
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
cancelReconnectTimer()
|
cancelReconnectTimer()
|
||||||
|
debugLogger.connected(connectedDeviceName.value, gatt.device.address)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||||||
@@ -468,6 +475,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
onConnectionStateChanged?.invoke(false)
|
onConnectionStateChanged?.invoke(false)
|
||||||
|
|
||||||
if (!isUserDisconnect && lastConnectedAddress != null) {
|
if (!isUserDisconnect && lastConnectedAddress != null) {
|
||||||
|
debugLogger.disconnected("unexpected", status)
|
||||||
onUnexpectedDisconnect?.invoke()
|
onUnexpectedDisconnect?.invoke()
|
||||||
scheduleAutoReconnect()
|
scheduleAutoReconnect()
|
||||||
}
|
}
|
||||||
@@ -562,19 +570,18 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
handler.post { processReceivedData(data) }
|
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) {
|
private fun processReceivedData(data: ByteArray) {
|
||||||
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||||||
logd { "Received: $prefix (${data.size} bytes), callback=${
|
debugLogger.rx(prefix.trimEnd(':', '!'), data.size)
|
||||||
when {
|
logd { "Received: $prefix (${data.size} bytes)" }
|
||||||
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"
|
|
||||||
}
|
|
||||||
}" }
|
|
||||||
when (prefix) {
|
when (prefix) {
|
||||||
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
||||||
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
-1
@@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.PathEffect
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -743,11 +744,14 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
if (!isPlacementTesting && !isMeasuring) {
|
if (!isPlacementTesting && !isMeasuring) {
|
||||||
isPlacementTesting = true
|
isPlacementTesting = true
|
||||||
measure()
|
measure()
|
||||||
// Reset placement flag when measurement completes
|
|
||||||
handler.postDelayed({ isPlacementTesting = false }, 20_000)
|
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
|
@Composable
|
||||||
private fun InfoCard(title: String, value: String, unit: String, color: Color, modifier: Modifier = Modifier) {
|
private fun InfoCard(title: String, value: String, unit: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
Reference in New Issue
Block a user