Browse Source

Align PC measurement recording and improve ribbon settings

Preserve native MF4 types, compression and metadata; add compatibility regressions. Add CSV milliseconds selection, fixed PC-style TXT timestamps, submenu alignment and clearer recording controls.
master
seuuung 4 weeks ago
parent
commit
690ce5c865
  1. 2
      Android/app/src/androidTest/java/com/dct/app/background/BackgroundMeasurementServiceTest.kt
  2. 94
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt
  3. 182
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenuUiTest.kt
  4. 31
      Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt
  5. 14
      Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt
  6. 10
      Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt
  7. 332
      Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt
  8. 75
      Android/app/src/main/java/com/dct/app/data/logger/PcMf4Channel.kt
  9. 3
      Android/app/src/main/java/com/dct/app/data/logger/PcOperationalLogger.kt
  10. 7
      Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt
  11. 12
      Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt
  12. 1
      Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt
  13. 118
      Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt
  14. 65
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonButton.kt
  15. 197
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/RibbonSettingsMenu.kt
  16. 117
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt
  17. 54
      Android/app/src/test/java/com/dct/app/presentation/monitor/components/RibbonSubmenuPositionTest.kt
  18. 2
      Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt
  19. 75
      Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt
  20. 15
      Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt
  21. 100
      Android/app/src/test/java/com/dct/app/tests/MeasurementRecordingConcurrencyTest.kt
  22. 168
      Android/app/src/test/java/com/dct/app/tests/Mf4RecordingRegressionTest.kt
  23. 114
      Android/app/src/test/java/com/dct/app/tests/PcCsvTimestampTest.kt
  24. 268
      Android/app/src/test/java/com/dct/app/tests/PcMf4CompatibilityTest.kt
  25. 19
      Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt
  26. 24
      Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt
  27. 82
      Android/scripts/PcMdf4Reference.cs
  28. 193
      Android/scripts/pc_mf4_reference.py
  29. 94
      Android/scripts/verify_mf4_fixtures.py
  30. 5
      DCT_test/DCT_test/LogFileReader.cs
  31. 101
      DCT_test/README.md
  32. 38
      DCT_test/Verify-LogPrecision.ps1

2
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)

94
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) }
}
}

182
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<TextLayoutResult>()
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()
}
}

31
Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt

