From 3e0e0286e782c6b2477a05599919b8ff67744d9d Mon Sep 17 00:00:00 2001 From: byun Date: Thu, 9 Oct 2025 20:41:29 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EC=8B=9C=EA=B0=84=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ESP32_CAN_Logger.ino | 175 +++++++++++++++++++++++++------------------ index.h | 170 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 260 insertions(+), 85 deletions(-) diff --git a/ESP32_CAN_Logger.ino b/ESP32_CAN_Logger.ino index a7c0c01..ee6c226 100644 --- a/ESP32_CAN_Logger.ino +++ b/ESP32_CAN_Logger.ino @@ -1,6 +1,6 @@ /* - * Byun CAN Logger with Web Interface - * Version: 1.1 + * Byun CAN Logger with Web Interface + Time Synchronization + * Version: 1.2 */ #include @@ -14,11 +14,14 @@ #include #include #include +#include +#include #include "index.h" #include "transmit.h" #include "graph.h" #include "graph_viewer.h" + // GPIO 핀 정의 #define CAN_INT_PIN 27 @@ -37,13 +40,13 @@ // 버퍼 설정 #define CAN_QUEUE_SIZE 1000 #define FILE_BUFFER_SIZE 8192 -#define MAX_FILENAME_LEN 32 +#define MAX_FILENAME_LEN 64 #define RECENT_MSG_COUNT 100 #define MAX_TX_MESSAGES 20 -// CAN 메시지 구조체 +// CAN 메시지 구조체 - 마이크로초 단위 타임스탬프 struct CANMessage { - uint32_t timestamp; + uint64_t timestamp_us; // 마이크로초 단위 Unix timestamp uint32_t id; uint8_t dlc; uint8_t data[8]; @@ -66,6 +69,14 @@ struct TxMessage { bool active; }; +// 시간 동기화 상태 +struct TimeSyncStatus { + bool synchronized; + uint64_t lastSyncTime; + int32_t offsetUs; + uint32_t syncCount; +} timeSyncStatus = {false, 0, 0, 0}; + // WiFi AP 설정 const char* ssid = "Byun_CAN_Logger"; const char* password = "12345678"; @@ -90,7 +101,6 @@ File logFile; char currentFilename[MAX_FILENAME_LEN]; uint8_t fileBuffer[FILE_BUFFER_SIZE]; uint16_t bufferIndex = 0; -uint32_t fileCounter = 0; // CAN 속도 설정 CAN_SPEED currentCanSpeed = CAN_1000KBPS; @@ -108,9 +118,37 @@ uint32_t lastMsgCount = 0; TxMessage txMessages[MAX_TX_MESSAGES]; uint32_t totalTxCount = 0; +// 정밀한 현재 시간 가져오기 (마이크로초) +uint64_t getMicrosecondTimestamp() { + struct timeval tv; + gettimeofday(&tv, NULL); + return (uint64_t)tv.tv_sec * 1000000ULL + (uint64_t)tv.tv_usec; +} + +// 시간 동기화 설정 +void setSystemTime(uint64_t timestampMs) { + struct timeval tv; + tv.tv_sec = timestampMs / 1000; + tv.tv_usec = (timestampMs % 1000) * 1000; + settimeofday(&tv, NULL); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = getMicrosecondTimestamp(); + timeSyncStatus.syncCount++; + + // 현재 시간 출력 + time_t now = tv.tv_sec; + struct tm timeinfo; + localtime_r(&now, &timeinfo); + char timeStr[64]; + strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); + + Serial.printf("⏰ 시간 동기화 완료: %s.%03d (동기화 횟수: %u)\n", + timeStr, (int)(tv.tv_usec / 1000), timeSyncStatus.syncCount); +} + // 함수 선언 void changeCanSpeed(CAN_SPEED newSpeed); -void scanExistingFiles(); bool createNewLogFile(); bool flushBuffer(); void startLogging(); @@ -123,6 +161,7 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length void handleCanTransmit(String msg); void handleStartMessage(String msg); void handleStopMessage(String msg); +void handleTimeSync(String msg); void webUpdateTask(void *pvParameters); // CAN 인터럽트 핸들러 @@ -145,60 +184,7 @@ void changeCanSpeed(CAN_SPEED newSpeed) { Serial.printf("CAN 속도 변경: %s\n", canSpeedNames[newSpeed]); } -// 기존 파일 번호 스캔 -void scanExistingFiles() { - if (!sdCardReady) { - fileCounter = 0; - Serial.println("SD 카드 없음 - 파일 카운터 0으로 시작"); - return; - } - - uint32_t maxFileNumber = 0; - bool foundFiles = false; - - File root = SD.open("/"); - if (!root) { - Serial.println("루트 디렉토리 열기 실패"); - fileCounter = 0; - return; - } - - File file = root.openNextFile(); - while (file) { - if (!file.isDirectory()) { - String name = file.name(); - if (name.startsWith("/")) name = name.substring(1); - - if (name.startsWith("canlog_") && (name.endsWith(".bin") || name.endsWith(".BIN"))) { - int startIdx = 7; - int endIdx = name.lastIndexOf('.'); - - if (endIdx > startIdx) { - String numStr = name.substring(startIdx, endIdx); - uint32_t fileNum = numStr.toInt(); - - if (fileNum > maxFileNumber) { - maxFileNumber = fileNum; - } - foundFiles = true; - } - } - } - file.close(); - file = root.openNextFile(); - } - root.close(); - - if (foundFiles) { - fileCounter = maxFileNumber + 1; - Serial.printf("기존 파일 발견 - 다음 파일 번호: %lu\n", fileCounter); - } else { - fileCounter = 0; - Serial.println("기존 파일 없음 - 파일 카운터 0으로 시작"); - } -} - -// 새 로그 파일 생성 +// 새 로그 파일 생성 - 시간 기반 파일명 bool createNewLogFile() { if (logFile) { logFile.flush(); @@ -206,8 +192,20 @@ bool createNewLogFile() { vTaskDelay(pdMS_TO_TICKS(10)); } + // 현재 시간으로 파일명 생성 + time_t now; + struct tm timeinfo; + time(&now); + localtime_r(&now, &timeinfo); + char filename[MAX_FILENAME_LEN]; - snprintf(filename, MAX_FILENAME_LEN, "/canlog_%05lu.bin", fileCounter++); + snprintf(filename, MAX_FILENAME_LEN, "/canlog_%04d%02d%02d_%02d%02d%02d.bin", + timeinfo.tm_year + 1900, + timeinfo.tm_mon + 1, + timeinfo.tm_mday, + timeinfo.tm_hour, + timeinfo.tm_min, + timeinfo.tm_sec); logFile = SD.open(filename, FILE_WRITE); @@ -218,6 +216,12 @@ bool createNewLogFile() { strncpy(currentFilename, filename, MAX_FILENAME_LEN); Serial.printf("새 로그 파일 생성: %s\n", currentFilename); + + // 시간 동기화 경고 + if (!timeSyncStatus.synchronized) { + Serial.println("⚠️ 경고: 시간이 동기화되지 않았습니다! 웹페이지에서 시간 동기화를 실행하세요."); + } + return true; } @@ -297,7 +301,8 @@ void canRxTask(void *pvParameters) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { - msg.timestamp = millis(); + // 마이크로초 단위 타임스탬프 + msg.timestamp_us = getMicrosecondTimestamp(); msg.id = frame.can_id; msg.dlc = frame.can_dlc; memcpy(msg.data, frame.data, 8); @@ -313,7 +318,7 @@ void canRxTask(void *pvParameters) { // 최근 메시지 저장 및 카운트 증가 bool found = false; for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.id == msg.id && recentData[i].msg.timestamp > 0) { + if (recentData[i].msg.id == msg.id && recentData[i].msg.timestamp_us > 0) { recentData[i].msg = msg; recentData[i].count++; found = true; @@ -323,7 +328,7 @@ void canRxTask(void *pvParameters) { if (!found) { for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.timestamp == 0) { + if (recentData[i].msg.timestamp_us == 0) { recentData[i].msg = msg; recentData[i].count = 1; found = true; @@ -383,7 +388,6 @@ void sdMonitorTask(void *pvParameters) { if (sdCardReady) { Serial.println("SD 카드 준비됨"); - scanExistingFiles(); } else { Serial.println("SD 카드 없음"); if (loggingEnabled) { @@ -526,6 +530,19 @@ void handleStopMessage(String msg) { } } +// 시간 동기화 처리 +void handleTimeSync(String msg) { + int timestampIdx = msg.indexOf("\"timestamp\":") + 12; + String timestampStr = msg.substring(timestampIdx); + timestampStr = timestampStr.substring(0, timestampStr.indexOf("}")); + + uint64_t clientTimestamp = strtoull(timestampStr.c_str(), NULL, 10); + + if (clientTimestamp > 0) { + setSystemTime(clientTimestamp); + } +} + // 웹소켓 이벤트 핸들러 void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) { switch(type) { @@ -539,6 +556,12 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length Serial.printf("WebSocket #%u 연결: %d.%d.%d.%d\n", num, ip[0], ip[1], ip[2], ip[3]); sendFileList(num); + + // 시간 동기화 상태 전송 + String syncStatus = "{\"type\":\"timeSyncStatus\",\"synchronized\":"; + syncStatus += timeSyncStatus.synchronized ? "true" : "false"; + syncStatus += ",\"syncCount\":" + String(timeSyncStatus.syncCount) + "}"; + webSocket.sendTXT(num, syncStatus); } break; @@ -577,6 +600,9 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length txMessages[i].active = false; } } + else if (msg.indexOf("\"cmd\":\"syncTime\"") >= 0) { + handleTimeSync(msg); + } } break; } @@ -632,6 +658,8 @@ void webUpdateTask(void *pvParameters) { status += "\"sdReady\":" + String(sdCardReady ? "true" : "false") + ","; status += "\"msgCount\":" + String(totalMsgCount) + ","; status += "\"msgSpeed\":" + String(msgPerSecond) + ","; + status += "\"timeSync\":" + String(timeSyncStatus.synchronized ? "true" : "false") + ","; + status += "\"syncCount\":" + String(timeSyncStatus.syncCount) + ","; if (loggingEnabled && logFile) { status += "\"currentFile\":\"" + String(currentFilename) + "\""; @@ -650,7 +678,7 @@ void webUpdateTask(void *pvParameters) { bool first = true; for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.timestamp > 0) { + if (recentData[i].msg.timestamp_us > 0) { CANMessage* msg = &recentData[i].msg; if (!first) canBatch += ","; @@ -670,7 +698,9 @@ void webUpdateTask(void *pvParameters) { if (j < msg->dlc - 1) canBatch += " "; } - canBatch += "\",\"timestamp\":" + String(msg->timestamp); + // 마이크로초 타임스탬프를 밀리초로 변환하여 전송 + uint64_t timestamp_ms = msg->timestamp_us / 1000; + canBatch += "\",\"timestamp\":" + String((uint32_t)timestamp_ms); canBatch += ",\"count\":" + String(recentData[i].count) + "}"; } } @@ -692,7 +722,7 @@ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); - Serial.println(" ESP32 CAN Logger with Web Interface "); + Serial.println(" ESP32 CAN Logger with Time Sync "); Serial.println("========================================"); memset(recentData, 0, sizeof(recentData)); @@ -711,7 +741,6 @@ void setup() { if (SD.begin(VSPI_CS, vspi)) { sdCardReady = true; Serial.println("✓ SD 카드 초기화 완료"); - scanExistingFiles(); } else { Serial.println("✗ SD 카드 초기화 실패"); } @@ -793,6 +822,7 @@ void setup() { Serial.println(" - Transmit: /transmit"); Serial.println(" - Graph: /graph"); Serial.println("========================================\n"); + Serial.println("⚠️ 시간 동기화를 위해 웹페이지에서 '⏰ 시간 동기화' 버튼을 클릭하세요"); } void loop() { @@ -801,11 +831,12 @@ void loop() { static uint32_t lastPrint = 0; if (millis() - lastPrint > 10000) { - Serial.printf("[상태] 큐: %d/%d | 로깅: %s | SD: %s | RX: %lu | TX: %lu\n", + Serial.printf("[상태] 큐: %d/%d | 로깅: %s | SD: %s | RX: %lu | TX: %lu | 시간동기: %s\n", uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, loggingEnabled ? "ON " : "OFF", sdCardReady ? "OK" : "NO", - totalMsgCount, totalTxCount); + totalMsgCount, totalTxCount, + timeSyncStatus.synchronized ? "OK" : "NO"); lastPrint = millis(); } } \ No newline at end of file diff --git a/index.h b/index.h index 7bb598b..33c42e6 100644 --- a/index.h +++ b/index.h @@ -52,6 +52,61 @@ const char index_html[] PROGMEM = R"rawliteral( .nav a:hover { background: #34495e; } .nav a.active { background: #3498db; } .content { padding: 15px; } + + .time-sync-banner { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 15px 20px; + border-radius: 10px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; + box-shadow: 0 4px 15px rgba(240, 147, 251, 0.4); + } + .time-sync-info { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; + } + .time-info-item { + display: flex; + flex-direction: column; + gap: 3px; + } + .time-label { + font-size: 0.75em; + opacity: 0.9; + font-weight: 600; + } + .time-value { + font-family: 'Courier New', monospace; + font-size: 1.1em; + font-weight: 700; + } + .btn-time-sync { + background: white; + color: #f5576c; + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 1em; + font-weight: 700; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 3px 10px rgba(0,0,0,0.2); + } + .btn-time-sync:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0,0,0,0.3); + } + .btn-time-sync:active { + transform: translateY(-1px); + } + .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); @@ -187,6 +242,17 @@ const char index_html[] PROGMEM = R"rawliteral( .nav a { padding: 8px 12px; font-size: 0.85em; } table { min-width: 400px; } th, td { padding: 6px 4px; font-size: 0.75em; } + .time-sync-banner { + flex-direction: column; + align-items: stretch; + padding: 12px 15px; + } + .time-sync-info { + gap: 10px; + } + .time-value { + font-size: 1em; + } } @@ -194,7 +260,7 @@ const char index_html[] PROGMEM = R"rawliteral(

