웹디바이스로 시간 동기화

This commit is contained in:
2025-10-09 20:41:29 +00:00
parent 5e2da19075
commit 3e0e0286e7
2 changed files with 260 additions and 85 deletions

View File

@@ -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 <Arduino.h>
@@ -14,11 +14,14 @@
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include <sys/time.h>
#include <time.h>
#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();
}
}

170
index.h
View File

@@ -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;
}
}
</style>
</head>
@@ -194,7 +260,7 @@ const char index_html[] PROGMEM = R"rawliteral(
<div class="container">
<div class="header">
<h1>Byun CAN Logger</h1>
<p>Real-time CAN Bus Monitor & Data Logger</p>
<p>Real-time CAN Bus Monitor & Data Logger with Time Sync</p>
</div>
<div class="nav">
@@ -204,6 +270,20 @@ const char index_html[] PROGMEM = R"rawliteral(
</div>
<div class="content">
<div class="time-sync-banner">
<div class="time-sync-info">
<div class="time-info-item">
<span class="time-label"> </span>
<span class="time-value" id="sync-status"> ...</span>
</div>
<div class="time-info-item">
<span class="time-label">🕐 </span>
<span class="time-value" id="current-time">--:--:--</span>
</div>
</div>
<button class="btn-time-sync" onclick="syncTime()"> </button>
</div>
<div class="status-grid">
<div class="status-card" id="logging-status">
<h3>LOGGING</h3>
@@ -221,9 +301,13 @@ const char index_html[] PROGMEM = R"rawliteral(
<h3>SPEED</h3>
<div class="value" id="msg-speed">0/s</div>
</div>
<div class="status-card" id="file-status" style="grid-column: span 2;">
<div class="status-card" id="time-sync-card">
<h3>TIME SYNC</h3>
<div class="value" id="sync-count">0</div>
</div>
<div class="status-card" id="file-status">
<h3>CURRENT FILE</h3>
<div class="value" id="current-file" style="font-size: 1em;">-</div>
<div class="value" id="current-file" style="font-size: 0.85em;">-</div>
</div>
</div>
@@ -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 = '';