@ -17,10 +17,11 @@ class XcpDaqStreamDecoder {
private val configuredSignals = mutableListOf<SignalDefinition>()
private val configuredDataTypes = mutableListOf<A2lDataType>()
private val configuredCompuMethods = mutableListOf<A2lParser.CompuMethod?>()
private val configuredMeasurements = mutableListOf<A2lParser.A2lMeasurement?>()
private var isMsbFirst: Boolean = false
sealed interface DecodeResult {
data class Success(val values: Map<String, Double>) : DecodeResult
data class Success(val values: Map<String, Double>, val rawValues: Map<String, Number> = 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<String, Double>(configuredSignals.size * 2)
val rawMap = if (captureRawValues) HashMap<String, Number>(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<String, Pair<A2lParser.A2lMeasurement?, A2lParser.CompuMethod?>> =
configuredSignals.indices.associate { index ->
configuredSignals[index].name to (configuredMeasurements[index] to configuredCompuMethods[index])
}
@Synchronized
fun clear() {
configuredSignals.clear()
configuredDataTypes.clear()
configuredCompuMethods.clear()
configuredMeasurements.clear()
}
}

14
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) {

10
Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt

@ -25,7 +25,8 @@ class CsvMeasurementLogger(
val signalHeaders: List<Pair<String, String>>, // List of (SignalName, Unit)
val customFileName: String? = null,
private val pcColumns: List<PcCsvColumn>? = 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 {

332
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<Pair<String, String>>, // List of (SignalName, Unit)
val customFileName: String? = null
val customFileName: String? = null,
private val pcChannels: List<PcMf4Channel>? = 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<String> = definitions.map { it.valueKey }
val physicalInputColumns: List<Boolean> = 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<Long>()
private var filePosition = 0L
private val lastRawValues = arrayOfNulls<Number>(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<String, Number> = emptyMap(),
initialPhysicalValues: Map<String, Double> = 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<ChannelMeta>()
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<ByteArray>() }
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<ByteArray>()
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<Double?>) {
fun logSample(sampleTimeSec: Double, values: List<Double?>) {
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<Number?>) {
check(pcChannels != null) { "Raw samples require a PC channel definition" }
writeSample((sampleTimeMillis - recordingStartedAtMillis) / 1000.0, values)
}
private fun writeSample(relativeTime: Double, values: List<Number?>) {
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 = "<HDcomment>\n<TX/>\n<common_properties/>\n</HDcomment>"
const val TIME_COMMENT = "<CNcomment><TX>Time Channel</TX><raster min=\"0.000000000000000\" max=\"0.000000000000000\">0.000000000000000</raster></CNcomment>"
// Do not claim Android used the Windows DLL. Retain PC tool fields, identify the actual writer.
const val HISTORY_COMMENT = "<FHcomment><TX></TX><tool_id>DCT</tool_id><tool_vendor>Doowon Climate Control</tool_vendor><tool_version>1.0</tool_version><common_properties><e name=\"writer\">DCT Android; MDF4Writer.dll-compatible</e><e name=\"reference_writer\">Free_MDF4_Lib 2.019 2016/05/05</e></common_properties></FHcomment>"
}
}

75
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<Double> = emptyList(),
val rawRange: Pair<Double, Double>? = 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<com.dct.app.data.parser.A2lParser.A2lMeasurement?, com.dct.app.data.parser.A2lParser.CompuMethod?>? = 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
}
}
}
}

3
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 ->

7
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<Int> = emptyList()
val matrixDim: List<Int> = 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()
)
}

12
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,

1
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<String, Long> = emptyMap(),
val canSignalDescriptions: Map<String, String> = emptyMap(),
val mdbSignalColors: Map<String, Long> = emptyMap(),

118
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<com.dct.app.data.logger.CsvMeasurementLogger>()
private val activeMf4Loggers = java.util.concurrent.CopyOnWriteArrayList<com.dct.app.data.logger.Mf4MeasurementLogger>()
// Protected by recordingSampleLock; seeds a newly started file from the PC-like live raw state.
private val latestMf4RawValues = HashMap<String, Number>()
private var latestMf4MdbSource: MdbDatabase? = null
private var latestMf4CanSource: List<SignalDefinition>? = null
private var latestMf4A2lSource: List<SignalDefinition>? = 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, Double>): String? {
private fun decodeMdbRecord(recordId: Int, data: ByteArray, output: MutableMap<String, Double>,
rawOutput: MutableMap<String, Number>? = 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<String, Double>()
// Preserve raw values during measurement, even if recording starts midway through this packet.
val rawRecordingValues = if (isMeasuring) mutableMapOf<String, Number>() 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_<idx> 바인딩을 계속 지원한다.
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) }
}
}
}
// 대시보드 타일 값 업데이트

65
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)
}
}
}
}
}

197
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<RibbonSettingsCategory?>(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() }
)
}
}
}
}
}
}
}
}
}
}

117
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 진입은 숨긴다.

54
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)
))
}
}

2
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))

75
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)
}
}

15
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)

100
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<Pair<String, String>>() {
override val size = 1
override fun get(index: Int): Pair<String, String> {
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<CsvMeasurementLogger>).add(csv)
(field(vm, "activeMf4Loggers").get(vm) as MutableList<Mf4MeasurementLogger>).add(mf4)
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState>
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()
}
}
}
}

168
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)
}
}

114
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<MainUiState>
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<CsvMeasurementLogger>)
.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<CsvMeasurementLogger>)
.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()
}
}
}
}