Byun CAN Logger

-

Real-time CAN Bus Monitor & Data Logger

+

Real-time CAN Bus Monitor & Data Logger with Time Sync

+
+
+
+ ⏰ 시간 동기화 상태 + 대기 중... +
+
+ 🕐 현재 시간 + --:--:-- +
+
+ +
+

LOGGING

@@ -221,9 +301,13 @@ const char index_html[] PROGMEM = R"rawliteral(

SPEED

0/s
-
+
+

TIME SYNC

+
0
+
+

CURRENT FILE

-
-
+
-
@@ -276,16 +360,54 @@ const char index_html[] PROGMEM = R"rawliteral( let reconnectInterval; let canMessages = {}; let messageOrder = []; - - // 마지막 업데이트 추적용 let lastMessageData = {}; const speedNames = ['125 Kbps', '250 Kbps', '500 Kbps', '1 Mbps']; + // 현재 시간 업데이트 + function updateCurrentTime() { + const now = new Date(); + const timeStr = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0') + ':' + + now.getSeconds().toString().padStart(2, '0'); + document.getElementById('current-time').textContent = timeStr; + } + + setInterval(updateCurrentTime, 1000); + updateCurrentTime(); + + // 시간 동기화 함수 + function syncTime() { + if (ws && ws.readyState === WebSocket.OPEN) { + const timestamp = Date.now(); + ws.send(JSON.stringify({ + cmd: 'syncTime', + timestamp: timestamp + })); + + document.getElementById('sync-status').textContent = '동기화 중...'; + + setTimeout(() => { + const now = new Date(); + const dateStr = now.getFullYear() + '-' + + (now.getMonth() + 1).toString().padStart(2, '0') + '-' + + now.getDate().toString().padStart(2, '0') + ' ' + + now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0') + ':' + + now.getSeconds().toString().padStart(2, '0'); + document.getElementById('sync-status').textContent = '✓ ' + dateStr; + }, 200); + + console.log('시간 동기화 전송:', new Date(timestamp).toLocaleString()); + } else { + alert('WebSocket이 연결되지 않았습니다!'); + } + } + function saveCanSpeed() { const speed = document.getElementById('can-speed').value; try { - localStorage.setItem('canSpeed', speed); + window.localStorage.setItem('canSpeed', speed); console.log('Saved CAN speed:', speedNames[speed]); } catch(e) { console.error('Failed to save CAN speed:', e); @@ -294,7 +416,7 @@ const char index_html[] PROGMEM = R"rawliteral( function loadCanSpeed() { try { - const savedSpeed = localStorage.getItem('canSpeed'); + const savedSpeed = window.localStorage.getItem('canSpeed'); if (savedSpeed !== null) { document.getElementById('can-speed').value = savedSpeed; console.log('Restored CAN speed:', speedNames[savedSpeed]); @@ -319,11 +441,18 @@ const char index_html[] PROGMEM = R"rawliteral( console.log('WebSocket connected'); clearInterval(reconnectInterval); setTimeout(() => { refreshFiles(); }, 500); + + // 연결 직후 자동 시간 동기화 + setTimeout(() => { + syncTime(); + console.log('자동 시간 동기화 실행'); + }, 1000); }; ws.onclose = function() { console.log('WebSocket disconnected'); reconnectInterval = setInterval(initWebSocket, 3000); + document.getElementById('sync-status').textContent = '연결 끊김'; }; ws.onmessage = function(event) { @@ -342,6 +471,13 @@ const char index_html[] PROGMEM = R"rawliteral( } else { updateFileList(data.files); } + } else if (data.type === 'timeSyncStatus') { + if (data.synchronized) { + document.getElementById('sync-count').textContent = data.syncCount; + const card = document.getElementById('time-sync-card'); + card.classList.add('status-on'); + card.classList.remove('status-off'); + } } }; } @@ -350,6 +486,7 @@ const char index_html[] PROGMEM = R"rawliteral( const loggingCard = document.getElementById('logging-status'); const sdCard = document.getElementById('sd-status'); const fileCard = document.getElementById('file-status'); + const timeSyncCard = document.getElementById('time-sync-card'); if (data.logging) { loggingCard.classList.add('status-on'); @@ -371,6 +508,18 @@ const char index_html[] PROGMEM = R"rawliteral( sdCard.querySelector('.value').textContent = 'NOT READY'; } + if (data.timeSync) { + timeSyncCard.classList.add('status-on'); + timeSyncCard.classList.remove('status-off'); + } else { + timeSyncCard.classList.add('status-off'); + timeSyncCard.classList.remove('status-on'); + } + + if (data.syncCount !== undefined) { + document.getElementById('sync-count').textContent = data.syncCount; + } + if (data.currentFile && data.currentFile !== '') { fileCard.classList.add('status-on'); fileCard.classList.remove('status-off'); @@ -424,7 +573,6 @@ const char index_html[] PROGMEM = R"rawliteral( const msg = canMessages[canId]; let row = existingRows.get(canId); - // 이전 데이터와 비교하여 실제 변경사항 확인 const prevData = lastMessageData[canId]; const hasChanged = !prevData || prevData.data !== msg.data || @@ -437,7 +585,6 @@ const char index_html[] PROGMEM = R"rawliteral( row.cells[3].textContent = msg.updateCount; row.cells[4].textContent = msg.timestamp; - // 실제로 변경된 경우에만 flash 효과 if (hasChanged) { row.classList.add('flash-row'); setTimeout(() => row.classList.remove('flash-row'), 300); @@ -455,7 +602,6 @@ const char index_html[] PROGMEM = R"rawliteral( setTimeout(() => row.classList.remove('flash-row'), 300); } - // 현재 데이터 저장 lastMessageData[canId] = { data: msg.data, dlc: msg.dlc, @@ -474,9 +620,7 @@ const char index_html[] PROGMEM = R"rawliteral( } files.sort((a, b) => { - const numA = parseInt(a.name.match(/\d+/)?.[0] || '0'); - const numB = parseInt(b.name.match(/\d+/)?.[0] || '0'); - return numB - numA; + return b.name.localeCompare(a.name); }); fileList.innerHTML = '';