diff --git a/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt b/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt index a1006d5..1195d90 100644 --- a/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt @@ -156,7 +156,7 @@ class BackgroundMeasurementServiceTest { mf4Bytes.copyOfRange(it, it + 4).contentEquals(name.toByteArray(Charsets.US_ASCII)) } val binary = ByteBuffer.wrap(mf4Bytes).order(ByteOrder.LITTLE_ENDIAN) - assertEquals(60L, binary.getLong(block("##CG") + 32)) + assertEquals(60L, binary.getLong(block("##CG") + 80)) val dataBlock = block("##DT") assertEquals(24L + 60 * 16, binary.getLong(dataBlock + 8)) assertEquals(60.0, binary.getDouble(dataBlock + 24 + 59 * 16 + 8), 0.0001) diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt new file mode 100644 index 0000000..7c0ee71 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt @@ -0,0 +1,94 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Density +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class RecordingRibbonButtonUiTest { + @get:Rule val compose = createComposeRule() + + private fun redPixels(image: ImageBitmap): Int { + val pixels = image.toPixelMap() + return (0 until image.width).sumOf { x -> + (0 until image.height).count { y -> + val c = pixels[x, y] + c.red > 0.65f && c.green < 0.35f && c.blue < 0.35f + } + } + } + + @Test fun startUsesRedFileBadgeAndStopUsesDarkSquare() { + compose.setContent { + Row(Modifier.background(Color.White)) { + RecordingRibbonButton("기록 시작", enabled = true, onClick = {}) + RecordingRibbonButton("기록 중지", enabled = true, isStop = true, onClick = {}) + } + } + val start = compose.onNodeWithContentDescription("기록 시작").assertIsEnabled().assertHasClickAction() + val stop = compose.onNodeWithContentDescription("기록 중지").assertIsEnabled().assertHasClickAction() + val density = compose.density.density + // A visible 12dp disc has ~113dp² area; the previous 8dp badge had only ~50dp². + assertTrue("Record dot must retain its enlarged area", redPixels(start.captureToImage()) > 90 * density * density) + assertEquals(0, redPixels(stop.captureToImage())) + val image = stop.captureToImage() + val pixels = image.toPixelMap() + // The square occupies the lower-right corner of the centered 24dp file icon. + val cx = (image.width / 2f + 5f * density).toInt() + val cy = (image.height / 2f + 5f * density).toInt() + for (dx in listOf(-4, 0, 4)) for (dy in listOf(-4, 0, 4)) { + val c = pixels[cx + (dx * density).toInt(), cy + (dy * density).toInt()] + assertTrue("Stop badge must be a filled square", c.red < 0.4f && c.green < 0.4f && c.blue < 0.4f) + } + } + + @Test fun disabledButtonsHaveNoRedBadgeAndCannotInvokeActions() { + var clicks = 0 + compose.setContent { + Row(Modifier.background(Color.White)) { + RecordingRibbonButton("기록 시작", enabled = false, onClick = { clicks++ }) + RecordingRibbonButton("기록 중지", enabled = false, isStop = true, onClick = { clicks++ }) + } + } + for (label in listOf("기록 시작", "기록 중지")) { + val button = compose.onNodeWithContentDescription(label).assertIsNotEnabled() + assertEquals(0, redPixels(button.captureToImage())) + button.performClick() + } + compose.runOnIdle { assertEquals(0, clicks) } + } + + @Test fun recordingStateSwitchKeepsSeparateActionsAndDescriptionsAtLargeFont() { + val recording = mutableStateOf(false) + var starts = 0 + var stops = 0 + compose.setContent { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1.3f)) { + Row(Modifier.background(Color.White)) { + RecordingRibbonButton("신호 기록 시작", enabled = !recording.value, + onClick = { starts++; recording.value = true }) + RecordingRibbonButton("신호 기록 중지", enabled = recording.value, isStop = true, + onClick = { stops++; recording.value = false }) + } + } + } + compose.onNodeWithContentDescription("신호 기록 시작").assertIsDisplayed().assertIsEnabled().performClick() + compose.onNodeWithContentDescription("신호 기록 시작").assertIsNotEnabled() + compose.onNodeWithContentDescription("신호 기록 중지").assertIsDisplayed().assertIsEnabled().performClick() + compose.onNodeWithContentDescription("신호 기록 시작").assertIsEnabled() + compose.onNodeWithContentDescription("신호 기록 중지").assertIsNotEnabled() + compose.runOnIdle { assertEquals(1, starts); assertEquals(1, stops) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt new file mode 100644 index 0000000..60b19ea --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt @@ -0,0 +1,182 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.chart.InterpolationMode +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(InternalTestApi::class) +class RibbonSettingsMenuUiTest { + @get:Rule val compose = createComposeRule() + private val expanded = mutableStateOf(false) + private val formatsEnabled = mutableStateOf(true) + private val mode = mutableStateOf(InterpolationMode.LINEAR) + private val csv = mutableStateOf(true) + private val mf4 = mutableStateOf(false) + private val autostart = mutableStateOf(false) + private val csvMilliseconds = mutableStateOf(false) + private var formatChanges = 0 + + private fun showMenu() { + compose.setContent { + val density = LocalDensity.current.density + CompositionLocalProvider(LocalDensity provides Density(density, 1.3f)) { + // A real parent popup away from (0, 0) catches nested-window coordinate errors. + Box(Modifier.offset(x = 40.dp, y = 80.dp)) { + Button(onClick = { expanded.value = true }) { Text("메뉴") } + RibbonFileMenu(expanded.value, onDismissRequest = { expanded.value = false }) { + RibbonSettingsMenuCategories( + menuExpanded = expanded.value, + graphMode = mode.value, + recordingFormatsEnabled = formatsEnabled.value, + writeCsv = csv.value, writeMf4 = mf4.value, + autostartRecording = autostart.value, recordingFolder = "Data", + onGraphModeChange = { mode.value = it }, + onRecordingFormatsChange = { c, m -> csv.value = c; mf4.value = m; formatChanges++ }, + onAutostartRecordingChange = { autostart.value = it }, + csvWithMilliseconds = csvMilliseconds.value, + onCsvWithMillisecondsChange = { csvMilliseconds.value = it }, + onDismissMenu = { expanded.value = false } + ) + } + } + } + } + compose.onNodeWithText("메뉴").performClick() + } + + private fun screenBounds(node: SemanticsNodeInteraction): Rect { + val semantics = node.fetchSemanticsNode() + val view = (semantics.root as ViewRootForTest).view + var bounds = Rect.Zero + compose.runOnIdle { + val location = IntArray(2) + view.getLocationOnScreen(location) + bounds = semantics.boundsInRoot.translate(Offset(location[0].toFloat(), location[1].toFloat())) + } + return bounds + } + + @Test fun allSubmenusAlignWithTheirCategoryInScreenCoordinates() { + showMenu() + listOf("Graph Type" to "GRAPH", "Measurement Recording" to "RECORDING", "계측과 함께 측정" to "AUTOSTART", "CSV 밀리초" to "CSV_MILLISECONDS") + .forEach { (label, category) -> + compose.onNodeWithText(label).performClick() + val row = screenBounds(compose.onNodeWithTag("RibbonSettingsCategory_$category")) + val submenu = screenBounds(compose.onNodeWithTag("RibbonSettingsSubmenu")) + assertEquals("$label right edge", row.right, submenu.left, 1f) + assertEquals("$label top edge", row.top, submenu.top, 1f) + } + } + + @Test fun bothMenuSurfacesHaveAnOpaqueGrayOutline() { + showMenu() + compose.onNodeWithText("Graph Type").performClick() + listOf("RibbonFileMenu", "RibbonSettingsSubmenu").forEach { tag -> + val pixels = compose.onNodeWithTag(tag).captureToImage().toPixelMap() + val border = pixels[0, pixels.height / 2] + assertEquals("$tag red", RibbonMenuBorderColor.red, border.red, 0.04f) + assertEquals("$tag green", RibbonMenuBorderColor.green, border.green, 0.04f) + assertEquals("$tag blue", RibbonMenuBorderColor.blue, border.blue, 0.04f) + assertEquals("$tag opacity", 1f, border.alpha, 0.01f) + } + } + + @Test fun csvMillisecondsDefaultsFalseAndCanSwitchBothWaysWhileMeasuring() { + formatsEnabled.value = false + showMenu() + compose.onNodeWithText("True").assertDoesNotExist() + compose.onNodeWithText("False").assertDoesNotExist() + compose.onNodeWithText("마이크로초").assertDoesNotExist() + compose.onNodeWithText("CSV 밀리초").assertIsEnabled().performClick() + compose.onNodeWithTag("CsvMilliseconds_false", useUnmergedTree = true).assertIsSelected() + compose.onNodeWithTag("CsvMilliseconds_true", useUnmergedTree = true).assertIsNotSelected() + compose.onNodeWithText("True").performClick() + compose.runOnIdle { assertEquals(true, csvMilliseconds.value) } + compose.onNodeWithText("CSV 밀리초").assertDoesNotExist() + compose.onNodeWithText("메뉴").performClick() + compose.onNodeWithText("CSV 밀리초").performClick() + compose.onNodeWithText("False").performClick() + compose.runOnIdle { assertEquals(false, csvMilliseconds.value) } + } + + @Test fun rootContainsOnlyCategoriesAndGraphSelectionClosesBothMenus() { + showMenu() + listOf("Graph Type", "Measurement Recording", "계측과 함께 측정").forEach { + compose.onNodeWithText(it).assertIsDisplayed() + } + listOf("Linear", "Pulse", "Write CSV", "Write MF4", "On", "Off").forEach { + compose.onNodeWithText(it).assertDoesNotExist() + } + compose.onNodeWithText("Graph Type").performClick() + compose.onNodeWithText("Pulse").assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(InterpolationMode.PWM_STEP, mode.value) } + compose.onNodeWithText("Graph Type").assertDoesNotExist() + compose.onNodeWithText("메뉴").performClick() + compose.onNodeWithText("Pulse").assertDoesNotExist() + } + + @Test fun recordingCheckboxesRemainOpenForMultipleSelections() { + showMenu() + compose.onNodeWithText("Measurement Recording").performClick() + compose.onNodeWithText("Write MF4").performClick() + compose.onNodeWithText("Write CSV").assertIsDisplayed().performClick() + compose.runOnIdle { + assertEquals(false, csv.value); assertEquals(true, mf4.value); assertEquals(2, formatChanges) + } + compose.onNodeWithText("Folder: Data").assertIsDisplayed() + } + + @Test fun recordingIsVisiblyDisabledAndCannotOpenWhileMeasuring() { + formatsEnabled.value = false + showMenu() + compose.onNodeWithText("Measurement Recording").assertIsNotEnabled().performClick() + compose.onNodeWithText("Write CSV").assertDoesNotExist() + compose.onNodeWithText("계측 중 변경 불가").assertDoesNotExist() + val layouts = mutableListOf() + compose.onNodeWithText("Measurement Recording", useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertEquals(ColorTextSecondary.copy(alpha = 0.5f), layouts.single().layoutInput.style.color) + compose.runOnIdle { assertEquals(0, formatChanges) } + } + + @Test fun measurementStartingClosesAlreadyOpenRecordingSubmenu() { + showMenu() + compose.onNodeWithText("Measurement Recording").performClick() + compose.onNodeWithText("Write CSV").assertIsDisplayed() + compose.runOnIdle { formatsEnabled.value = false } + compose.onNodeWithText("Write CSV").assertDoesNotExist() + compose.onNodeWithText("Measurement Recording").assertIsNotEnabled() + compose.runOnIdle { formatsEnabled.value = true } + compose.onNodeWithText("Write CSV").assertDoesNotExist() + } + + @Test fun switchingCategoryReplacesSubmenuAndAutostartSelectionClosesMenu() { + showMenu() + compose.onNodeWithText("Graph Type").performClick() + compose.onNodeWithText("Linear").assertIsDisplayed() + compose.onNodeWithText("계측과 함께 측정").performClick() + compose.onNodeWithText("Linear").assertDoesNotExist() + compose.onNodeWithText("On").assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(true, autostart.value) } + compose.onNodeWithText("계측과 함께 측정").assertDoesNotExist() + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt b/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt index 4ebbc2a..5521e90 100644 --- a/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt +++ b/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt @@ -17,10 +17,11 @@ class XcpDaqStreamDecoder { private val configuredSignals = mutableListOf() private val configuredDataTypes = mutableListOf() private val configuredCompuMethods = mutableListOf() + private val configuredMeasurements = mutableListOf() private var isMsbFirst: Boolean = false sealed interface DecodeResult { - data class Success(val values: Map) : DecodeResult + data class Success(val values: Map, val rawValues: Map = emptyMap()) : DecodeResult data class Failure(val reason: String) : DecodeResult } @@ -38,12 +39,14 @@ class XcpDaqStreamDecoder { configuredSignals.addAll(signals) configuredDataTypes.clear() configuredCompuMethods.clear() + configuredMeasurements.clear() this.isMsbFirst = isMsbFirst val measMap = rawMeasurements.associateBy { it.name.lowercase() } for (sig in signals) { val baseName = sig.name.substringBefore('[').lowercase() val rawMeas = measMap[baseName] + configuredMeasurements += rawMeas val dataType = rawMeas?.a2lDataType ?: sig.a2lDataType ?: when (sig.attr.byteSize) { 1 -> if (sig.attr.isSigned) A2lDataType.SBYTE else A2lDataType.UBYTE 2 -> if (sig.attr.isSigned) A2lDataType.SWORD else A2lDataType.UWORD @@ -72,10 +75,10 @@ class XcpDaqStreamDecoder { * 바이트 입력 경로는 구조 검증/파싱 후 공통 디코더로 위임합니다. */ @Synchronized - fun decodeDaqPayloadResult(payload: ByteArray): DecodeResult { + fun decodeDaqPayloadResult(payload: ByteArray, captureRawValues: Boolean = false): DecodeResult { val stream = DctStreamPayloadParser.parseXcpStream(payload) ?: return DecodeResult.Failure("XCP D0 payload 구조가 올바르지 않습니다.") - return decodeDaqStream(stream) + return decodeDaqStream(stream, captureRawValues) } /** @@ -83,10 +86,10 @@ class XcpDaqStreamDecoder { * ACK 생성과 그래프 디코드가 같은 프레임을 두 번 파싱하지 않도록 한다. */ @Synchronized - fun decodeDaqPayloadResult(stream: DctStreamPayloadParser.XcpStream): DecodeResult = - decodeDaqStream(stream) + fun decodeDaqPayloadResult(stream: DctStreamPayloadParser.XcpStream, captureRawValues: Boolean = false): DecodeResult = + decodeDaqStream(stream, captureRawValues) - private fun decodeDaqStream(stream: DctStreamPayloadParser.XcpStream): DecodeResult { + private fun decodeDaqStream(stream: DctStreamPayloadParser.XcpStream, captureRawValues: Boolean): DecodeResult { if (configuredSignals.isEmpty()) return DecodeResult.Failure("XCP 측정 신호가 설정되지 않았습니다.") if (stream.values.size != configuredSignals.size) { return DecodeResult.Failure( @@ -95,6 +98,7 @@ class XcpDaqStreamDecoder { } val resultMap = HashMap(configuredSignals.size * 2) + val rawMap = if (captureRawValues) HashMap(configuredSignals.size) else null for (index in configuredSignals.indices) { val sig = configuredSignals[index] val expectedType = configuredDataTypes[index] @@ -106,6 +110,11 @@ class XcpDaqStreamDecoder { } val raw = received.dataType.unpackRawValue(received.data, 0, isMsbFirst) + if (rawMap != null) rawMap[sig.name] = if (!received.dataType.isFloat && received.dataType.byteSize == 8) { + // Preserve uint64's exact wire bits instead of the rounded physical Double. + java.nio.ByteBuffer.wrap(received.data).order(if (isMsbFirst) + java.nio.ByteOrder.BIG_ENDIAN else java.nio.ByteOrder.LITTLE_ENDIAN).long + } else raw val compu = configuredCompuMethods[index] val phy = if (compu != null) { compu.calculatePhysicalValue(raw) @@ -118,16 +127,24 @@ class XcpDaqStreamDecoder { resultMap["sig_0_${sig.idx}"] = phy } - return DecodeResult.Success(resultMap) + return DecodeResult.Success(resultMap, rawMap ?: emptyMap()) } @Synchronized fun isConfigured(): Boolean = configuredSignals.isNotEmpty() + /** Snapshot only at recording start; never search the A2L definition set per sample. */ + @Synchronized + fun recordingMetadata(): Map> = + configuredSignals.indices.associate { index -> + configuredSignals[index].name to (configuredMeasurements[index] to configuredCompuMethods[index]) + } + @Synchronized fun clear() { configuredSignals.clear() configuredDataTypes.clear() configuredCompuMethods.clear() + configuredMeasurements.clear() } } diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt index 3e525cc..ab5bd73 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt @@ -39,11 +39,6 @@ class WifiSocketManager( private var readJob: Job? = null private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() private val ingressSequenceCounter = java.util.concurrent.atomic.AtomicLong() - // System.nanoTime() is monotonic but has no epoch. Pair it once with the - // wall clock so graph timestamps stay compatible with the existing epoch - // based x-axis while retaining sub-millisecond ordering. - @Volatile private var timestampBaseNanos: Long = System.nanoTime() - @Volatile private var timestampBaseMillis: Long = System.currentTimeMillis() @Volatile private var wifiDirectRouteProvider: (() -> WifiDirectRoute)? = null private var connectingSocket: Socket? = null @@ -301,13 +296,16 @@ class WifiSocketManager( val sessionId = sessionCounter.incrementAndGet() currentSessionId = sessionId operationalLogger?.connected(sessionId, hostName) - timestampBaseNanos = System.nanoTime() - timestampBaseMillis = System.currentTimeMillis() + // Keep each socket's epoch anchor local so reconnects cannot retime an old worker. + // Pair the monotonic clock with wall time once, retaining sub-millisecond ordering. + val timestampBaseNanos = System.nanoTime() + val timestampBaseMillis = System.currentTimeMillis() packetWriter = OrderedPacketWriter(scope, onFailure = { error -> Log.e("WifiSocketManager", "TCP writer failed: session=$sessionId, host=$hostName", error) }, write = { bytes -> socket.getOutputStream().write(bytes) - operationalLogger?.frame(sessionId, "Send", bytes) + val sentAtMillis = timestampBaseMillis + (System.nanoTime() - timestampBaseNanos) / 1_000_000L + operationalLogger?.frame(sessionId, "Send", bytes, sentAtMillis) }, closeTransport = { try { socket.close() } catch (_: IOException) {} synchronized(this@WifiSocketManager) { diff --git a/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt index e98afa4..f6b6bba 100644 --- a/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt +++ b/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt @@ -25,7 +25,8 @@ class CsvMeasurementLogger( val signalHeaders: List>, // List of (SignalName, Unit) val customFileName: String? = null, private val pcColumns: List? = null, - private val missingValueToken: String = "No value" + private val missingValueToken: String = "No value", + private val withMilliseconds: Boolean = false ) { private var writer: BufferedWriter? = null var outputFile: File? = null @@ -35,7 +36,9 @@ class CsvMeasurementLogger( private var lastFlushNanos = System.nanoTime() private val row = StringBuilder() private val formatter = java.util.Formatter(row, Locale.US) - private val pcTimeFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + // PC WRITE_CSV_THREAD includes the date and defaults to whole seconds. + private val pcTimeFormatter = SimpleDateFormat( + if (withMilliseconds) "yyyy-MM-dd HH:mm:ss.SSS" else "yyyy-MM-dd HH:mm:ss", Locale.US) @Synchronized fun start(): File? { @@ -99,7 +102,8 @@ class CsvMeasurementLogger( row.setLength(0) val line = row.apply { if (pcColumns != null) { - append(pcTimeFormatter.format(Date())) + // Use the same ingress timestamp as MF4, not the later disk-write time. + append(pcTimeFormatter.format(Date((elapsedSec * 1000.0).toLong()))) append(", ") append(sampleCount) } else { diff --git a/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt index 028e2d2..5d7a694 100644 --- a/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt +++ b/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt @@ -4,6 +4,8 @@ import java.io.BufferedOutputStream import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater import java.nio.ByteBuffer import java.nio.ByteOrder import java.text.SimpleDateFormat @@ -12,12 +14,14 @@ import java.util.Locale /** * ASAM MDF v4.10 (MF4) 실시간 계측 데이터 바이너리 스트리밍 로거 - * Vector CANape, ETAS INCA, MATLAB, Python asammdf 표준 호환 + * PC 채널 정의가 있으면 DLL 기준 raw layout/CC/SI/FH와 전치 zlib 압축을 사용한다. + * 기존 name/unit 전용 호출은 물리값 Double/DT 형식을 유지한다(호환 API). */ class Mf4MeasurementLogger( val outputDir: File, val signalHeaders: List>, // List of (SignalName, Unit) - val customFileName: String? = null + val customFileName: String? = null, + private val pcChannels: List? = null ) { private var outputStream: BufferedOutputStream? = null var outputFile: File? = null @@ -26,24 +30,44 @@ class Mf4MeasurementLogger( private var recordCount: Long = 0L private var cgRecordCountOffset: Long = 0L private var dtBlockOffset: Long = 0L - private val recordSize = 8 + (signalHeaders.size * 8) // 8 bytes time + 8 bytes per signal (Double) + private var recordingStartedAtSec: Double = 0.0 + private var recordingStartedAtMillis: Long = 0L + private var headerWritten = false + private val definitions = pcChannels ?: signalHeaders.map { PcMf4Channel(it.first, it.second) } + val rawValueKeys: List = definitions.map { it.valueKey } + val physicalInputColumns: List = definitions.map { it.physicalInput } + private val recordSize = 8 + definitions.sumOf { it.byteSize } private val sampleBuffer = ByteBuffer.allocate(recordSize).order(ByteOrder.LITTLE_ENDIAN) + // AddGroup64's A2L buffer is 10,000 records; also bound memory for wide DBC files. + private val recordsPerChunk = minOf(10_000, (4 * 1024 * 1024 / recordSize).coerceAtLeast(1)) + private val pendingRecords = ByteArrayOutputStream() + private var pendingCount = 0 + private val dataLists = mutableListOf() + private var filePosition = 0L + private val lastRawValues = arrayOfNulls(definitions.size) + private var writeFailed = false private var lastFlushNanos = System.nanoTime() private data class ChannelMeta( val name: String, val unit: String, val byteOffset: Int, - val isMaster: Boolean + val isMaster: Boolean, + val definition: PcMf4Channel ) - private fun createTxBlock(text: String): ByteArray { + private fun block(id: String, links: Int, dataBytes: Int): ByteBuffer = + ByteBuffer.allocate(24 + links * 8 + dataBytes).order(ByteOrder.LITTLE_ENDIAN).apply { + put(id.toByteArray(Charsets.US_ASCII)); putInt(0); putLong(capacity().toLong()); putLong(links.toLong()) + } + + private fun createTxBlock(text: String, id: String = "##TX"): ByteArray { val textBytes = text.toByteArray(Charsets.UTF_8) val contentLen = textBytes.size + 1 // null-terminated val paddedContentLen = ((contentLen + 7) / 8) * 8 val blockLen = 24 + paddedContentLen val buf = ByteBuffer.allocate(blockLen).order(ByteOrder.LITTLE_ENDIAN) - buf.put("##TX".toByteArray(Charsets.US_ASCII)) // 0..3 + buf.put(id.toByteArray(Charsets.US_ASCII)) // 0..3 buf.putInt(0) // 4..7 reserved buf.putLong(blockLen.toLong()) // 8..15 block length buf.putLong(0L) // 16..23 link_count = 0 @@ -53,7 +77,13 @@ class Mf4MeasurementLogger( } @Synchronized - fun start(): File? { + fun start(startedAtMillis: Long = System.currentTimeMillis(), + initialRawValues: Map = emptyMap(), + initialPhysicalValues: Map = emptyMap()): File? { + if (outputStream != null) return outputFile + var openingStream: BufferedOutputStream? = null + headerWritten = false + writeFailed = false try { if (!outputDir.exists()) { outputDir.mkdirs() @@ -67,29 +97,41 @@ class Mf4MeasurementLogger( val fos = FileOutputStream(file) val bos = BufferedOutputStream(fos, 64 * 1024) + openingStream = bos + recordingStartedAtSec = startedAtMillis / 1000.0 + recordingStartedAtMillis = startedAtMillis + pendingRecords.reset(); pendingCount = 0; dataLists.clear() + lastRawValues.fill(null) + definitions.forEachIndexed { index, channel -> + lastRawValues[index] = if (channel.physicalInput) initialPhysicalValues[channel.valueKey] + else initialRawValues[channel.valueKey] + } + require(definitions.size == signalHeaders.size) // 채널 메타데이터 정의 (Time 마스터 채널 + 신호 채널들) val channels = mutableListOf() - channels.add(ChannelMeta(name = "Time", unit = "s", byteOffset = 0, isMaster = true)) - for ((i, header) in signalHeaders.withIndex()) { + channels.add(ChannelMeta(name = "Time", unit = "s", byteOffset = 0, isMaster = true, + definition = PcMf4Channel("Time", "s"))) + var byteOffset = 8 + for (definition in definitions) { channels.add( ChannelMeta( - name = header.first, - unit = header.second, - byteOffset = 8 + (i * 8), - isMaster = false + name = definition.name, + unit = definition.unit, + byteOffset = byteOffset, + isMaster = false, + definition = definition ) ) + byteOffset += definition.byteSize } // 고정 블록 오프셋 정의 (ASAM MDF 4.10) - val idBlockOffset = 0L - val hdBlockOffset = 64L val dgBlockOffset = 168L // 64 + 104 val cgBlockOffset = 232L // 168 + 64 - val startOfChannelsOffset = 296L // 232 + 64 + val startOfChannelsOffset = 336L // 232 + 104 - cgRecordCountOffset = cgBlockOffset + 32L // CG 블록 내 32바이트 위치 (cycle_count) + cgRecordCountOffset = cgBlockOffset + 80L // common header + 6 links + record_id // 각 채널(CNBLOCK) 및 문자열(TXBLOCK) 오프셋 사전 계산 var offsetCursor = startOfChannelsOffset @@ -98,10 +140,14 @@ class Mf4MeasurementLogger( val unitTxOffsets = LongArray(channels.size) val nameTxBytes = Array(channels.size) { ByteArray(0) } val unitTxBytes = Array(channels.size) { ByteArray(0) } + val extras = Array(channels.size) { mutableListOf() } + val commentOffsets = LongArray(channels.size) + val sourceOffsets = LongArray(channels.size) + val conversionOffsets = LongArray(channels.size) for (i in channels.indices) { cnOffsets[i] = offsetCursor - offsetCursor += 128L // CNBlock 크기 (128B) + offsetCursor += 160L // 24B common header + 8 links + 72B data nameTxBytes[i] = createTxBlock(channels[i].name) nameTxOffsets[i] = offsetCursor @@ -114,8 +160,53 @@ class Mf4MeasurementLogger( } else { unitTxOffsets[i] = 0L } + if (pcChannels != null) { + val definition = channels[i].definition + fun text(text: String, id: String = "##TX"): Long { + val block = createTxBlock(text, id) + val offset = offsetCursor; offsetCursor += block.size; extras[i].add(block) + return offset + } + commentOffsets[i] = if (i == 0) text(TIME_COMMENT, "##MD") else + if (definition.description.isNotBlank()) text(definition.description) else 0L + if (definition.sourceName != null) { + sourceOffsets[i] = offsetCursor + val si = block("##SI", 3, 8) + offsetCursor += si.capacity(); extras[i].add(si.array()) + si.putLong(24, text(definition.sourceName)) + si.putLong(32, text(definition.sourcePath)) + si.putLong(40, if (definition.sourceComment.isNotBlank()) text(definition.sourceComment) else 0L) + si.put(48, 3.toByte()) // PC SignalDescription: source type 3, bus type 0. + } + val ccType = definition.conversionType ?: if (definition.factor != 1.0 || definition.offset != 0.0) 1 else null + if (i != 0 && ccType != null) { + val parameters = if (definition.conversionType != null) definition.conversionParameters + else listOf(definition.offset, definition.factor) + conversionOffsets[i] = offsetCursor + val cc = block("##CC", 4, 24 + parameters.size * 8) + cc.putLong(32, unitTxOffsets[i]); cc.put(56, ccType.toByte()) + cc.putShort(62, parameters.size.toShort()) + parameters.forEachIndexed { index, value -> cc.putDouble(80 + index * 8, value) } + offsetCursor += cc.capacity(); extras[i].add(cc.array()) + } + } + } + val fileMetadata = mutableListOf() + var headerCommentOffset = 0L + var historyOffset = 0L + var groupCommentOffset = 0L + if (pcChannels != null) { + groupCommentOffset = offsetCursor + createTxBlock("Measurements").also { fileMetadata.add(it); offsetCursor += it.size } + headerCommentOffset = offsetCursor + createTxBlock(HEADER_COMMENT, "##MD").also { fileMetadata.add(it); offsetCursor += it.size } + historyOffset = offsetCursor + val fh = block("##FH", 2, 16) + offsetCursor += fh.capacity() + fh.putLong(32, offsetCursor); fh.putLong(40, startedAtMillis * 1_000_000L) + fileMetadata.add(fh.array()) + createTxBlock(HISTORY_COMMENT, "##MD").also { fileMetadata.add(it); offsetCursor += it.size } } - dtBlockOffset = offsetCursor // 1. ID Block (64 Bytes) @@ -123,13 +214,11 @@ class Mf4MeasurementLogger( idBuf.put("MDF ".toByteArray(Charsets.US_ASCII)) // 0..7 idBuf.put("4.10 ".toByteArray(Charsets.US_ASCII)) // 8..15 idBuf.put("DCT_AND ".toByteArray(Charsets.US_ASCII)) // 16..23 - idBuf.putShort(410.toShort()) // 24..25 version 410 - idBuf.putShort(0.toShort()) // 26..27 reserved - idBuf.putInt(0) // 28..31 - idBuf.putLong(0L) // 32..39 - idBuf.putLong(0L) // 40..47 - idBuf.putLong(0L) // 48..55 - idBuf.putLong(0L) // 56..63 + idBuf.putInt(0) // 24..27 reserved + idBuf.putShort(410.toShort()) // 28..29 numeric version + idBuf.put(ByteArray(30)) // 30..59 reserved + idBuf.putShort((if (pcChannels != null) 1 else 0).toShort()) // Pending cycle count until close. + idBuf.putShort(0.toShort()) // 62..63 custom flags bos.write(idBuf.array()) // 2. HD Block (104 Bytes) @@ -139,13 +228,15 @@ class Mf4MeasurementLogger( hdBuf.putLong(104L) // 8..15 length of HD block hdBuf.putLong(6L) // 16..23 link count: 6 links hdBuf.putLong(dgBlockOffset) // 24..31 hd_dg_first -> DGBLOCK 오프셋 (168) - hdBuf.putLong(0L) // 32..39 hd_fh_first + hdBuf.putLong(historyOffset) // 32..39 hd_fh_first hdBuf.putLong(0L) // 40..47 hd_ch_first hdBuf.putLong(0L) // 48..55 hd_at_first hdBuf.putLong(0L) // 56..63 hd_et_first - hdBuf.putLong(0L) // 64..71 hd_md_comment - hdBuf.putLong(System.currentTimeMillis() * 1_000_000L) // 72..79 start time in ns - hdBuf.putShort(0.toShort()) // 80..81 tz offset + hdBuf.putLong(headerCommentOffset) // 64..71 hd_md_comment + hdBuf.putLong(startedAtMillis * 1_000_000L) // 72..79 UTC start time in ns + // Compatibility with the supplied PC DLL output: its caller passes 9 minutes, + // not 9 hours. Do not silently reinterpret that historical field as KST. + hdBuf.putShort((if (pcChannels != null) 9 else 0).toShort()) // 80..81 tz offset hdBuf.putShort(0.toShort()) // 82..83 dst offset hdBuf.put(0.toByte()) // 84 time flags hdBuf.put(0.toByte()) // 85 time class @@ -164,24 +255,30 @@ class Mf4MeasurementLogger( dgBuf.putLong(0L) // 24..31 dg_next dgBuf.putLong(cgBlockOffset) // 32..39 dg_cg_first -> CGBLOCK 오프셋 (232) dgBuf.putLong(dtBlockOffset) // 40..47 dg_data -> DTBLOCK 오프셋 - dgBuf.putLong(0L) // 48..55 dg_md_comment + dgBuf.putLong(groupCommentOffset) // 48..55 dg_md_comment dgBuf.put(0.toByte()) // 56 dg_rec_id_size (0: no record ID) dgBuf.put(ByteArray(7)) // 57..63 reserved bos.write(dgBuf.array()) - // 4. CG Block (64 Bytes) - val cgBuf = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) + // 4. CG Block (104 Bytes): common header, six links, then data. + val cgBuf = ByteBuffer.allocate(104).order(ByteOrder.LITTLE_ENDIAN) cgBuf.put("##CG".toByteArray(Charsets.US_ASCII)) // 0..3 cgBuf.putInt(0) // 4..7 reserved - cgBuf.putLong(64L) // 8..15 length of CG - cgBuf.putLong(cnOffsets[0]) // 16..23 cg_cn_first -> 첫 번째 CNBLOCK 오프셋 - cgBuf.putLong(0L) // 24..31 record_id - cgBuf.putLong(0L) // 32..39 cycle_count (updated on stop, offset 264) - cgBuf.putShort(0.toShort()) // 40..41 flags - cgBuf.putShort(0.toShort()) // 42..43 path_separator - cgBuf.putInt(recordSize) // 44..47 data_bytes (record size) - cgBuf.putInt(0) // 48..51 invalidation_bytes - cgBuf.put(ByteArray(12)) // 52..63 reserved + cgBuf.putLong(104L) // 8..15 length + cgBuf.putLong(6L) // 16..23 link count + cgBuf.putLong(0L) // 24..31 next CG + cgBuf.putLong(cnOffsets[0]) // 32..39 first CN + cgBuf.putLong(0L) // 40..47 acquisition name + cgBuf.putLong(0L) // 48..55 acquisition source + cgBuf.putLong(0L) // 56..63 sample reduction + cgBuf.putLong(0L) // 64..71 comment + cgBuf.putLong(0L) // 72..79 record_id + cgBuf.putLong(0L) // 80..87 cycle_count + cgBuf.putShort(0.toShort()) // 88..89 flags + cgBuf.putShort(0.toShort()) // 90..91 path_separator + cgBuf.putInt(0) // 92..95 reserved + cgBuf.putInt(recordSize) // 96..99 data_bytes + cgBuf.putInt(0) // 100..103 invalidation_bytes bos.write(cgBuf.array()) // 5. CN Blocks & TX Blocks 체인 기록 @@ -189,35 +286,39 @@ class Mf4MeasurementLogger( val ch = channels[i] val nextCnOffset = if (i < channels.size - 1) cnOffsets[i + 1] else 0L - // CN Block (128 Bytes) - val cnBuf = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN) + // CN Block (160 Bytes) + val cnBuf = ByteBuffer.allocate(160).order(ByteOrder.LITTLE_ENDIAN) cnBuf.put("##CN".toByteArray(Charsets.US_ASCII)) // 0..3 cnBuf.putInt(0) // 4..7 reserved - cnBuf.putLong(128L) // 8..15 length + cnBuf.putLong(160L) // 8..15 length cnBuf.putLong(8L) // 16..23 link_count: 8 links cnBuf.putLong(nextCnOffset) // 24..31 cn_next cnBuf.putLong(0L) // 32..39 cn_composition cnBuf.putLong(nameTxOffsets[i]) // 40..47 cn_tx_name -> TXBLOCK - cnBuf.putLong(0L) // 48..55 cn_si_source - cnBuf.putLong(0L) // 56..63 cn_cc_conversion + cnBuf.putLong(sourceOffsets[i]) // 48..55 cn_si_source + cnBuf.putLong(conversionOffsets[i]) // 56..63 cn_cc_conversion cnBuf.putLong(0L) // 64..71 cn_data cnBuf.putLong(unitTxOffsets[i]) // 72..79 cn_md_unit -> TXBLOCK (또는 0) - cnBuf.putLong(0L) // 80..87 cn_md_comment + cnBuf.putLong(commentOffsets[i]) // 80..87 cn_md_comment - // Data Section (40 Bytes) + // Data Section (72 Bytes). PC ranges come from definitions, not observed sample statistics. cnBuf.put(if (ch.isMaster) 2.toByte() else 0.toByte()) // 88 cn_type (2: master, 0: data) cnBuf.put(if (ch.isMaster) 1.toByte() else 0.toByte()) // 89 cn_sync_type (1: time sync) - cnBuf.put(4.toByte()) // 90 cn_data_type (4: Float LE 64-bit) + cnBuf.put(ch.definition.dataType.toByte()) // 90 cn_data_type cnBuf.put(0.toByte()) // 91 bit_offset cnBuf.putInt(ch.byteOffset) // 92..95 byte_offset - cnBuf.putInt(64) // 96..99 bit_count (64 bits) - cnBuf.putInt(0) // 100..103 flags + cnBuf.putInt(ch.definition.bitCount) // 96..99 bit_count + cnBuf.putInt(if (pcChannels != null && ch.definition.rawRange != null) 8 else 0) cnBuf.putInt(0) // 104..107 inval_bit_pos cnBuf.put(0.toByte()) // 108 precision cnBuf.put(0.toByte()) // 109 reserved - cnBuf.putShort(0.toShort()) // 110..111 min_sample_count - cnBuf.putDouble(0.0) // 112..119 val_range_min - cnBuf.putDouble(0.0) // 120..127 val_range_max + cnBuf.putShort(0.toShort()) // 110..111 attachment_count + cnBuf.putDouble(ch.definition.rawRange?.first ?: 0.0) // 112..119 val_range_min + cnBuf.putDouble(ch.definition.rawRange?.second ?: 0.0) // 120..127 val_range_max + cnBuf.putDouble(0.0) // 128..135 lower_limit + cnBuf.putDouble(0.0) // 136..143 upper_limit + cnBuf.putDouble(0.0) // 144..151 lower_ext_limit + cnBuf.putDouble(0.0) // 152..159 upper_ext_limit bos.write(cnBuf.array()) // Name TXBlock @@ -227,41 +328,86 @@ class Mf4MeasurementLogger( if (unitTxBytes[i].isNotEmpty()) { bos.write(unitTxBytes[i]) } + extras[i].forEach { bos.write(it) } } - + fileMetadata.forEach { bos.write(it) } + if (pcChannels != null) { + val hl = block("##HL", 1, 8) + hl.putShort(32, 1.toShort()); hl.put(34, 1.toByte()) // Equal-length, transposed deflate. + bos.write(hl.array()) + filePosition = dtBlockOffset + hl.capacity() + } else { // 6. Data Block Header (##DT) val dtHeader = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN) dtHeader.put("##DT".toByteArray(Charsets.US_ASCII)) dtHeader.putInt(0) - dtHeader.putLong(0L) // data length (updated on stop) + dtHeader.putLong(24L) // A zero-sample file still has a complete DT header. dtHeader.putLong(0L) // links bos.write(dtHeader.array()) + } bos.flush() outputStream = bos + headerWritten = true recordCount = 0L lastFlushNanos = System.nanoTime() return file } catch (e: Exception) { + try { openingStream?.close() } catch (closeError: Exception) { e.addSuppressed(closeError) } e.printStackTrace() return null } } @Synchronized - fun logSample(elapsedSec: Double, values: List) { + fun logSample(sampleTimeSec: Double, values: List) { + check(pcChannels == null) { "PC layout requires raw samples, not physical Double values" } + writeSample(sampleTimeSec - recordingStartedAtSec, values) + } + + /** PC's relative seconds are derived from millisecond ticks; epoch/UTC remain in HD. */ + @Synchronized + fun logRawSampleAtMillis(sampleTimeMillis: Long, values: List) { + check(pcChannels != null) { "Raw samples require a PC channel definition" } + writeSample((sampleTimeMillis - recordingStartedAtMillis) / 1000.0, values) + } + + private fun writeSample(relativeTime: Double, values: List) { val os = outputStream ?: return + if (!relativeTime.isFinite() || writeFailed) return try { val buf = sampleBuffer buf.clear() - buf.putDouble(elapsedSec) + // Preserve ingress time for packets queued before the recording header was created. + // A small negative offset reconstructs the same absolute time as CSV; clamping loses it. + buf.putDouble(relativeTime) for (index in signalHeaders.indices) { val v = values.getOrNull(index) - // A missing channel must not become an apparently measured zero. - val num = if (v != null && v.isFinite()) v else Double.NaN - buf.putDouble(num) + if (pcChannels == null) { + val num = if (v != null && v.toDouble().isFinite()) v.toDouble() else Double.NaN + buf.putDouble(num) + } else { + // PC EnvSetting retains other channels; WriteData submits the full snapshot. + // Never-received raw channels start at zero, with no invalid bytes in PC files. + val definition = definitions[index] + // An explicit NaN float replaces stale Formula/XCP data; null means no new update. + if (v != null && (definition.dataType == 4 || v.toDouble().isFinite())) lastRawValues[index] = v + val value = lastRawValues[index] ?: 0L + when (definition.dataType) { + 4 -> if (definition.bitCount == 32) buf.putFloat(value.toFloat()) else buf.putDouble(value.toDouble()) + else -> when (definition.bitCount) { + 8 -> buf.put(value.toLong().toByte()) + 16 -> buf.putShort(value.toLong().toShort()) + 32 -> buf.putInt(value.toLong().toInt()) + 64 -> buf.putLong(value.toLong()) // Includes uint64's complete bit pattern. + } + } + } + } + if (pcChannels == null) os.write(buf.array()) else { + pendingRecords.write(buf.array()); pendingCount++ + if (pendingCount == recordsPerChunk) flushCompressedChunk() } - os.write(buf.array()) recordCount++ val now = System.nanoTime() if (now - lastFlushNanos >= 1_000_000_000L) { @@ -269,16 +415,54 @@ class Mf4MeasurementLogger( os.flush() } } catch (e: Exception) { + writeFailed = true e.printStackTrace() } } + private fun flushCompressedChunk() { + val os = outputStream ?: return + if (pendingCount == 0) return + val raw = pendingRecords.toByteArray() + val transposed = ByteArray(raw.size) + for (column in 0 until recordSize) for (row in 0 until pendingCount) { + transposed[column * pendingCount + row] = raw[row * recordSize + column] + } + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) // zlib-wrapped DEFLATE, like PC DZ. + val compressed = ByteArrayOutputStream() + try { + deflater.setInput(transposed); deflater.finish() + val buffer = ByteArray(64 * 1024) + while (!deflater.finished()) { + val size = deflater.deflate(buffer) + check(size > 0) { "MF4 compression made no progress" } + compressed.write(buffer, 0, size) + } + } finally { deflater.end() } + val bytes = compressed.toByteArray() + val dl = block("##DL", 2, 16) + dl.putLong(32, filePosition + dl.capacity()) + dl.put(40, 1.toByte()); dl.putInt(44, 1); dl.putLong(48, recordsPerChunk.toLong() * recordSize) + dataLists.add(filePosition) + os.write(dl.array()); filePosition += dl.capacity() + val dz = block("##DZ", 0, 24) + dz.putLong(8, 48L + bytes.size) + dz.put(24, 'D'.code.toByte()); dz.put(25, 'T'.code.toByte()); dz.put(26, 1.toByte()) + dz.putInt(28, recordSize); dz.putLong(32, raw.size.toLong()); dz.putLong(40, bytes.size.toLong()) + os.write(dz.array()); os.write(bytes) + val padding = ((8 - bytes.size % 8) % 8) + os.write(ByteArray(padding)); filePosition += 48 + bytes.size + padding + pendingRecords.reset(); pendingCount = 0 + } + @Synchronized fun stop() { try { + if (pcChannels != null && !writeFailed) flushCompressedChunk() outputStream?.flush() outputStream?.close() } catch (e: Exception) { + writeFailed = true e.printStackTrace() } finally { outputStream = null @@ -286,24 +470,42 @@ class Mf4MeasurementLogger( // 헤더에 총 레코드 카운트 및 DT 블록 크기 백필(Backfill) val file = outputFile ?: return - if (file.exists() && recordCount > 0L) { + if (file.exists() && headerWritten && !writeFailed) { try { RandomAccessFile(file, "rw").use { raf -> - // 1. CG Block cycle_count 갱신 (CG 블록 내 32바이트 위치) + // 1. CG cycle_count at +80, not a link field. raf.seek(cgRecordCountOffset) val buf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) buf.putLong(recordCount) raf.write(buf.array()) + if (pcChannels != null) { + fun longAt(position: Long, value: Long) { + raf.seek(position) + raf.write(ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(value).array()) + } + longAt(dtBlockOffset + 24, dataLists.firstOrNull() ?: 0L) + dataLists.forEachIndexed { index, offset -> longAt(offset + 24, dataLists.getOrNull(index + 1) ?: 0L) } + raf.seek(60) + raf.write(byteArrayOf(0, 0)) // Mark finalized only after links/counts are patched. + } else { // 2. DT Block length 갱신 (24 bytes header + recordCount * recordSize) raf.seek(dtBlockOffset + 8) val dtLenBuf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) dtLenBuf.putLong(24L + (recordCount * recordSize)) raf.write(dtLenBuf.array()) + } } } catch (e: Exception) { e.printStackTrace() } } } + + private companion object { + const val HEADER_COMMENT = "\n\n\n" + const val TIME_COMMENT = "Time Channel0.000000000000000" + // Do not claim Android used the Windows DLL. Retain PC tool fields, identify the actual writer. + const val HISTORY_COMMENT = "DCTDoowon Climate Control1.0DCT Android; MDF4Writer.dll-compatibleFree_MDF4_Lib 2.019 2016/05/05" + } } diff --git a/Android/app/src/main/java/com/dct/app/data/logger/PcMf4Channel.kt b/Android/app/src/main/java/com/dct/app/data/logger/PcMf4Channel.kt new file mode 100644 index 0000000..73a9351 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/PcMf4Channel.kt @@ -0,0 +1,75 @@ +package com.dct.app.data.logger + +import com.dct.app.domain.model.mdb.SignalDefinition + +/** Raw channel layout from WriteA2LMDF4/WriteDBCMDF4's MDF4Writer.dll path. */ +data class PcMf4Channel( + val name: String, + val unit: String = "", + val dataType: Int = 4, // MDF: unsigned LE=0, signed LE=2, IEEE float LE=4. + val bitCount: Int = 64, + val valueKey: String = name, + val factor: Double = 1.0, + val offset: Double = 0.0, + val description: String = "", + val sourceName: String? = null, + val sourcePath: String = "", + val sourceComment: String = description, + val conversionType: Int? = null, + val conversionParameters: List = emptyList(), + val rawRange: Pair? = null, + val physicalInput: Boolean = false +) { + init { + require(dataType in listOf(0, 2, 4)) + require(bitCount in listOf(8, 16, 32, 64)) + require(dataType != 4 || bitCount == 32 || bitCount == 64) + require(factor.isFinite() && offset.isFinite()) + require(conversionType == null || conversionType in listOf(1, 2, 4)) + require(conversionParameters.all { it.isFinite() }) + require(conversionType != 1 || conversionParameters.size == 2) + require(conversionType != 2 || conversionParameters.size == 6) + require(conversionType != 4 || (conversionParameters.size >= 2 && conversionParameters.size % 2 == 0)) + require(rawRange == null || (rawRange.first.isFinite() && rawRange.second.isFinite())) + } + + val byteSize: Int get() = bitCount / 8 + + companion object { + fun mdb(signal: SignalDefinition) = PcMf4Channel( + // DLL branch explicitly overrides every MDB channel to signed 32-bit. + signal.name, "", 2, 32, "sig_${signal.reclid}_${signal.idx}", + signal.cvsA, signal.cvsB) + + fun dbc(signal: SignalDefinition): PcMf4Channel { + // PC rounds DBC bit lengths to 8/16/32/64-bit storage buckets, always LE. + val bits = when (signal.bitLength) { in 1..8 -> 8; in 9..16 -> 16; in 17..32 -> 32; else -> 64 } + // PC DLL path deliberately leaves DBC unit/description empty. + return PcMf4Channel(signal.name, "", if (signal.attr.isSigned) 2 else 0, + bits, com.dct.app.domain.model.mdb.CanSignalKey.stable(signal), + signal.cvsA, signal.cvsB) + } + + fun a2l(signal: SignalDefinition, + metadata: Pair? = null): PcMf4Channel { + val method = metadata?.second + val channel = PcMf4Channel( + signal.name, metadata?.first?.unit?.takeIf { it.isNotBlank() } + ?: method?.unit?.takeIf { it.isNotBlank() } ?: signal.unit, + signal.a2lDataType?.let { if (it.isFloat) 4 else if (it.isSigned) 2 else 0 } + ?: if (signal.attr.isSigned) 2 else 0, + signal.a2lDataType?.let { if (it.isFloat && it.byteSize == 2) 32 else it.byteSize * 8 } + ?: signal.attr.byteSize * 8, + signal.name, signal.cvsA, signal.cvsB, signal.description, + signal.name, metadata?.first?.symbolLink?.takeIf { it.isNotBlank() } ?: signal.name, signal.description, + rawRange = signal.min to signal.max) + return when (method?.conversionType?.uppercase()) { + "RAT_FUNC" -> channel.copy(conversionType = 2, conversionParameters = listOf( + method.cvsA, method.cvsB, method.cvsC, method.cvsD, method.cvsE, method.cvsF)) + "TAB_INTP" -> if (method.tabIntpPoints.isEmpty()) channel else channel.copy( + conversionType = 4, conversionParameters = method.tabIntpPoints.flatMap { listOf(it.first, it.second) }) + else -> channel + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt index 6ffe89a..17d500a 100644 --- a/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt +++ b/Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt @@ -149,7 +149,8 @@ class PcOperationalLogger( .bufferedWriter(Charsets.UTF_8) } connection.writer?.apply { - write("[${entry.direction}][${millis.format(Date(entry.time))}] ") + val timestamp = millis.format(Date(entry.time)) + write("[${entry.direction}][$timestamp] ") val hex = "0123456789ABCDEF" val text = CharArray(entry.bytes.size * 3) entry.bytes.forEachIndexed { index, byte -> diff --git a/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt b/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt index 59cd642..20752cd 100644 --- a/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt @@ -100,7 +100,8 @@ object A2lParser { /** 측정 블록 자체에 FORMAT이 있었는지. 없으면 PC처럼 COMPU_METHOD FORMAT을 사용한다. */ val hasExplicitFormat: Boolean = false, val unit: String = "", - val matrixDim: List = emptyList() + val matrixDim: List = emptyList(), + val symbolLink: String = "" ) /** @@ -738,7 +739,9 @@ object A2lParser { format = format, hasExplicitFormat = hasExplicitFormat, unit = unit, - matrixDim = matrixDim + matrixDim = matrixDim, + symbolLink = tokens.indexOfFirst { it.equals("SYMBOL_LINK", ignoreCase = true) } + .takeIf { it >= 0 }?.let { tokens.getOrNull(it + 1) }.orEmpty() ) } diff --git a/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt b/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt index b4bd864..477d1b6 100644 --- a/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt @@ -130,7 +130,7 @@ object DpcProjectManager { } /** - * PC 버전(common_def.h)과 100% 호환되는 8대 표준 서브폴더 목록 + * 현재 앱에서 사용하는 PC 호환 기본 폴더. 미사용 Screen/SMC는 미리 만들지 않는다. */ val SUB_FOLDERS = listOf( "Configuration", @@ -138,13 +138,12 @@ object DpcProjectManager { "MDB", "DBC", "A2L", - "SMC", - "Log", - "Screen" + "Log" ) /** - * Documents/DCT Work 및 8대 서브폴더(Configuration, Data, MDB, DBC, A2L, SMC, Log, Screen) 일괄 자동 생성 + * Documents/DCT Work 및 기본 6개 폴더 생성. 기존 폴더/파일은 삭제하지 않는다. + * Screen/SMC가 필요한 기능이 추가되면 해당 기능에서 사용 시점에 생성한다. */ fun ensureDctWorkDirectories(context: Context? = null): File { var baseDir: File? = null @@ -277,6 +276,8 @@ object DpcProjectManager { root.put("logfile_path_use_basefolder", 1) } root.put("autostart_measuring_recording", if (project.autostartMeasuringRecording) 1 else 0) + root.remove("android_log_microseconds") // Remove the obsolete TXT-only extension. + root.put("csv_with_miliseconds", project.csvWithMilliseconds) // PC key spelling. // DID_INFO val didObj = JSONObject() @@ -1017,6 +1018,7 @@ object DpcProjectManager { maker = maker, isaveLogfile = isaveLogfile, autostartMeasuringRecording = autostartMeasuring, + csvWithMilliseconds = root.optBoolean("csv_with_miliseconds", false), canSignalColors = canSignalColors, canSignalDescriptions = canSignalDescriptions, mdbSignalColors = mdbSignalColors, diff --git a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt index 89d5012..48528f8 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt @@ -108,6 +108,7 @@ data class DpcProject( val maker: Int = 2, val isaveLogfile: Boolean = true, val autostartMeasuringRecording: Boolean = false, + val csvWithMilliseconds: Boolean = false, val canSignalColors: Map = emptyMap(), val canSignalDescriptions: Map = emptyMap(), val mdbSignalColors: Map = emptyMap(), diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt index 5e4ee2d..78e9362 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt @@ -126,6 +126,8 @@ class MainViewModel( private var calibrationJob: Job? = null private val sessionMutex = kotlinx.coroutines.sync.Mutex() private val recordingMutex = kotlinx.coroutines.sync.Mutex() + // A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths. + private val recordingSampleLock = Any() private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() @Volatile private var gapMarkedGeneration = Long.MIN_VALUE @Volatile private var measurementRequestMillis = 0L @@ -3086,6 +3088,21 @@ class MainViewModel( private val activeCsvLoggers = java.util.concurrent.CopyOnWriteArrayList() private val activeMf4Loggers = java.util.concurrent.CopyOnWriteArrayList() + // Protected by recordingSampleLock; seeds a newly started file from the PC-like live raw state. + private val latestMf4RawValues = HashMap() + private var latestMf4MdbSource: MdbDatabase? = null + private var latestMf4CanSource: List? = null + private var latestMf4A2lSource: List? = null + + private fun refreshMf4RawSources() { + val mdb = _uiState.value.mdbDatabase + val can = _canSignals.value + val a2l = _a2lSignals.value + if (latestMf4MdbSource !== mdb || latestMf4CanSource !== can || latestMf4A2lSource !== a2l) { + latestMf4RawValues.clear() + latestMf4MdbSource = mdb; latestMf4CanSource = can; latestMf4A2lSource = a2l + } + } private var storageContext: Context? = null private var activeRecordingStage: File? = null private var lastRecordingSecond = "" @@ -3303,12 +3320,13 @@ class MainViewModel( fun startMeasurementRecording() { scope.launch(dispatchers.io) { recordingMutex.withLock { + synchronized(recordingSampleLock) recording@{ val state = _uiState.value if (!state.isConnected || !state.isMeasuring || state.recordingState == MeasurementRecordingState.STARTING || state.recordingState == MeasurementRecordingState.RECORDING || state.recordingState == MeasurementRecordingState.STOPPING - ) return@withLock + ) return@recording val configuredProject = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() @@ -3319,7 +3337,7 @@ class MainViewModel( statusMessage = "기록 형식을 선택해 주세요 (CSV 또는 MF4)." ) } - return@withLock + return@recording } _uiState.update { @@ -3339,7 +3357,7 @@ class MainViewModel( if (appContext == null) { _uiState.update { it.copy(recordingState = MeasurementRecordingState.ERROR, statusMessage = "선택한 기록 폴더에 접근할 수 없습니다. 앱을 다시 열어 주세요.") } - return@withLock + return@recording } try { com.dct.app.data.logger.MeasurementSafExports.createStage(appContext, currentProj.measuredFolderUri).also { @@ -3348,7 +3366,7 @@ class MainViewModel( } catch (error: Exception) { _uiState.update { it.copy(recordingState = MeasurementRecordingState.ERROR, statusMessage = "기록 임시 파일 생성 실패: ${error.message}") } - return@withLock + return@recording } } else com.dct.app.data.logger.MeasurementLogPaths.resolve( currentProj.measuredFilesFolder, currentProj.baseFolder, @@ -3367,7 +3385,8 @@ class MainViewModel( ?: "PROJECT" } } - val recordingSecond = com.dct.app.data.logger.PcMeasurementNames.timestamp(Date()) + val recordingStartMillis = System.currentTimeMillis() + val recordingSecond = com.dct.app.data.logger.PcMeasurementNames.timestamp(Date(recordingStartMillis)) if (recordingSecond == lastRecordingSecond) recordingWithinSecond++ else recordingWithinSecond = 0 lastRecordingSecond = recordingSecond var timeStamp = if (recordingWithinSecond == 0) recordingSecond else @@ -3378,6 +3397,7 @@ class MainViewModel( } val mdbDefinitions = _uiState.value.mdbDatabase?.allSignals.orEmpty() + refreshMf4RawSources() val canDefinitions = _canSignals.value val a2lDefinitions = _a2lSignals.value val mdbSignals = mdbDefinitions.map { it.name to it.unit } @@ -3399,20 +3419,25 @@ class MainViewModel( val hasMdb = mdbSignals.isNotEmpty() val hasCan = canSignals.isNotEmpty() val hasA2l = a2lSignals.isNotEmpty() + // Snapshot display precision for this file; menu changes affect the next recording. + // Do not couple display precision to the device's save interval or sampling clock. + val csvWithMilliseconds = currentProj.csvWithMilliseconds // 1. MDB 신호 로거 (프로젝트명_날짜_시간_mdb.csv / .mf4) if (hasMdb) { val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "mdb", "csv") val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "mdb", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, mdbSignals, fnCsv, mdbColumns).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, mdbSignals, fnCsv, mdbColumns, + withMilliseconds = csvWithMilliseconds).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } if (currentProj.writeMf4) { - com.dct.app.data.logger.Mf4MeasurementLogger(outDir, mdbSignals, fnMf4).also { - if (it.start() != null) activeMf4Loggers.add(it) + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, mdbSignals, fnMf4, + mdbDefinitions.map { com.dct.app.data.logger.PcMf4Channel.mdb(it) }).also { + if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it) else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } @@ -3423,14 +3448,16 @@ class MainViewModel( val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "dbc", "csv") val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "dbc", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv, canColumns).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv, canColumns, + withMilliseconds = csvWithMilliseconds).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } if (currentProj.writeMf4) { - com.dct.app.data.logger.Mf4MeasurementLogger(outDir, canSignals, fnMf4).also { - if (it.start() != null) activeMf4Loggers.add(it) + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, canSignals, fnMf4, + canDefinitions.map { com.dct.app.data.logger.PcMf4Channel.dbc(it) }).also { + if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it) else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } @@ -3441,14 +3468,17 @@ class MainViewModel( val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "measurement", "csv") val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "measurement", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, a2lSignals, fnCsv, a2lColumns, "NC").also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, a2lSignals, fnCsv, a2lColumns, "NC", + withMilliseconds = csvWithMilliseconds).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } if (currentProj.writeMf4) { - com.dct.app.data.logger.Mf4MeasurementLogger(outDir, a2lSignals, fnMf4).also { - if (it.start() != null) activeMf4Loggers.add(it) + val metadata = xcpDaqDecoder.recordingMetadata() + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, a2lSignals, fnMf4, + a2lDefinitions.map { com.dct.app.data.logger.PcMf4Channel.a2l(it, metadata[it.name]) }).also { + if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it) else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } @@ -3465,14 +3495,16 @@ class MainViewModel( formulaList.mapIndexed { index, formula -> com.dct.app.data.logger.PcCsvColumn(index.toString(), formula.name, displayName = formula.expression, displayUnit = "") - }).also { + }, withMilliseconds = csvWithMilliseconds).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } if (currentProj.writeMf4) { - com.dct.app.data.logger.Mf4MeasurementLogger(outDir, formulaSignals, fnMf4).also { - if (it.start() != null) activeMf4Loggers.add(it) + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, formulaSignals, fnMf4, + formulaList.map { com.dct.app.data.logger.PcMf4Channel(it.name, it.unit, bitCount = 32, + description = it.expression, physicalInput = true) }).also { + if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it) else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } @@ -3495,14 +3527,17 @@ class MainViewModel( val fnCsv = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "sm", "csv") val fnMf4 = com.dct.app.data.logger.PcMeasurementNames.fileName(rawProjName, timeStamp, "sm", "mf4") if (currentProj.writeCsv) { - com.dct.app.data.logger.CsvMeasurementLogger(outDir, smSignals, fnCsv, smColumns).also { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, smSignals, fnCsv, smColumns, + withMilliseconds = csvWithMilliseconds).also { if (it.start() != null) activeCsvLoggers.add(it) else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } if (currentProj.writeMf4) { - com.dct.app.data.logger.Mf4MeasurementLogger(outDir, smSignals, fnMf4).also { - if (it.start() != null) activeMf4Loggers.add(it) + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, smSignals, fnMf4, + smSignals.map { com.dct.app.data.logger.PcMf4Channel(it.first, it.second, bitCount = 32, + physicalInput = true) }).also { + if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it) else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } @@ -3520,10 +3555,11 @@ class MainViewModel( _uiState.update { it.copy( recordingState = if (started) MeasurementRecordingState.RECORDING else MeasurementRecordingState.ERROR, - recordingStartedAtMillis = if (started) System.currentTimeMillis() else null, + recordingStartedAtMillis = if (started) recordingStartMillis else null, statusMessage = if (started) "계측 데이터 기록 중" else "기록 파일을 만들 수 없습니다." ) } + } } } } @@ -3567,16 +3603,26 @@ class MainViewModel( _currentProject.value = project.copy(autostartMeasuringRecording = enabled) } + fun setCsvWithMilliseconds(enabled: Boolean) { + val project = _currentProject.value + ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() + _currentProject.value = project.copy(csvWithMilliseconds = enabled) + } + private fun closeMeasurementLoggers() { + synchronized(recordingSampleLock) { + if (_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING, MeasurementRecordingState.RECORDING)) { + _uiState.update { it.copy(recordingState = MeasurementRecordingState.STOPPING) } + } if (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty()) operationalLogger?.action("Stopped writing measurement files (CSV/MF4)") for (logger in activeCsvLoggers) logger.stop() activeCsvLoggers.clear() for (logger in activeMf4Loggers) logger.stop() activeMf4Loggers.clear() - val stage = activeRecordingStage ?: return + val stage = activeRecordingStage ?: return@synchronized activeRecordingStage = null - val appContext = storageContext ?: return + val appContext = storageContext ?: return@synchronized try { com.dct.app.data.logger.MeasurementSafExports.markReady(stage) scope.launch(dispatchers.io) { @@ -3591,6 +3637,7 @@ class MainViewModel( Log.e(LOG_TAG, "측정 파일 내보내기 준비 실패; 임시 사본 보존", error) setStatusMessage("기록 파일 내보내기 준비 실패: ${error.message}") } + } } /** 계측 정지/통신 단절 시 모든 그래프 시리즈에 동일한 NaN 경계점을 삽입합니다. */ @@ -3647,7 +3694,8 @@ class MainViewModel( ) } - private fun decodeMdbRecord(recordId: Int, data: ByteArray, output: MutableMap): String? { + private fun decodeMdbRecord(recordId: Int, data: ByteArray, output: MutableMap, + rawOutput: MutableMap? = null): String? { val database = _uiState.value.mdbDatabase ?: return "MDB가 로드되지 않았습니다." if (preparedMdbSource !== database) { preparedMdb = com.dct.app.data.protocol.MdbRecordDecoder.PreparedDefinitions(database.allSignals) @@ -3662,6 +3710,7 @@ class MainViewModel( output[signal.name] = decoded.physicalValue // Keep the received integer, including all bytes, independently of physical conversion. output["raw_sig_${signal.reclid}_${signal.idx}"] = decoded.rawValue.toDouble() + rawOutput?.set("sig_${signal.reclid}_${signal.idx}", decoded.rawValue) } result.warning?.let { warning -> if (shouldLogMdb("length_${recordId}")) Log.w(LOG_TAG, warning) @@ -3712,6 +3761,8 @@ class MainViewModel( val payload = packet.payload val telemetryMap = mutableMapOf() + // Preserve raw values during measurement, even if recording starts midway through this packet. + val rawRecordingValues = if (isMeasuring) mutableMapOf() else null when (packet.command) { com.dct.app.domain.model.protocol.DctCommand.RES_START_TRANSMIT_DATA.code -> { @@ -3770,7 +3821,7 @@ class MainViewModel( ?: com.dct.app.data.protocol.DctStreamPayloadParser.parseCanStream(payload)) { is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Mdb -> { a3MdbPackets++ - val error = decodeMdbRecord(stream.recordId, stream.data, telemetryMap) + val error = decodeMdbRecord(stream.recordId, stream.data, telemetryMap, rawRecordingValues) if (error != null) { if (error.startsWith("정의되지 않은 MDB ReclID ")) { // 로그 재생/장비가 현재 프로젝트에 등록되지 않은 레코드를 @@ -3834,6 +3885,7 @@ class MainViewModel( ) canDecodedSignals += decodedSignals.size for (decoded in decodedSignals) { + rawRecordingValues?.set(com.dct.app.domain.model.mdb.CanSignalKey.stable(decoded.definition), decoded.rawValue) telemetryMap[com.dct.app.domain.model.mdb.CanSignalKey.stable(decoded.definition)] = decoded.physicalValue // 구형 .dpc 및 이미 열린 창의 can_ 바인딩을 계속 지원한다. telemetryMap[com.dct.app.domain.model.mdb.CanSignalKey.legacy(decoded.definition)] = decoded.physicalValue @@ -3874,13 +3926,14 @@ class MainViewModel( val cachedStream = packet.decodedStreamPayload as? com.dct.app.data.protocol.DctStreamPayloadParser.XcpStream val decoded = if (cachedStream != null) { - xcpDaqDecoder.decodeDaqPayloadResult(cachedStream) + xcpDaqDecoder.decodeDaqPayloadResult(cachedStream, captureRawValues = rawRecordingValues != null) } else { - xcpDaqDecoder.decodeDaqPayloadResult(payload) + xcpDaqDecoder.decodeDaqPayloadResult(payload, captureRawValues = rawRecordingValues != null) } when (decoded) { is com.dct.app.core.engine.XcpDaqStreamDecoder.DecodeResult.Success -> { telemetryMap.putAll(decoded.values) + rawRecordingValues?.putAll(decoded.rawValues) } is com.dct.app.core.engine.XcpDaqStreamDecoder.DecodeResult.Failure -> { if (shouldLogMdb("xcp_payload")) Log.w( @@ -3927,6 +3980,9 @@ class MainViewModel( } // 파일 로거에 샘플 기록 (CSV / MF4) + synchronized(recordingSampleLock) { + refreshMf4RawSources() + rawRecordingValues?.let { latestMf4RawValues.putAll(it) } if (_uiState.value.recordingState == MeasurementRecordingState.RECORDING && (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty())) { var wroteSample = false @@ -3938,12 +3994,16 @@ class MainViewModel( } for (logger in activeMf4Loggers) { if (logger.signalHeaders.none { it.first in updatedSignals }) continue - val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] } - logger.logSample(elapsedSec, sampleValues) + val sampleValues = logger.rawValueKeys.mapIndexed { index, key -> + if (logger.physicalInputColumns[index]) telemetryMap[key] else rawRecordingValues?.get(key) + } + logger.logRawSampleAtMillis(packet.receivedAtMillis.takeIf { it > 0 } + ?: (elapsedSec * 1000).toLong(), sampleValues) wroteSample = true } if (wroteSample) _uiState.update { it.copy(recordingSampleCount = it.recordingSampleCount + 1L) } } + } } // 대시보드 타일 값 업데이트 diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonButton.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonButton.kt new file mode 100644 index 0000000..16067fd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonButton.kt @@ -0,0 +1,65 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** File recording stays distinct from measurement play/stop and project Save. */ +@Composable +internal fun RecordingRibbonButton( + tooltip: String, + enabled: Boolean, + isStop: Boolean = false, + onClick: () -> Unit +) { + val ink = if (enabled) Color(0xFF374151) else ColorOutlineVariant.copy(alpha = 0.5f) + val badge = if (!enabled || isStop) ink else Color(0xFFDC2626) + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(32.dp).semantics { contentDescription = tooltip } + ) { + Box(Modifier.size(24.dp)) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, + contentDescription = null, + tint = ink, + modifier = Modifier.matchParentSize() + ) + Canvas(Modifier.matchParentSize()) { + val unit = size.width / 24f + val center = Offset(17f * unit, 17f * unit) + // The ribbon surface is white; clear the file outline behind the badge. + if (isStop) { + drawRect( + color = Color.White, + topLeft = Offset(10f * unit, 10f * unit), + size = Size(14f * unit, 14f * unit) + ) + drawRoundRect( + color = badge, + topLeft = Offset(11f * unit, 11f * unit), + size = Size(12f * unit, 12f * unit), + cornerRadius = CornerRadius(0.3f * unit) + ) + } else { + drawCircle(Color.White, radius = 7f * unit, center = center) + drawCircle(badge, radius = 6f * unit, center = center) + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenu.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenu.kt new file mode 100644 index 0000000..c6dfb42 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenu.kt @@ -0,0 +1,197 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.dct.app.domain.model.chart.InterpolationMode + +private enum class RibbonSettingsCategory(val label: String) { + GRAPH("Graph Type"), RECORDING("Measurement Recording"), AUTOSTART("계측과 함께 측정"), CSV_MILLISECONDS("CSV 밀리초") +} + +internal val RibbonMenuBorderColor = Color(0xFF94A3B8) + +@Composable +internal fun RibbonFileMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier.testTag("RibbonFileMenu") + .background(Color.White, MaterialTheme.shapes.extraSmall) + .border(1.dp, RibbonMenuBorderColor, MaterialTheme.shapes.extraSmall), + content = content + ) +} + +/** Anchor to the selected row, not below the entire hamburger menu. */ +internal class RibbonSubmenuPositionProvider( + private val parentWindowOffset: () -> IntOffset = { IntOffset.Zero } +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + // A nested Popup supplies bounds relative to the parent popup's window, not the screen. + val screenAnchor = anchorBounds.translate(parentWindowOffset()) + // Prefer the requested right-hand cascade; keep it reachable on a narrow screen. + val x = if (screenAnchor.right + popupContentSize.width <= windowSize.width) { + screenAnchor.right + } else { + screenAnchor.left - popupContentSize.width + } + return IntOffset( + x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)), + screenAnchor.top.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)) + ) + } +} + +@Composable +internal fun RibbonSettingsMenuCategories( + menuExpanded: Boolean, + graphMode: InterpolationMode, + recordingFormatsEnabled: Boolean, + writeCsv: Boolean, + writeMf4: Boolean, + autostartRecording: Boolean, + recordingFolder: String, + onGraphModeChange: (InterpolationMode) -> Unit, + onRecordingFormatsChange: (Boolean, Boolean) -> Unit, + onAutostartRecordingChange: (Boolean) -> Unit, + onDismissMenu: () -> Unit, + csvWithMilliseconds: Boolean, + onCsvWithMillisecondsChange: (Boolean) -> Unit +) { + val parentView = LocalView.current + val positionProvider = remember(parentView) { + val screenLocation = IntArray(2) + val windowLocation = IntArray(2) + RibbonSubmenuPositionProvider { + parentView.getLocationOnScreen(screenLocation) + parentView.getLocationInWindow(windowLocation) + IntOffset(screenLocation[0] - windowLocation[0], screenLocation[1] - windowLocation[1]) + } + } + var selectedCategory by remember { mutableStateOf(null) } + LaunchedEffect(menuExpanded, recordingFormatsEnabled) { + if (!menuExpanded || (!recordingFormatsEnabled && selectedCategory == RibbonSettingsCategory.RECORDING)) { + selectedCategory = null + } + } + val dismissAll = { + selectedCategory = null + onDismissMenu() + } + RibbonSettingsCategory.entries.forEach { category -> + val enabled = category != RibbonSettingsCategory.RECORDING || recordingFormatsEnabled + val color = if (enabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f) + Box(Modifier.testTag("RibbonSettingsCategory_${category.name}")) { + DropdownMenuItem( + enabled = enabled, + modifier = Modifier.background( + if (selectedCategory == category && enabled) Color(0xFFEFF6FF) else Color.Transparent + ), + text = { + Text(category.label, color = color, fontSize = 13.sp) + }, + trailingIcon = { + Icon(Icons.Default.ChevronRight, null, tint = color, modifier = Modifier.size(18.dp)) + }, + onClick = { selectedCategory = if (selectedCategory == category) null else category } + ) + if (menuExpanded && selectedCategory == category && enabled) { + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { selectedCategory = null }, + // The parent owns focus/Back. Outside touches can still select a sibling category. + properties = PopupProperties(focusable = false) + ) { + Surface( + modifier = Modifier.testTag("RibbonSettingsSubmenu"), + color = Color.White, + shape = MaterialTheme.shapes.extraSmall, + border = BorderStroke(1.dp, RibbonMenuBorderColor), + shadowElevation = 8.dp + ) { + Column(Modifier.width(240.dp).heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) { + when (category) { + RibbonSettingsCategory.GRAPH -> { + listOf("Linear" to InterpolationMode.LINEAR, "Pulse" to InterpolationMode.PWM_STEP) + .forEach { (label, mode) -> + DropdownMenuItem( + text = { Text(label, fontSize = 13.sp) }, + leadingIcon = { RadioButton(graphMode == mode, onClick = null) }, + onClick = { onGraphModeChange(mode); dismissAll() } + ) + } + } + RibbonSettingsCategory.RECORDING -> { + DropdownMenuItem( + text = { Text("Write CSV", fontSize = 13.sp) }, + leadingIcon = { Checkbox(writeCsv, onCheckedChange = null) }, + onClick = { onRecordingFormatsChange(!writeCsv, writeMf4) } + ) + DropdownMenuItem( + text = { Text("Write MF4", fontSize = 13.sp) }, + leadingIcon = { Checkbox(writeMf4, onCheckedChange = null) }, + onClick = { onRecordingFormatsChange(writeCsv, !writeMf4) } + ) + if (recordingFolder.isNotBlank()) Text( + "Folder: $recordingFolder", fontSize = 10.sp, color = ColorTextSecondary, + maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + } + RibbonSettingsCategory.AUTOSTART -> { + listOf(true to "On", false to "Off").forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label, fontSize = 13.sp) }, + leadingIcon = { RadioButton(autostartRecording == value, onClick = null) }, + onClick = { onAutostartRecordingChange(value); dismissAll() } + ) + } + } + RibbonSettingsCategory.CSV_MILLISECONDS -> { + listOf(true to "True", false to "False").forEach { (value, label) -> + DropdownMenuItem( + modifier = Modifier.testTag("CsvMilliseconds_$value") + .semantics { selected = csvWithMilliseconds == value }, + text = { Text(label, fontSize = 13.sp) }, + leadingIcon = { RadioButton(csvWithMilliseconds == value, onClick = null) }, + onClick = { onCsvWithMillisecondsChange(value); dismissAll() } + ) + } + } + } + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt index 62d76bd..293315b 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt @@ -26,7 +26,6 @@ import com.dct.app.presentation.main.MainViewModel import com.dct.app.presentation.main.MeasurementRecordingState import com.dct.app.presentation.mdi.MdiWindowType import com.dct.app.presentation.mdi.ALPHANUMERICAL_DEFAULT_WIDTH_DP -import com.dct.app.domain.model.chart.InterpolationMode /** * 리본 툴바 버튼 컴포넌트 @@ -130,10 +129,9 @@ fun WorkspaceRibbonBar( tooltip = "File / Graph Menu", onClick = { showFileMenu = true } ) - DropdownMenu( + RibbonFileMenu( expanded = showFileMenu, - onDismissRequest = { showFileMenu = false }, - modifier = Modifier.background(Color.White) + onDismissRequest = { showFileMenu = false } ) { DropdownMenuItem( enabled = projectActionsEnabled, @@ -253,94 +251,24 @@ fun WorkspaceRibbonBar( } ) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) - Text( - text = "Graph Type", - fontSize = 12.sp, - color = ColorTextSecondary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) - ) - listOf("Linear" to InterpolationMode.LINEAR, "Pulse" to InterpolationMode.PWM_STEP).forEach { (label, mode) -> - DropdownMenuItem( - text = { Text(label, fontSize = 13.sp, color = ColorTextPrimary) }, - leadingIcon = { - RadioButton(selected = mainUiState.graphInterpolationMode == mode, onClick = null) - }, - onClick = { - viewModel.setGraphInterpolationMode(mode) - showFileMenu = false - } - ) - } - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) - Text( - text = "Measurement Recording", - fontSize = 12.sp, - color = ColorTextSecondary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) - ) - DropdownMenuItem( - enabled = mainUiState.recordingState != MeasurementRecordingState.RECORDING, - text = { Text("Write CSV", fontSize = 13.sp, color = ColorTextPrimary) }, - leadingIcon = { - Checkbox(checked = currentProject?.writeCsv == true, onCheckedChange = null) - }, - onClick = { - viewModel.setMeasurementRecordingFormats( - writeCsv = currentProject?.writeCsv != true, - writeMf4 = currentProject?.writeMf4 == true - ) - } - ) - DropdownMenuItem( - enabled = mainUiState.recordingState != MeasurementRecordingState.RECORDING, - text = { Text("Write MF4", fontSize = 13.sp, color = ColorTextPrimary) }, - leadingIcon = { - Checkbox(checked = currentProject?.writeMf4 == true, onCheckedChange = null) + RibbonSettingsMenuCategories( + menuExpanded = showFileMenu, + graphMode = mainUiState.graphInterpolationMode, + recordingFormatsEnabled = !mainUiState.isMeasuring && + mainUiState.recordingState != MeasurementRecordingState.RECORDING, + writeCsv = currentProject?.writeCsv == true, + writeMf4 = currentProject?.writeMf4 == true, + autostartRecording = currentProject?.autostartMeasuringRecording == true, + recordingFolder = currentProject?.measuredFilesFolder.orEmpty(), + onGraphModeChange = { viewModel.setGraphInterpolationMode(it) }, + onRecordingFormatsChange = { csv, mf4 -> + viewModel.setMeasurementRecordingFormats(csv, mf4) }, - onClick = { - viewModel.setMeasurementRecordingFormats( - writeCsv = currentProject?.writeCsv == true, - writeMf4 = currentProject?.writeMf4 != true - ) - } - ) - Text( - text = "계측과 함께 측정", - fontSize = 13.sp, - color = ColorTextPrimary, - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 2.dp) + onAutostartRecordingChange = { viewModel.setAutostartMeasuringRecording(it) }, + csvWithMilliseconds = currentProject?.csvWithMilliseconds == true, + onCsvWithMillisecondsChange = { viewModel.setCsvWithMilliseconds(it) }, + onDismissMenu = { showFileMenu = false } ) - listOf(true to "On", false to "Off").forEach { (enabled, label) -> - DropdownMenuItem( - text = { - Text( - text = label, - fontSize = 13.sp, - color = ColorTextPrimary, - modifier = Modifier.padding(start = 12.dp) - ) - }, - leadingIcon = { - RadioButton( - selected = currentProject?.autostartMeasuringRecording == enabled, - onClick = null - ) - }, - onClick = { - viewModel.setAutostartMeasuringRecording(enabled) - showFileMenu = false - } - ) - } - currentProject?.measuredFilesFolder?.takeIf { it.isNotBlank() }?.let { folder -> - Text( - text = "Folder: $folder", - fontSize = 10.sp, - color = ColorTextSecondary, - maxLines = 2, - modifier = Modifier.widthIn(max = 300.dp).padding(horizontal = 16.dp, vertical = 6.dp) - ) - } } } @@ -420,8 +348,7 @@ fun WorkspaceRibbonBar( MeasurementRecordingState.STOPPING ) && (currentProject?.writeCsv == true || currentProject?.writeMf4 == true) val canStopRecording = recordingState == MeasurementRecordingState.RECORDING - RibbonButton( - icon = Icons.Default.FiberManualRecord, + RecordingRibbonButton( tooltip = when { !isCommConnected -> "신호 기록: 통신 연결 필요" !isMeasuring -> "신호 기록: 계측 시작 필요" @@ -429,14 +356,12 @@ fun WorkspaceRibbonBar( else -> "신호 기록 시작" }, enabled = canStartRecording, - tint = Color(0xFFDC2626), onClick = { viewModel.startMeasurementRecording() } ) - RibbonButton( - icon = Icons.Default.Stop, + RecordingRibbonButton( + isStop = true, tooltip = if (canStopRecording) "신호 기록 중지" else "신호 기록 중이 아닙니다", enabled = canStopRecording, - tint = Color(0xFF374151), onClick = { viewModel.stopMeasurementRecording() } ) // 유지보수 기능은 구현 상태로 보존하되 사용자 요구에 따라 UI 진입은 숨긴다. diff --git a/Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt b/Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt new file mode 100644 index 0000000..4845208 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt @@ -0,0 +1,54 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.ui.unit.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RibbonSubmenuPositionTest { + private val provider = RibbonSubmenuPositionProvider() + + @Test fun opensToRightAlignedWithSelectedCategory() { + assertEquals(IntOffset(280, 180), provider.calculatePosition( + IntRect(20, 180, 280, 228), IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) + )) + } + + @Test fun narrowScreenFallsBackToLeftAndAvoidsBottomClipping() { + assertEquals(IntOffset(360, 340), provider.calculatePosition( + IntRect(600, 450, 860, 498), IntSize(900, 500), LayoutDirection.Ltr, IntSize(240, 160) + )) + } + + @Test fun oversizedPopupNeverProducesNegativeCoordinates() { + assertEquals(IntOffset.Zero, provider.calculatePosition( + IntRect(10, 20, 90, 68), IntSize(100, 80), LayoutDirection.Ltr, IntSize(240, 160) + )) + } + + @Test fun nestedMenuAddsParentWindowOriginToBothCoordinates() { + val nested = RibbonSubmenuPositionProvider { IntOffset(55, 190) } + assertEquals(IntOffset(335, 370), nested.calculatePosition( + IntRect(20, 180, 280, 228), IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) + )) + } + + @Test fun fallbackUsesScreenCoordinatesRatherThanLocalMenuCoordinates() { + val nested = RibbonSubmenuPositionProvider { IntOffset(600, 190) } + assertEquals(IntOffset(380, 340), nested.calculatePosition( + IntRect(20, 180, 280, 228), IntSize(900, 500), LayoutDirection.Ltr, IntSize(240, 160) + )) + } + + @Test fun parentWindowOriginIsReadAgainAfterRelocation() { + var offset = IntOffset(55, 190) + val nested = RibbonSubmenuPositionProvider { offset } + val anchor = IntRect(20, 180, 280, 228) + assertEquals(IntOffset(335, 370), nested.calculatePosition( + anchor, IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) + )) + offset = IntOffset(85, 220) + assertEquals(IntOffset(365, 400), nested.calculatePosition( + anchor, IntSize(1200, 800), LayoutDirection.Ltr, IntSize(240, 160) + )) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt index cbb46d9..a51d161 100644 --- a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt @@ -32,6 +32,7 @@ class A2lParserTest { /begin MEASUREMENT VehicleSpeed "speed" UWORD Conv 1 0 0 300 ECU_ADDRESS 0x1000 + SYMBOL_LINK "ECU.VehicleSpeed" 0 /end MEASUREMENT /begin MEASUREMENT Temperature "temp" SWORD Conv 1 0 -40 200 @@ -45,6 +46,7 @@ class A2lParserTest { assertEquals(0x690L, result.ifData!!.masterCanId) assertEquals(false, result.ifData!!.isMsbFirst) val speed = result.measurements.first { it.name == "VehicleSpeed" } + assertEquals("ECU.VehicleSpeed", result.rawMeasurements.first { it.name == "VehicleSpeed" }.symbolLink) assertEquals("%8.4", speed.measurementFormat) assertEquals("km/h", speed.unit) assertEquals("2.2350", speed.formatMeasurementValue(2.2346)) diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt index 5acbe2e..a16bc37 100644 --- a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt @@ -1,5 +1,6 @@ package com.dct.app.tests +import android.os.Environment import com.dct.app.data.repository.DpcProjectManager import com.dct.app.domain.model.mdb.CompensationMap import com.dct.app.domain.model.mdb.CompensationMapType @@ -9,6 +10,10 @@ import com.dct.app.domain.model.mdb.MdbDatabase import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.domain.model.mdb.SignalGroup import com.dct.app.domain.model.project.DpcProject +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import java.io.File import org.json.JSONObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -16,8 +21,23 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir class DpcProjectManagerTest { + @Test + fun csvMillisecondsDefaultFalseAndProjectSaveRestorePreservesThePcKey() { + assertFalse(DpcProject().csvWithMilliseconds) + assertFalse(DpcProjectManager.parseProjectFromJson("{}", null).csvWithMilliseconds) + val legacy = DpcProjectManager.parseProjectFromJson("{\"android_log_microseconds\":true}", null) + assertFalse(legacy.csvWithMilliseconds) + assertFalse(JSONObject(DpcProjectManager.serializeProjectToJson(legacy)).has("android_log_microseconds")) + listOf(false, true).forEach { enabled -> + val json = DpcProjectManager.serializeProjectToJson(DpcProject(csvWithMilliseconds = enabled)) + assertEquals(enabled, JSONObject(json).getBoolean("csv_with_miliseconds")) + assertEquals(enabled, DpcProjectManager.parseProjectFromJson(json, null).csvWithMilliseconds) + } + } + @Test fun pcProjectNameOmitsDpcExtensionWithoutChangingTheStoredFileName() { val project = DpcProject(projectFileName = "example.dpc") @@ -610,24 +630,43 @@ class DpcProjectManagerTest { } @Test - @DisplayName("ensureDctWorkDirectories 호출 시 PC 표준 8대 서브폴더가 모두 생성되어야 한다") - fun testEnsureDctWorkDirectoriesWithAllSubfolders() { - val baseDir = DpcProjectManager.ensureDctWorkDirectories() - assertTrue(baseDir.exists()) - - val expectedSubFolders = listOf( - "Configuration", - "Data", - "MDB", - "DBC", - "A2L", - "SMC", - "Log", - "Screen" - ) - for (sub in expectedSubFolders) { - val subDir = java.io.File(baseDir, sub) - assertTrue(subDir.exists(), "서브폴더 $sub 가 자동 생성되어야 합니다.") + @DisplayName("기본 6개 폴더만 만들고 미사용 Screen/SMC는 반복 초기화에도 생성하지 않는다") + fun testEnsureDctWorkDirectoriesSkipsUnusedFolders(@TempDir documents: File) { + mockkStatic(Environment::class) + try { + every { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) } returns documents + val baseDir = DpcProjectManager.ensureDctWorkDirectories() + assertEquals(File(documents, "DCT Work"), baseDir) + DpcProjectManager.ensureDctWorkDirectories() + for (sub in listOf("Configuration", "Data", "MDB", "DBC", "A2L", "Log")) { + assertTrue(File(baseDir, sub).isDirectory, "서브폴더 $sub 가 자동 생성되어야 합니다.") + } + assertFalse(File(baseDir, "Screen").exists()) + assertFalse(File(baseDir, "SMC").exists()) + } finally { + unmockkStatic(Environment::class) + } + } + + @Test + @DisplayName("기존 Screen/SMC 폴더와 파일은 초기화 시 삭제하거나 덮어쓰지 않는다") + fun testEnsureDctWorkDirectoriesPreservesExistingUnusedFolders(@TempDir documents: File) { + val base = File(documents, "DCT Work") + val screenFolder = File(base, "Screen").also { assertTrue(it.mkdirs()) } + val smcFolder = File(base, "SMC").also { assertTrue(it.mkdirs()) } + val screenFile = File(screenFolder, "screen.keep").apply { writeText("existing screen") } + val smcFile = File(smcFolder, "preset.smc").apply { writeText("existing sensor config") } + mockkStatic(Environment::class) + try { + every { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) } returns documents + assertEquals(base, DpcProjectManager.ensureDctWorkDirectories()) + DpcProjectManager.ensureDctWorkDirectories() + assertEquals("existing screen", screenFile.readText()) + assertEquals("existing sensor config", smcFile.readText()) + assertEquals(listOf("screen.keep"), screenFolder.list()?.toList()) + assertEquals(listOf("preset.smc"), smcFolder.list()?.toList()) + } finally { + unmockkStatic(Environment::class) } } diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt index e19e40f..1da7e71 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt @@ -43,7 +43,7 @@ class MeasurementLoggerTest { assertTrue(lines[3].contains("12,")) assertTrue(lines[4].contains("[0xA3-12]")) assertTrue(lines[6].startsWith(" Time, Count,")) - assertTrue(lines[7].matches(Regex("\\d{2}:\\d{2}:\\d{2}\\.\\d{3}, 0,1500,No value"))) + assertTrue(lines[7].matches(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}, 0,1500,No value"))) assertTrue(lines[8].endsWith(",1512,87.13")) } @@ -150,18 +150,21 @@ class MeasurementLoggerTest { val dtOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // dg_data assertTrue(dtOffset > cgOffset) - // 4. CG Block (232..295, 64B) + // ASAM 4.10 CG: 24-byte common header, six links, 32-byte data. raf.seek(cgOffset) raf.readFully(buf4) assertEquals("##CG", String(buf4, Charsets.US_ASCII)) raf.seek(cgOffset + 8) - assertEquals(64L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CG length + assertEquals(104L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CG length + assertEquals(6L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) + raf.readFully(buf8) // next CG val firstCnOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cg_cn_first - assertEquals(296L, firstCnOffset) + assertEquals(336L, firstCnOffset) + raf.seek(cgOffset + 72) val recordId = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // record_id assertEquals(0L, recordId) - // [ISSUE-R2-02 검증] cycle_count 백필 위치가 CG 블록 내 32바이트 위치 (232 + 32 = 264)인지 검증 + // cycle_count follows all six links and record_id: CG + 80. val cycleCount = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cycle_count assertEquals(2L, cycleCount, "stop() 후 cycle_count는 기록된 레코드 수(2)와 일치해야 합니다.") @@ -170,7 +173,7 @@ class MeasurementLoggerTest { raf.readFully(buf4) assertEquals("##CN", String(buf4, Charsets.US_ASCII)) raf.seek(firstCnOffset + 8) - assertEquals(128L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CN length + assertEquals(160L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // CN length assertEquals(8L, java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long) // link count val secondCnOffset = java.nio.ByteBuffer.wrap(buf8.apply { raf.readFully(this) }).order(java.nio.ByteOrder.LITTLE_ENDIAN).long // cn_next assertTrue(secondCnOffset > firstCnOffset) diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt new file mode 100644 index 0000000..a1d95ec --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt @@ -0,0 +1,100 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.logger.PcCsvColumn +import com.dct.app.data.logger.PcMf4Channel +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MeasurementRecordingState +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +@Suppress("UNCHECKED_CAST") +class MeasurementRecordingConcurrencyTest { + @TempDir lateinit var directory: File + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + + @Test fun stopAckAndDisconnectCannotSplitAnInFlightCsvMf4Sample() { + // Both automatic termination and explicit recording-stop share the actual close path. + for (termination in listOf("finishMeasurementSession", "closeMeasurementLoggers")) { + val fixture = MainViewModelTest().also { it.setUp() } + val vm = field(fixture, "viewModel").get(fixture) as MainViewModel + val betweenFormats = CountDownLatch(1) + val releaseSample = CountDownLatch(1) + val stopping = CountDownLatch(1) + val armed = AtomicBoolean(false) + val executor = Executors.newFixedThreadPool(2) + val blockedOnce = AtomicBoolean(false) + val headers = object : AbstractList>() { + override val size = 1 + override fun get(index: Int): Pair { + if (armed.get() && blockedOnce.compareAndSet(false, true)) { + betweenFormats.countDown() + check(releaseSample.await(10, TimeUnit.SECONDS)) + } + return "Incar_Snr" to "C" + } + } + val csv = CsvMeasurementLogger(directory, listOf("Incar_Snr" to "C"), "$termination.csv", listOf(PcCsvColumn())) + val mf4 = Mf4MeasurementLogger(directory, headers, "$termination.mf4", + listOf(PcMf4Channel("Incar_Snr", "C", bitCount = 32, physicalInput = true))) + try { + vm.setCurrentProject(DpcProject(writeCsv = false, writeMf4 = false)) + assertNotNull(csv.start()) + assertNotNull(mf4.start(0L)) + (field(vm, "activeCsvLoggers").get(vm) as MutableList).add(csv) + (field(vm, "activeMf4Loggers").get(vm) as MutableList).add(mf4) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(isMeasuring = true, recordingState = MeasurementRecordingState.RECORDING) + val packet = DctPacket(0x02, ByteArray(18).also { + val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) + buffer.position(2) + buffer.putFloat(15f).putFloat(2f).putFloat(3f).putFloat(4f) + }) + val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, Double::class.javaPrimitiveType).also { it.isAccessible = true } + armed.set(true) + val writing = executor.submit { process.invoke(vm, packet, 0.5) } + assertTrue(betweenFormats.await(5, TimeUnit.SECONDS)) + val stop = executor.submit { + stopping.countDown() + MainViewModel::class.java.getDeclaredMethod(termination).also { it.isAccessible = true }.invoke(vm) + } + assertTrue(stopping.await(5, TimeUnit.SECONDS)) + assertThrows(TimeoutException::class.java) { stop.get(100, TimeUnit.MILLISECONDS) } + releaseSample.countDown() + writing.get(5, TimeUnit.SECONDS) + stop.get(5, TimeUnit.SECONDS) + assertEquals(1, csv.outputFile!!.readLines().drop(7).size) + val binary = ByteBuffer.wrap(mf4.outputFile!!.readBytes()).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(1L, binary.getLong(232 + 80)) + assertEquals(1L, vm.uiState.value.recordingSampleCount) + // A late packet after close must not reopen or extend either file. + process.invoke(vm, packet, 1.0) + assertEquals(1, csv.outputFile!!.readLines().drop(7).size) + assertEquals(1L, vm.uiState.value.recordingSampleCount) + } finally { + releaseSample.countDown() + executor.shutdown() + executor.awaitTermination(10, TimeUnit.SECONDS) + csv.stop() + mf4.stop() + vm.scope.cancel() + fixture.tearDown() + } + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt new file mode 100644 index 0000000..ced4cd3 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt @@ -0,0 +1,168 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.logger.PcCsvColumn +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class Mf4RecordingRegressionTest { + @TempDir lateinit var directory: File + private val startedAt = 1_790_923_490_000L + private fun bytes(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) + private fun dt(buffer: ByteBuffer) = buffer.getLong(168 + 40).toInt() + private fun export(file: File) { + // Optional artifacts for an independent asammdf check, not an app dependency. + System.getenv("DCT_RECORDING_FIXTURES_DIR")?.let { path -> + File(path).apply { mkdirs() }.resolve(file.name).also { file.copyTo(it, overwrite = true) } + } + } + + @Test fun epochInputBecomesRelativeTimeWithoutChangingPhysicalValuesOrUnits() { + val headers = listOf("온도" to "°C", "Speed" to "km/h", "Missing" to "") + val logger = Mf4MeasurementLogger(directory, headers, "relative.mf4") + val file = logger.start(startedAt)!! + logger.logSample(startedAt / 1000.0 + 0.125, listOf(-12.5, 87.25, null)) + logger.logSample(startedAt / 1000.0 + 0.5, listOf(3.75, 88.0, Double.POSITIVE_INFINITY)) + logger.stop() + val data = bytes(file) + assertEquals(410, data.getShort(28).toInt()) + assertEquals(startedAt * 1_000_000, data.getLong(64 + 72)) + assertEquals(6L, data.getLong(232 + 16)) + assertEquals(2L, data.getLong(232 + 80)) + assertEquals(0.125, data.getDouble(dt(data) + 24), 0.000001) + assertEquals(0.5, data.getDouble(dt(data) + 24 + 32), 0.000001) + assertEquals(-12.5, data.getDouble(dt(data) + 32)) + assertTrue(data.getDouble(dt(data) + 48).isNaN()) + assertTrue(data.getDouble(dt(data) + 80).isNaN()) + export(file) + } + + @Test fun emptyMeasurementAndTimeOnlyFilesAreFinalized() { + for ((name, headers) in listOf("empty.mf4" to listOf("Signal" to "V"), + "time_only.mf4" to emptyList())) { + val logger = Mf4MeasurementLogger(directory, headers, name) + val file = logger.start(startedAt)!! + logger.stop() + logger.stop() // Idempotent close must not damage an empty recording. + val data = bytes(file) + assertEquals(0L, data.getLong(232 + 80)) + assertEquals(24L, data.getLong(dt(data) + 8)) + assertEquals(file.length(), dt(data) + 24L) + export(file) + } + } + + @Test fun wideDbcRecordingKeepsAllChannelsAndMissingTail() { + val headers = (0 until 4939).map { "DBC_$it" to if (it % 2 == 0) "V" else "" } + val logger = Mf4MeasurementLogger(directory, headers, "wide.mf4") + val file = logger.start(startedAt)!! + logger.logSample(startedAt / 1000.0 + 0.25, listOf(123.5)) + logger.stop() + val data = bytes(file) + assertEquals(1L, data.getLong(232 + 80)) + assertEquals(24L + 4940 * 8, data.getLong(dt(data) + 8)) + assertTrue(data.getDouble(data.limit() - 8).isNaN()) + export(file) + } + + @Test fun loggerRestartUsesNewOriginAndInvalidTimeDoesNotAddASample() { + val logger = Mf4MeasurementLogger(directory, listOf("Signal" to "V"), "restart.mf4") + assertNotNull(logger.start(startedAt)) + logger.logSample(startedAt / 1000.0 + 1.0, listOf(1.0)) + logger.stop() + val file = logger.start(startedAt + 5000)!! + logger.logSample(Double.NaN, listOf(99.0)) + logger.logSample((startedAt + 5000) / 1000.0 + 0.25, listOf(2.0)) + logger.stop() + val data = bytes(file) + assertEquals(1L, data.getLong(232 + 80)) + assertEquals(0.25, data.getDouble(dt(data) + 24), 0.000001) + export(file) + } + + @Test fun pcCsvUsesPacketTimeRatherThanDiskWriteTimeAndMatchesMf4Rows() { + val headers = listOf("Signal" to "V") + val csv = CsvMeasurementLogger(directory, headers, "paired.csv", listOf(PcCsvColumn("0", "[0x1]")), + withMilliseconds = true) + val mf4 = Mf4MeasurementLogger(directory, headers, "paired.mf4") + val csvFile = csv.start()!! + val mf4File = mf4.start(startedAt)!! + for ((offset, value) in listOf(0.125 to 12.25, 0.5 to -3.5)) { + val packetTime = startedAt / 1000.0 + offset + csv.logSample(packetTime, listOf(value)) + mf4.logSample(packetTime, listOf(value)) + } + csv.stop() + mf4.stop() + val rows = csvFile.readLines().drop(7) + assertEquals(2, rows.size) + assertTrue(rows[0].startsWith(SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(startedAt + 125)))) + assertTrue(rows[0].endsWith(", 0,12.250")) + assertTrue(rows[1].endsWith(", 1,-3.500")) + assertEquals(rows.size.toLong(), bytes(mf4File).getLong(232 + 80)) + export(csvFile) + export(mf4File) + } + + @Test fun queuedPacketsBeforeSharedOriginKeepTheirCsvTimeAcrossSources() { + // Reproduce HE1i: ingress .905/.906 precedes the common header origin .915. + val originMillis = startedAt + 915 + val originSec = originMillis / 1000.0 + for ((source, earlyMillis) in listOf("mdb" to 905, "dbc" to 906, "formula" to 905)) { + val headers = listOf("Signal" to "V") + val csv = CsvMeasurementLogger(directory, headers, "queued_$source.csv", + listOf(PcCsvColumn("0", "[0x1]")), withMilliseconds = true) + val mf4 = Mf4MeasurementLogger(directory, headers, "queued_$source.mf4") + val csvFile = csv.start()!! + val mf4File = mf4.start(originMillis)!! + val packetMillis = listOf(startedAt + earlyMillis, originMillis, originMillis + 125) + for ((index, millis) in packetMillis.withIndex()) { + val packetSec = millis / 1000.0 + csv.logSample(packetSec, listOf(index.toDouble())) + mf4.logSample(packetSec, listOf(index.toDouble())) + } + csv.stop() + mf4.stop() + val data = bytes(mf4File) + val rows = csvFile.readLines().drop(7) + assertEquals(originMillis * 1_000_000, data.getLong(64 + 72)) + assertEquals(3L, data.getLong(232 + 80)) + assertEquals(3, rows.size) + assertTrue(data.getDouble(dt(data) + 24) < 0.0, "Queued ingress must not be clamped to zero") + for ((index, millis) in packetMillis.withIndex()) { + val relativeSec = data.getDouble(dt(data) + 24 + index * 16) + assertEquals(millis / 1000.0 - originSec, relativeSec, 0.000001) + assertEquals(millis / 1000.0, originSec + relativeSec, 0.000001) + val expectedClock = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(millis)) + assertTrue(rows[index].startsWith("$expectedClock, $index,")) + assertEquals(index.toDouble(), data.getDouble(dt(data) + 32 + index * 16)) + } + export(csvFile) + export(mf4File) + } + } + + @Test fun queuedPacketAcrossMidnightKeepsThePreviousDayIngressTime() { + val originMillis = 1_790_985_600_005L + val originSec = originMillis / 1000.0 + val logger = Mf4MeasurementLogger(directory, listOf("Signal" to "V"), "queued_midnight.mf4") + val file = logger.start(originMillis)!! + val packetMillis = originMillis - 10 + logger.logSample(packetMillis / 1000.0, listOf(7.0)) + logger.stop() + val data = bytes(file) + val relativeSec = data.getDouble(dt(data) + 24) + assertEquals(-0.01, relativeSec, 0.000001) + assertEquals(packetMillis / 1000.0, originSec + relativeSec, 0.000001) + assertEquals(1L, data.getLong(232 + 80)) + export(file) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt new file mode 100644 index 0000000..636a6fc --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt @@ -0,0 +1,114 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.PcCsvColumn +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.mdb.SignalGroup +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MeasurementRecordingState +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +class PcCsvTimestampTest { + @TempDir lateinit var folder: File + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + + @Test fun wholeSecondDisplayPreservesEverySampleAndTheDateAcrossMidnight() { + val start = Calendar.getInstance().apply { + clear(); set(2026, Calendar.OCTOBER, 6, 23, 59, 59); set(Calendar.MILLISECOND, 100) + }.timeInMillis + for (milliseconds in listOf(false, true)) { + val logger = CsvMeasurementLogger(folder, listOf("Value" to "V"), "midnight-$milliseconds.csv", + listOf(PcCsvColumn()), withMilliseconds = milliseconds) + val file = logger.start()!! + listOf(0L, 100L, 900L).forEachIndexed { index, delta -> + logger.logSample((start + delta) / 1000.0, listOf(index.toDouble())) + } + logger.stop() + val times = if (milliseconds) listOf("2026-10-06 23:59:59.100", "2026-10-06 23:59:59.200", "2026-10-07 00:00:00.000") + else listOf("2026-10-06 23:59:59", "2026-10-06 23:59:59", "2026-10-07 00:00:00") + val rows = file.readLines().drop(7) + assertEquals(3, rows.size) + rows.forEachIndexed { index, row -> assertEquals("${times[index]}, $index,${index}.000", row) } + } + } + + @Test fun existingPcTimestampKeysSurviveProjectSaveAndRestore() { + for (enabled in listOf(false, true)) for (interval in listOf(0, 1, 5)) { + val raw = JSONObject().put("csv_with_miliseconds", enabled).put("save_interval", interval) + val json = DpcProjectManager.serializeProjectToJson(DpcProject(rawJsonRoot = raw, csvWithMilliseconds = enabled, + baseFolder = folder.absolutePath, measuredFilesFolder = folder.resolve("Data").absolutePath)) + val restored = DpcProjectManager.parseProjectFromJson(json, null).rawJsonRoot!! + assertEquals(enabled, restored.getBoolean("csv_with_miliseconds")) + assertEquals(interval, restored.getInt("save_interval")) + } + } + + @Suppress("UNCHECKED_CAST") + @Test fun actualRecordingUsesCsvChoiceRegardlessOfSaveIntervalOrObsoleteTxtSetting() { + val cases = listOf("{}" to false, + "{\"csv_with_miliseconds\":false,\"save_interval\":0}" to false, + "{\"android_log_microseconds\":true}" to false, + "{\"csv_with_miliseconds\":true}" to true, + "{\"csv_with_miliseconds\":true,\"save_interval\":1}" to true, + "{\"csv_with_miliseconds\":true,\"save_interval\":0}" to true) + for ((settings, milliseconds) in cases) { + val fixture = MainViewModelTest().also { it.setUp() } + val vm = field(fixture, "viewModel").get(fixture) as MainViewModel + try { + vm.setCurrentProject(DpcProjectManager.parseProjectFromJson(settings, null).copy( + projectFileName = "CSV.dpc", mdbFileName = "fixture.mdb", + measuredFilesFolder = folder.absolutePath, writeCsv = true, writeMf4 = false)) + val signal = SignalDefinition(1, 1, "Value", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "V", 0L, 0.0, 255.0) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(isConnected = true, isMeasuring = true, + recordingState = MeasurementRecordingState.IDLE, + mdbDatabase = MdbDatabase("fixture.mdb", listOf(SignalGroup(1, "G", "", true, signals = listOf(signal))))) + vm.startMeasurementRecording() + assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) + vm.setCsvWithMilliseconds(!milliseconds) // Current file must keep its starting format. + val time = vm.uiState.value.recordingStartedAtMillis!! + 400 + val packet = DctPacket(0xA3.toByte(), byteArrayOf(1, 1, 7), receivedAtMillis = time) + MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, + Double::class.javaPrimitiveType).also { it.isAccessible = true }.invoke(vm, packet, time / 1000.0) + val logger = (field(vm, "activeCsvLoggers").get(vm) as List) + .single { it.outputFile!!.name.endsWith("_mdb.csv") } + vm.stopMeasurementRecording() + val pattern = if (milliseconds) "yyyy-MM-dd HH:mm:ss.SSS" else "yyyy-MM-dd HH:mm:ss" + val expected = SimpleDateFormat(pattern, Locale.US).format(Date(time)) + assertEquals(listOf("$expected, 0,7"), logger.outputFile!!.readLines().drop(7)) + vm.startMeasurementRecording() + assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) + val nextTime = vm.uiState.value.recordingStartedAtMillis!! + 123 + MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, + Double::class.javaPrimitiveType).also { it.isAccessible = true }.invoke(vm, + packet.copy(receivedAtMillis = nextTime), nextTime / 1000.0) + val nextLogger = (field(vm, "activeCsvLoggers").get(vm) as List) + .single { it.outputFile!!.name.endsWith("_mdb.csv") } + vm.stopMeasurementRecording() + val nextPattern = if (milliseconds) "yyyy-MM-dd HH:mm:ss" else "yyyy-MM-dd HH:mm:ss.SSS" + val nextExpected = SimpleDateFormat(nextPattern, Locale.US).format(Date(nextTime)) + assertEquals(listOf("$nextExpected, 0,7"), nextLogger.outputFile!!.readLines().drop(7)) + } finally { + vm.stopMeasurementRecording() + vm.scope.cancel() + fixture.tearDown() + } + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt new file mode 100644 index 0000000..fc1e574 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt @@ -0,0 +1,268 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.logger.PcMf4Channel +import com.dct.app.data.parser.A2lParser +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.mdb.SignalGroup +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MeasurementRecordingState +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.Inflater + +class PcMf4CompatibilityTest { + @TempDir lateinit var folder: File + private fun bytes(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) + private fun export(file: File) { + System.getenv("DCT_PC_MF4_INPUT")?.let { root -> file.copyTo(File(root, file.name), overwrite = true) } + } + + private fun records(file: File): ByteBuffer { + val fileBytes = bytes(file) + val size = fileBytes.getInt(232 + 96) + val hl = fileBytes.getLong(168 + 40).toInt() + assertEquals("##HL", String(fileBytes.array(), hl, 4)) + assertEquals(1, fileBytes.get(hl + 34).toInt()) + var dl = fileBytes.getLong(hl + 24).toInt() + val output = java.io.ByteArrayOutputStream() + while (dl != 0) { + val dz = fileBytes.getLong(dl + 32).toInt() + assertEquals("##DZ", String(fileBytes.array(), dz, 4)) + assertEquals(size, fileBytes.getInt(dz + 28)) + val rawSize = fileBytes.getLong(dz + 32).toInt() + val zipSize = fileBytes.getLong(dz + 40).toInt() + val transposed = ByteArray(rawSize) + val inflater = Inflater() + try { + inflater.setInput(fileBytes.array(), dz + 48, zipSize) + var read = 0 + while (!inflater.finished() && read < rawSize) { + val amount = inflater.inflate(transposed, read, rawSize - read) + assertTrue(amount > 0, "Truncated compressed record data") + read += amount + } + assertEquals(rawSize, read) + } finally { inflater.end() } + val rows = rawSize / size + val raw = ByteArray(rawSize) + for (column in 0 until size) for (row in 0 until rows) raw[row * size + column] = transposed[column * rows + row] + output.write(raw) + dl = fileBytes.getLong(dl + 24).toInt() + } + return ByteBuffer.wrap(output.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + } + + @Test fun rawSignedWidthsAndConversionDoNotRoundTripThroughPhysicalDouble() { + val channels = listOf(PcMf4Channel("U8", dataType=0, bitCount=8), + PcMf4Channel("S16", "°C", 2, 16, factor=.1, offset=-40.0, description="sensor", + sourceName="sensor", sourcePath="ECU.sensor"), + PcMf4Channel("U64", dataType=0, bitCount=64), PcMf4Channel("F32", bitCount=32)) + val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "raw.mf4", channels) + val file = logger.start(1000)!! + logger.logRawSampleAtMillis(1125, listOf(255, -134, -1L, 3.75)) + logger.logRawSampleAtMillis(1500, listOf(null, null, null, null)) + logger.stop() + val record = records(file) + assertEquals(23, bytes(file).getInt(232 + 96)) + assertEquals(.125, record.double) + assertEquals(255, record.get().toInt() and 255) + assertEquals(-134, record.short.toInt()) + assertEquals(-1L, record.long) // All 64 uint bits survive without Double conversion. + assertEquals(3.75f, record.float) + assertEquals(.5, record.double) + assertEquals(255, record.get().toInt() and 255) + assertEquals(-134, record.short.toInt()) // PC preserves previous values for other messages. + assertEquals(-1L, record.long) + assertEquals(3.75f, record.float) + val data = bytes(file) + assertEquals(9, data.getShort(64 + 80).toInt()) + assertTrue(data.getLong(64 + 32) > 0) + assertTrue(data.getLong(64 + 64) > 0) + val secondCn = data.getLong(336 + 24).toInt() + val thirdCn = data.getLong(secondCn + 24).toInt() + assertTrue(data.getLong(thirdCn + 48) > 0) + assertTrue(data.getLong(thirdCn + 56) > 0) + } + + @Test fun productionA2lDefinitionsPreserveNativeTypesUnitsSourceAndNonlinearConversions() { + val base = SignalDefinition(0, 1, "Rational", MdbAttrType.ATTR_3_INT16, 1.0, 0.0, "C", 0L, -100.0, 100.0, + description = "ECU measurement") + val measurement = A2lParser.A2lMeasurement("Rational", unit = "degC", symbolLink = "ECU.Rational") + for (type in A2lDataType.entries) { + val definition = PcMf4Channel.a2l(base.copy(a2lDataType = type), measurement to null) + assertEquals(if (type.isFloat) 4 else if (type.isSigned) 2 else 0, definition.dataType) + assertEquals(if (type == A2lDataType.FLOAT16_IEEE) 32 else type.byteSize * 8, definition.bitCount) + assertEquals("degC", definition.unit) // MF4 retains the original unit, not the UI alias. + assertEquals("ECU.Rational", definition.sourcePath) + assertEquals(-100.0 to 100.0, definition.rawRange) + } + val rational = A2lParser.CompuMethod("R", conversionType = "RAT_FUNC", + cvsA = 1.0, cvsB = 2.0, cvsC = 3.0, cvsD = 0.0, cvsE = 0.0, cvsF = 1.0) + val table = A2lParser.CompuMethod("T", conversionType = "TAB_INTP", tabIntpPoints = listOf(0.0 to 0.0, 10.0 to 100.0)) + val channels = listOf(PcMf4Channel.a2l(base.copy(a2lDataType = A2lDataType.SWORD), measurement to rational), + PcMf4Channel.a2l(base.copy(name = "Table", a2lDataType = A2lDataType.SWORD), + measurement.copy(name = "Table", symbolLink = "ECU.Table") to table)) + assertEquals(listOf(2, 4), channels.map { it.conversionType }) + val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "conversions-pc.mf4", channels) + val file = logger.start(1000)!! + logger.logRawSampleAtMillis(1000, listOf(2, 2)) + logger.logRawSampleAtMillis(1125, listOf(5, 5)) + logger.stop() + export(file) // Independent reader verifies CC evaluation, not only the parameter bytes. + } + + @Test fun compressedChunksStayBoundedAndFollowTheDataListChain() { + val channels = listOf(PcMf4Channel("Count", dataType=0, bitCount=16)) + val logger = Mf4MeasurementLogger(folder, listOf("Count" to ""), "chunks.mf4", channels) + val file = logger.start(1000)!! + repeat(10_003) { logger.logRawSampleAtMillis(1000L + it, listOf(it)) } + logger.stop(); logger.stop() + val raw = records(file) + assertEquals(10_003 * 10, raw.remaining()) + repeat(10_003) { assertEquals(it / 1000.0, raw.double); assertEquals(it, raw.short.toInt() and 65535) } + assertEquals(10_003L, bytes(file).getLong(232 + 80)) + export(file) + } + + @Test fun emptyCompressedFileAndRestartRemainFinalized() { + val channel = PcMf4Channel("Value", dataType=2, bitCount=16) + val logger = Mf4MeasurementLogger(folder, listOf("Value" to ""), "empty-pc.mf4", listOf(channel)) + val file = logger.start(1000)!! + logger.stop(); assertEquals(0, records(file).remaining()); export(file) + logger.start(2000) + logger.logRawSampleAtMillis(1990, listOf(-2)) // Preserve the previously fixed queued ingress case. + logger.stop() + val raw = records(file) + assertEquals(-.01, raw.double); assertEquals(-2, raw.short.toInt()) + assertEquals(1L, bytes(file).getLong(232 + 80)) + export(file.copyTo(File(folder, "queued-pc.mf4"))) + } + + @Test fun newRecordingSeedsLiveRawStateAndAnExplicitNanDoesNotKeepAnOldFormulaValue() { + val channels = listOf(PcMf4Channel("Raw", dataType=0, bitCount=16), + PcMf4Channel("Formula", bitCount=32, physicalInput=true)) + val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "seeded-pc.mf4", channels) + val file = logger.start(1000, mapOf("Raw" to 65535), mapOf("Formula" to 1.5))!! + logger.logRawSampleAtMillis(1000, listOf(null, null)) + logger.logRawSampleAtMillis(1125, listOf(null, Double.NaN)) + logger.logRawSampleAtMillis(1500, listOf(null, null)) + logger.stop() + val raw = records(file) + assertEquals(0.0, raw.double); assertEquals(65535, raw.short.toInt() and 65535); assertEquals(1.5f, raw.float) + assertEquals(.125, raw.double); assertEquals(65535, raw.short.toInt() and 65535); assertTrue(raw.float.isNaN()) + assertEquals(.5, raw.double); assertEquals(65535, raw.short.toInt() and 65535); assertTrue(raw.float.isNaN()) + } + + @Suppress("UNCHECKED_CAST") + @Test fun realRecordingStartAndRestartKeepLiveRawValuesFromOtherMdbMessages() { + fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + val fixture = MainViewModelTest().also { it.setUp() } + val vm = field(fixture, "viewModel").get(fixture) as MainViewModel + val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, + Double::class.javaPrimitiveType).also { it.isAccessible = true } + val close = MainViewModel::class.java.getDeclaredMethod("closeMeasurementLoggers").also { it.isAccessible = true } + fun receive(recordId: Int, value: Int, time: Long) { + val payload = if (recordId == 1) byteArrayOf(1, 1, value.toByte()) + else byteArrayOf(1, 2, (value ushr 8).toByte(), value.toByte()) + process.invoke(vm, DctPacket(0xA3.toByte(), payload, receivedAtMillis = time), time / 1000.0) + } + try { + vm.setCurrentProject(DpcProject(mdbFileName = "fixture.mdb", projectFileName = "MID.dpc", + measuredFilesFolder = folder.absolutePath, writeCsv = false, writeMf4 = true)) + val a = SignalDefinition(1, 1, "A", MdbAttrType.ATTR_0_UINT8, 2.0, -10.0, "V", 0L, 0.0, 255.0) + val b = SignalDefinition(2, 2, "B", MdbAttrType.ATTR_2_UINT16, .1, -40.0, "C", 0L, 0.0, 65535.0) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(isConnected = true, isMeasuring = true, + recordingState = MeasurementRecordingState.IDLE, + mdbDatabase = MdbDatabase("fixture.mdb", listOf( + SignalGroup(1, "A", "", true, signals = listOf(a)), + SignalGroup(2, "B", "", true, signals = listOf(b))))) + receive(1, 12, System.currentTimeMillis()) + receive(2, 500, System.currentTimeMillis()) + repeat(2) { recording -> + vm.startMeasurementRecording() + assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) + val start = vm.uiState.value.recordingStartedAtMillis!! + // Only one message arrives in each file. The other channel must come from live raw state. + receive(if (recording == 0) 1 else 2, if (recording == 0) 20 else 600, start + 125) + val logger = (field(vm, "activeMf4Loggers").get(vm) as List) + .single { it.outputFile!!.name.endsWith("_mdb.mf4") } + vm.stopMeasurementRecording() + assertEquals(MeasurementRecordingState.IDLE, vm.uiState.value.recordingState) + val raw = records(logger.outputFile!!) + assertEquals(16, raw.remaining()) // Exactly one sample: Time + 2 signed32 channels. + assertEquals(.125, raw.double) + assertEquals(20, raw.int) // Not the physical value 30, including on restart. + assertEquals(if (recording == 0) 500 else 600, raw.int) // Not physical 10/20. + } + } finally { + close.invoke(vm) + vm.scope.cancel() + fixture.tearDown() + } + } + + @Test fun wideTypedRecordingUsesBoundedCompressedChunksWithoutDroppingTailChannels() { + val channels = (0 until 4939).map { PcMf4Channel("DBC_$it", dataType=0, bitCount=32) } + val logger = Mf4MeasurementLogger(folder, channels.map { it.name to it.unit }, "wide-pc.mf4", channels) + val file = logger.start(1000)!! + repeat(400) { logger.logRawSampleAtMillis(1000L + it, listOf(it)) } + logger.stop() + val record = records(file) + assertEquals(19764, bytes(file).getInt(232 + 96)) + repeat(400) { row -> + assertEquals(row / 1000.0, record.double) + assertEquals(row, record.int) + repeat(4938) { assertEquals(0, record.int) } + } + export(file) + } + + @Test fun regenerateCommonInputForIndependentPcDllComparison() { + val path = System.getenv("DCT_PC_MF4_INPUT") + assumeTrue(path != null, "Optional PC raw-input artifacts for independent comparison") + val root = File(path!!) + for (name in listOf("reference", "synthetic")) { + val input = JSONObject(File(root, "$name.json").readText()) + val definitions = input.getJSONArray("channels") + val channels = (1 until definitions.length()).map { index -> + val c = definitions.getJSONObject(index) + val source = c.optJSONObject("source") + val conversion = c.optJSONObject("conversion") + val parameters = conversion?.getJSONArray("parameters") + PcMf4Channel(c.getString("name"), c.getString("unit"), c.getInt("data_type"), c.getInt("bit_count"), + factor=parameters?.getDouble(1) ?: 1.0, offset=parameters?.getDouble(0) ?: 0.0, + description=c.getString("description"), sourceName=source?.getString("name"), + sourcePath=source?.getString("path").orEmpty(), sourceComment=source?.getString("comment").orEmpty(), + rawRange=if(c.getInt("flags") and 8 != 0) c.getDouble("min_raw") to c.getDouble("max_raw") else null) + } + val logger = Mf4MeasurementLogger(root, channels.map { it.name to it.unit }, "android-$name.mf4", channels) + val start = input.getLong("start_ns") / 1_000_000L + val file = logger.start(start)!! + val samples = input.getJSONArray("records") + repeat(samples.length()) { index -> + val row = samples.getJSONArray(index) + logger.logRawSampleAtMillis(start + Math.round(row.getDouble(0) * 1000), + (1 until row.length()).map { if (row.isNull(it)) null else row.get(it) as Number }) + } + logger.stop() + assertEquals(input.getInt("record_size"), bytes(file).getInt(232 + 96)) + assertEquals(samples.length().toLong(), bytes(file).getLong(232 + 80)) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt index d7df872..2028e83 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt @@ -74,6 +74,24 @@ class PcOperationalLoggerTest { assertEquals(2, files("txt").sumOf { it.readLines().size }) } + @Test fun fixedPcMillisecondsKeepEveryPacketInOrderEvenWithEqualTimestamps() = runTest { + val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) + logger.connected(1, "PC") + val expected = (1..4).map { DctPacket(0xA3.toByte(), byteArrayOf(it.toByte())) } + logger.frame(1, "Recv", codec.encode(expected[0]), time) + logger.frame(1, "Recv", codec.encode(expected[1]), time) + logger.frame(1, "Recv", codec.encode(expected[2]), time) + logger.frame(1, "Send", codec.encode(expected[3]), time + 211) + logger.close(); logger.awaitClosed() + val lines = files("txt").single().readLines() + assertEquals(4, lines.size) + assertTrue(lines[0].startsWith("[Recv][12:34:56.789]")) + assertTrue(lines[1].startsWith("[Recv][12:34:56.789]")) + assertTrue(lines[2].startsWith("[Recv][12:34:56.789]")) + assertTrue(lines[3].startsWith("[Send][12:34:57.000]")) + assertEquals(expected, lines.map { codec.decode(decodeLine(it)) }) + } + @Test fun sameSecondAppStartsPreservePreviousCsv() = runTest { repeat(2) { val logger = PcOperationalLogger({ folder }, StandardTestDispatcher(testScheduler), { time }) @@ -153,6 +171,7 @@ class PcOperationalLoggerTest { val lines = files("txt").single().readLines() assertEquals(1, lines.count { it.startsWith("[Recv]") }) assertEquals(1, lines.count { it.startsWith("[Send]") }) + lines.forEach { assertTrue(it.matches(Regex("\\[(Recv|Send)]\\[\\d{2}:\\d{2}:\\d{2}\\.\\d{3}] .*"))) } lines.forEach { assertEquals(packet, codec.decode(decodeLine(it))) } val csv = files("csv").single().readText() assertTrue(csv.contains("Connected 127.0.0.1")) diff --git a/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt b/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt index e4f40db..2c6063b 100644 --- a/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt @@ -123,7 +123,10 @@ class XcpEngineTest { write(0x96) }.toByteArray() - val decoded = decoder.decodeDaqPayload(payload) + val result = decoder.decodeDaqPayloadResult(payload, captureRawValues = true) as XcpDaqStreamDecoder.DecodeResult.Success + assertEquals(4000.0, result.rawValues["Engine_Speed"]!!.toDouble(), 0.0) + assertEquals(150.0, result.rawValues["Throttle_Pos"]!!.toDouble(), 0.0) + val decoded = result.values assertEquals(1000.0, decoded["Engine_Speed"] ?: 0.0, 0.001) assertEquals(75.0, decoded["Throttle_Pos"] ?: 0.0, 0.001) assertEquals(1000.0, decoded["a2l_4096"] ?: 0.0, 0.001) // 0x1000 = 4096 @@ -133,4 +136,23 @@ class XcpEngineTest { assertTrue(decoder.decodeDaqPayload(wrongType).isEmpty()) } + @Test + fun rawUInt64BitsSurviveBothWireByteOrdersWithoutPhysicalDoubleRounding() { + for (msb in listOf(false, true)) { + val decoder = XcpDaqStreamDecoder() + val signal = SignalDefinition(0, 0, "Counter", MdbAttrType.ATTR_4_UINT32, + 1.0, 0.0, "", 0L, 0.0, 0.0, a2lDataType = A2lDataType.UINT64) + decoder.configureDaqSignals(listOf(signal), isMsbFirst = msb) + val bits = 0xFEDCBA9876543210uL.toLong() + val data = java.nio.ByteBuffer.allocate(8).order(if (msb) + java.nio.ByteOrder.BIG_ENDIAN else java.nio.ByteOrder.LITTLE_ENDIAN).putLong(bits).array() + val payload = byteArrayOf(0, 1, A2lDataType.UINT64.code.toByte()) + data + val result = decoder.decodeDaqPayloadResult(payload, captureRawValues = true) as XcpDaqStreamDecoder.DecodeResult.Success + assertEquals(bits, result.rawValues["Counter"]) + assertTrue((decoder.decodeDaqPayloadResult(payload) as XcpDaqStreamDecoder.DecodeResult.Success).rawValues.isEmpty()) + decoder.clear() + assertTrue(decoder.recordingMetadata().isEmpty()) + } + } + } diff --git a/Android/scripts/PcMdf4Reference.cs b/Android/scripts/PcMdf4Reference.cs new file mode 100644 index 0000000..3dbce1e --- /dev/null +++ b/Android/scripts/PcMdf4Reference.cs @@ -0,0 +1,82 @@ +// Read-only reference harness for the PC's bundled Windows x86 MDF4Writer.dll. +// Mirrors WriteA2LMDF4.cpp's DLL branch and accepts common raw input JSON. +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Web.Script.Serialization; + +class PcMdf4Reference { + const string Dll = "MDF4Writer.dll"; + [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern bool SetDllDirectory(string path); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern IntPtr M4WRInitDll(); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRExitDll(IntPtr h); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern void M4WRSetPathName(IntPtr h, string path); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRCreateMDF4(IntPtr h, string tool, string vendor, string version, string comment, IntPtr mdfVersion); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRFileDescriptionEx(IntPtr h, string comment, int quality, double start, ushort tz, ushort dst, byte flags); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern void M4WRAddGroup64(IntPtr h, string name, long count, out IntPtr group); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRAddTimeSignal(IntPtr h, int group, string name, int type, int first, int bits, string unit, double factor, double offset, out int signal); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRAddSignal(IntPtr h, int group, string name, string label, int type, int first, int bits, string unit, double factor, double offset, int hasNoValue, double noValue, int invalidBit, out int signal); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] static extern bool M4WRSignalDescription(IntPtr h, int group, int signal, string name, string path, string comment); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRMakeGroups(IntPtr h); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRSetSignalValue(IntPtr h, int group, int signal, double value, int noValue); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRWriteRecord(IntPtr h, int group); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRSetMinMax(IntPtr h, int group, int signal, double min, double max); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern bool M4WRFlushGroup(IntPtr h, int group); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRClose(IntPtr h); + [DllImport(Dll, CallingConvention=CallingConvention.Cdecl)] static extern void M4WRSetNanoTimeUTC(IntPtr h, long time); + static double Number(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); } + static int Main(string[] args) { + if (args.Length != 3) throw new ArgumentException("dll-directory input-json new-output-mf4"); + if (File.Exists(args[2])) throw new IOException("Refusing to overwrite existing reference output"); + SetDllDirectory(Path.GetFullPath(args[0])); + var json = new JavaScriptSerializer { MaxJsonLength = Int32.MaxValue }; + var data = json.Deserialize>(File.ReadAllText(args[1])); + var channels = (ArrayList)data["channels"]; + var ids = new int[channels.Count]; + IntPtr h = M4WRInitDll(); + try { + M4WRSetPathName(h, Path.GetFullPath(args[2])); + if (!M4WRCreateMDF4(h, "DCT", "Doowon Climate Control", "1.0", "", new IntPtr(410))) throw new Exception("CreateMDF4 failed"); + M4WRSetNanoTimeUTC(h, Convert.ToInt64(data["start_ns"])); + if (!M4WRFileDescriptionEx(h, "", -1, Number(data["start_ns"])/1000000000.0, + Convert.ToUInt16(data["timezone_minutes"]), Convert.ToUInt16(data["dst_minutes"]), Convert.ToByte(data["time_flags"]))) throw new Exception("FileDescription failed"); + IntPtr group; + M4WRAddGroup64(h, "Measurements", 10000, out group); + int g = group.ToInt32(); + if (!M4WRAddTimeSignal(h, g, "Time", 4, 0, 64, "s", 1, 0, out ids[0])) throw new Exception("Time failed"); + for (int i=1; i)channels[i]; + double factor=1, offset=0; + if (c["conversion"] != null) { + var cc=(Dictionary)c["conversion"]; + if (Convert.ToInt32(cc["type"])!=1) throw new Exception("DLL AddSignal reference only supports linear conversion"); + var p=(ArrayList)cc["parameters"]; offset=Number(p[0]); factor=Number(p[1]); + } + if (!M4WRAddSignal(h,g,(string)c["name"],(string)c["description"],Convert.ToInt32(c["data_type"]),-1,Convert.ToInt32(c["bit_count"]),(string)c["unit"],factor,offset,0,0,-1,out ids[i])) throw new Exception("Signal failed: "+c["name"]); + if ((Convert.ToInt32(c["flags"]) & 8) != 0) + M4WRSetMinMax(h,g,ids[i],Number(c["min_raw"]),Number(c["max_raw"])); + if (c["source"]!=null) { + var s=(Dictionary)c["source"]; + M4WRSignalDescription(h,g,ids[i],(string)s["name"],(string)s["path"],(string)s["comment"]); + } + } + if (!M4WRMakeGroups(h)) throw new Exception("MakeGroups failed"); + // PC WriteData reads every EnvSetting item's current value on every record. + // That state survives unreceived messages; the DLL's record buffer itself resets. + var currentValues = new double[ids.Length]; + foreach(ArrayList record in (ArrayList)data["records"]) { + for(int i=0; i 0 + for a, b in zip(left.file_history, right.file_history): + x, y = ET.fromstring(a.comment), ET.fromstring(b.comment) + for key in ('TX', 'tool_id', 'tool_vendor', 'tool_version'): + assert x.findtext(key) == y.findtext(key), ('File History', key) + original = x.find("./common_properties/e[@name='Free_MDF4_Lib']") + if original is not None: + same_writer = y.find("./common_properties/e[@name='Free_MDF4_Lib']") + if same_writer is not None: + assert original.text == same_writer.text + else: + # Android must identify its actual writer, not impersonate the Windows DLL. + assert y.findtext("./common_properties/e[@name='writer']") == 'DCT Android; MDF4Writer.dll-compatible' + assert y.findtext("./common_properties/e[@name='reference_writer']") == 'Free_MDF4_Lib ' + original.text + for group_index, (a, b) in enumerate(zip(left.groups, right.groups)): + for key in ('comment', 'record_id_len'): + assert getattr(a.data_group, key) == getattr(b.data_group, key), ('DG', key) + for key in ('acq_name', 'comment', 'flags', 'record_id', 'invalidation_bytes_nr'): + assert getattr(a.channel_group, key) == getattr(b.channel_group, key), ('CG', key) + assert a.channel_group.samples_byte_nr == b.channel_group.samples_byte_nr + assert a.channel_group.cycles_nr == b.channel_group.cycles_nr + assert len(a.channels) == len(b.channels) + for index, (x, y) in enumerate(zip(a.channels, b.channels)): + cx, cy = channel(x), channel(y) + for key in ('name', 'unit', 'description', 'data_type', 'bit_count', 'bit_offset', + 'byte_offset', 'conversion', 'source', 'flags', 'min_raw', 'max_raw'): + assert cx[key] == cy[key], (index, x.name, key, cx[key], cy[key]) + np.testing.assert_array_equal(left.get(group=group_index, index=index, raw=True).samples, + right.get(group=group_index, index=index, raw=True).samples) + np.testing.assert_allclose(left.get(group=group_index, index=index).samples, + right.get(group=group_index, index=index).samples, rtol=0, atol=1e-12) + np.testing.assert_array_equal(left.get_master(group_index), right.get_master(group_index)) + print('PASS: version, HD/FH metadata (explicit actual writer exception), epoch/timezone, DG/CG metadata, channel order/types/bits/offsets, record size, count, raw/physical values, Time, units/descriptions/SI/conversion') + print('HEADER COMMENTS', left.header.comment == right.header.comment) + print('HISTORY LEFT', [h.comment for h in left.file_history]) + print('HISTORY RIGHT', [h.comment for h in right.file_history]) + reference_blocks, candidate_blocks = blocks(reference), blocks(candidate) + for kind in ('##HD', '##DG', '##CG', '##CN', '##SI', '##CC', '##FH', '##MD', '##HL'): + assert reference_blocks['counts'].get(kind, 0) == candidate_blocks['counts'].get(kind, 0), kind + sizes = {g.channel_group.samples_byte_nr for g in right.groups} + for info in (reference_blocks, candidate_blocks): + assert info['counts'].get('##DT', 0) == 0, 'PC profile requires compressed data blocks' + for dz in info['compression']: + assert dz['original'] == 'DT' and dz['zip_type'] == 1 + assert dz['parameter'] in sizes and dz['raw_size'] % dz['parameter'] == 0 + assert sum(d['raw_size'] for d in reference_blocks['compression']) == sum( + d['raw_size'] for d in candidate_blocks['compression']) + print('PASS: semantic block counts and transposed zlib compression; TX deduplication, DL chunking, compressed bytes and actual writer may differ') + print('REFERENCE BLOCKS', json.dumps(reference_blocks)) + print('CANDIDATE BLOCKS', json.dumps(candidate_blocks)) + + +def verify_fixtures(root): + # These files are generated by the Android implementation, but decoded only by asammdf. + for name, count, width in [('chunks.mf4', 10003, 10), ('wide-pc.mf4', 400, 19764), + ('empty-pc.mf4', 0, 10), ('queued-pc.mf4', 1, 10)]: + with MDF(root / name) as m: + g = m.groups[0] + assert g.channel_group.cycles_nr == count + assert g.channel_group.samples_byte_nr == width + expected_time = [-.01] if name == 'queued-pc.mf4' else np.arange(count) / 1000 + np.testing.assert_array_equal(m.get_master(0), expected_time) + if count: + expected_values = [-2] if name == 'queued-pc.mf4' else np.arange(count) + np.testing.assert_array_equal(m.get(group=0, index=1, raw=True).samples, expected_values) + if name == 'wide-pc.mf4': + assert len(g.channels) == 4940 + np.testing.assert_array_equal(m.get(group=0, index=4939, raw=True).samples, np.zeros(count)) + print('PASS independent reader:', name, 'samples', count, 'record bytes', width) + with MDF(root / 'conversions-pc.mf4') as m: + np.testing.assert_array_equal(m.get('Rational', raw=True).samples, [2, 5]) + np.testing.assert_array_equal(m.get('Table', raw=True).samples, [2, 5]) + np.testing.assert_allclose(m.get('Rational').samples, [11, 38], rtol=0, atol=1e-12) + np.testing.assert_allclose(m.get('Table').samples, [20, 50], rtol=0, atol=1e-12) + assert m.groups[0].channels[1].source.path == 'ECU.Rational' + assert m.groups[0].channels[1].unit == 'degC' + print('PASS independent reader: A2L factory, rational and interpolated CC, raw/physical values, original unit/SI') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['export', 'compare', 'fixtures']) + parser.add_argument('source', type=Path) + parser.add_argument('target', type=Path, nargs='?') + args = parser.parse_args() + if args.action == 'fixtures': + verify_fixtures(args.source) + else: + if args.target is None: + parser.error('export/compare requires target') + (export if args.action == 'export' else compare)(args.source, args.target) diff --git a/Android/scripts/verify_mf4_fixtures.py b/Android/scripts/verify_mf4_fixtures.py new file mode 100644 index 0000000..8246b8d --- /dev/null +++ b/Android/scripts/verify_mf4_fixtures.py @@ -0,0 +1,94 @@ +"""Independent MDF 4.10 check of artifacts exported by Mf4RecordingRegressionTest. + +Set DCT_RECORDING_FIXTURES_DIR when running the JUnit test, then run this script +with that directory. Requires asammdf; it is not an Android/build dependency. +""" +import argparse +import csv +from datetime import datetime +from pathlib import Path + +import numpy as np +from asammdf import MDF + + +def verify(directory): + checks = [] + with MDF(directory / 'relative.mf4') as recording: + assert recording.version == '4.10' + assert len(recording.groups) == 1 + assert recording.groups[0].channel_group.cycles_nr == 2 + temperature = recording.get('온도') + assert temperature.unit == '°C' + np.testing.assert_array_equal(temperature.samples, [-12.5, 3.75]) + np.testing.assert_allclose(temperature.timestamps, [0.125, 0.5], atol=1e-6) + np.testing.assert_array_equal(recording.get('Speed').samples, [87.25, 88.0]) + assert np.isnan(recording.get('Missing').samples).all() + checks.append('relative time, names, units, negative/fractional values, NaN') + for name, channels in [('empty.mf4', 2), ('time_only.mf4', 1)]: + with MDF(directory / name) as recording: + group = recording.groups[0] + assert group.channel_group.cycles_nr == 0 + assert len(group.channels) == channels + assert len(recording.get_master(0)) == 0 + checks.append(name) + with MDF(directory / 'wide.mf4') as recording: + assert len(recording.groups[0].channels) == 4940 + assert recording.groups[0].channel_group.cycles_nr == 1 + assert recording.get('DBC_0').samples.tolist() == [123.5] + assert np.isnan(recording.get('DBC_4938').samples).all() + np.testing.assert_allclose(recording.get_master(0), [0.25], atol=1e-6) + checks.append('4939-signal DBC width and missing tail') + with MDF(directory / 'restart.mf4') as recording: + assert recording.get('Signal').samples.tolist() == [2.0] + np.testing.assert_allclose(recording.get_master(0), [0.25], atol=1e-6) + checks.append('restart resets origin, rejects nonfinite timestamps') + with (directory / 'paired.csv').open(encoding='utf-8', newline='') as stream: + rows = list(csv.reader(stream))[7:] + with MDF(directory / 'paired.mf4') as recording: + signal = recording.get('Signal') + assert len(rows) == len(signal.samples) == 2 + assert [int(row[1]) for row in rows] == [0, 1] + np.testing.assert_array_equal([float(row[2]) for row in rows], signal.samples) + assert [row[0][-4:] for row in rows] == ['.125', '.500'] + np.testing.assert_allclose(signal.timestamps, [0.125, 0.5], atol=1e-6) + checks.append('CSV/MF4 rows, values and ingress timestamps match') + shared_origins = [] + for source, first_offset in [('mdb', -0.010), ('dbc', -0.009), ('formula', -0.010)]: + with (directory / f'queued_{source}.csv').open(encoding='utf-8', newline='') as stream: + rows = list(csv.reader(stream))[7:] + with MDF(directory / f'queued_{source}.mf4') as recording: + signal = recording.get('Signal') + origin = recording.start_time.timestamp() + shared_origins.append(origin) + assert len(rows) == len(signal.samples) == 3 + assert [int(row[1]) for row in rows] == [0, 1, 2] + np.testing.assert_array_equal(signal.samples, [0., 1., 2.]) + np.testing.assert_array_equal([float(row[2]) for row in rows], signal.samples) + np.testing.assert_allclose(signal.timestamps, [first_offset, 0., 0.125], rtol=0, atol=1e-6) + np.testing.assert_allclose(origin, 1790923490.915, rtol=0, atol=1e-6) + for row, offset in zip(rows, signal.timestamps): + # UTC/local HH:mm:ss differences do not change the offset within a minute. + csv_second = float(row[0].rsplit(':', 1)[1]) + mf4_second = (origin + offset) % 60 + np.testing.assert_allclose(csv_second, mf4_second, rtol=0, atol=0.001) + checks.append(f'{source}: queued ingress preserved, CSV/MF4 absolute time and values match') + assert len(set(shared_origins)) == 1 + with MDF(directory / 'queued_midnight.mf4') as recording: + signal = recording.get('Signal') + origin = recording.start_time.timestamp() + np.testing.assert_allclose(signal.timestamps, [-0.010], rtol=0, atol=1e-6) + np.testing.assert_array_equal(signal.samples, [7.]) + absolute = origin + signal.timestamps[0] + np.testing.assert_allclose(absolute, 1790985599.995, rtol=0, atol=1e-6) + assert datetime.fromtimestamp(absolute, recording.start_time.tzinfo).date() < recording.start_time.date() + checks.append('queued ingress crossing UTC midnight preserves the previous date') + for check in checks: + print('PASS:', check) + print('PASS: ten MF4 files opened by independent reader') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', type=Path) + verify(parser.parse_args().directory) diff --git a/DCT_test/DCT_test/LogFileReader.cs b/DCT_test/DCT_test/LogFileReader.cs index d22f769..a3ea4d8 100644 --- a/DCT_test/DCT_test/LogFileReader.cs +++ b/DCT_test/DCT_test/LogFileReader.cs @@ -17,6 +17,7 @@ namespace DCT_test public class LogFileReader { + private static readonly string[] TimestampFormats = { "HH:mm:ss.fff", "HH:mm:ss.ffffff" }; public LogFileReadResult Load(string filePath) { var result = new LogFileReadResult { FileName = Path.GetFileName(filePath) }; @@ -47,9 +48,9 @@ namespace DCT_test int timestampEnd = line.IndexOf(']', 6); if (timestampEnd < 0) throw new InvalidDataException("타임스탬프 오류"); string timestampText = line.Substring(7, timestampEnd - 7); - if (!DateTime.TryParseExact(timestampText, "HH:mm:ss.fff", CultureInfo.InvariantCulture, + if (!DateTime.TryParseExact(timestampText, TimestampFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime timestamp)) - throw new InvalidDataException("타임스탬프 형식 오류 (HH:mm:ss.fff)"); + throw new InvalidDataException("타임스탬프 형식 오류 (HH:mm:ss.fff 또는 HH:mm:ss.ffffff)"); double timeOfDay = timestamp.TimeOfDay.TotalMilliseconds; if (timeOfDay < lastTimeOfDay - 12 * 60 * 60 * 1000) dayOffset += 24 * 60 * 60 * 1000; lastTimeOfDay = timeOfDay; diff --git a/DCT_test/README.md b/DCT_test/README.md index 4f16982..3b747ce 100644 --- a/DCT_test/README.md +++ b/DCT_test/README.md @@ -1,5 +1,16 @@ # DCT 테스트 서버 프로그램 구조 및 실차 로그 파일(log.txt) 연동 가이드 +## 현재 동작 및 검증 (2026-09-30) + +- 2026-10-06 로그 정밀도 보완: 수신 TXT의 `HH:mm:ss.fff`(밀리초)와 `HH:mm:ss.ffffff`(마이크로초), 한 파일 내 혼합 형식을 읽는다. 같은 밀리초의 모든 패킷은 파일 순서로 보존하고 마이크로초 간격·자정 경과도 유지한다. 실제 전송의 마이크로초 단위 스케줄링 정확도를 보장한다는 뜻은 아니다. 관련 검증은 Release 빌드 후 `Verify-LogPrecision.ps1`을 실행한다. + +- 서버 시작은 연결 대기만 수행한다. 로그 재생은 앱의 초기 설정 및 `0x20` 요청/A0 응답 후 시작한다. `0x21`은 연결을 유지한 채 재생을 정지하며, 재시작은 로그 처음부터다. +- 로그 파일 재생 전용이다. 모의신호 모드·프로필·MDB/CAN/A2L 합성 값 생성 로직과 PC의 재생·일시정지·정지 버튼을 제거했다. 재생 옵션에는 배속·반복만 표시하며 시작·정지는 태블릿 앱의 요청으로 제어한다. 파일이 없으면 재생을 시작하지 않는다. +- `송신 중인 신호 목록`을 제거했다. 하단은 연결된 클라이언트 목록과 전체 폭의 패킷 로그로 구성하며, 파일 로드 결과는 파일명·레코드 수·용량으로 확인한다. +- 파일 재생은 ACK/송신 지연 시 기다리고, 전송 승인 실패 때문에 다음 레코드로 건너뛰지 않는다. TX 로그의 `record`와 `wire`는 해당 클라이언트에 대한 실제 패킷 쓰기 완료에 대응한다(앱 처리 완료와 다름). +- `build_csharp.bat`로 Release 빌드, Windows PowerShell에서 `Verify-Transport.ps1`, `Verify-Regression.ps1`로 검증한다. +- 아래의 예시 코드·과거 UI 설명보다 실제 구현 및 [운영 기능 문서](../docs/07_기능_및_제스처/01_기능_목록.md)의 NET-04 보완을 우선한다. + 본 문서는 안드로이드 태블릿용 DCT 캘리브레이션 및 진단 계측 앱의 통신 테스트를 위해 제작된 C# WPF 기반 DCT 테스트 서버(DCT_test.exe)의 전체 소프트웨어 아키텍처와, 실제 차량 및 제어기에서 수집된 대규모 실차 계측 데이터(log.txt)를 프로그램과 연결하기 위한 코드 작성 가이드를 기술합니다. --- @@ -18,33 +29,32 @@ ## 1. 프로그램 개요 및 실행 방법 ### 1.1 프로그램 목적 -- 실제 차량이나 전용 하드웨어 인터페이스 장비 없이도 안드로이드 태블릿 앱이 Wi-Fi TCP(포트 6000)를 통해 실시간 계측 신호를 수신하고, UDP 비콘(포트 6005)을 통해 PC 서버를 자동 탐색(Discovery)할 수 있도록 지원하는 테스트 도구입니다. -- 기본 탑재된 실제 차량 주행 거동 물리 시뮬레이션(가속, 감속, 회생제동, 공조 AUTO 연동)뿐만 아니라, 실제 차량 시험에서 획득한 대용량 실차 데이터(log.txt)를 읽어 실시간으로 단말에 재전송(Replay)하는 기능을 제공합니다. +- 실제 차량이나 전용 하드웨어 인터페이스 장비 없이도 안드로이드 태블릿 앱이 Wi-Fi Direct P2P 그룹의 TCP 포트 6000을 통해 실시간 계측 신호를 수신하도록 지원하는 테스트 도구입니다. +- 실제 차량 시험에서 획득한 대용량 실차 데이터(log.txt)를 읽어 단말에 재전송(Replay)합니다. 신호 값을 임의로 생성하지 않습니다. ### 1.2 실행 방법 - dct_test 폴더 또는 최상위 경로의 `Run_DCT_test.bat` 배치 파일을 실행합니다. - 기본 동작 사양: - - 전송 주기: 30 Hz (33ms, 최적) - 안드로이드 앱의 30Hz 배치 렌더러와 1:1 동기화 - - 기본 프로필: 1. [MDB + CAN] MDB 실차공조 및 CAN DBC 동시 스트리밍 - - 재생 모드: 실차 거동 모의 (Simulation) 기본 선택 + - 전송 간격: 로그에 기록된 시간 간격과 선택한 배속에 따릅니다. 모의신호 전용 Hz 선택은 제거했습니다. + - 데이터 소스: 로그 파일 재생 (File Playback) 전용 + - 배속: 0.5x / 1x / 2x / 5x, 기본 1x. 반복 재생은 기본 활성화입니다. --- ## 2. 테스트 프로그램 전체 아키텍처 및 모듈 구조 -테스트 프로그램은 계층형 아키텍처(Layered Architecture)로 설계되어 있으며, 고속 통신 패킷 브로드캐스트 스레드와 WPF UI 렌더링 스레드가 완전히 분리되어 송신 지터(Jitter)를 0ms 수준으로 제어합니다. +파일 재생 워커와 WPF UI 갱신을 분리합니다. 워커는 로그 시간에 따라 순서대로 전송하고, ACK·송신 지연 시 실제 쓰기 완료까지 기다립니다. ```mermaid graph TD subgraph PresentationLayer ["WPF UI 계층"] UI["MainWindow.xaml / MainWindow.xaml.cs"] - Grid["송신 신호 모니터링 테이블 (GridTxSignals)"] + Clients["연결된 클라이언트 목록 (GridClients)"] Log["실시간 패킷 모니터링 콘솔 (TextLogConsole)"] - Ctrl["재생 제어부 (재생 / 일시정지 / 정지 / 주기 / 배속)"] + Ctrl["재생 옵션 (배속 / 반복), 시작·정지는 태블릿 앱 요청"] end subgraph DataSourceLayer ["데이터 소스 계층"] - SimEngine["실제 차량 주행 물리 시뮬레이터 (관성 가감속/회생제동/수온/DATC)"] LogReader["로그 파일 파서 (LogFileReader.cs)"] LogFile["실제 차량 주행/수신 로그 파일 (log.txt)"] LogFile --> LogReader @@ -52,27 +62,26 @@ graph TD subgraph ProtocolLayer ["바이너리 프로토콜 계층"] Packet["DctPacket.cs / DctPacketType.cs"] - Framing["헤더(0xA1) + 커맨드 + 시퀀스 + 길이 + 페이로드 + 체크섬 + 푸터(0x7E)"] + Framing["헤더(0xA1) + 길이(BE) + 커맨드 + 페이로드 + 체크섬 + 푸터(0x7E)"] Packet --> Framing end subgraph NetworkLayer ["소켓 통신 계층"] Server["DctServer.cs"] - TcpServer["TCP Server (포트 6000) - 다중 클라이언트 관리 및 브로드캐스트"] - UdpBeacon["UDP Beacon (포트 6005) - LAN 단말 자동 검색용 DCT_BEACON 송출"] + TcpServer["Loopback TCP (포트 6000) - Wi-Fi Direct 브리지 후 프로토콜 처리"] + P2PHost["WifiDirectHost.cs - Windows P2P 광고/수락 및 소켓 브리지"] Server --> TcpServer - Server --> UdpBeacon + Server --> P2PHost end subgraph TargetClient ["외부 수신 단말"] Android["안드로이드 태블릿 앱 (DCT App)"] end - SimEngine -->|시뮬레이션 모드| Packet LogReader -->|파일 리플레이 모드| Packet Packet --> Server - TcpServer -->|TCP 패킷 스트리밍| Android - UdpBeacon -.->|LAN 비콘 디스커버리| Android + P2PHost -->|Wi-Fi Direct TCP| Android + TcpServer -->|브리지된 DCT 패킷| P2PHost Server -->|100ms 비동기 분리| UI ``` @@ -82,10 +91,11 @@ graph TD | 파일 경로 | 주요 역할 및 상세 기능 | | :--- | :--- | -| `dct_test/DCT_test/MainWindow.xaml` | WPF UI 레이아웃 정의 (서버 제어 패널, 데이터 소스 라디오 버튼, 파일 드롭존, 재생 제어부, 송신 신호 그리드, 실시간 로그 콘솔) | -| `dct_test/DCT_test/MainWindow.xaml.cs` | UI 이벤트 처리 및 전송 타이머 제어
• `TelemetryStreamWorker`: 고해상도 Stopwatch 기반 정밀 틱 타이머(30Hz) 및 100ms 주기 비동기 UI 분리 루프
• `LoadLogFile`: 드래그 앤 드롭 파일 수신 및 LogFileReader 결과 적재 | +| `dct_test/DCT_test/MainWindow.xaml` | WPF UI 레이아웃 정의 (서버 제어 패널, 로그 파일 드롭존, 재생 제어부, 연결된 클라이언트 목록, 실시간 로그 콘솔) | +| `dct_test/DCT_test/MainWindow.xaml.cs` | UI 이벤트 및 로그 재생 제어
• `TelemetryStreamWorker`: `LogPlaybackClock` 기반 로그 시간·배속·반복 처리와 100ms 주기 비동기 UI 갱신
• 앱의 시작·정지 요청으로 재생/취소하며 재시작은 파일 처음부터
• `LoadLogFile`: 드래그 앤 드롭 파일 수신 및 LogFileReader 결과 적재 | | `dct_test/DCT_test/LogFileReader.cs` | **실제 차량 계측 로그 파일(log.txt)을 읽어 DctPacket 바이너리 패킷으로 변환하는 전담 파서**
사용자가 보유한 실차 데이터 포맷에 맞추어 직접 파싱 코드를 작성할 수 있는 핵심 확장 지점 | -| `dct_test/DCT_test/DctServer.cs` | 비동기 TCP 6000 포트 소켓 서버 (태블릿 연결 세션 유지 및 패킷 브로드캐스트), Keep-Alive ACK(0x80) 자동 응답, UDP 6005 비콘 브로드캐스터 | +| `dct_test/DCT_test/DctTcpServer.cs` | 루프백 TCP 6000 포트의 DCT 프로토콜 처리 및 패킷 브로드캐스트 | +| `dct_test/DCT_test/WifiDirectHost.cs` | Windows Wi-Fi Direct 광고/연결 수락, P2P StreamSocket과 루프백 DCT 서버 간 브리지 | | `dct_test/DCT_test/DctPacket.cs` | PC 레거시 및 안드로이드 태블릿 앱 규격과 100% 호환되는 바이너리 프레임 인코딩/디코딩, 8비트 Inverted Sum 체크섬 계산, 원시 바이트 스트림 파서(`ParseStream`) 내장 | --- @@ -94,9 +104,9 @@ graph TD `LogFileReader`에서 `log.txt`를 읽어 생성해야 하는 표준 `DctPacket`의 세부 바이트 구조입니다. -### 4.1 패킷 프레임 공통 구조 (7 바이트 오버헤드) +### 4.1 패킷 프레임 공통 구조 (6 바이트 오버헤드) ```text -[0xA1] [Command: 1B] [Seq: 2B (Little-Endian)] [Length: 2B (Little-Endian)] [Payload: N Bytes] [Checksum: 1B] [0x7E] +[0xA1] [Length: 2B (Big-Endian)] [Command: 1B] [Payload: N Bytes] [Checksum: 1B] [0x7E] ``` - **시작 바이트**: `0xA1` - **종료 바이트**: `0x7E` @@ -112,7 +122,7 @@ graph TD - `Offset + 4`: PsTempSet (1 바이트, 값 / 0.5f) - `Offset + 5`: BlrwLevel (1 바이트, 1~8단) - `Offset + 6`: BlrwVolt (1 바이트, 값 / 0.1f) - - `Offset + 7..8`: Incar_Snr (2 바이트 signed short Little-Endian, 값 * 10, 예: 23.5도 -> 235) + - `Offset + 7..8`: Incar_Snr (2 바이트 signed short Big-Endian, 값 * 10, 예: 23.5도 -> 235) - `Offset + 9..10`: Incar_Flt (2 바이트 signed short) - `Offset + 11..12`: TIN (2 바이트 signed short) - `Offset + 13..14`: Amb_Snr (2 바이트 signed short, 값 * 10) @@ -164,12 +174,14 @@ sequenceDiagram User->>UI: 1. log.txt 파일을 UI 드롭존에 드래그 앤 드롭 (또는 파일 찾아보기) UI->>Reader: 2. LoadLogFile(filePath) 호출 -> _fileReader.Load(filePath) Note over Reader: 3. log.txt 파싱 및 데이터 변환 (DctPacket 생성 및 신호 메타데이터 등록) - Reader-->>UI: 4. LogFileReadResult 반환 (Packets 리스트, InitialSignalItems 목록) - UI->>UI: 5. _loadedLogRecords 리스트에 패킷 적재 및 좌측 신호 테이블 갱신 - User->>UI: 6. 데이터 소스를 [실차 로그 파일 리플레이]로 선택 후 [재생] 클릭 - UI->>Worker: 7. TelemetryStreamWorker 가동 (설정된 전송 주기, 기본 30Hz) + Reader-->>UI: 4. LogFileReadResult 반환 (Packets 리스트, 파일 메타데이터) + UI->>UI: 5. _loadedLogRecords 리스트에 패킷 적재 및 파일명·레코드 수·용량 표시 + User->>App: 6. Wi-Fi Direct 연결 및 신호 재생 시작 + App->>Server: 7. 초기 설정 후 0x20 요청 + Server->>UI: A0 응답 완료 후 로그 재생 시작 요청 + UI->>Worker: TelemetryStreamWorker 가동 (로그 시간 및 배속) loop 파일 패킷 브로드캐스트 - Worker->>Server: 8. _server.BroadcastPacket(_loadedLogRecords[idx++]) + Worker->>Server: 8. BroadcastLogPacketAsync로 레코드 송신 완료 대기 Server->>App: 9. TCP 6000 소켓을 통해 태블릿으로 바이너리 패킷 송출 App->>App: 10. 태블릿 차트 및 수치 그리드에 실차 전체 신호 렌더링 end @@ -236,10 +248,6 @@ namespace DCT_test // 2. DctPacket.ParseStream을 호출하여 전체 원시 패킷(MDB 160/161, CAN, XCP 등) 자동 추출 result.Packets = DctPacket.ParseStream(byteBuffer); - // 3. UI 신호 목록에 표시할 정보 구성 - result.InitialSignalItems.Add(new TxSignalItem { Source = "[FILE]", Name = "Total Frames", Value = $"{result.Packets.Count}", Unit = "pkts" }); - result.InitialSignalItems.Add(new TxSignalItem { Source = "[FILE]", Name = "Raw Bytes", Value = $"{byteBuffer.Count}", Unit = "bytes" }); - result.Success = result.Packets.Count > 0; if (!result.Success) { @@ -307,18 +315,6 @@ namespace DCT_test colMap[headers[i].Trim()] = i; } - // 2. UI '송신 중인 신호 목록'에 파일에 포함된 주요 신호들을 동적으로 등록 - foreach (var h in headers) - { - result.InitialSignalItems.Add(new TxSignalItem - { - Source = "[FILE]", - Name = h.Trim(), - Value = "0.0", - Unit = "" - }); - } - // 헬퍼 함수: 특정 신호 열이 존재하면 파싱, 없으면 기본값 반환 float GetVal(string[] tokens, string sigName, float defVal = 0.0f) { @@ -437,14 +433,21 @@ namespace DCT_test 1. **파일 로드 및 메타데이터 인식 확인**: - `Run_DCT_test.bat`를 실행합니다. - - 상단 `2. 데이터 소스 모드 선택`에서 `실차 로그 파일 리플레이 (File Replay)` 라디오 버튼을 선택합니다. + - 상단 `2. 로그 파일` 영역에서 재생할 파일을 지정합니다. - 준비한 실차 대용량 `log.txt` 파일을 드롭존 영역에 드래그 앤 드롭하거나 `[파일 찾아보기]` 버튼으로 지정합니다. - - `현재 로드된 파일: [log.txt] (N 레코드, M MB)` 텍스트가 표시되고 좌측 테이블에 파일 신호 목록이 갱신되는지 확인합니다. + - `현재 로드된 파일: [log.txt] (N 레코드, M MB)` 텍스트로 로드 결과를 확인합니다. 2. **서버 시작 및 패킷 스트리밍**: - - `[서버 시작]` 버튼을 클릭하여 TCP 6000 포트 수신 대기 상태로 전환합니다. - - `[재생]` 버튼을 클릭합니다. - - 우측 로그 콘솔에 `[TX (FILE)]` 로그가 순차적으로 출력되며 좌측 수치 테이블의 값들이 실제 주행 데이터 흐름에 맞게 갱신되는지 확인합니다. +- `[서버 시작]` 버튼을 클릭하여 Wi-Fi Direct 광고와 P2P TCP 6000 포트를 시작합니다. + - 태블릿 앱에서 연결한 뒤 `신호 재생`을 시작합니다. PC에는 재생·일시정지·정지 버튼이 없으며 앱의 시작·정지 요청에 따라 자동으로 동작합니다. + - 하단 패킷 로그의 파일 TX 기록에서 `record`와 실제 쓰기 완료 시각 `wire`를 확인합니다. 연결된 클라이언트 목록에서 접속 정보와 송수신 패킷 수를 확인합니다. 3. **태블릿 앱 연동 및 다채널 데이터 렌더링 검증**: - - 안드로이드 태블릿 앱을 실행하여 PC의 IP로 접속합니다. + - 안드로이드 태블릿 앱의 Wi-Fi Direct 목록에서 PC를 선택합니다. - 차트 화면에서 `Incar_Snr`, `Speed`, `ALDC_VBVoltVal`, `BlrwLevel` 등 여러 신호를 등록합니다. - `log.txt`에 기록된 실제 주행 거동(도심 주행 가감속, 회생제동 전압, 공조 냉각 등)이 왜곡 없이 태블릿 화면에 실시간으로 재생되는지 확인합니다. +## Wi-Fi Direct 연결 + +테스트 서버는 Windows Wi-Fi Direct 광고를 사용해 태블릿과 P2P 그룹을 구성합니다. +일반 공유기/LAN TCP 접속은 서버에서 받지 않으며, DCT TCP 처리는 Wi-Fi Direct 소켓을 +루프백(127.0.0.1)으로 브리지한 뒤 수행합니다. PC 무선 어댑터가 Wi-Fi Direct GO/Client를 +지원해야 하고, 최초 빌드 시 `Microsoft.Windows.SDK.Contracts 10.0.22621.755` NuGet +패키지를 복원해야 합니다. 태블릿에서는 Wi-Fi Direct 탭에서 PC를 검색해 선택합니다. diff --git a/DCT_test/Verify-LogPrecision.ps1 b/DCT_test/Verify-LogPrecision.ps1 new file mode 100644 index 0000000..b8769b4 --- /dev/null +++ b/DCT_test/Verify-LogPrecision.ps1 @@ -0,0 +1,38 @@ +$ErrorActionPreference = 'Stop' +[void][Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot 'DCT_test/bin/Release/DCT_test.exe')) +function Assert-True($value, $message) { if (!$value) { throw $message } } +$taskDirectory = Join-Path ([IO.Path]::GetTempPath()) ('dct-log-precision-' + [Guid]::NewGuid().ToString('N')) +[void][IO.Directory]::CreateDirectory($taskDirectory) +try { + $reader = New-Object DCT_test.LogFileReader + $timestamps = @('23:59:59.999', '23:59:59.999', '23:59:59.999123', '00:00:00.000007') + $lines = for ($index = 0; $index -lt $timestamps.Count; $index++) { + $packet = [DCT_test.DctPacket]::Create(0xA3, [byte[]]@($index + 1), 0) + $hex = ($packet.Encode() | ForEach-Object { $_.ToString('X2') }) -join ' ' + '[Recv][' + $timestamps[$index] + '] ' + $hex + } + $file = Join-Path $taskDirectory 'precision.txt' + [IO.File]::WriteAllLines($file, [string[]]$lines) + $result = $reader.Load($file) + Assert-True $result.Success $result.ErrorMessage + Assert-True ($result.TotalRecords -eq 4) 'Equal millisecond packets were lost' + $expectedMilliseconds = @(0.0, 0.0, 0.123, 1.007) + for ($index = 0; $index -lt 4; $index++) { + Assert-True ($result.Packets[$index].Payload[0] -eq $index + 1) 'Packet order changed' + Assert-True ([Math]::Abs($result.Packets[$index].LogElapsedMilliseconds - $expectedMilliseconds[$index]) -lt 0.00001) 'Fraction or midnight rollover lost' + } + [IO.File]::WriteAllLines($file, [string[]]@($lines[0], $lines[2].Replace('.999123]', '.99912]'))) + $invalid = $reader.Load($file) + Assert-True (-not $invalid.Success -and $invalid.TotalRecords -eq 0) 'Invalid precision accepted or partial packets retained' + Write-Output 'PASS: 3/6-digit and mixed timestamps, equal-ms packet order, microsecond gaps, midnight rollover, invalid precision rejection' +} +finally { + # Only the unique temporary fixture owned by this check is removed. + $resolvedTaskDirectory = [IO.Path]::GetFullPath($taskDirectory) + $resolvedTemporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $resolvedTaskDirectory.StartsWith($resolvedTemporaryRoot, [StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($resolvedTaskDirectory) -notmatch '^dct-log-precision-[a-f0-9]{32}$') { + throw 'Refusing to remove a fixture outside the expected temporary directory' + } + Remove-Item -LiteralPath $resolvedTaskDirectory -Recurse -Force +}