268
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<MainUiState>
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<Mf4MeasurementLogger>)
.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))
}
}
}

19
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"))

24
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())
}
}
}

82
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<Dictionary<string, object>>(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, "<HDcomment></HDcomment>", -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.Count; i++) {
var c = (Dictionary<string,object>)channels[i];
double factor=1, offset=0;
if (c["conversion"] != null) {
var cc=(Dictionary<string,object>)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<string,object>)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<ids.Length; i++) {
if (record[i] != null) currentValues[i]=Number(record[i]);
if (!M4WRSetSignalValue(h,g,ids[i],currentValues[i],0)) throw new Exception("SetSignalValue failed");
}
if (!M4WRWriteRecord(h,g)) throw new Exception("WriteRecord failed");
}
M4WRFlushGroup(h,g); M4WRClose(h);
Console.WriteLine("PC DLL reference generated: "+args[2]);
} finally { M4WRExitDll(h); }
return 0;
}
}

193
Android/scripts/pc_mf4_reference.py

@ -0,0 +1,193 @@
"""Export the supplied PC MF4 as common raw input, inspect/compare reader semantics.
Requires the independent asammdf reader, not an Android dependency. Original files
are read-only. The exported input also drives the actual bundled PC writer DLL.
"""
import argparse
import json
import struct
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
import numpy as np
from asammdf import MDF
def blocks(path):
data = path.read_bytes()
visited, counts, compression = set(), Counter(), []
def visit(address):
if not address or address in visited:
return
visited.add(address)
kind, _, length, links_nr = struct.unpack_from('<4sIQQ', data, address)
assert address + length <= len(data), (address, kind, length)
counts[kind.decode()] += 1
links = struct.unpack_from('<' + 'Q' * links_nr, data, address + 24)
if kind == b'##DZ':
original, zip_type, _, parameter, raw_size, zip_size = struct.unpack_from('<2sBBIQQ', data, address + 24)
compression.append(dict(original=original.decode(), zip_type=zip_type,
parameter=parameter, raw_size=raw_size, zip_size=zip_size))
for link in links:
visit(link)
visit(64)
return dict(counts=dict(counts), compression=compression, file_size=len(data))
def channel(c):
conversion = c.conversion
cc = None if conversion is None else dict(
type=conversion.conversion_type,
parameters=[getattr(conversion, key) for key in ('b', 'a')]
if conversion.conversion_type == 1 else [])
source = None if c.source is None else {
key: getattr(c.source, key) for key in ('name', 'path', 'comment', 'source_type', 'bus_type')}
return dict(name=c.name, unit=c.unit, description=c.comment,
data_type=c.data_type, bit_count=c.bit_count, bit_offset=c.bit_offset,
byte_offset=c.byte_offset, flags=c.flags, conversion=cc, source=source,
min_raw=c.min_raw_value, max_raw=c.max_raw_value)
def export(source, destination):
with MDF(source) as m:
assert len(m.groups) == 1, 'The reference fixture requires one PC channel group'
g = m.groups[0]
signals = [m.get(group=0, index=i, raw=True) for i in range(1, len(g.channels))]
times = m.get_master(0)
result = dict(reference=str(source), version=m.version, start_ns=m.header.abs_time,
timezone_minutes=m.header.tz_offset, dst_minutes=m.header.daylight_save_time,
time_flags=m.header.time_flags, header_comment=m.header.comment,
history=[h.comment for h in m.file_history],
channels=[channel(c) for c in g.channels],
record_size=g.channel_group.samples_byte_nr,
records=[[float(t)] + [s.samples[i].item() for s in signals]
for i, t in enumerate(times)], blocks=blocks(source))
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print('EXPORTED', len(result['channels']), 'channels,', len(result['records']), 'records,', result['record_size'], 'bytes')
print(json.dumps(result['blocks'], indent=2))
specs = [('U8', 0, 8, [0, 255, 17]), ('S8', 2, 8, [-128, 127, -3]),
('U16', 0, 16, [0, 65535, 12345]), ('S16', 2, 16, [-32768, 32767, -134]),
('U32', 0, 32, [0, 4294967295, 3735928559]), ('S32', 2, 32, [-2147483648, 2147483647, -10]),
('U64', 0, 64, [0, 9007199254740992, 123456789]), ('S64', 2, 64, [-9007199254740992, 9007199254740992, -9]),
('F32', 4, 32, [-12.5, 3.75, 1.25]), ('F64', 4, 64, [-1.25, 3.141592653589793, 0.125]),
('Temperature', 2, 16, [-300, 125, 1000])]
synthetic = {k: v for k, v in result.items() if k not in ('records', 'channels', 'blocks')}
offset = 8
synthetic['channels'] = [result['channels'][0]]
for name, dtype, bits, values in specs:
synthetic['channels'].append(dict(name=name, unit='degC' if name == 'Temperature' else 'V',
description='raw boundary '+name, data_type=dtype, bit_count=bits, bit_offset=0,
byte_offset=offset, flags=0, min_raw=0., max_raw=0., conversion=dict(type=1, parameters=[-40., .1])
if name == 'Temperature' else None,
source=dict(name=name, path='ECU.'+name, comment='raw boundary '+name, source_type=3, bus_type=0)))
offset += bits // 8
synthetic['record_size'] = offset
synthetic['records'] = [[time] + [s[3][i] for s in specs] for i, time in enumerate([-.01, 0., .125])]
synthetic['records'][2][1] = None # PC EnvSetting retains raw values and submits every field to the DLL.
synthetic['records'][2][-1] = None
destination.with_name('synthetic.json').write_text(json.dumps(synthetic, ensure_ascii=False, indent=2), encoding='utf-8')
def compare(reference, candidate):
with MDF(reference) as left, MDF(candidate) as right:
assert left.version == right.version
assert len(left.groups) == len(right.groups)
assert left.header.abs_time == right.header.abs_time
assert (left.header.tz_offset, left.header.daylight_save_time, left.header.time_flags) == (
right.header.tz_offset, right.header.daylight_save_time, right.header.time_flags)
assert left.header.comment == right.header.comment
assert len(left.file_history) == len(right.file_history) > 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)

