diff --git a/DCT_test b/DCT_test
deleted file mode 160000
index 74d2da3..0000000
--- a/DCT_test
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 74d2da3be96ca194382d4eb0eddf6145933a4587
diff --git a/DCT_test/DCT_test.slnx b/DCT_test/DCT_test.slnx
new file mode 100644
index 0000000..60a5859
--- /dev/null
+++ b/DCT_test/DCT_test.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/DCT_test/DCT_test/App.config b/DCT_test/DCT_test/App.config
new file mode 100644
index 0000000..56efbc7
--- /dev/null
+++ b/DCT_test/DCT_test/App.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/DCT_test/DCT_test/App.xaml b/DCT_test/DCT_test/App.xaml
new file mode 100644
index 0000000..0dffeef
--- /dev/null
+++ b/DCT_test/DCT_test/App.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/DCT_test/DCT_test/App.xaml.cs b/DCT_test/DCT_test/App.xaml.cs
new file mode 100644
index 0000000..e3fdfc2
--- /dev/null
+++ b/DCT_test/DCT_test/App.xaml.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Configuration;
+using System.Data;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace DCT_test
+{
+ ///
+ /// App.xaml에 대한 상호 작용 논리
+ ///
+ public partial class App : Application
+ {
+ }
+}
diff --git a/DCT_test/DCT_test/ClientSession.cs b/DCT_test/DCT_test/ClientSession.cs
new file mode 100644
index 0000000..ddfabd3
--- /dev/null
+++ b/DCT_test/DCT_test/ClientSession.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Net.Sockets;
+
+namespace DCT_test
+{
+ public class ClientSession
+ {
+ public TcpClient Client { get; set; }
+ public string IpPort { get; set; }
+ public string ConnectTime { get; set; }
+ public uint SentPackets { get; set; }
+ public uint ReceivedPackets { get; set; }
+ public long FirmwareReceivedBytes { get; set; }
+ public string FirmwareUploadName { get; set; }
+
+ public ClientSession(TcpClient client, string ipPort)
+ {
+ Client = client;
+ IpPort = ipPort;
+ ConnectTime = DateTime.Now.ToString("HH:mm:ss");
+ SentPackets = 0;
+ ReceivedPackets = 0;
+ FirmwareReceivedBytes = 0;
+ FirmwareUploadName = string.Empty;
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/DCT_test.csproj b/DCT_test/DCT_test/DCT_test.csproj
new file mode 100644
index 0000000..8350cee
--- /dev/null
+++ b/DCT_test/DCT_test/DCT_test.csproj
@@ -0,0 +1,102 @@
+
+
+
+
+ Debug
+ AnyCPU
+ {2A257DC9-45C2-4539-9992-57340A291C74}
+ WinExe
+ DCT_test
+ DCT_test
+ v4.7.2
+ 512
+ {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
+ 4
+ true
+ true
+
+
+ AnyCPU
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ AnyCPU
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+ 4.0
+
+
+
+
+
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ App.xaml
+ Code
+
+
+
+
+
+
+ MainWindow.xaml
+ Code
+
+
+
+
+ Code
+
+
+ True
+ True
+ Resources.resx
+
+
+ True
+ Settings.settings
+ True
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+ SettingsSingleFileGenerator
+ Settings.Designer.cs
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/DCT_test/DCT_test/DctPacket.cs b/DCT_test/DCT_test/DctPacket.cs
new file mode 100644
index 0000000..078f1cb
--- /dev/null
+++ b/DCT_test/DCT_test/DctPacket.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+
+namespace DCT_test
+{
+ ///
+ /// DCT 표준 통신 프로토콜 바이너리 코덱 (PC C++ MainFrm 및 Android DctPacketCodec과 100% 동일)
+ /// 프레임 포맷: [Header(0xA1)] [Len_H(1B)] [Len_L(1B)] [CmdID(1B)] [Payload(N B)] [Checksum(1B)] [Footer(0x7E)]
+ /// - packLen = Payload.Length + 1 (CmdID 1바이트 포함)
+ /// - TotalLength = packLen + 5 (Header 1B + Len 2B + Checksum 1B + Footer 1B)
+ /// - Checksum = (sum(frame[0 .. TotalLength - 3]) ^ 0xFF) & 0xFF (1의 보수 NOT 누적합)
+ ///
+ public class DctPacket
+ {
+ public const byte START_BYTE = 0xA1;
+ public const byte END_BYTE = 0x7E;
+ public const int OVERHEAD_LENGTH = 5; // START(1) + LenH(1) + LenL(1) + Checksum(1) + END(1)
+
+ public byte CommandId { get; set; }
+ public ushort Sequence { get; set; }
+ public byte[] Payload { get; set; } = new byte[0];
+
+ public ushort PackLen => (ushort)(Payload.Length + 1); // CmdID 1바이트 포함
+ public int TotalLength => PackLen + OVERHEAD_LENGTH;
+
+ public static DctPacket Create(byte cmdId, byte[] payload = null, ushort seq = 0)
+ {
+ return new DctPacket
+ {
+ CommandId = cmdId,
+ Sequence = seq,
+ Payload = payload ?? new byte[0]
+ };
+ }
+
+ public byte[] Encode()
+ {
+ ushort packLen = PackLen;
+ int totalLen = TotalLength;
+ byte[] frame = new byte[totalLen];
+
+ // 1. Header & Length (Big-Endian)
+ frame[0] = START_BYTE;
+ frame[1] = (byte)((packLen >> 8) & 0xFF);
+ frame[2] = (byte)(packLen & 0xFF);
+
+ // 2. Command ID
+ frame[3] = CommandId;
+
+ // 3. Payload
+ if (Payload != null && Payload.Length > 0)
+ {
+ Buffer.BlockCopy(Payload, 0, frame, 4, Payload.Length);
+ }
+
+ // 4. Checksum (8비트 1의 보수 누적합: sum ^ 0xFF)
+ int sum = 0;
+ for (int i = 0; i < totalLen - 2; i++)
+ {
+ sum = (sum + frame[i]) & 0xFF;
+ }
+ frame[totalLen - 2] = (byte)((sum ^ 0xFF) & 0xFF);
+
+ // 5. Footer
+ frame[totalLen - 1] = END_BYTE;
+
+ return frame;
+ }
+
+ public static List ParseStream(List buffer)
+ {
+ var packets = new List();
+
+ while (buffer.Count >= 6) // 최소 패킷 크기: START(1)+Len(2)+Cmd(1)+Chk(1)+END(1) = 6
+ {
+ if (buffer[0] != START_BYTE)
+ {
+ buffer.RemoveAt(0);
+ continue;
+ }
+
+ ushort packLen = (ushort)((buffer[1] << 8) | buffer[2]);
+ int totalLength = packLen + OVERHEAD_LENGTH;
+
+ if (packLen < 1 || totalLength > 65535)
+ {
+ buffer.RemoveAt(0);
+ continue;
+ }
+
+ if (buffer.Count < totalLength)
+ {
+ break; // 패킷 전체가 올 때까지 대기
+ }
+
+ if (buffer[totalLength - 1] != END_BYTE)
+ {
+ buffer.RemoveAt(0);
+ continue;
+ }
+
+ // 체크섬 검증
+ int sum = 0;
+ for (int i = 0; i < totalLength - 2; i++)
+ {
+ sum = (sum + buffer[i]) & 0xFF;
+ }
+ byte expectedChk = (byte)((sum ^ 0xFF) & 0xFF);
+ if (buffer[totalLength - 2] != expectedChk)
+ {
+ buffer.RemoveAt(0);
+ continue;
+ }
+
+ byte cmdId = buffer[3];
+ int payloadSize = packLen - 1;
+ byte[] payload = new byte[payloadSize];
+
+ if (payloadSize > 0)
+ {
+ buffer.CopyTo(4, payload, 0, payloadSize);
+ }
+
+ packets.Add(new DctPacket
+ {
+ CommandId = cmdId,
+ Payload = payload
+ });
+
+ buffer.RemoveRange(0, totalLength);
+ }
+
+ return packets;
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/DctTcpServer.cs b/DCT_test/DCT_test/DctTcpServer.cs
new file mode 100644
index 0000000..a781279
--- /dev/null
+++ b/DCT_test/DCT_test/DctTcpServer.cs
@@ -0,0 +1,764 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DCT_test
+{
+ public class DctTcpServer
+ {
+ private TcpListener _listener;
+ private CancellationTokenSource _cts;
+ private readonly ConcurrentDictionary _clients = new ConcurrentDictionary();
+ private readonly object _xcpConfigLock = new object();
+ private byte[] _xcpMeasurementTypes = new byte[] { 3, 3, 2, 0 };
+ private bool _xcpIsMsbFirst;
+ private readonly string[] _maintenanceLogNames = { "TEST_MDB_LOG.dat", "TEST_CAN_LOG.dat" };
+ private readonly byte[][] _maintenanceLogData =
+ {
+ Encoding.ASCII.GetBytes("DCT TEST MDB LOG\r\n0001,12.5\r\n0002,13.0\r\n"),
+ Encoding.ASCII.GetBytes("DCT TEST CAN LOG\r\n0001,7E8,8,0102030405060708\r\n")
+ };
+
+ public bool IsRunning { get; private set; }
+ public int Port { get; private set; } = 6000;
+
+ public ulong TotalCanAcks { get; private set; }
+ public ulong TotalSmAcks { get; private set; }
+ public ulong TotalXcpAcks { get; private set; }
+
+ public bool ShowRxPackets { get; set; } = true;
+ public bool ShowHexDump { get; set; } = false;
+
+ public event Action OnLogMessage;
+ public event Action OnClientConnected;
+ public event Action OnClientDisconnected;
+ public event Action OnClientListChanged;
+ public event Action OnStartSimRequested;
+ public event Action OnStopSimRequested;
+
+ public ICollection ActiveClients => _clients.Values;
+
+ ///
+ /// 마지막 0x41/0x42 설정과 같은 count/type/endian으로 PC 호환 0xD0 payload를 생성한다.
+ /// rawValues가 부족한 나머지 항목은 0으로 채운다.
+ ///
+ public byte[] BuildXcpDaqPayload(params double[] rawValues)
+ {
+ byte[] types;
+ bool isMsbFirst;
+ lock (_xcpConfigLock)
+ {
+ types = (byte[])_xcpMeasurementTypes.Clone();
+ isMsbFirst = _xcpIsMsbFirst;
+ }
+
+ var payload = new List(2 + types.Length * 5);
+ payload.Add((byte)((types.Length >> 8) & 0xFF));
+ payload.Add((byte)(types.Length & 0xFF));
+ for (int i = 0; i < types.Length; i++)
+ {
+ payload.Add(types[i]);
+ double value = i < rawValues.Length ? rawValues[i] : 0.0;
+ AppendXcpValue(payload, types[i], value, isMsbFirst);
+ }
+ return payload.ToArray();
+ }
+
+ public bool Start(int port)
+ {
+ if (IsRunning) return true;
+
+ try
+ {
+ Port = port;
+ _cts = new CancellationTokenSource();
+ _listener = new TcpListener(IPAddress.Any, port);
+ _listener.Start(10);
+ IsRunning = true;
+
+ var ips = GetActiveIpAddresses();
+ string primaryIp = ips.FirstOrDefault() ?? "127.0.0.1";
+ Log($"[OK] C# DCT TCP 서버가 포트 {port}에서 대기 중입니다. (태블릿 접속용 IP: {primaryIp}:{port})");
+ Task.Run(() => AcceptClientsAsync(_cts.Token));
+ Task.Run(() => BroadcastBeaconAsync(port, _cts.Token));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log($"[ERROR] 서버 시작 실패: {ex.Message}");
+ Stop();
+ return false;
+ }
+ }
+
+ public void Stop()
+ {
+ if (!IsRunning) return;
+
+ IsRunning = false;
+ _cts?.Cancel();
+
+ try
+ {
+ _listener?.Stop();
+ }
+ catch { }
+
+ foreach (var kv in _clients)
+ {
+ try
+ {
+ kv.Value.Client?.Close();
+ }
+ catch { }
+ }
+ _clients.Clear();
+
+ OnClientListChanged?.Invoke();
+ Log("[INFO] TCP 서버가 중지되었습니다.");
+ }
+
+ public void BroadcastPacket(DctPacket packet)
+ {
+ if (!IsRunning || _clients.IsEmpty) return;
+
+ byte[] encoded = packet.Encode();
+ foreach (var kv in _clients)
+ {
+ var session = kv.Value;
+ try
+ {
+ if (session.Client.Connected)
+ {
+ var stream = session.Client.GetStream();
+ stream.Write(encoded, 0, encoded.Length);
+ session.SentPackets++;
+ }
+ }
+ catch
+ {
+ // Handled in read loop
+ }
+ }
+ }
+
+ private async Task BroadcastBeaconAsync(int port, CancellationToken ct)
+ {
+ string hostName = Dns.GetHostName();
+
+ while (!ct.IsCancellationRequested && IsRunning)
+ {
+ try
+ {
+ var activeIps = GetActiveIpAddresses();
+
+ // 1. 기본 라우트를 통한 글로벌 255.255.255.255 브로드캐스트
+ try
+ {
+ using (var globalUdp = new UdpClient())
+ {
+ globalUdp.EnableBroadcast = true;
+ string primaryIp = activeIps.FirstOrDefault() ?? "127.0.0.1";
+ string beacon = $"DCT_BEACON|DCT PC Server ({hostName})|{primaryIp}|{port}";
+ byte[] bytes = Encoding.UTF8.GetBytes(beacon);
+ await globalUdp.SendAsync(bytes, bytes.Length, new IPEndPoint(IPAddress.Broadcast, 6005));
+ }
+ }
+ catch { }
+
+ // 2. 각 네트워크 어댑터별 서브넷 브로드캐스트 전송
+ foreach (var ip in activeIps)
+ {
+ if (IPAddress.TryParse(ip, out var localAddr))
+ {
+ try
+ {
+ using (var udp = new UdpClient(new IPEndPoint(localAddr, 0)))
+ {
+ udp.EnableBroadcast = true;
+ string beacon = $"DCT_BEACON|DCT PC Server ({hostName})|{ip}|{port}";
+ byte[] bytes = Encoding.UTF8.GetBytes(beacon);
+
+ try { await udp.SendAsync(bytes, bytes.Length, new IPEndPoint(IPAddress.Broadcast, 6005)); } catch { }
+
+ var parts = ip.Split('.');
+ if (parts.Length == 4)
+ {
+ string subnetBroadcast = $"{parts[0]}.{parts[1]}.{parts[2]}.255";
+ if (IPAddress.TryParse(subnetBroadcast, out var subIp))
+ {
+ try { await udp.SendAsync(bytes, bytes.Length, new IPEndPoint(subIp, 6005)); } catch { }
+ }
+ }
+ }
+ }
+ catch { }
+ }
+ }
+ }
+ catch { }
+
+ await Task.Delay(1000, ct);
+ }
+ }
+
+ public static List GetActiveIpAddresses()
+ {
+ var result = new List();
+ try
+ {
+ var wifiIps = new List();
+ var ethIps = new List();
+
+ foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (ni.OperationalStatus != OperationalStatus.Up) continue;
+ if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
+
+ string desc = ni.Description.ToLower();
+ string name = ni.Name.ToLower();
+ if (desc.Contains("vmware") || desc.Contains("virtual") || name.Contains("vethernet")) continue;
+
+ var ipProps = ni.GetIPProperties();
+ foreach (var addr in ipProps.UnicastAddresses)
+ {
+ if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ string ipStr = addr.Address.ToString();
+ if (ipStr.StartsWith("127.") || ipStr.StartsWith("169.254.")) continue;
+
+ if (ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211 || name.Contains("wi-fi") || desc.Contains("wi-fi") || desc.Contains("wireless"))
+ {
+ wifiIps.Add(ipStr);
+ }
+ else
+ {
+ ethIps.Add(ipStr);
+ }
+ }
+ }
+ }
+
+ result.AddRange(wifiIps);
+ result.AddRange(ethIps);
+ }
+ catch { }
+
+ if (!result.Any()) result.Add("127.0.0.1");
+ return result.Distinct().ToList();
+ }
+
+ private async Task AcceptClientsAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested && IsRunning)
+ {
+ try
+ {
+ var client = await _listener.AcceptTcpClientAsync();
+ client.NoDelay = true;
+
+ string ipPort = client.Client.RemoteEndPoint?.ToString() ?? "Unknown";
+ var session = new ClientSession(client, ipPort);
+ _clients[ipPort] = session;
+
+ Log($"[+] 태블릿 클라이언트 접속됨: {ipPort}");
+ OnClientConnected?.Invoke(session);
+ OnClientListChanged?.Invoke();
+
+ _ = Task.Run(() => HandleClientAsync(session, ct), ct);
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ if (!ct.IsCancellationRequested)
+ {
+ Log($"[WARN] Accept 오류: {ex.Message}");
+ }
+ }
+ }
+ }
+
+ private async Task HandleClientAsync(ClientSession session, CancellationToken ct)
+ {
+ var buffer = new List();
+ byte[] readBuffer = new byte[4096];
+
+ try
+ {
+ var stream = session.Client.GetStream();
+ while (!ct.IsCancellationRequested && session.Client.Connected)
+ {
+ int bytesRead = await stream.ReadAsync(readBuffer, 0, readBuffer.Length, ct);
+ if (bytesRead <= 0) break;
+
+ for (int i = 0; i < bytesRead; i++)
+ {
+ buffer.Add(readBuffer[i]);
+ }
+
+ session.ReceivedPackets++;
+
+ var packets = DctPacket.ParseStream(buffer);
+ foreach (var pkt in packets)
+ {
+ HandlePacket(session, pkt);
+ }
+
+ OnClientListChanged?.Invoke();
+ }
+ }
+ catch
+ {
+ // Disconnected
+ }
+ finally
+ {
+ _clients.TryRemove(session.IpPort, out _);
+ try { session.Client.Close(); } catch { }
+ Log($"[-] 클라이언트 접속 해제: {session.IpPort}");
+ OnClientDisconnected?.Invoke(session.IpPort);
+ OnClientListChanged?.Invoke();
+ }
+ }
+
+ private void HandlePacket(ClientSession session, DctPacket packet)
+ {
+ string cmdName = GetCommandName(packet.CommandId);
+ string rxLog = null;
+
+ if (ShowRxPackets)
+ {
+ rxLog = $"[📥 RX <- {session.IpPort}] #{packet.Sequence:D5} | 0x{packet.CommandId:X2} ({cmdName}) | Payload: {packet.Payload.Length}B";
+ if (ShowHexDump)
+ {
+ string hex = BitConverter.ToString(packet.Encode()).Replace("-", " ");
+ rxLog += $"\r\n └ [Raw RX]: {hex}";
+ }
+ }
+
+ switch (packet.CommandId)
+ {
+ case 0x00: // CMD_ENABLE
+ SendPacket(session, DctPacket.Create(0x80, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[HANDSHAKE] CMD_ENABLE (0x00) -> [SEND] RES_ENABLE (0x80, ACK: 0x01)");
+ break;
+
+ case 0x01: // CMD_VERSION
+ // PC RES_VERSION payload: [length:1][ASCII version:length], no trailing NUL.
+ byte[] verBytes = Encoding.ASCII.GetBytes("DCT-MOBI-v1.0.0");
+ byte[] verPayload = new byte[verBytes.Length + 1];
+ verPayload[0] = checked((byte)verBytes.Length);
+ Buffer.BlockCopy(verBytes, 0, verPayload, 1, verBytes.Length);
+ SendPacket(session, DctPacket.Create(0x81, verPayload, packet.Sequence));
+ LogRx(rxLog, $"[HANDSHAKE] CMD_VERSION (0x01) -> [SEND] RES_VERSION (0x81, Len: {verBytes.Length}, 'DCT-MOBI-v1.0.0')");
+ break;
+
+ case 0x03: // CMD_HWID
+ byte[] hwBytes = new byte[] { 0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E };
+ SendPacket(session, DctPacket.Create(0x83, hwBytes, packet.Sequence));
+ LogRx(rxLog, $"[HANDSHAKE] CMD_HWID (0x03) -> [SEND] RES_HWID (0x83, 00:1A:2B:3C:4D:5E)");
+ break;
+
+ case 0x04: // CMD_SYNC_TIME
+ SendPacket(session, DctPacket.Create(0x84, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[HANDSHAKE] CMD_SYNC_TIME (0x04) -> [SEND] RES_SYNC_TIME (0x84, ACK: 0x01)");
+ break;
+
+ case 0x06: // CMD_CARTYPE
+ string carType = "Unknown";
+ if (packet.Payload.Length > 1)
+ {
+ int len = packet.Payload[0];
+ if (len > 0 && len < packet.Payload.Length)
+ carType = Encoding.ASCII.GetString(packet.Payload, 1, len);
+ }
+ SendPacket(session, DctPacket.Create(0x86, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_CARTYPE (0x06, CarType: '{carType}') -> [SEND] RES_CARTYPE (0x86, 0x01)");
+ break;
+
+ case 0x07: // CMD_CARTYPE_CONTROL
+ SendPacket(session, DctPacket.Create(0x87, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_CARTYPE_CONTROL (0x07) -> [SEND] RES_CARTYPE_CONTROL (0x87, 0x01)");
+ break;
+
+ case 0x08: // CMD_DB
+ SendPacket(session, DctPacket.Create(0x88, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_DB (0x08) -> [SEND] RES_DB (0x88, 0x01)");
+ break;
+
+ case 0x09: // CMD_DCT_ENV
+ SendPacket(session, DctPacket.Create(0x89, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_DCT_ENV (0x09) -> [SEND] RES_DCT_ENV (0x89, 0x01)");
+ break;
+
+ case 0x0F: // CMD_DISABLE
+ SendPacket(session, DctPacket.Create(0x8F, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[HANDSHAKE] CMD_DISABLE (0x0F) -> [SEND] RES_DISABLE (0x8F, 0x01)");
+ break;
+
+ case 0x10: // CMD_MDB_INFO
+ SendPacket(session, DctPacket.Create(0x90, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_MDB_INFO (0x10, Recl Info {packet.Payload.Length}B) -> [SEND] RES_MDB_INFO (0x90, 0x01)");
+ break;
+
+ case 0x11: // CMD_CANDBC_INFO
+ SendPacket(session, DctPacket.Create(0x91, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_CANDBC_INFO (0x11, CAN IDs {packet.Payload.Length}B) -> [SEND] RES_CANDBC_INFO (0x91, 0x01)");
+ break;
+
+ case 0x12: // CMD_SM_INFO
+ SendPacket(session, DctPacket.Create(0x92, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_SM_INFO (0x12, Sensor Module {packet.Payload.Length}B) -> [SEND] RES_SM_INFO (0x92, 0x01)");
+ break;
+
+ case 0x13: // CMD_MDB_SELECTED_ID
+ SendPacket(session, DctPacket.Create(0x93, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONFIG] CMD_MDB_SELECTED_ID (0x13, Monitored IDs {packet.Payload.Length}B) -> [SEND] RES_MDB_SELECTED_ID (0x93, 0x01)");
+ break;
+
+ case 0x14: // CMD_COMPENSATION_TYPE_DATA
+ SendPacket(session, DctPacket.Create(0x94, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CALIB] CMD_COMPENSATION_TYPE_DATA (0x14) -> [SEND] RES_COMPENSATION_TYPE_DATA (0x94, 0x01)");
+ break;
+
+ case 0x15: // CMD_DID_INFO
+ SendPacket(session, DctPacket.Create(0x95, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[DID] CMD_DID_INFO (0x15) -> [SEND] RES_DID_INFO (0x95, 0x01)");
+ break;
+
+ case 0x16: // CMD_REQ_DID_INFO
+ SendPacket(session, DctPacket.Create(0x96, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[DID] CMD_REQ_DID_INFO (0x16) -> [SEND] RES_REQ_DID_INFO (0x96, 0x01)");
+ break;
+
+ case 0x17: // CMD_SETUP_DID_INFO
+ SendPacket(session, DctPacket.Create(0x97, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[DID] CMD_SETUP_DID_INFO (0x17) -> [SEND] RES_SETUP_DID_INFO (0x97, 0x01)");
+ break;
+
+ case 0x18: // CMD_REQ_ALL_DID_INFO
+ SendPacket(session, DctPacket.Create(0x98, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[DID] CMD_REQ_ALL_DID_INFO (0x18) -> [SEND] RES_REQ_ALL_DID_INFO (0x98, 0x01)");
+ break;
+
+ case 0x20: // CMD_START_TRANSMIT_DATA
+ SendPacket(session, DctPacket.Create(0xA0, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONTROL] CMD_START_TRANSMIT_DATA (0x20) -> [SEND] RES_START_TRANSMIT_DATA (0xA0, 0x01) ACK 및 스트리밍 개시");
+ OnStartSimRequested?.Invoke();
+ break;
+
+ case 0x21: // CMD_STOP_TRANSMIT_DATA
+ SendPacket(session, DctPacket.Create(0xA1, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[CONTROL] CMD_STOP_TRANSMIT_DATA (0x21) -> [SEND] RES_STOP_TRANSMIT_DATA (0xA1, 0x01) ACK 및 스트리밍 정지");
+ OnStopSimRequested?.Invoke();
+ break;
+
+ case 0x23: // CMD_CAN_FRAME_ACK
+ TotalCanAcks++;
+ if (ShowRxPackets && TotalCanAcks % 20 == 1)
+ Log($"[📥 RX ACK <- {session.IpPort}] CMD_CAN_FRAME_ACK (0x23) 누적 {TotalCanAcks}건 수신");
+ break;
+
+ case 0x24: // CMD_SM_DATA_ACK
+ TotalSmAcks++;
+ if (ShowRxPackets && TotalSmAcks % 20 == 1)
+ Log($"[📥 RX ACK <- {session.IpPort}] CMD_SM_DATA_ACK (0x24) 누적 {TotalSmAcks}건 수신");
+ break;
+
+ case 0x25: // CMD_SAVED_LOGFILE_INFO
+ HandleSavedLogInfo(session, packet);
+ break;
+
+ case 0x26: // CMD_SAVED_LOGFILE_COPY
+ HandleSavedLogCopy(session, packet);
+ break;
+
+ case 0x31: // CMD_FW_INFO
+ HandleFirmwareInfo(session, packet);
+ break;
+
+ case 0x32: // CMD_FW_KEY_VALUE (host response; no ACK)
+ LogRx(rxLog, $"[P3 ASK] CMD_FW_KEY_VALUE (0x32, {packet.Payload.Length}B) 수신");
+ break;
+
+ case 0x40: // CMD_XCP_USE
+ SendPacket(session, DctPacket.Create(0xC0, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[XCP] CMD_XCP_USE (0x40, Use: {packet.Payload.FirstOrDefault()}) -> [SEND] RES_XCP_USE (0xC0, 0x01)");
+ break;
+
+ case 0x41: // CMD_XCP_IFDATAS
+ if (packet.Payload.Length > 0)
+ {
+ lock (_xcpConfigLock) _xcpIsMsbFirst = packet.Payload[0] == 0x01;
+ }
+ SendPacket(session, DctPacket.Create(0xC1, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[XCP] CMD_XCP_IFDATAS (0x41, CAN IDs {packet.Payload.Length}B) -> [SEND] RES_XCP_IFDATAS (0xC1, 0x01)");
+ break;
+
+ case 0x42: // CMD_XCP_MEASUREMENTS
+ bool xcpConfigOk = TryApplyXcpMeasurements(packet.Payload, out int xcpItemCount);
+ SendPacket(session, DctPacket.Create(0xC2, new byte[] { xcpConfigOk ? (byte)0x01 : (byte)0x00 }, packet.Sequence));
+ LogRx(rxLog, $"[XCP] CMD_XCP_MEASUREMENTS (0x42, Items {xcpItemCount}, {packet.Payload.Length}B) -> [SEND] RES_XCP_MEASUREMENTS (0xC2, 0x{(xcpConfigOk ? 1 : 0):X2})");
+ break;
+
+ case 0x50: // CMD_XCP_MEASUREMENT_DATA (Host ACK)
+ TotalXcpAcks++;
+ break;
+
+ case 0x51: // CMD_XCP_CHARACTERISTIC_DATA
+ SendPacket(session, DctPacket.Create(0xD1, new byte[] { 0x01 }, packet.Sequence));
+ LogRx(rxLog, $"[XCP] CMD_XCP_CHARACTERISTIC_DATA (0x51) -> [SEND] RES_XCP_CHARACTERISTIC_DATA (0xD1, 0x01)");
+ break;
+
+ case 0x52: // CMD_XCP_REQ_CHARACTERISTIC_DATA
+ SendPacket(session, DctPacket.Create(0xD2, new byte[] { 0x00, 0x00 }, packet.Sequence));
+ LogRx(rxLog, $"[XCP] CMD_XCP_REQ_CHARACTERISTIC_DATA (0x52) -> [SEND] RES_XCP_REQ_CHARACTERISTIC_DATA (0xD2)");
+ break;
+
+ default:
+ LogRx(rxLog, $"[📥 RX <- {session.IpPort}] 0x{packet.CommandId:X2} ({cmdName}, Payload: {packet.Payload.Length}B)");
+ break;
+ }
+ }
+
+ private void LogRx(string rxDetail, string actionLog)
+ {
+ if (ShowRxPackets && !string.IsNullOrEmpty(rxDetail))
+ {
+ Log(rxDetail);
+ }
+ Log(actionLog);
+ }
+
+ private void HandleSavedLogInfo(ClientSession session, DctPacket packet)
+ {
+ if (packet.Payload.Length < 1) return;
+ byte sub = packet.Payload[0];
+ if (sub == 0x01)
+ {
+ int count = _maintenanceLogNames.Length;
+ SendPacket(session, DctPacket.Create(0xA5, new byte[] { 0x01, (byte)(count >> 8), (byte)count }, packet.Sequence));
+ Log($"[P3 LOG] 파일 수 {count} 응답");
+ }
+ else if (sub == 0x02 && packet.Payload.Length >= 4)
+ {
+ int index = (packet.Payload[1] << 8) | packet.Payload[2];
+ if (index < 0 || index >= _maintenanceLogNames.Length) return;
+ byte[] name = Encoding.ASCII.GetBytes(_maintenanceLogNames[index]);
+ var response = new byte[4 + name.Length];
+ response[0] = 0x02;
+ response[1] = (byte)(index >> 8);
+ response[2] = (byte)index;
+ response[3] = (byte)name.Length;
+ Buffer.BlockCopy(name, 0, response, 4, name.Length);
+ SendPacket(session, DctPacket.Create(0xA5, response, packet.Sequence));
+ }
+ }
+
+ private void HandleSavedLogCopy(ClientSession session, DctPacket packet)
+ {
+ if (packet.Payload.Length < 3) return;
+ byte sub = packet.Payload[0];
+ int index = (packet.Payload[1] << 8) | packet.Payload[2];
+ if (index < 0 || index >= _maintenanceLogData.Length) return;
+ byte[] source = _maintenanceLogData[index];
+ if (sub == 0x01)
+ {
+ int size = source.Length;
+ byte[] response =
+ {
+ 0x01, (byte)(index >> 8), (byte)index,
+ (byte)(size >> 24), (byte)(size >> 16), (byte)(size >> 8), (byte)size
+ };
+ SendPacket(session, DctPacket.Create(0xA6, response, packet.Sequence));
+ }
+ else if (sub == 0x02 && packet.Payload.Length >= 9)
+ {
+ long offset = ((long)packet.Payload[3] << 24) | ((long)packet.Payload[4] << 16) |
+ ((long)packet.Payload[5] << 8) | packet.Payload[6];
+ int requested = (packet.Payload[7] << 8) | packet.Payload[8];
+ if (offset < 0 || offset > source.Length || requested <= 0) return;
+ int length = Math.Min(requested, source.Length - (int)offset);
+ byte status = offset + length >= source.Length ? (byte)0x03 : (byte)0x01;
+ var response = new byte[6 + length];
+ response[0] = 0x02;
+ response[1] = (byte)(index >> 8);
+ response[2] = (byte)index;
+ response[3] = (byte)(length >> 8);
+ response[4] = (byte)length;
+ response[5] = status;
+ Buffer.BlockCopy(source, (int)offset, response, 6, length);
+ SendPacket(session, DctPacket.Create(0xA6, response, packet.Sequence));
+ }
+ }
+
+ private void HandleFirmwareInfo(ClientSession session, DctPacket packet)
+ {
+ if (packet.Payload.Length < 1) return;
+ byte sub = packet.Payload[0];
+ if (sub == 0x04)
+ {
+ SendPacket(session, DctPacket.Create(0xB1, new byte[] { 0x04, 0x00, 0x00 }, packet.Sequence));
+ Log("[P3 FW] 저장 펌웨어 수 0 응답");
+ }
+ else if (sub == 0x02 && packet.Payload.Length >= 7)
+ {
+ int slot = packet.Payload[1];
+ int nameLength = packet.Payload[6];
+ if (packet.Payload.Length < 7 + nameLength) return;
+ session.FirmwareUploadName = Encoding.ASCII.GetString(packet.Payload, 7, nameLength);
+ session.FirmwareReceivedBytes = 0;
+ SendPacket(session, DctPacket.Create(0xB1, new byte[] { 0x02, (byte)slot, 0x01 }, packet.Sequence));
+ Log($"[P3 FW] '{session.FirmwareUploadName}' 메타데이터 승인");
+ }
+ else if (sub == 0x05 && packet.Payload.Length >= 7)
+ {
+ int slot = packet.Payload[1];
+ int dataLength = (packet.Payload[5] << 8) | packet.Payload[6];
+ if (dataLength < 1 || dataLength > 512 || packet.Payload.Length != 7 + dataLength) return;
+ session.FirmwareReceivedBytes += dataLength;
+ SendPacket(session, DctPacket.Create(0xB1, new byte[] { 0x05, (byte)slot, 0x01 }, packet.Sequence));
+ if (packet.Payload[4] == 0x02)
+ Log($"[P3 FW] '{session.FirmwareUploadName}' 테스트 수신 완료: {session.FirmwareReceivedBytes} bytes");
+ }
+ }
+
+ private bool TryApplyXcpMeasurements(byte[] payload, out int itemCount)
+ {
+ itemCount = 0;
+ if (payload == null || payload.Length < 2) return false;
+ itemCount = (payload[0] << 8) | payload[1];
+ if (payload.Length != 2 + itemCount * 5) return false;
+
+ var types = new byte[itemCount];
+ for (int i = 0; i < itemCount; i++)
+ {
+ byte type = payload[2 + i * 5];
+ if (GetXcpTypeSize(type) == 0) return false;
+ types[i] = type;
+ }
+ lock (_xcpConfigLock) _xcpMeasurementTypes = types;
+ return true;
+ }
+
+ private static int GetXcpTypeSize(byte type)
+ {
+ switch (type)
+ {
+ case 0: case 1: return 1;
+ case 2: case 3: case 8: return 2;
+ case 4: case 5: case 9: case 11: return 4;
+ case 6: case 7: case 10: return 8;
+ default: return 0;
+ }
+ }
+
+ private static void AppendXcpValue(List output, byte type, double value, bool isMsbFirst)
+ {
+ byte[] bytes;
+ switch (type)
+ {
+ case 0: bytes = new byte[] { (byte)Math.Max(0, Math.Min(255, Math.Round(value))) }; break;
+ case 1: bytes = new byte[] { unchecked((byte)(sbyte)Math.Max(sbyte.MinValue, Math.Min(sbyte.MaxValue, Math.Round(value)))) }; break;
+ case 2: bytes = BitConverter.GetBytes((ushort)Math.Max(0, Math.Min(ushort.MaxValue, Math.Round(value)))); break;
+ case 3: bytes = BitConverter.GetBytes((short)Math.Max(short.MinValue, Math.Min(short.MaxValue, Math.Round(value)))); break;
+ case 4: bytes = BitConverter.GetBytes((uint)Math.Max(0, Math.Min(uint.MaxValue, Math.Round(value)))); break;
+ case 5: bytes = BitConverter.GetBytes((int)Math.Max(int.MinValue, Math.Min(int.MaxValue, Math.Round(value)))); break;
+ case 6: bytes = BitConverter.GetBytes((ulong)Math.Max(0, Math.Round(value))); break;
+ case 7: bytes = BitConverter.GetBytes((long)Math.Round(value)); break;
+ case 8: bytes = BitConverter.GetBytes(FloatToHalf((float)value)); break;
+ case 9: case 11: bytes = BitConverter.GetBytes((float)value); break;
+ case 10: bytes = BitConverter.GetBytes(value); break;
+ default: throw new ArgumentOutOfRangeException(nameof(type));
+ }
+ if (bytes.Length > 1 && (BitConverter.IsLittleEndian == isMsbFirst)) Array.Reverse(bytes);
+ output.AddRange(bytes);
+ }
+
+ private static ushort FloatToHalf(float value)
+ {
+ uint bits = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
+ uint sign = (bits >> 16) & 0x8000;
+ int exponent = (int)((bits >> 23) & 0xFF) - 127 + 15;
+ uint mantissa = bits & 0x7FFFFF;
+ if (exponent <= 0) return (ushort)sign;
+ if (exponent >= 31) return (ushort)(sign | 0x7C00);
+ return (ushort)(sign | ((uint)exponent << 10) | (mantissa >> 13));
+ }
+
+ private void SendPacket(ClientSession session, DctPacket pkt)
+ {
+ try
+ {
+ if (session.Client.Connected)
+ {
+ byte[] encoded = pkt.Encode();
+ var stream = session.Client.GetStream();
+ stream.Write(encoded, 0, encoded.Length);
+ session.SentPackets++;
+ }
+ }
+ catch { }
+ }
+
+ public static string GetCommandName(byte cmd)
+ {
+ switch (cmd)
+ {
+ case 0x00: return "CMD_ENABLE";
+ case 0x01: return "CMD_VERSION";
+ case 0x02: return "CMD_CUR_STATUS";
+ case 0x03: return "CMD_HWID";
+ case 0x04: return "CMD_SYNC_TIME";
+ case 0x05: return "CMD_POSSIBLE_TO_CONNECT";
+ case 0x06: return "CMD_CARTYPE";
+ case 0x07: return "CMD_CARTYPE_CONTROL";
+ case 0x08: return "CMD_DB";
+ case 0x09: return "CMD_DCT_ENV";
+ case 0x0F: return "CMD_DISABLE";
+ case 0x10: return "CMD_MDB_INFO";
+ case 0x11: return "CMD_CANDBC_INFO";
+ case 0x12: return "CMD_SM_INFO";
+ case 0x13: return "CMD_MDB_SELECTED_ID";
+ case 0x14: return "CMD_COMPENSATION_TYPE_DATA";
+ case 0x15: return "CMD_DID_INFO";
+ case 0x16: return "CMD_REQ_DID_INFO";
+ case 0x17: return "CMD_SETUP_DID_INFO";
+ case 0x18: return "CMD_REQ_ALL_DID_INFO";
+ case 0x20: return "CMD_START_TRANSMIT_DATA";
+ case 0x21: return "CMD_STOP_TRANSMIT_DATA";
+ case 0x22: return "CMD_CAN_FRAME_VIA_DCT";
+ case 0x23: return "CMD_CAN_FRAME_ACK";
+ case 0x24: return "CMD_SM_DATA_ACK";
+ case 0x25: return "CMD_SAVED_LOGFILE_INFO";
+ case 0x26: return "CMD_SAVED_LOGFILE_COPY";
+ case 0x27: return "CMD_MDB_CONSTANT_DATA";
+ case 0x28: return "CMD_MDB_REQ_CONSTANT_DATA";
+ case 0x31: return "CMD_FW_INFO";
+ case 0x32: return "CMD_FW_KEY_VALUE";
+ case 0x40: return "CMD_XCP_USE";
+ case 0x41: return "CMD_XCP_IFDATAS";
+ case 0x42: return "CMD_XCP_MEASUREMENTS";
+ case 0x50: return "CMD_XCP_MEASUREMENT_DATA";
+ case 0x51: return "CMD_XCP_CHARACTERISTIC_DATA";
+ case 0x52: return "CMD_XCP_REQ_CHARACTERISTIC_DATA";
+ default: return $"UNKNOWN_0x{cmd:X2}";
+ }
+ }
+
+ private void Log(string message)
+ {
+ OnLogMessage?.Invoke(message);
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/LogFileReader.cs b/DCT_test/DCT_test/LogFileReader.cs
new file mode 100644
index 0000000..e7b3b85
--- /dev/null
+++ b/DCT_test/DCT_test/LogFileReader.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace DCT_test
+{
+ ///
+ /// 파일 로드 결과 데이터 구조체
+ ///
+ public class LogFileReadResult
+ {
+ public bool Success { get; set; }
+ public string FileName { get; set; }
+ public double FileSizeMb { get; set; }
+ public int TotalRecords => Packets.Count;
+
+ ///
+ /// 재생 시 전송될 바이너리 패킷 목록 (DctPacket.Create(cmd, payload, seq))
+ ///
+ public List Packets { get; set; } = new List();
+
+ ///
+ /// UI '송신 중인 신호 목록' 테이블에 표시할 신호 항목 목록
+ ///
+ public List InitialSignalItems { get; set; } = new List();
+
+ public string ErrorMessage { get; set; }
+ }
+
+ ///
+ /// 로그 파일 리더 (사용자 구현 가이드 템플릿)
+ ///
+ public class LogFileReader
+ {
+ public LogFileReadResult Load(string filePath)
+ {
+ var result = new LogFileReadResult
+ {
+ FileName = Path.GetFileName(filePath)
+ };
+
+ if (!File.Exists(filePath))
+ {
+ result.ErrorMessage = $"파일이 존재하지 않습니다: {filePath}";
+ return result;
+ }
+
+ try
+ {
+ result.FileSizeMb = new FileInfo(filePath).Length / (1024.0 * 1024.0);
+
+ // =========================================================================
+ // [사용자 구현 가이드 영역]
+ // 1. filePath로부터 파일(CSV, TXT, MDF4, 바이너리 등)을 읽습니다.
+ // 2. 파싱한 신호명들을 result.InitialSignalItems에 추가합니다.
+ // 예: result.InitialSignalItems.Add(new TxSignalItem { Source = "[FILE]", Name = "신호명", Value = "0.0", Unit = "단위" });
+ // 3. 각 시간/주기별 데이터를 DctPacket으로 인코딩하여 result.Packets에 추가합니다.
+ // 예: result.Packets.Add(DctPacket.Create(0xA3, payloadBytes, seq++));
+ // =========================================================================
+
+ // TODO: 사용자 정의 파일 파싱 및 패킷 조립 로직을 이곳에 구현하세요.
+
+ result.Success = result.Packets.Count > 0;
+ if (!result.Success && string.IsNullOrEmpty(result.ErrorMessage))
+ {
+ result.ErrorMessage = "패킷 데이터가 생성되지 않았습니다.";
+ }
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.ErrorMessage = $"파일 파싱 오류: {ex.Message}";
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/MainWindow.xaml b/DCT_test/DCT_test/MainWindow.xaml
new file mode 100644
index 0000000..8d67da6
--- /dev/null
+++ b/DCT_test/DCT_test/MainWindow.xaml
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DCT_test/DCT_test/MainWindow.xaml.cs b/DCT_test/DCT_test/MainWindow.xaml.cs
new file mode 100644
index 0000000..e00e6da
--- /dev/null
+++ b/DCT_test/DCT_test/MainWindow.xaml.cs
@@ -0,0 +1,710 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using Microsoft.Win32;
+
+namespace DCT_test
+{
+ public class TxSignalItem : INotifyPropertyChanged
+ {
+ public string Source { get; set; }
+ public string Name { get; set; }
+ private string _value;
+ public string Value
+ {
+ get => _value;
+ set { if (_value != value) { _value = value; OnPropertyChanged(); } }
+ }
+ public string Unit { get; set; }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+
+ public partial class MainWindow : Window
+ {
+ private readonly DctTcpServer _server = new DctTcpServer();
+ private readonly ObservableCollection _txSignals = new ObservableCollection();
+ private readonly List _loadedLogRecords = new List();
+ private readonly LogFileReader _fileReader = new LogFileReader();
+ private CancellationTokenSource _streamCts;
+
+ private bool _isPlaying = false;
+ private bool _isPaused = false;
+ private volatile int _sendRateHz = 30; // 30Hz (태블릿 ChartBatchRenderer 33ms 최적화)
+ private float _playbackSpeed = 1.0f;
+ private int _selectedProfile = 0; // 0: MDB+CAN, 1: A2L+CAN
+ private bool _isFileMode = false;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ InitializeServerEvents();
+ RefreshIpAddresses();
+ if (GridTxSignals != null) GridTxSignals.ItemsSource = _txSignals;
+ UpdateSignalsForProfile(0);
+ AppendLog("DCT C# WPF 테스트 서버 가동 준비 완료.");
+ }
+
+ private void InitializeServerEvents()
+ {
+ _server.OnLogMessage += msg => Dispatcher.Invoke(() => AppendLog(msg));
+ _server.OnClientListChanged += () => Dispatcher.Invoke(RefreshClientsGrid);
+ _server.OnStartSimRequested += () => Dispatcher.Invoke(StartTelemetryStreaming);
+ _server.OnStopSimRequested += () => Dispatcher.Invoke(StopTelemetryStreaming);
+ }
+
+ private void RefreshIpAddresses()
+ {
+ ComboIp.Items.Clear();
+ try
+ {
+ var ips = DctTcpServer.GetActiveIpAddresses();
+ foreach (var ip in ips)
+ {
+ ComboIp.Items.Add(ip);
+ }
+ if (ComboIp.Items.Count > 0)
+ {
+ ComboIp.SelectedIndex = 0;
+ }
+ }
+ catch
+ {
+ ComboIp.Items.Add("127.0.0.1");
+ ComboIp.SelectedIndex = 0;
+ }
+ }
+
+ private void RefreshClientsGrid()
+ {
+ GridClients.ItemsSource = null;
+ GridClients.ItemsSource = _server.ActiveClients.ToList();
+ }
+
+ private void AppendLog(string message)
+ {
+ string logLine = $"[{DateTime.Now:HH:mm:ss.fff}] {message}\r\n";
+ if (TextLogConsole.Text.Length > 40000)
+ {
+ TextLogConsole.Text = TextLogConsole.Text.Substring(15000);
+ }
+ TextLogConsole.AppendText(logLine);
+
+ if (CheckAutoScroll.IsChecked == true)
+ {
+ TextLogConsole.ScrollToEnd();
+ }
+ }
+
+ private void RadioMode_Checked(object sender, RoutedEventArgs e)
+ {
+ if (RadioModeSim == null || RadioModeFile == null) return;
+
+ _isFileMode = RadioModeFile.IsChecked == true;
+
+ if (_isFileMode)
+ {
+ PanelFileZone.Opacity = 1.0;
+ PanelFileZone.IsEnabled = true;
+ ComboProfile.IsEnabled = false;
+ TextLoadedFile.Text = _loadedLogRecords.Count > 0
+ ? $"현재 모드: [ 로그 파일 재생 모드 ({_loadedLogRecords.Count} 레코드) ]"
+ : "현재 모드: [ 로그 파일 재생 모드 - 파일 선택 필요 ]";
+ TextLoadedFile.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(217, 119, 6));
+ AppendLog("모드 전환: [로그 파일 재생 모드] 활성화 (모의신호 선택 비활성화)");
+ }
+ else
+ {
+ PanelFileZone.Opacity = 0.5;
+ PanelFileZone.IsEnabled = false;
+ ComboProfile.IsEnabled = true;
+ TextLoadedFile.Text = "현재 모드: [ 실시간 모의신호 생성 모드 ]";
+ TextLoadedFile.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(37, 99, 235));
+ UpdateSignalsForProfile(ComboProfile.SelectedIndex);
+ AppendLog("모드 전환: [실시간 모의신호 생성 모드] 활성화");
+ }
+ }
+
+ private void UpdateSignalsForProfile(int profile)
+ {
+ _selectedProfile = profile;
+ _txSignals.Clear();
+
+ if (profile == 0) // 1. [MDB + CAN]
+ {
+ // MDB Signals
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "Incar_Snr", Value = "23.50", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "TIN", Value = "23.50", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "Amb_Snr", Value = "30.00", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "Evap_Snr", Value = "4.50", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "Speed", Value = "60.0", Unit = "KPH" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "Water", Value = "85.0", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "DrTempSet", Value = "22.0", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "PsTempSet", Value = "22.0", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "BlrwLevel", Value = "4", Unit = "단" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "BlrwVolt", Value = "13.5", Unit = "V" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "AutoFlag", Value = "1", Unit = "ON" });
+ _txSignals.Add(new TxSignalItem { Source = "[MDB]", Name = "Humidity", Value = "50", Unit = "%" });
+
+ // CAN DBC Signals
+ _txSignals.Add(new TxSignalItem { Source = "[CAN]", Name = "ALDC_VBVoltVal", Value = "13.80", Unit = "V" });
+ _txSignals.Add(new TxSignalItem { Source = "[CAN]", Name = "ALDC_HSActVoltVal", Value = "14.00", Unit = "V" });
+ _txSignals.Add(new TxSignalItem { Source = "[CAN]", Name = "ALDC_LSCmdVoltVal", Value = "12.00", Unit = "V" });
+ }
+ else // 2. [A2L + CAN]
+ {
+ // A2L XCP DAQ Signals
+ _txSignals.Add(new TxSignalItem { Source = "[A2L]", Name = "s16_Auto_ClimDtcDrVal_SWAP_MEASURMENT", Value = "24.00", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[A2L]", Name = "s16_Auto_ClimDtcPsVal_SWAP_MEASURMENT", Value = "23.00", Unit = "'C" });
+ _txSignals.Add(new TxSignalItem { Source = "[A2L]", Name = "u16_DrTempActrFbAd_SWAP_MEASURMENT", Value = "512", Unit = "cnt" });
+ _txSignals.Add(new TxSignalItem { Source = "[A2L]", Name = "u8_Acon_AconAutoCtrl_SWAP_MEASURMENT", Value = "1", Unit = "raw" });
+
+ // CAN DBC Signals
+ _txSignals.Add(new TxSignalItem { Source = "[CAN]", Name = "ALDC_VBVoltVal", Value = "13.50", Unit = "V" });
+ _txSignals.Add(new TxSignalItem { Source = "[CAN]", Name = "ALDC_HSActVoltVal", Value = "14.20", Unit = "V" });
+ _txSignals.Add(new TxSignalItem { Source = "[CAN]", Name = "ALDC_LSCmdVoltVal", Value = "12.00", Unit = "V" });
+ }
+ }
+
+ private void ComboProfile_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (ComboProfile != null && ComboProfile.SelectedIndex >= 0 && !_isFileMode)
+ {
+ UpdateSignalsForProfile(ComboProfile.SelectedIndex);
+ }
+ }
+
+ private void BtnToggleServer_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_server.IsRunning)
+ {
+ if (!int.TryParse(TextPort.Text.Trim(), out int port)) port = 6000;
+
+ if (_server.Start(port))
+ {
+ BtnToggleServer.Content = "⏹ 서버 중지 (Stop Server)";
+ BtnToggleServer.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(220, 38, 38));
+ TextServerStatus.Text = $"🟢 서버 동작 중 (Port {port})";
+ TextServerStatus.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(22, 163, 74));
+
+ // 서버 가동 시 IP/포트 변경 비활성화
+ ComboIp.IsEnabled = false;
+ TextPort.IsEnabled = false;
+ BtnCopyIp.IsEnabled = true;
+
+ StartTelemetryStreaming();
+ }
+ }
+ else
+ {
+ _server.Stop();
+ StopTelemetryStreaming();
+
+ BtnToggleServer.Content = "▶ 서버 시작 (Start Server)";
+ BtnToggleServer.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(22, 163, 74));
+ TextServerStatus.Text = "🔴 서버 중지됨 (Stopped)";
+ TextServerStatus.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(220, 38, 38));
+
+ // 서버 중지 시 IP/포트 변경 다시 활성화
+ ComboIp.IsEnabled = true;
+ TextPort.IsEnabled = true;
+ }
+ }
+
+ private void BtnCopyIp_Click(object sender, RoutedEventArgs e)
+ {
+ string ip = ComboIp.SelectedItem?.ToString();
+ if (!string.IsNullOrEmpty(ip))
+ {
+ Clipboard.SetText(ip);
+ MessageBox.Show($"PC IP 주소가 클립보드에 복사되었습니다:\n{ip}", "복사 완료", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private void BtnBrowse_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "재생할 로그 파일 선택",
+ Filter = "로그 파일 (*.csv;*.txt;*.dpc)|*.csv;*.txt;*.dpc|모든 파일 (*.*)|*.*"
+ };
+
+ if (dlg.ShowDialog() == true)
+ {
+ LoadLogFile(dlg.FileName);
+ }
+ }
+
+ private void BtnClearFile_Click(object sender, RoutedEventArgs e)
+ {
+ _loadedLogRecords.Clear();
+ TextLoadedFile.Text = "현재 모드: [ 로그 파일 재생 모드 - 파일 선택 필요 ]";
+ TextLoadedFile.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(217, 119, 6));
+ TextDropTitle.Text = "📂 여기에 재생할 로그 파일(.csv, .txt, .dpc)을 끌어다 놓으세요 (Drag & Drop)";
+ AppendLog("로드된 로그 파일이 초기화되었습니다.");
+ }
+
+ private void Window_DragEnter(object sender, DragEventArgs e)
+ {
+ if (_isFileMode && e.Data.GetDataPresent(DataFormats.FileDrop))
+ e.Effects = DragDropEffects.Copy;
+ }
+
+ private void Window_Drop(object sender, DragEventArgs e)
+ {
+ if (!_isFileMode) return;
+ if (e.Data.GetDataPresent(DataFormats.FileDrop))
+ {
+ string[] files = (string[])e.Data.GetData(DataFormats.FileDrop);
+ if (files != null && files.Length > 0)
+ {
+ LoadLogFile(files[0]);
+ }
+ }
+ }
+
+ private void DropZone_DragEnter(object sender, DragEventArgs e) => Window_DragEnter(sender, e);
+ private void DropZone_Drop(object sender, DragEventArgs e) => Window_Drop(sender, e);
+
+ private void LoadLogFile(string filePath)
+ {
+ var result = _fileReader.Load(filePath);
+ _loadedLogRecords.Clear();
+
+ if (result.Success)
+ {
+ _loadedLogRecords.AddRange(result.Packets);
+
+ // 파일 모드 신호 테이블 갱신
+ if (_isFileMode)
+ {
+ _txSignals.Clear();
+ foreach (var item in result.InitialSignalItems)
+ {
+ _txSignals.Add(item);
+ }
+ }
+
+ TextLoadedFile.Text = $"현재 로드된 파일: [{result.FileName}] ({result.TotalRecords} 레코드, {result.FileSizeMb:F2} MB)";
+ TextLoadedFile.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(22, 163, 74));
+ TextDropTitle.Text = $"✅ 로드됨: {result.FileName} ({result.TotalRecords} 레코드)";
+ AppendLog($"[FILE] 파일 '{result.FileName}' 로드 완료 ({result.TotalRecords} 레코드, {result.FileSizeMb:F2} MB)");
+ }
+ else
+ {
+ TextLoadedFile.Text = $"파일 로드 실패: {result.ErrorMessage}";
+ TextLoadedFile.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(220, 38, 38));
+ AppendLog($"[ERROR] {result.ErrorMessage}");
+ }
+ }
+
+ private void ComboRate_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ int[] rates = { 10, 20, 30, 50, 100 };
+ if (ComboRate.SelectedIndex >= 0 && ComboRate.SelectedIndex < rates.Length)
+ {
+ _sendRateHz = rates[ComboRate.SelectedIndex];
+ }
+ }
+
+ private void ComboSpeed_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ float[] speeds = { 0.5f, 1.0f, 2.0f, 5.0f };
+ if (ComboSpeed.SelectedIndex >= 0 && ComboSpeed.SelectedIndex < speeds.Length)
+ {
+ _playbackSpeed = speeds[ComboSpeed.SelectedIndex];
+ }
+ }
+
+ private void BtnPlay_Click(object sender, RoutedEventArgs e)
+ {
+ StartTelemetryStreaming();
+ }
+
+ private void BtnPause_Click(object sender, RoutedEventArgs e)
+ {
+ _isPaused = !_isPaused;
+ BtnPause.Content = _isPaused ? "▶ 재개" : "⏸ 일시정지";
+ AppendLog(_isPaused ? "스트리밍 일시정지" : "스트리밍 재개");
+ }
+
+ private void BtnStop_Click(object sender, RoutedEventArgs e)
+ {
+ StopTelemetryStreaming();
+ }
+
+ private void StartTelemetryStreaming()
+ {
+ if (_isPlaying)
+ {
+ _isPaused = false;
+ BtnPause.Content = "⏸ 일시정지";
+ return;
+ }
+
+ _isPlaying = true;
+ _isPaused = false;
+ _streamCts = new CancellationTokenSource();
+ BtnPlay.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(22, 163, 74));
+ BtnPlay.Content = "▶ 재생 중";
+
+ Task.Run(() => TelemetryStreamWorker(_streamCts.Token));
+ AppendLog("실시간 텔레메트리 스트리밍 전송 시작.");
+ }
+
+ private void StopTelemetryStreaming()
+ {
+ _isPlaying = false;
+ _isPaused = false;
+ _streamCts?.Cancel();
+ BtnPlay.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(37, 99, 235));
+ BtnPlay.Content = "▶ 재생";
+ BtnPause.Content = "⏸ 일시정지";
+ }
+
+private async Task TelemetryStreamWorker(CancellationToken ct)
+ {
+ ushort seq = 0;
+ float simTime = 0.0f;
+ int fileIdx = 0;
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ long nextTickMs = sw.ElapsedMilliseconds;
+ long lastUiUpdateMs = 0;
+ long lastSimTickMs = sw.ElapsedMilliseconds;
+
+ while (!ct.IsCancellationRequested && _isPlaying)
+ {
+ if (_isPaused)
+ {
+ await Task.Delay(50, ct);
+ nextTickMs = sw.ElapsedMilliseconds;
+ lastSimTickMs = sw.ElapsedMilliseconds;
+ continue;
+ }
+
+ int rate = Math.Max(1, _sendRateHz);
+ double targetIntervalMs = 1000.0 / rate;
+
+ long nowMs = sw.ElapsedMilliseconds;
+ long dtMs = Math.Max(1, nowMs - lastSimTickMs);
+ lastSimTickMs = nowMs;
+ simTime += (float)(dtMs / 1000.0 * _playbackSpeed);
+
+ float v1 = 0, v2 = 0, v3 = 0, v4 = 0, vEvap = 0;
+ float vWater = 85.0f;
+ float drTempSet = 22.0f, psTempSet = 22.0f;
+ byte blowerLevel = 4;
+ float blowerVolt = 12.8f;
+ byte autoFlag = 1;
+ float lsCmdVolt = 12.0f, hsActVolt = 14.0f;
+
+ int profile = _selectedProfile;
+ bool isFileMode = _isFileMode;
+
+ if (isFileMode && _loadedLogRecords.Count > 0) // File playback
+ {
+ if (fileIdx >= _loadedLogRecords.Count)
+ {
+ bool loop = true;
+ Dispatcher.Invoke(() => loop = CheckLoop.IsChecked == true);
+ if (loop) fileIdx = 0;
+ else { Dispatcher.Invoke(StopTelemetryStreaming); break; }
+ }
+ var filePkt = _loadedLogRecords[fileIdx++];
+ _server.BroadcastPacket(filePkt);
+ }
+ else if (profile == 0) // 1. [MDB + CAN] 실제 차량 주행 및 DATC 공조 물리 연동
+ {
+ // 기본 주행 사이클: 약 24초 주기의 현실적인 도심/간선 주행 패턴 (가속 -> 정속 -> 감속/회생제동)
+ float driveCycle = (float)(simTime % 24.0f);
+ // 가속/감속 상태 인자 (accelFactor: -1.0 [회생제동/감속] ~ +1.0 [가속])
+ float accelFactor = (float)Math.Sin(driveCycle * (2.0 * Math.PI / 24.0));
+
+ // 1) 차속 (Speed, KPH): 차량 관성(Mass Inertia) 기반 S-Curve 가감속 및 주행 (15 ~ 95 KPH)
+ float baseSpeed = 55.0f + 40.0f * (float)Math.Sin((driveCycle - 6.0f) * (2.0 * Math.PI / 24.0));
+ float speedJitter = 0.6f * (float)Math.Sin(simTime * 4.2f);
+ v4 = Math.Max(0.0f, Math.Min(160.0f, baseSpeed + speedJitter)); // Speed
+
+ // 2) 배터리 전압 (ALDC_VBVoltVal, V): 실차 회생제동/가속 부하 연동
+ // 회생제동(accelFactor < -0.2f) 시 LDC 발전 전압 상승(14.2 ~ 14.5V), 가속 시 방전 부하(13.4 ~ 13.7V)
+ v3 = 13.90f - 0.45f * accelFactor + 0.1f * (float)Math.Sin(simTime * 1.5f);
+
+ // 3) 냉각수온 (Water, 'C): 주행 부하 반응 (83.0 ~ 91.0'C 완만한 수온 곡선)
+ vWater = 87.0f + 3.5f * (float)Math.Sin((driveCycle - 8.0f) * (2.0 * Math.PI / 24.0));
+
+ // 4) 실내온도 (Incar_Snr, 'C): 공조 냉각 열역학 곡선 (21.5 ~ 24.5'C)
+ v1 = 23.0f + 1.5f * (float)Math.Cos(simTime * 0.15f);
+
+ // 5) 에바온도 (Evap_Snr, 'C): 컴프레셔 압축-팽창 사이클 (2.8 ~ 6.2'C, 주기 약 14초)
+ vEvap = 4.5f + 1.7f * (float)Math.Sin(simTime * 0.45f);
+
+ // 6) 외기온도 (Amb_Snr, 'C): 외부 기온 (29.5 ~ 31.5'C)
+ v2 = 30.5f + 1.0f * (float)Math.Sin(simTime * 0.08f);
+
+ // 7) 풍량단수 (BlrwLevel): 실내온도 및 주행 상태 연동 직각 스텝 (약 3~4초 주기)
+ float blrScore = (v1 - 21.5f) * 1.5f + (accelFactor * 0.5f);
+ blowerLevel = (byte)Math.Max(2, Math.Min(6, (int)Math.Round(3.5f + blrScore)));
+ blowerVolt = 11.2f + blowerLevel * 0.45f; // 풍량단수에 연동되는 구동 전압
+
+ // 8) 설정온도 (DrTempSet): 다이얼 조작 모의 (22.0'C <-> 21.5'C <-> 22.5'C, 약 7초 주기)
+ float tempDialCycle = (float)(simTime % 21.0f);
+ drTempSet = (tempDialCycle < 7.0f) ? 22.0f : ((tempDialCycle < 14.0f) ? 21.5f : 22.5f);
+ psTempSet = (tempDialCycle < 10.5f) ? 22.0f : 23.0f;
+
+ // 9) AUTO 플래그 (AutoFlag): 8초 주기 사각 펄스
+ autoFlag = (byte)((simTime % 16.0f) < 12.0f ? 1 : 0);
+
+ // 10) CAN LDC 저전압 지령 (ALDC_LSCmdVoltVal): 회생제동/충전 모드 스텝
+ lsCmdVolt = (accelFactor < -0.3f) ? 14.2f : ((accelFactor > 0.3f) ? 12.0f : 13.5f);
+ hsActVolt = 14.1f + 0.1f * (float)Math.Sin(simTime * 0.5f);
+
+ short incarRaw = (short)Math.Round(v1 * 10.0f);
+ short ambRaw = (short)Math.Round(v2 * 10.0f);
+ short evapRaw = (short)Math.Round(vEvap * 10.0f);
+
+ // 1) 0xA3 Type 0x01 (MDB Stream) - Record 161 (DATC Climate: 41 Signals = Exactly 68 Bytes)
+ byte[] mdb161 = new byte[2 + 68];
+ mdb161[0] = 0x01; // Type: MDB
+ mdb161[1] = 161; // ReclID: 161
+ int off = 2;
+
+ mdb161[off + 0] = autoFlag; // AutoFlag (Offset 0: 펄스 상태)
+ mdb161[off + 1] = 0; // SpecSel (Offset 1)
+ mdb161[off + 2] = blowerLevel; // AutoBlrStep (Offset 2)
+ mdb161[off + 3] = (byte)Math.Round(drTempSet / 0.5f); // DrTempSet (Offset 3: 다이얼 스텝 21.5/22.0/22.5'C)
+ mdb161[off + 4] = (byte)Math.Round(psTempSet / 0.5f); // PsTempSet (Offset 4: 다이얼 스텝)
+ mdb161[off + 5] = blowerLevel; // BlrwLevel (Offset 5: 6->5->4->3->2단 직각 스텝!)
+ mdb161[off + 6] = (byte)Math.Round(blowerVolt / 0.1f); // BlrwVolt (Offset 6: 단수 비례 전압 스텝)
+
+ // Incar_Snr (Offset 7..8)
+ mdb161[off + 7] = (byte)(incarRaw & 0xFF); mdb161[off + 8] = (byte)((incarRaw >> 8) & 0xFF);
+ // Incar_Flt (Offset 9..10)
+ mdb161[off + 9] = (byte)(incarRaw & 0xFF); mdb161[off + 10] = (byte)((incarRaw >> 8) & 0xFF);
+ // TIN (Offset 11..12)
+ mdb161[off + 11] = (byte)(incarRaw & 0xFF); mdb161[off + 12] = (byte)((incarRaw >> 8) & 0xFF);
+ // Amb_Snr (Offset 13..14)
+ mdb161[off + 13] = (byte)(ambRaw & 0xFF); mdb161[off + 14] = (byte)((ambRaw >> 8) & 0xFF);
+ // Amb_Flt (Offset 15..16)
+ mdb161[off + 15] = (byte)(ambRaw & 0xFF); mdb161[off + 16] = (byte)((ambRaw >> 8) & 0xFF);
+ // Evap_Snr (Offset 17..18)
+ mdb161[off + 17] = (byte)(evapRaw & 0xFF); mdb161[off + 18] = (byte)((evapRaw >> 8) & 0xFF);
+ // Evap_Flt (Offset 19..20)
+ mdb161[off + 19] = (byte)(evapRaw & 0xFF); mdb161[off + 20] = (byte)((evapRaw >> 8) & 0xFF);
+
+ var mdbPkt161 = DctPacket.Create(0xA3, mdb161, seq);
+ _server.BroadcastPacket(mdbPkt161);
+
+ // 2) 0xA3 Type 0x01 (MDB Stream) - Record 160 (Speed, Water, Humidity: 29 Signals = Exactly 30 Bytes)
+ byte[] mdb160 = new byte[2 + 30];
+ mdb160[0] = 0x01; // Type: MDB
+ mdb160[1] = 160; // ReclID: 160
+ int off160 = 2;
+ mdb160[off160 + 21] = 50; // Humidity (Offset 21)
+ mdb160[off160 + 25] = (byte)Math.Max(0, Math.Min(255, (int)Math.Round(v4))); // Speed (Offset 25: 실차 가감속 프로파일)
+ mdb160[off160 + 26] = (byte)Math.Max(0, Math.Min(255, (int)Math.Round((vWater + 48.0f) / 0.75f))); // Water (Offset 26: 85'C)
+
+ var mdbPkt160 = DctPacket.Create(0xA3, mdb160, seq);
+ _server.BroadcastPacket(mdbPkt160);
+
+ // 3) 0xA3 Type 0x02 (CAN DBC Stream) - ALDC_01_100ms (CAN ID: 548 = 0x0224, DLC 32)
+ byte[] canData = new byte[32];
+ canData[15] = (byte)Math.Round(lsCmdVolt / 0.1f); // ALDC_LSCmdVoltVal (Bit 120 -> Byte 15: 12.0/13.5/14.0V 직각 제어 스텝)
+ short hsRaw = (short)Math.Round(hsActVolt / 0.1f); // ALDC_HSActVoltVal (Bit 136 -> Byte 17..18)
+ canData[17] = (byte)(hsRaw & 0xFF); canData[18] = (byte)((hsRaw >> 8) & 0xFF);
+
+ // ALDC_VBVoltVal (Bit 226, 8 bits: Byte 28 bits 2..7 + Byte 29 bits 0..1)
+ uint vbRaw = (uint)Math.Round(v3 / 0.1f);
+ canData[28] = (byte)(((uint)canData[28] & 0x03u) | (((uint)vbRaw & 0x3Fu) << 2));
+ canData[29] = (byte)(((uint)canData[29] & 0xFCu) | (((uint)vbRaw >> 6) & 0x03u));
+
+ byte[] canPayload = new byte[4 + 3 + 32];
+ canPayload[0] = 0x02; // Type: CAN DBC
+ canPayload[1] = 0x01; // CurPage: 1
+ canPayload[2] = 0x01; // TotPage: 1
+ canPayload[3] = 0x01; // FrameCount: 1
+ canPayload[4] = 0x24; // ID Low
+ canPayload[5] = 0x02; // ID High
+ canPayload[6] = 32; // DLC: 32 bytes
+ Array.Copy(canData, 0, canPayload, 7, 32);
+
+ var canPkt = DctPacket.Create(0xA3, canPayload, seq);
+ _server.BroadcastPacket(canPkt);
+ }
+ else // 2. [A2L + CAN] 실차 주행 및 XCP DAQ 연동
+ {
+ float driveCycle = (float)(simTime % 24.0f);
+ float accelFactor = (float)Math.Sin(driveCycle * (2.0 * Math.PI / 24.0));
+
+ // 토출온도 연속 아날로그 제어 (18.5 ~ 24.5'C)
+ v1 = 21.5f + 3.0f * (float)Math.Cos(simTime * 0.18f); // s16_Auto_ClimDtcDrVal ('C)
+ v2 = 21.0f + 2.5f * (float)Math.Cos(simTime * 0.15f); // s16_Auto_ClimDtcPsVal ('C)
+ v3 = 13.90f - 0.45f * accelFactor + 0.1f * (float)Math.Sin(simTime * 1.5f); // ALDC_VBVoltVal (V)
+ v4 = 520.0f + 180.0f * (float)Math.Sin(simTime * 0.35f); // u16_DrTempActrFbAd (340 ~ 700 cnt)
+
+ autoFlag = (byte)((simTime % 14.0f) < 10.0f ? 1 : 0); // 에어컨 자동 제어 플래그 사각 펄스
+ lsCmdVolt = (accelFactor < -0.3f) ? 14.2f : ((accelFactor > 0.3f) ? 12.0f : 13.5f);
+ hsActVolt = 14.1f + 0.1f * (float)Math.Sin(simTime * 0.5f);
+
+ // 1) 0xD0: 마지막 0x41/0x42의 count/type/endian 설정과 동일하게 생성
+ short drRaw = (short)Math.Round(v1 * 10.0f);
+ short psRaw = (short)Math.Round(v2 * 10.0f);
+ short actrRaw = (short)Math.Round(v4);
+ byte[] xcpPayload = _server.BuildXcpDaqPayload(drRaw, psRaw, actrRaw, autoFlag);
+
+ _server.BroadcastPacket(DctPacket.Create(0xD0, xcpPayload, seq));
+
+ // 2) 0xA3 Type 0x02 (CAN DBC Stream) - ALDC_01_100ms
+ byte[] canData = new byte[32];
+ canData[15] = (byte)Math.Round(lsCmdVolt / 0.1f); // ALDC_LSCmdVoltVal
+ short hsRaw = (short)Math.Round(hsActVolt / 0.1f); // ALDC_HSActVoltVal
+ canData[17] = (byte)(hsRaw & 0xFF); canData[18] = (byte)((hsRaw >> 8) & 0xFF);
+
+ // ALDC_VBVoltVal (Bit 226)
+ uint vbRaw = (uint)Math.Round(v3 / 0.1f);
+ canData[28] = (byte)(((uint)canData[28] & 0x03u) | (((uint)vbRaw & 0x3Fu) << 2));
+ canData[29] = (byte)(((uint)canData[29] & 0xFCu) | (((uint)vbRaw >> 6) & 0x03u));
+
+ byte[] canPayload = new byte[4 + 3 + 32];
+ canPayload[0] = 0x02; canPayload[1] = 0x01; canPayload[2] = 0x01; canPayload[3] = 0x01;
+ canPayload[4] = 0x24; canPayload[5] = 0x02; canPayload[6] = 32;
+ Array.Copy(canData, 0, canPayload, 7, 32);
+
+ _server.BroadcastPacket(DctPacket.Create(0xA3, canPayload, seq));
+
+ // 3) MDB Record 160 for unified Speed & Water
+ float vSpeedSim = Math.Max(0.0f, Math.Min(160.0f, 55.0f + 40.0f * (float)Math.Sin((driveCycle - 6.0f) * (2.0 * Math.PI / 24.0))));
+ byte[] mdb160 = new byte[2 + 30];
+ mdb160[0] = 0x01; mdb160[1] = 160;
+ mdb160[2 + 25] = (byte)Math.Max(0, Math.Min(255, (int)Math.Round(vSpeedSim)));
+ mdb160[2 + 26] = (byte)Math.Max(0, Math.Min(255, (int)Math.Round((85.0f + 48.0f) / 0.75f)));
+ _server.BroadcastPacket(DctPacket.Create(0xA3, mdb160, seq));
+ }
+
+ // UI 테이블 및 로그 갱신: 송신 지터(Jitter) 0화를 위해 100ms 주기로 비동기(BeginInvoke) 분리
+ if (nowMs - lastUiUpdateMs >= 100 || lastUiUpdateMs == 0)
+ {
+ lastUiUpdateMs = nowMs;
+ int pProfile = profile;
+ ushort pSeq = seq;
+ float pV1 = v1, pV2 = v2, pV3 = v3, pV4 = v4, pVEvap = vEvap;
+ float pWater = vWater, pDrSet = drTempSet, pPsSet = psTempSet, pBlrVolt = blowerVolt, pHsVolt = hsActVolt, pLsVolt = lsCmdVolt;
+ byte pBlrLevel = blowerLevel, pAuto = autoFlag;
+ int activeClientCount = _server.ActiveClients.Count;
+ ulong totalCanAcks = _server.TotalCanAcks;
+ ulong totalXcpAcks = _server.TotalXcpAcks;
+
+ _ = Dispatcher.BeginInvoke(new Action(() =>
+ {
+ bool cShowTx = CheckShowTx.IsChecked == true;
+ bool cShowHex = CheckShowHex.IsChecked == true;
+ _server.ShowRxPackets = CheckShowRx.IsChecked == true;
+ _server.ShowHexDump = cShowHex;
+
+ if (pProfile == 0) // 1. [MDB + CAN]
+ {
+ if (_txSignals.Count >= 15)
+ {
+ _txSignals[0].Value = $"{pV1:F2}";
+ _txSignals[1].Value = $"{pV1:F2}";
+ _txSignals[2].Value = $"{pV2:F2}";
+ _txSignals[3].Value = $"{pVEvap:F2}";
+ _txSignals[4].Value = $"{pV4:F1}";
+ _txSignals[5].Value = $"{pWater:F1}";
+ _txSignals[6].Value = $"{pDrSet:F1}";
+ _txSignals[7].Value = $"{pPsSet:F1}";
+ _txSignals[8].Value = $"{pBlrLevel}";
+ _txSignals[9].Value = $"{pBlrVolt:F1}";
+ _txSignals[10].Value = $"{pAuto}";
+ _txSignals[11].Value = "50";
+ _txSignals[12].Value = $"{pV3:F2}";
+ _txSignals[13].Value = $"{pHsVolt:F2}";
+ _txSignals[14].Value = $"{pLsVolt:F2}";
+ }
+ }
+ else // 2. [A2L + CAN]
+ {
+ if (_txSignals.Count >= 7)
+ {
+ _txSignals[0].Value = $"{pV1:F2}";
+ _txSignals[1].Value = $"{pV2:F2}";
+ _txSignals[2].Value = $"{pV4:F0}";
+ _txSignals[3].Value = $"{pAuto}";
+ _txSignals[4].Value = $"{pV3:F2}";
+ _txSignals[5].Value = $"{pHsVolt:F2}";
+ _txSignals[6].Value = $"{pLsVolt:F2}";
+ }
+ }
+
+ if (cShowTx && (pSeq % 5 == 0 || activeClientCount > 0))
+ {
+ string clientInfo = activeClientCount > 0 ? $"-> {activeClientCount} device(s)" : "(Idle)";
+ string txLog = (pProfile == 0)
+ ? $"[📤 TX (MDB+CAN 30Hz) {clientInfo}] #{pSeq:D5} | Incar: {pV1:F2}'C | Speed: {pV4:F1}KPH | Blrw: {pBlrLevel}단 | VBatt: {pV3:F2}V (ACK: CAN={totalCanAcks})"
+ : $"[📤 TX (A2L+CAN 30Hz) {clientInfo}] #{pSeq:D5} | DrVal: {pV1:F2}'C | DrFb: {pV4:F0}cnt | VBatt: {pV3:F2}V (ACK: XCP={totalXcpAcks})";
+
+ if (cShowHex)
+ {
+ byte[] mdbSample = new byte[2 + 68];
+ mdbSample[0] = 0x01; mdbSample[1] = 161;
+ byte[] mdbEnc = DctPacket.Create(0xA3, mdbSample, pSeq).Encode();
+ string hex = BitConverter.ToString(mdbEnc).Replace("-", " ");
+ txLog += $"\r\n └ [0xA3 Frame]: {hex}";
+ }
+ AppendLog(txLog);
+ }
+
+ if (pSeq % 10 == 0)
+ {
+ RefreshClientsGrid();
+ }
+ }));
+ }
+
+ seq++;
+
+ // 고해상도 정밀 타이머 슬립 (Windows 타이머 지터 누적 제거)
+ nextTickMs += (long)targetIntervalMs;
+ long sleepMs = nextTickMs - sw.ElapsedMilliseconds;
+ if (sleepMs > 2)
+ {
+ await Task.Delay((int)sleepMs, ct);
+ }
+ else if (sleepMs < -100)
+ {
+ // 지연 누적 방지 리셋
+ nextTickMs = sw.ElapsedMilliseconds;
+ }
+ else
+ {
+ await Task.Yield();
+ }
+ }
+ }
+
+ private void BtnClearLog_Click(object sender, RoutedEventArgs e)
+ {
+ TextLogConsole.Clear();
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ _server.Stop();
+ StopTelemetryStreaming();
+ base.OnClosed(e);
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/Properties/AssemblyInfo.cs b/DCT_test/DCT_test/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..29c6dd1
--- /dev/null
+++ b/DCT_test/DCT_test/Properties/AssemblyInfo.cs
@@ -0,0 +1,52 @@
+using System.Reflection;
+using System.Resources;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Windows;
+
+// 어셈블리에 대한 일반 정보는 다음 특성 집합을 통해
+// 제어됩니다. 어셈블리와 관련된 정보를 수정하려면
+// 이러한 특성 값을 변경하세요.
+[assembly: AssemblyTitle("DCT_test")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("DCT_test")]
+[assembly: AssemblyCopyright("Copyright © 2026")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// ComVisible을 false로 설정하면 이 어셈블리의 형식이 COM 구성 요소에
+// 표시되지 않습니다. COM에서 이 어셈블리의 형식에 액세스하려면
+// 해당 형식에 대해 ComVisible 특성을 true로 설정하세요.
+[assembly: ComVisible(false)]
+
+//지역화 가능 애플리케이션 빌드를 시작하려면 다음을 설정하세요.
+//.csproj 파일의 CultureYouAreCodingWith
+// 내부. 예를 들어 미국 영어를 사용하는 경우
+//사용하는 경우 를 en-US로 설정합니다. 그런 다음 아래
+//NeutralResourceLanguage 특성의 주석 처리를 제거합니다. 아래 줄의 "en-US"를 업데이트하여
+//프로젝트 파일의 UICulture 설정과 일치시킵니다.
+
+//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)]
+
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //테마별 리소스 사전의 위치
+ //(페이지 또는 응용 프로그램 리소스 사진에
+ // 리소스가 없는 경우에 사용됨)
+ ResourceDictionaryLocation.SourceAssembly //제네릭 리소스 사전의 위치
+ //(페이지 또는 응용 프로그램 리소스 사진에
+ // 리소스가 없는 경우에 사용됨)
+)]
+
+
+// 어셈블리의 버전 정보는 다음 네 가지 값으로 구성됩니다.
+//
+// 주 버전
+// 부 버전
+// 빌드 번호
+// 수정 버전
+//
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/DCT_test/DCT_test/Properties/Resources.Designer.cs b/DCT_test/DCT_test/Properties/Resources.Designer.cs
new file mode 100644
index 0000000..2a84eba
--- /dev/null
+++ b/DCT_test/DCT_test/Properties/Resources.Designer.cs
@@ -0,0 +1,71 @@
+//------------------------------------------------------------------------------
+//
+// 이 코드는 도구를 사용하여 생성되었습니다.
+// 런타임 버전:4.0.30319.42000
+//
+// 파일 내용을 변경하면 잘못된 동작이 발생할 수 있으며, 코드를 다시 생성하면
+// 이러한 변경 내용이 손실됩니다.
+//
+//------------------------------------------------------------------------------
+
+namespace DCT_test.Properties
+{
+
+
+ ///
+ /// 지역화된 문자열 등을 찾기 위한 강력한 형식의 리소스 클래스입니다.
+ ///
+ // 이 클래스는 ResGen 또는 Visual Studio와 같은 도구를 통해 StronglyTypedResourceBuilder
+ // 클래스에서 자동으로 생성되었습니다.
+ // 멤버를 추가하거나 제거하려면 .ResX 파일을 편집한 다음 /str 옵션을 사용하여
+ // ResGen을 다시 실행하거나 VS 프로젝트를 다시 빌드하십시오.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources
+ {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources()
+ {
+ }
+
+ ///
+ /// 이 클래스에서 사용하는 캐시된 ResourceManager 인스턴스를 반환합니다.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager
+ {
+ get
+ {
+ if ((resourceMan == null))
+ {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DCT_test.Properties.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// 이 강력한 형식의 리소스 클래스를 사용하여 모든 리소스 조회에 대해 현재 스레드의 CurrentUICulture 속성을
+ /// 재정의합니다.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture
+ {
+ get
+ {
+ return resourceCulture;
+ }
+ set
+ {
+ resourceCulture = value;
+ }
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/Properties/Resources.resx b/DCT_test/DCT_test/Properties/Resources.resx
new file mode 100644
index 0000000..af7dbeb
--- /dev/null
+++ b/DCT_test/DCT_test/Properties/Resources.resx
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/DCT_test/DCT_test/Properties/Settings.Designer.cs b/DCT_test/DCT_test/Properties/Settings.Designer.cs
new file mode 100644
index 0000000..4ec695c
--- /dev/null
+++ b/DCT_test/DCT_test/Properties/Settings.Designer.cs
@@ -0,0 +1,30 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace DCT_test.Properties
+{
+
+
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
+ internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
+ {
+
+ private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
+
+ public static Settings Default
+ {
+ get
+ {
+ return defaultInstance;
+ }
+ }
+ }
+}
diff --git a/DCT_test/DCT_test/Properties/Settings.settings b/DCT_test/DCT_test/Properties/Settings.settings
new file mode 100644
index 0000000..033d7a5
--- /dev/null
+++ b/DCT_test/DCT_test/Properties/Settings.settings
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/DCT_test/README.md b/DCT_test/README.md
new file mode 100644
index 0000000..4f16982
--- /dev/null
+++ b/DCT_test/README.md
@@ -0,0 +1,450 @@
+# DCT 테스트 서버 프로그램 구조 및 실차 로그 파일(log.txt) 연동 가이드
+
+본 문서는 안드로이드 태블릿용 DCT 캘리브레이션 및 진단 계측 앱의 통신 테스트를 위해 제작된 C# WPF 기반 DCT 테스트 서버(DCT_test.exe)의 전체 소프트웨어 아키텍처와, 실제 차량 및 제어기에서 수집된 대규모 실차 계측 데이터(log.txt)를 프로그램과 연결하기 위한 코드 작성 가이드를 기술합니다.
+
+---
+
+## 목차
+1. 프로그램 개요 및 실행 방법
+2. 테스트 프로그램 전체 아키텍처 및 모듈 구조
+3. 핵심 소스코드 파일 및 역할 명세
+4. 전송 패킷 바이너리 프로토콜 명세
+5. log.txt 실차 대용량 데이터 연동 메커니즘
+6. 실차 로그 파서 코드 작성 가이드 및 C# 템플릿
+7. 수동 검증 절차 및 시나리오
+
+---
+
+## 1. 프로그램 개요 및 실행 방법
+
+### 1.1 프로그램 목적
+- 실제 차량이나 전용 하드웨어 인터페이스 장비 없이도 안드로이드 태블릿 앱이 Wi-Fi TCP(포트 6000)를 통해 실시간 계측 신호를 수신하고, UDP 비콘(포트 6005)을 통해 PC 서버를 자동 탐색(Discovery)할 수 있도록 지원하는 테스트 도구입니다.
+- 기본 탑재된 실제 차량 주행 거동 물리 시뮬레이션(가속, 감속, 회생제동, 공조 AUTO 연동)뿐만 아니라, 실제 차량 시험에서 획득한 대용량 실차 데이터(log.txt)를 읽어 실시간으로 단말에 재전송(Replay)하는 기능을 제공합니다.
+
+### 1.2 실행 방법
+- dct_test 폴더 또는 최상위 경로의 `Run_DCT_test.bat` 배치 파일을 실행합니다.
+- 기본 동작 사양:
+ - 전송 주기: 30 Hz (33ms, 최적) - 안드로이드 앱의 30Hz 배치 렌더러와 1:1 동기화
+ - 기본 프로필: 1. [MDB + CAN] MDB 실차공조 및 CAN DBC 동시 스트리밍
+ - 재생 모드: 실차 거동 모의 (Simulation) 기본 선택
+
+---
+
+## 2. 테스트 프로그램 전체 아키텍처 및 모듈 구조
+
+테스트 프로그램은 계층형 아키텍처(Layered Architecture)로 설계되어 있으며, 고속 통신 패킷 브로드캐스트 스레드와 WPF UI 렌더링 스레드가 완전히 분리되어 송신 지터(Jitter)를 0ms 수준으로 제어합니다.
+
+```mermaid
+graph TD
+ subgraph PresentationLayer ["WPF UI 계층"]
+ UI["MainWindow.xaml / MainWindow.xaml.cs"]
+ Grid["송신 신호 모니터링 테이블 (GridTxSignals)"]
+ Log["실시간 패킷 모니터링 콘솔 (TextLogConsole)"]
+ Ctrl["재생 제어부 (재생 / 일시정지 / 정지 / 주기 / 배속)"]
+ end
+
+ subgraph DataSourceLayer ["데이터 소스 계층"]
+ SimEngine["실제 차량 주행 물리 시뮬레이터 (관성 가감속/회생제동/수온/DATC)"]
+ LogReader["로그 파일 파서 (LogFileReader.cs)"]
+ LogFile["실제 차량 주행/수신 로그 파일 (log.txt)"]
+ LogFile --> LogReader
+ end
+
+ subgraph ProtocolLayer ["바이너리 프로토콜 계층"]
+ Packet["DctPacket.cs / DctPacketType.cs"]
+ Framing["헤더(0xA1) + 커맨드 + 시퀀스 + 길이 + 페이로드 + 체크섬 + 푸터(0x7E)"]
+ Packet --> Framing
+ end
+
+ subgraph NetworkLayer ["소켓 통신 계층"]
+ Server["DctServer.cs"]
+ TcpServer["TCP Server (포트 6000) - 다중 클라이언트 관리 및 브로드캐스트"]
+ UdpBeacon["UDP Beacon (포트 6005) - LAN 단말 자동 검색용 DCT_BEACON 송출"]
+ Server --> TcpServer
+ Server --> UdpBeacon
+ end
+
+ subgraph TargetClient ["외부 수신 단말"]
+ Android["안드로이드 태블릿 앱 (DCT App)"]
+ end
+
+ SimEngine -->|시뮬레이션 모드| Packet
+ LogReader -->|파일 리플레이 모드| Packet
+ Packet --> Server
+ TcpServer -->|TCP 패킷 스트리밍| Android
+ UdpBeacon -.->|LAN 비콘 디스커버리| Android
+ Server -->|100ms 비동기 분리| UI
+```
+
+---
+
+## 3. 핵심 소스코드 파일 및 역할 명세
+
+| 파일 경로 | 주요 역할 및 상세 기능 |
+| :--- | :--- |
+| `dct_test/DCT_test/MainWindow.xaml` | WPF UI 레이아웃 정의 (서버 제어 패널, 데이터 소스 라디오 버튼, 파일 드롭존, 재생 제어부, 송신 신호 그리드, 실시간 로그 콘솔) |
+| `dct_test/DCT_test/MainWindow.xaml.cs` | UI 이벤트 처리 및 전송 타이머 제어
• `TelemetryStreamWorker`: 고해상도 Stopwatch 기반 정밀 틱 타이머(30Hz) 및 100ms 주기 비동기 UI 분리 루프
• `LoadLogFile`: 드래그 앤 드롭 파일 수신 및 LogFileReader 결과 적재 |
+| `dct_test/DCT_test/LogFileReader.cs` | **실제 차량 계측 로그 파일(log.txt)을 읽어 DctPacket 바이너리 패킷으로 변환하는 전담 파서**
사용자가 보유한 실차 데이터 포맷에 맞추어 직접 파싱 코드를 작성할 수 있는 핵심 확장 지점 |
+| `dct_test/DCT_test/DctServer.cs` | 비동기 TCP 6000 포트 소켓 서버 (태블릿 연결 세션 유지 및 패킷 브로드캐스트), Keep-Alive ACK(0x80) 자동 응답, UDP 6005 비콘 브로드캐스터 |
+| `dct_test/DCT_test/DctPacket.cs` | PC 레거시 및 안드로이드 태블릿 앱 규격과 100% 호환되는 바이너리 프레임 인코딩/디코딩, 8비트 Inverted Sum 체크섬 계산, 원시 바이트 스트림 파서(`ParseStream`) 내장 |
+
+---
+
+## 4. 전송 패킷 바이너리 프로토콜 명세
+
+`LogFileReader`에서 `log.txt`를 읽어 생성해야 하는 표준 `DctPacket`의 세부 바이트 구조입니다.
+
+### 4.1 패킷 프레임 공통 구조 (7 바이트 오버헤드)
+```text
+[0xA1] [Command: 1B] [Seq: 2B (Little-Endian)] [Length: 2B (Little-Endian)] [Payload: N Bytes] [Checksum: 1B] [0x7E]
+```
+- **시작 바이트**: `0xA1`
+- **종료 바이트**: `0x7E`
+- **Checksum 계산 공식**: `(byte)((~sum) & 0xFF)` (헤더 0xA1부터 페이로드 마지막 바이트까지의 단순 누적합에 대한 1의 보수)
+
+### 4.2 MDB 스트림 패킷 (`Command = 0xA3`, Type = `0x01`)
+- **페이로드 공통 접두사**: `[Type = 0x01] [ReclID = 160 또는 161]`
+- **Record 161 (DATC 공조 계측 신호: 총 41개 신호 = 페이로드 70 바이트)**:
+ - `Offset + 0`: AutoFlag (1 바이트, 1=AUTO, 0=MANUAL)
+ - `Offset + 1`: SpecSel (1 바이트)
+ - `Offset + 2`: AutoBlrStep (1 바이트, 1~8단)
+ - `Offset + 3`: DrTempSet (1 바이트, 값 / 0.5f, 예: 22.0도 -> 44)
+ - `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 + 9..10`: Incar_Flt (2 바이트 signed short)
+ - `Offset + 11..12`: TIN (2 바이트 signed short)
+ - `Offset + 13..14`: Amb_Snr (2 바이트 signed short, 값 * 10)
+ - `Offset + 15..16`: Amb_Flt (2 바이트 signed short)
+ - `Offset + 17..18`: Evap_Snr (2 바이트 signed short, 값 * 10)
+ - `Offset + 19..20`: Evap_Flt (2 바이트 signed short)
+- **Record 160 (차속, 수온, 습도 등 차체/엔진 신호: 총 29개 신호 = 페이로드 32 바이트)**:
+ - `Offset + 21`: Humidity (1 바이트, 0~100%)
+ - `Offset + 25`: Speed (1 바이트 unsigned char, 0~255 KPH)
+ - `Offset + 26`: Water (1 바이트, (수온도 + 48.0f) / 0.75f)
+
+### 4.3 CAN DBC 스트림 패킷 (`Command = 0xA3`, Type = `0x02`)
+- **CAN ID 548 (ALDC_01_100ms, DLC 32 바이트)**:
+ - `페이로드 헤더 (7 바이트)`: `[Type: 0x02] [CurPage: 0x01] [TotPage: 0x01] [FrameCount: 0x01] [ID_Low: 0x24] [ID_High: 0x02] [DLC: 32]`
+ - `CAN Data Byte 15`: ALDC_LSCmdVoltVal (1 바이트, 값 / 0.1f)
+ - `CAN Data Byte 17..18`: ALDC_HSActVoltVal (2 바이트 signed short, 값 / 0.1f)
+ - `CAN Data Byte 28~29 (Bit 226, 8 bits)`: ALDC_VBVoltVal (배터리 전압, 값 / 0.1f)
+
+### 4.4 A2L XCP DAQ 스트림 패킷 (`Command = 0xD0`)
+- **페이로드**: XCP DAQ ODT 버퍼 (실제 설정된 DAQ 리스트에 따른 바이너리 바이트열)
+
+---
+
+## 5. log.txt 실차 대용량 데이터 연동 메커니즘
+
+### 5.1 실차 데이터 특성 이해
+실차 시험 계측 로그(`log.txt`)는 단순 몇 개의 신호만 추출된 파일이 아닙니다. 실제 환경에서는 다음 두 가지 형태 중 하나로 수집됩니다:
+
+1. **원시 통신 패킷 스트림 로그 (Raw Captured Packet Log)**:
+ - 실제 ECU나 통신 모듈(CAN, MDB, XCP)로부터 수신된 원시 바이너리 프레임(`0xA1 ... 0x7E`)이 헥사 텍스트(Hex Dump) 또는 바이너리로 연속 기록된 형태.
+ - 단일 파일 내에 Record 160(29개 신호), Record 161(41개 신호), CAN DBC(수십 개 신호), XCP DAQ 등 수백 개의 실차 파라미터가 프레임 단위로 온전히 포함되어 있습니다.
+2. **다채널 시계열 계측 로그 (Multi-Channel Time-Series CSV/TXT Log)**:
+ - 시험 장비에서 수십 개에서 수백 개의 센서 신호(열, Columns)가 타임스탬프와 함께 나열된 텍스트 형태.
+ - 예: `Time, Incar_Snr, Amb_Snr, Evap_Snr, Speed, Water, RPM, Throttle, BlrwLevel, VBatt, DrTempSet, ...` (수십~수백 개 신호)
+
+### 5.2 프로그램과 연결되는 전체 데이터 파이프라인
+프로그램 내부에서 로그 파일이 안드로이드 태블릿 앱까지 전달되는 흐름은 다음과 같습니다:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant User as "사용자"
+ participant UI as "MainWindow (WPF)"
+ participant Reader as "LogFileReader.cs"
+ participant Worker as "TelemetryStreamWorker"
+ participant Server as "DctServer (TCP 6000)"
+ participant App as "안드로이드 태블릿 앱"
+
+ 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)
+ loop 파일 패킷 브로드캐스트
+ Worker->>Server: 8. _server.BroadcastPacket(_loadedLogRecords[idx++])
+ Server->>App: 9. TCP 6000 소켓을 통해 태블릿으로 바이너리 패킷 송출
+ App->>App: 10. 태블릿 차트 및 수치 그리드에 실차 전체 신호 렌더링
+ end
+```
+
+---
+
+## 6. 실차 로그 파서 코드 작성 가이드 및 C# 템플릿
+
+사용자가 보유한 `log.txt`의 형식에 맞추어 `dct_test/DCT_test/LogFileReader.cs` 파일에 구현할 수 있는 두 가지 대표적인 완성형 코드 템플릿입니다.
+
+### 6.1 케이스 A: 수신된 원시 패킷 Hex 로그 파싱 템플릿
+실제 수신된 프레임이 헥사 텍스트(예: `A1 00 46 A3 01 A1 ... 7E`) 형태로 기록된 로그 파일을 처리하는 경우입니다. `DctPacket.ParseStream`을 활용하면 수백 개의 신호가 포함된 원시 프레임이 바이트 손실 없이 그대로 복원됩니다.
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace DCT_test
+{
+ public class LogFileReader
+ {
+ public LogFileReadResult Load(string filePath)
+ {
+ var result = new LogFileReadResult
+ {
+ FileName = Path.GetFileName(filePath)
+ };
+
+ if (!File.Exists(filePath))
+ {
+ result.ErrorMessage = $"파일이 존재하지 않습니다: {filePath}";
+ return result;
+ }
+
+ try
+ {
+ result.FileSizeMb = new FileInfo(filePath).Length / (1024.0 * 1024.0);
+
+ var byteBuffer = new List();
+
+ // 1. log.txt로부터 Hex 텍스트 라인을 읽어 바이트 버퍼 구성
+ using (var reader = new StreamReader(filePath))
+ {
+ string line;
+ while ((line = reader.ReadLine()) != null)
+ {
+ line = line.Trim();
+ if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue;
+
+ // 공백, 쉼표, 콜론 등으로 분리된 Hex 바이트 문자열 파싱
+ string[] hexTokens = line.Split(new[] { ' ', ',', '\t', '-' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var token in hexTokens)
+ {
+ if (byte.TryParse(token, System.Globalization.NumberStyles.HexNumber, null, out byte b))
+ {
+ byteBuffer.Add(b);
+ }
+ }
+ }
+ }
+
+ // 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)
+ {
+ result.ErrorMessage = "유효한 DCT 패킷(0xA1...0x7E)을 찾을 수 없습니다.";
+ }
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.ErrorMessage = $"파일 파싱 오류: {ex.Message}";
+ }
+
+ return result;
+ }
+ }
+}
+```
+
+---
+
+### 6.2 케이스 B: 수십~수백 개 신호가 포함된 다채널 텍스트/CSV 로그 파싱 템플릿
+타임스탬프와 함께 수십 개의 센서 및 제어 파라미터가 기록된 실차 주행 로그를 MDB 161(공조 41종) 및 MDB 160(주행 29종), CAN DBC 프레임으로 동적 매핑하여 전송하는 구조입니다.
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+
+namespace DCT_test
+{
+ public class LogFileReader
+ {
+ public LogFileReadResult Load(string filePath)
+ {
+ var result = new LogFileReadResult
+ {
+ FileName = Path.GetFileName(filePath)
+ };
+
+ if (!File.Exists(filePath))
+ {
+ result.ErrorMessage = $"파일이 존재하지 않습니다: {filePath}";
+ return result;
+ }
+
+ try
+ {
+ result.FileSizeMb = new FileInfo(filePath).Length / (1024.0 * 1024.0);
+
+ using (var reader = new StreamReader(filePath))
+ {
+ // 1. 헤더 행을 읽어 수십~수백 개의 전체 신호 열(Column) 인덱스 매핑 구성
+ string headerLine = reader.ReadLine();
+ if (string.IsNullOrEmpty(headerLine))
+ {
+ result.ErrorMessage = "파일 헤더가 비어 있습니다.";
+ return result;
+ }
+
+ string[] headers = headerLine.Split(new[] { ',', '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ var colMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < headers.Length; i++)
+ {
+ 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)
+ {
+ if (colMap.TryGetValue(sigName, out int idx) && idx < tokens.Length)
+ {
+ if (float.TryParse(tokens[idx], NumberStyles.Float, CultureInfo.InvariantCulture, out float v))
+ return v;
+ }
+ return defVal;
+ }
+
+ // 3. 데이터 행을 순차적으로 읽어 MDB / CAN 바이너리 패킷 조립
+ string line;
+ ushort seq = 0;
+
+ while ((line = reader.ReadLine()) != null)
+ {
+ line = line.Trim();
+ if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue;
+
+ string[] tokens = line.Split(new[] { ',', '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length < 2) continue;
+
+ // A. 실차 MDB Record 161 (DATC 공조 41종 신호 패킹: 70 바이트)
+ byte[] mdb161 = new byte[2 + 68];
+ mdb161[0] = 0x01; // Type: MDB
+ mdb161[1] = 161; // ReclID: 161
+ int off161 = 2;
+
+ byte autoFlag = (byte)GetVal(tokens, "AutoFlag", 1.0f);
+ byte blowerLevel = (byte)GetVal(tokens, "BlrwLevel", 4.0f);
+ float drTempSet = GetVal(tokens, "DrTempSet", 22.0f);
+ float psTempSet = GetVal(tokens, "PsTempSet", 22.0f);
+ float blowerVolt = GetVal(tokens, "BlrwVolt", 12.8f);
+
+ mdb161[off161 + 0] = autoFlag;
+ mdb161[off161 + 2] = blowerLevel;
+ mdb161[off161 + 3] = (byte)Math.Round(drTempSet / 0.5f);
+ mdb161[off161 + 4] = (byte)Math.Round(psTempSet / 0.5f);
+ mdb161[off161 + 5] = blowerLevel;
+ mdb161[off161 + 6] = (byte)Math.Round(blowerVolt / 0.1f);
+
+ // 온도 신호들 (0.1도 스케일링 short)
+ short incarRaw = (short)Math.Round(GetVal(tokens, "Incar_Snr", 23.0f) * 10.0f);
+ short ambRaw = (short)Math.Round(GetVal(tokens, "Amb_Snr", 30.0f) * 10.0f);
+ short evapRaw = (short)Math.Round(GetVal(tokens, "Evap_Snr", 4.5f) * 10.0f);
+
+ mdb161[off161 + 7] = (byte)(incarRaw & 0xFF); mdb161[off161 + 8] = (byte)((incarRaw >> 8) & 0xFF);
+ mdb161[off161 + 9] = (byte)(incarRaw & 0xFF); mdb161[off161 + 10] = (byte)((incarRaw >> 8) & 0xFF);
+ mdb161[off161 + 11] = (byte)(incarRaw & 0xFF); mdb161[off161 + 12] = (byte)((incarRaw >> 8) & 0xFF);
+ mdb161[off161 + 13] = (byte)(ambRaw & 0xFF); mdb161[off161 + 14] = (byte)((ambRaw >> 8) & 0xFF);
+ mdb161[off161 + 15] = (byte)(ambRaw & 0xFF); mdb161[off161 + 16] = (byte)((ambRaw >> 8) & 0xFF);
+ mdb161[off161 + 17] = (byte)(evapRaw & 0xFF); mdb161[off161 + 18] = (byte)((evapRaw >> 8) & 0xFF);
+ mdb161[off161 + 19] = (byte)(evapRaw & 0xFF); mdb161[off161 + 20] = (byte)((evapRaw >> 8) & 0xFF);
+
+ result.Packets.Add(DctPacket.Create(0xA3, mdb161, seq));
+
+ // B. 실차 MDB Record 160 (차속, 수온, 습도 패킹: 32 바이트)
+ byte[] mdb160 = new byte[2 + 30];
+ mdb160[0] = 0x01;
+ mdb160[1] = 160;
+ int off160 = 2;
+
+ float speed = GetVal(tokens, "Speed", 0.0f);
+ float water = GetVal(tokens, "Water", 85.0f);
+ float humidity = GetVal(tokens, "Humidity", 50.0f);
+
+ mdb160[off160 + 21] = (byte)Math.Max(0, Math.Min(100, (int)Math.Round(humidity)));
+ mdb160[off160 + 25] = (byte)Math.Max(0, Math.Min(255, (int)Math.Round(speed)));
+ mdb160[off160 + 26] = (byte)Math.Max(0, Math.Min(255, (int)Math.Round((water + 48.0f) / 0.75f)));
+
+ result.Packets.Add(DctPacket.Create(0xA3, mdb160, seq));
+
+ // C. 실차 CAN DBC ALDC_01_100ms 패킷 (배터리 전압 및 지령 전압 패킹: 39 바이트)
+ byte[] canData = new byte[32];
+ float lsVolt = GetVal(tokens, "ALDC_LSCmdVoltVal", 13.5f);
+ float vBatt = GetVal(tokens, "ALDC_VBVoltVal", 13.8f);
+
+ canData[15] = (byte)Math.Round(lsVolt / 0.1f);
+ uint vbRaw = (uint)Math.Round(vBatt / 0.1f);
+ canData[28] = (byte)(((uint)canData[28] & 0x03u) | (((uint)vbRaw & 0x3Fu) << 2));
+ canData[29] = (byte)(((uint)canData[29] & 0xFCu) | (((uint)vbRaw >> 6) & 0x03u));
+
+ byte[] canPayload = new byte[4 + 3 + 32];
+ canPayload[0] = 0x02; canPayload[1] = 0x01; canPayload[2] = 0x01; canPayload[3] = 0x01;
+ canPayload[4] = 0x24; canPayload[5] = 0x02; canPayload[6] = 32;
+ Array.Copy(canData, 0, canPayload, 7, 32);
+
+ result.Packets.Add(DctPacket.Create(0xA3, canPayload, seq));
+
+ seq++;
+ }
+ }
+
+ result.Success = result.Packets.Count > 0;
+ if (!result.Success)
+ {
+ result.ErrorMessage = "파싱된 패킷 레코드가 0개입니다. 파일 데이터 구조를 확인하세요.";
+ }
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.ErrorMessage = $"파일 파싱 오류: {ex.Message}";
+ }
+
+ return result;
+ }
+ }
+}
+```
+
+---
+
+## 7. 수동 검증 절차 및 시나리오
+
+1. **파일 로드 및 메타데이터 인식 확인**:
+ - `Run_DCT_test.bat`를 실행합니다.
+ - 상단 `2. 데이터 소스 모드 선택`에서 `실차 로그 파일 리플레이 (File Replay)` 라디오 버튼을 선택합니다.
+ - 준비한 실차 대용량 `log.txt` 파일을 드롭존 영역에 드래그 앤 드롭하거나 `[파일 찾아보기]` 버튼으로 지정합니다.
+ - `현재 로드된 파일: [log.txt] (N 레코드, M MB)` 텍스트가 표시되고 좌측 테이블에 파일 신호 목록이 갱신되는지 확인합니다.
+2. **서버 시작 및 패킷 스트리밍**:
+ - `[서버 시작]` 버튼을 클릭하여 TCP 6000 포트 수신 대기 상태로 전환합니다.
+ - `[재생]` 버튼을 클릭합니다.
+ - 우측 로그 콘솔에 `[TX (FILE)]` 로그가 순차적으로 출력되며 좌측 수치 테이블의 값들이 실제 주행 데이터 흐름에 맞게 갱신되는지 확인합니다.
+3. **태블릿 앱 연동 및 다채널 데이터 렌더링 검증**:
+ - 안드로이드 태블릿 앱을 실행하여 PC의 IP로 접속합니다.
+ - 차트 화면에서 `Incar_Snr`, `Speed`, `ALDC_VBVoltVal`, `BlrwLevel` 등 여러 신호를 등록합니다.
+ - `log.txt`에 기록된 실제 주행 거동(도심 주행 가감속, 회생제동 전압, 공조 냉각 등)이 왜곡 없이 태블릿 화면에 실시간으로 재생되는지 확인합니다.
diff --git a/DCT_test/Run_DCT_test.bat b/DCT_test/Run_DCT_test.bat
new file mode 100644
index 0000000..df33c2b
--- /dev/null
+++ b/DCT_test/Run_DCT_test.bat
@@ -0,0 +1,2 @@
+@echo off
+start "" "%~dp0DCT_test\bin\Release\DCT_test.exe"
diff --git a/DCT_test/build_csharp.bat b/DCT_test/build_csharp.bat
new file mode 100644
index 0000000..79752e9
--- /dev/null
+++ b/DCT_test/build_csharp.bat
@@ -0,0 +1,9 @@
+@echo off
+chcp 65001 > nul
+echo [C# WPF] DCT_test 빌드 시작...
+"C:\Program Files\Microsoft Visual Studio\18\Insiders\MSBuild\Current\Bin\amd64\MSBuild.exe" "%~dp0DCT_test\DCT_test.csproj" -p:Configuration=Release -v:minimal
+if %ERRORLEVEL% EQU 0 (
+ echo [SUCCESS] DCT_test.exe C# 빌드가 성공적으로 완료되었습니다!
+) else (
+ echo [ERROR] C# 빌드 실패!
+)