package com.medithings.vesiscan.services import android.os.Environment import java.io.File import java.text.SimpleDateFormat import java.util.* /** * ADC 데이터를 세션별 CSV 파일로 저장. * * 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD_HHmmss.csv * newSession() 호출 시 새 파일로 전환 (BLE 연결 시 자동, 수동 호출 가능) */ object AdcCsvLogger { private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) var lastScanId: Int = 0 private set private var scanCounter = 0 private var sessionFile: File? = null private val counterLock = Any() /** * 다음 scan_id 를 미리 발급 · scanCounter 증가. * 2026-08-04: LOG_STORAGE_ANALYSIS §A off-by-one 수정 · 호출자가 미리 id 를 * 확보한 뒤 measurementResult() · logMeasurement() · log() 셋 다 동일 id 로 * 기록해 4개 로그 파일이 정합하게 조인됨. */ fun nextScanId(): Int { synchronized(counterLock) { scanCounter++ lastScanId = scanCounter return scanCounter } } private fun getDir(): File { val dir = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "VesiScan_ADC" ) if (!dir.exists()) dir.mkdirs() return dir } fun newSession() { scanCounter = 0 lastScanId = 0 sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv") } /** * 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용. * 일반 모드: 기존 세션 파일 사용. */ private fun getFile(): File { val clinicalDir = ClinicalSessionStore.currentLogDir() if (clinicalDir != null) { clinicalDir.mkdirs() return File(clinicalDir, "adc.csv") } if (sessionFile == null) newSession() return sessionFile!! } /** * ADC CSV 기록. * @param scanId 호출자가 nextScanId() 로 미리 발급한 id. null 이면 auto-increment (legacy). */ fun log( channels: List>, volumeMl: Double? = null, lrRatio: Double = 1.0, scanId: Int? = null, ) { try { val file = getFile() val isNew = !file.exists() || file.length() == 0L val id = scanId ?: nextScanId() // legacy: scanCounter 는 nextScanId 안에서만 증가 · id 미제공 시에도 동일 경로. scanCounter = id lastScanId = id val timestamp = timeFmt.format(Date()) val volStr = volumeMl?.let { "%.1f".format(it) } ?: "" val lrStr = "%.2f".format(lrRatio) val analyzer = com.medithings.vesiscan.managers.PiezoEchoAnalyzer.shared val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0) "%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.medithings.vesiscan.managers.GreenZoneConstants.lowEchoAmp) val dpsStr = "%.3f".format(com.medithings.vesiscan.managers.PiezoHW.distancePerSample) val sgStr = if (analyzer.useSgFilter) "ON" else "OFF" val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 "" // BLE 펌웨어 버전 (연결 안 됐으면 "") val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: "" java.io.FileWriter(file, !isNew).buffered().use { writer -> if (isNew) { val header = buildString { append("scan_id,timestamp,clinical_label,firmware_version,volume_ml,lr_ratio,threshold,dps,sg_filter,channel") for (i in 0 until 100) append(",s$i") } writer.write(header) writer.newLine() } for ((chIdx, buffer) in channels.withIndex()) { val row = buildString { append("$id,$timestamp,$clinicalLabel,$fwVersion,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx") for (sample in buffer) { append(",${sample.toInt()}") } for (i in buffer.size until 100) { append(",") } } writer.write(row) writer.newLine() } } } catch (_: Exception) {} } /** * VesiScan_ADC/ 폴더의 keepDays 일 이전 CSV 삭제 (§C purge). * 2026-08-04: LOG_STORAGE_ANALYSIS Priority 3 — 시간당 40MB 누적 방지. */ fun purgeOldSessions(keepDays: Int = 30) { try { val calendar = Calendar.getInstance() calendar.add(Calendar.DAY_OF_YEAR, -keepDays) val cutoff = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(calendar.time) val dir = getDir() dir.listFiles { f -> f.extension == "csv" }?.forEach { file -> val prefix = file.nameWithoutExtension.take(10) if (prefix < cutoff) file.delete() } } catch (_: Exception) { } } }