94
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)

5
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;

101
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 이벤트 처리 및 전송 타이머 제어<br>• `TelemetryStreamWorker`: 고해상도 Stopwatch 기반 정밀 틱 타이머(30Hz) 및 100ms 주기 비동기 UI 분리 루프<br>• `LoadLogFile`: 드래그 앤 드롭 파일 수신 및 LogFileReader 결과 적재 |
| `dct_test/DCT_test/MainWindow.xaml` | WPF UI 레이아웃 정의 (서버 제어 패널, 로그 파일 드롭존, 재생 제어부, 연결된 클라이언트 목록, 실시간 로그 콘솔) |
| `dct_test/DCT_test/MainWindow.xaml.cs` | UI 이벤트 및 로그 재생 제어<br>• `TelemetryStreamWorker`: `LogPlaybackClock` 기반 로그 시간·배속·반복 처리와 100ms 주기 비동기 UI 갱신<br>• 앱의 시작·정지 요청으로 재생/취소하며 재시작은 파일 처음부터<br>• `LoadLogFile`: 드래그 앤 드롭 파일 수신 및 LogFileReader 결과 적재 |
| `dct_test/DCT_test/LogFileReader.cs` | **실제 차량 계측 로그 파일(log.txt)을 읽어 DctPacket 바이너리 패킷으로 변환하는 전담 파서**<br>사용자가 보유한 실차 데이터 포맷에 맞추어 직접 파싱 코드를 작성할 수 있는 핵심 확장 지점 |
| `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를 검색해 선택합니다.

38
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
}
Loading…
Cancel
Save