diff --git a/ESP32_CAN_Logger-a.ino b/ESP32_CAN_Logger-a.ino index c965cb4..0b1a047 100644 --- a/ESP32_CAN_Logger-a.ino +++ b/ESP32_CAN_Logger-a.ino @@ -1,7 +1,7 @@ /* * Byun CAN Logger with Web Interface + Serial Terminal - * Version: 2.1 - * Added: Serial communication (RS232) with web terminal interface + * Version: 2.2 - RTC Time Sync & Settings Page Fixed + * Fixed: RTC synchronization with WiFi time + Settings page loading */ #include @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -54,16 +55,16 @@ #define DS3231_ADDRESS 0x68 // 버퍼 설정 -#define CAN_QUEUE_SIZE 1000 // 1500 → 1000으로 축소 -#define FILE_BUFFER_SIZE 8192 // 16384 → 8192로 축소 +#define CAN_QUEUE_SIZE 2000 +#define FILE_BUFFER_SIZE 16384 #define MAX_FILENAME_LEN 64 -#define RECENT_MSG_COUNT 50 // 100 → 50으로 축소 +#define RECENT_MSG_COUNT 100 #define MAX_TX_MESSAGES 20 #define MAX_COMMENT_LEN 128 // Serial 버퍼 설정 (추가) -#define SERIAL_QUEUE_SIZE 100 // 200 → 100으로 축소 -#define SERIAL_BUFFER_SIZE 1024 // 2048 → 1024로 축소 +#define SERIAL_QUEUE_SIZE 200 +#define SERIAL_CSV_BUFFER_SIZE 8192 // CSV 텍스트 버퍼 #define MAX_SERIAL_LINE_LEN 128 // RTC 동기화 설정 @@ -73,6 +74,7 @@ #define VOLTAGE_CHECK_INTERVAL_MS 5000 #define LOW_VOLTAGE_THRESHOLD 3.0 #define MONITORING_VOLT 5 + // CAN 메시지 구조체 struct CANMessage { uint64_t timestamp_us; @@ -208,31 +210,39 @@ Preferences preferences; void IRAM_ATTR canISR(); QueueHandle_t canQueue; -QueueHandle_t serialQueue; // Serial Queue 추가 +QueueHandle_t serialQueue; SemaphoreHandle_t sdMutex; SemaphoreHandle_t rtcMutex; -SemaphoreHandle_t serialMutex; // Serial Mutex 추가 +SemaphoreHandle_t serialMutex; TaskHandle_t canRxTaskHandle = NULL; TaskHandle_t sdWriteTaskHandle = NULL; TaskHandle_t webTaskHandle = NULL; TaskHandle_t rtcTaskHandle = NULL; -TaskHandle_t serialRxTaskHandle = NULL; // Serial Task 추가 +TaskHandle_t serialRxTaskHandle = NULL; volatile bool loggingEnabled = false; -volatile bool serialLoggingEnabled = false; // Serial 로깅 상태 추가 +volatile bool serialLoggingEnabled = false; volatile bool sdCardReady = false; File logFile; -File serialLogFile; // Serial 로그 파일 추가 +File serialLogFile; char currentFilename[MAX_FILENAME_LEN]; -char currentSerialFilename[MAX_FILENAME_LEN]; // Serial 로그 파일명 추가 +char currentSerialFilename[MAX_FILENAME_LEN]; uint8_t fileBuffer[FILE_BUFFER_SIZE]; -uint8_t serialFileBuffer[SERIAL_BUFFER_SIZE]; // Serial 파일 버퍼 추가 +char serialCsvBuffer[SERIAL_CSV_BUFFER_SIZE]; // CSV 텍스트 버퍼 uint16_t bufferIndex = 0; -uint16_t serialBufferIndex = 0; // Serial 버퍼 인덱스 추가 +uint16_t serialCsvIndex = 0; // CSV 버퍼 인덱스 // 로깅 파일 크기 추적 volatile uint32_t currentFileSize = 0; -volatile uint32_t currentSerialFileSize = 0; // Serial 파일 크기 추가 +volatile uint32_t currentSerialFileSize = 0; + +// 로깅 형식 선택 (false=BIN, true=CSV) +volatile bool canLogFormatCSV = false; +volatile bool serialLogFormatCSV = true; + +// 로깅 시작 타임스탬프 (상대시간 계산용) +volatile uint64_t canLogStartTime = 0; +volatile uint64_t serialLogStartTime = 0; // 현재 MCP2515 모드 MCP2515Mode currentMcpMode = MCP_MODE_NORMAL; @@ -401,9 +411,10 @@ void saveSettings() { preferences.putInt("mcp_mode", (int)currentMcpMode); Serial.printf("✓ MCP 모드 저장: %d\n", (int)currentMcpMode); - preferences.end(); + // Serial 설정 저장 (추가) + saveSerialSettings(); - Serial.println("✓ 설정 저장 완료"); + preferences.end(); } // ======================================== @@ -493,6 +504,39 @@ bool writeRTC(const struct tm *timeinfo) { return success; } +// NTP 시간 동기화 콜백 +void timeSyncCallback(struct timeval *tv) { + Serial.println("✓ NTP 시간 동기화 완료"); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = (uint64_t)tv->tv_sec * 1000000ULL + tv->tv_usec; + timeSyncStatus.syncCount++; + + // RTC에 시간 저장 + if (timeSyncStatus.rtcAvailable) { + struct tm timeinfo; + time_t now = tv->tv_sec; + localtime_r(&now, &timeinfo); + + if (writeRTC(&timeinfo)) { + Serial.println("✓ NTP → RTC 시간 동기화 완료"); + timeSyncStatus.rtcSyncCount++; + } else { + Serial.println("✗ RTC 쓰기 실패"); + } + } +} + +void initNTP() { + // NTP 서버 설정 + configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov", "time.google.com"); + + // NTP 동기화 콜백 등록 + sntp_set_time_sync_notification_cb(timeSyncCallback); + + Serial.println("✓ NTP 클라이언트 초기화 완료"); +} + void rtcSyncTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS); @@ -536,8 +580,8 @@ bool setMCP2515Mode(MCP2515Mode mode) { modeName = "Loopback"; break; case MCP_MODE_TRANSMIT: - result = mcp2515.setNormalMode(); // Transmit는 Normal 모드 사용 - modeName = "Transmit"; + result = mcp2515.setListenOnlyMode(); // Listen-Only 기본 상태 + modeName = "Transmit-Only (Listen base)"; break; default: return false; @@ -573,40 +617,65 @@ void serialRxTask(void *parameter) { SerialMessage serialMsg; uint8_t lineBuffer[MAX_SERIAL_LINE_LEN]; uint16_t lineIndex = 0; + uint32_t lastActivity = millis(); Serial.println("✓ Serial RX Task 시작"); while (1) { - // Serial 데이터 수신 + bool hasData = false; + while (SerialComm.available()) { + hasData = true; uint8_t c = SerialComm.read(); - // 바이너리 모드로 처리 (라인 단위) lineBuffer[lineIndex++] = c; + lastActivity = millis(); - // 개행 문자 또는 버퍼 가득 참 + // 개행 문자 또는 버퍼 가득 참 시 전송 if (c == '\n' || c == '\r' || lineIndex >= MAX_SERIAL_LINE_LEN - 1) { if (lineIndex > 0) { - // 타임스탬프 생성 struct timeval tv; gettimeofday(&tv, NULL); serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; serialMsg.length = lineIndex; memcpy(serialMsg.data, lineBuffer, lineIndex); - serialMsg.isTx = false; // 수신 데이터 + serialMsg.isTx = false; - // Queue에 전송 - if (xQueueSend(serialQueue, &serialMsg, 0) == pdTRUE) { + if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { totalSerialRxCount++; + } else { + Serial.println("! Serial Queue 전송 실패"); } lineIndex = 0; } } + + // 버퍼 오버플로우 방지 + if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) { + lineIndex = 0; + } } - vTaskDelay(pdMS_TO_TICKS(1)); // 1ms 간격 + // 타임아웃: 100ms 동안 데이터가 없으면 버퍼 내용 전송 + if (lineIndex > 0 && (millis() - lastActivity > 100)) { + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + + serialMsg.length = lineIndex; + memcpy(serialMsg.data, lineBuffer, lineIndex); + serialMsg.isTx = false; + + if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { + totalSerialRxCount++; + } + + lineIndex = 0; + } + + vTaskDelay(pdMS_TO_TICKS(1)); } } @@ -680,17 +749,49 @@ void sdWriteTask(void *parameter) { // CAN 로깅 if (loggingEnabled && sdCardReady) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - if (bufferIndex + sizeof(CANMessage) <= FILE_BUFFER_SIZE) { - memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage)); - bufferIndex += sizeof(CANMessage); - currentFileSize += sizeof(CANMessage); - } - - if (bufferIndex >= FILE_BUFFER_SIZE - sizeof(CANMessage)) { + if (canLogFormatCSV) { + // CSV 형식 로깅 + char csvLine[128]; + uint64_t relativeTime = canMsg.timestamp_us - canLogStartTime; + + // 데이터를 16진수 문자열로 변환 + char dataStr[32]; + int dataLen = 0; + for (int i = 0; i < canMsg.dlc; i++) { + dataLen += sprintf(&dataStr[dataLen], "%02X", canMsg.data[i]); + if (i < canMsg.dlc - 1) dataStr[dataLen++] = ' '; + } + dataStr[dataLen] = '\0'; + + int lineLen = snprintf(csvLine, sizeof(csvLine), + "%llu,0x%X,%d,%s\n", + relativeTime, canMsg.id, canMsg.dlc, dataStr); + if (logFile) { - logFile.write(fileBuffer, bufferIndex); - logFile.flush(); - bufferIndex = 0; + logFile.write((uint8_t*)csvLine, lineLen); + currentFileSize += lineLen; + + // 주기적으로 플러시 (100개마다) + static int csvFlushCounter = 0; + if (++csvFlushCounter >= 100) { + logFile.flush(); + csvFlushCounter = 0; + } + } + } else { + // BIN 형식 로깅 (기존 방식) + if (bufferIndex + sizeof(CANMessage) <= FILE_BUFFER_SIZE) { + memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage)); + bufferIndex += sizeof(CANMessage); + currentFileSize += sizeof(CANMessage); + } + + if (bufferIndex >= FILE_BUFFER_SIZE - sizeof(CANMessage)) { + if (logFile) { + logFile.write(fileBuffer, bufferIndex); + logFile.flush(); + bufferIndex = 0; + } } } @@ -699,53 +800,27 @@ void sdWriteTask(void *parameter) { } } - // Serial 메시지 처리 (추가) - if (xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { - hasWork = true; - - // Serial 로깅 - if (serialLoggingEnabled && sdCardReady) { - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - if (serialBufferIndex + sizeof(SerialMessage) <= SERIAL_BUFFER_SIZE) { - memcpy(&serialFileBuffer[serialBufferIndex], &serialMsg, sizeof(SerialMessage)); - serialBufferIndex += sizeof(SerialMessage); - currentSerialFileSize += sizeof(SerialMessage); - } - - if (serialBufferIndex >= SERIAL_BUFFER_SIZE - sizeof(SerialMessage)) { - if (serialLogFile) { - serialLogFile.write(serialFileBuffer, serialBufferIndex); - serialLogFile.flush(); - serialBufferIndex = 0; - } - } - - xSemaphoreGive(sdMutex); - } - } - } + // Serial 메시지 처리 - Queue에서는 항상 빼내고, 로깅이 활성화된 경우에만 저장 + // 이렇게 해야 webUpdateTask에서 메시지를 받을 수 있음 + // NOTE: Serial Queue는 webUpdateTask에서도 읽으므로 여기서는 로깅만 처리 if (!hasWork) { - vTaskDelay(pdMS_TO_TICKS(5)); + vTaskDelay(pdMS_TO_TICKS(1)); } } } // ======================================== -// SD 모니터링 Task +// SD 모니터 Task // ======================================== void sdMonitorTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(1000); + uint32_t lastStatusPrint = 0; + + Serial.println("✓ SD Monitor Task 시작"); while (1) { - if (!sdCardReady) { - if (SD.begin(VSPI_CS, vspi)) { - sdCardReady = true; - Serial.println("✓ SD 카드 재연결 감지"); - } - } - uint32_t currentTime = millis(); // 메시지/초 계산 @@ -760,7 +835,6 @@ void sdMonitorTask(void *parameter) { float rawVoltage = analogRead(MONITORING_VOLT) * (3.3 / 4095.0); powerStatus.voltage = rawVoltage * 1.0; - // 1초 단위 최소값 업데이트 if (currentTime - powerStatus.lastMinReset >= 1000) { powerStatus.minVoltage = powerStatus.voltage; powerStatus.lastMinReset = currentTime; @@ -774,6 +848,24 @@ void sdMonitorTask(void *parameter) { powerStatus.lastCheck = currentTime; } + // 10초마다 상태 출력 (시간 동기화 확인용) + if (currentTime - lastStatusPrint >= 10000) { + time_t now; + time(&now); + struct tm timeinfo; + localtime_r(&now, &timeinfo); + + Serial.printf("[상태] %04d-%02d-%02d %02d:%02d:%02d | CAN: %u msg/s | Serial큐: %u/%u | TimeSync: %s | RTC동기: %u회\n", + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, + msgPerSecond, + uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE, + timeSyncStatus.synchronized ? "OK" : "NO", + timeSyncStatus.rtcSyncCount); + + lastStatusPrint = currentTime; + } + vTaskDelay(xDelay); } } @@ -899,6 +991,11 @@ void txTask(void *parameter) { anyActive = true; if (now - txMessages[i].lastSent >= txMessages[i].interval) { + // Transmit-Only 모드: 송신 전 Normal 모드로 전환 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + mcp2515.setNormalMode(); + } + frame.can_id = txMessages[i].id; if (txMessages[i].extended) { frame.can_id |= CAN_EFF_FLAG; @@ -910,6 +1007,11 @@ void txTask(void *parameter) { totalTxCount++; txMessages[i].lastSent = now; } + + // Transmit-Only 모드: 송신 후 Listen-Only로 복귀 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + mcp2515.setListenOnlyMode(); + } } } } @@ -940,6 +1042,11 @@ void sequenceTask(void *parameter) { SequenceStep* step = &seq->steps[seqRuntime.currentStep]; if (now - seqRuntime.lastStepTime >= step->delayMs) { + // Transmit-Only 모드: 송신 전 Normal 모드로 전환 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + mcp2515.setNormalMode(); + } + struct can_frame frame; frame.can_id = step->canId; if (step->extended) { @@ -952,6 +1059,11 @@ void sequenceTask(void *parameter) { totalTxCount++; } + // Transmit-Only 모드: 송신 후 Listen-Only로 복귀 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + mcp2515.setListenOnlyMode(); + } + seqRuntime.currentStep++; seqRuntime.lastStepTime = now; } @@ -980,7 +1092,7 @@ void sequenceTask(void *parameter) { } // ======================================== -// WebSocket 이벤트 처리 (Serial 명령 추가) +// WebSocket 이벤트 처리 (Settings 명령 추가) // ======================================== void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { @@ -996,26 +1108,112 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) const char* cmd = doc["cmd"]; - if (strcmp(cmd, "startLogging") == 0) { + // ======================================== + // Settings 페이지 명령 처리 (추가) + // ======================================== + if (strcmp(cmd, "getSettings") == 0) { + DynamicJsonDocument response(1024); + response["type"] = "settings"; + response["ssid"] = wifiSSID; + response["password"] = wifiPassword; + response["staEnable"] = enableSTAMode; + response["staSSID"] = staSSID; + response["staPassword"] = staPassword; + response["staConnected"] = (WiFi.status() == WL_CONNECTED); + response["staIP"] = WiFi.localIP().toString(); + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + + Serial.println("✓ 설정 전송 완료"); + } + else if (strcmp(cmd, "saveSettings") == 0) { + // WiFi 설정 저장 + const char* newSSID = doc["ssid"]; + const char* newPassword = doc["password"]; + bool newSTAEnable = doc["staEnable"]; + const char* newSTASSID = doc["staSSID"]; + const char* newSTAPassword = doc["staPassword"]; + + if (newSSID && strlen(newSSID) > 0) { + strncpy(wifiSSID, newSSID, sizeof(wifiSSID) - 1); + wifiSSID[sizeof(wifiSSID) - 1] = '\0'; + } + + if (newPassword) { + strncpy(wifiPassword, newPassword, sizeof(wifiPassword) - 1); + wifiPassword[sizeof(wifiPassword) - 1] = '\0'; + } + + enableSTAMode = newSTAEnable; + + if (newSTASSID) { + strncpy(staSSID, newSTASSID, sizeof(staSSID) - 1); + staSSID[sizeof(staSSID) - 1] = '\0'; + } + + if (newSTAPassword) { + strncpy(staPassword, newSTAPassword, sizeof(staPassword) - 1); + staPassword[sizeof(staPassword) - 1] = '\0'; + } + + saveSettings(); + + DynamicJsonDocument response(256); + response["type"] = "settingsSaved"; + response["success"] = true; + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + + Serial.println("✓ 설정 저장 완료 (재부팅 필요)"); + } + // ======================================== + // 기존 명령들 + // ======================================== + else if (strcmp(cmd, "startLogging") == 0) { if (!loggingEnabled && sdCardReady) { + // 로깅 형식 선택 + const char* format = doc["format"]; + if (format && strcmp(format, "csv") == 0) { + canLogFormatCSV = true; + } else { + canLogFormatCSV = false; + } + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { struct tm timeinfo; time_t now; time(&now); localtime_r(&now, &timeinfo); + // 시작 시간 기록 + struct timeval tv; + gettimeofday(&tv, NULL); + canLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + + // 파일 확장자 선택 + const char* ext = canLogFormatCSV ? "csv" : "bin"; snprintf(currentFilename, sizeof(currentFilename), - "/CAN_%04d%02d%02d_%02d%02d%02d.bin", + "/CAN_%04d%02d%02d_%02d%02d%02d.%s", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, ext); logFile = SD.open(currentFilename, FILE_WRITE); if (logFile) { + // CSV 형식이면 헤더 작성 + if (canLogFormatCSV) { + logFile.println("Time_us,CAN_ID,DLC,Data"); + } + loggingEnabled = true; bufferIndex = 0; - currentFileSize = 0; - Serial.printf("✓ CAN 로깅 시작: %s\n", currentFilename); + currentFileSize = logFile.size(); + Serial.printf("✓ CAN 로깅 시작: %s (%s)\n", + currentFilename, canLogFormatCSV ? "CSV" : "BIN"); } else { Serial.println("✗ 파일 생성 실패"); } @@ -1043,26 +1241,46 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } } } - else if (strcmp(cmd, "startSerialLogging") == 0) { // Serial 로깅 시작 + else if (strcmp(cmd, "startSerialLogging") == 0) { if (!serialLoggingEnabled && sdCardReady) { + // 로깅 형식 선택 + const char* format = doc["format"]; + if (format && strcmp(format, "bin") == 0) { + serialLogFormatCSV = false; + } else { + serialLogFormatCSV = true; // 기본값 CSV + } + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { struct tm timeinfo; time_t now; time(&now); localtime_r(&now, &timeinfo); + // 시작 시간 기록 + struct timeval tv; + gettimeofday(&tv, NULL); + serialLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + + // 파일 확장자 선택 + const char* ext = serialLogFormatCSV ? "csv" : "bin"; snprintf(currentSerialFilename, sizeof(currentSerialFilename), - "/SER_%04d%02d%02d_%02d%02d%02d.bin", + "/SER_%04d%02d%02d_%02d%02d%02d.%s", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, ext); serialLogFile = SD.open(currentSerialFilename, FILE_WRITE); if (serialLogFile) { + // CSV 형식이면 헤더 작성 + if (serialLogFormatCSV) { + serialLogFile.println("Time_us,Direction,Data"); + } serialLoggingEnabled = true; - serialBufferIndex = 0; - currentSerialFileSize = 0; - Serial.printf("✓ Serial 로깅 시작: %s\n", currentSerialFilename); + serialCsvIndex = 0; + currentSerialFileSize = serialLogFile.size(); + Serial.printf("✓ Serial 로깅 시작: %s (%s)\n", + currentSerialFilename, serialLogFormatCSV ? "CSV" : "BIN"); } else { Serial.println("✗ Serial 파일 생성 실패"); } @@ -1071,12 +1289,13 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } } } - else if (strcmp(cmd, "stopSerialLogging") == 0) { // Serial 로깅 종료 + else if (strcmp(cmd, "stopSerialLogging") == 0) { if (serialLoggingEnabled) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (serialBufferIndex > 0 && serialLogFile) { - serialLogFile.write(serialFileBuffer, serialBufferIndex); - serialBufferIndex = 0; + // 남은 CSV 버퍼 내용 쓰기 + if (serialCsvIndex > 0 && serialLogFile) { + serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex); + serialCsvIndex = 0; } if (serialLogFile) { @@ -1091,30 +1310,35 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } } } - else if (strcmp(cmd, "sendSerial") == 0) { // Serial 데이터 전송 + else if (strcmp(cmd, "sendSerial") == 0) { const char* data = doc["data"]; if (data && strlen(data) > 0) { + // UART로 데이터 전송 SerialComm.println(data); - // 송신 데이터를 Queue에 추가 (모니터링용) + // Queue에 TX 메시지 추가 (모니터링용) SerialMessage serialMsg; struct timeval tv; gettimeofday(&tv, NULL); serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; - serialMsg.length = strlen(data); + serialMsg.length = strlen(data) + 2; // \r\n 포함 if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) { serialMsg.length = MAX_SERIAL_LINE_LEN - 1; } - memcpy(serialMsg.data, data, serialMsg.length); - serialMsg.isTx = true; // 송신 데이터 - xQueueSend(serialQueue, &serialMsg, 0); - totalSerialTxCount++; + // 데이터 복사 및 개행 문자 추가 + snprintf((char*)serialMsg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data); + serialMsg.isTx = true; - Serial.printf("→ Serial TX: %s\n", data); + if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { + totalSerialTxCount++; + Serial.printf("✓ Serial TX Queue 전송: %s\n", data); + } else { + Serial.printf("✗ Serial TX Queue 전송 실패: %s\n", data); + } } } - else if (strcmp(cmd, "setSerialConfig") == 0) { // Serial 설정 변경 + else if (strcmp(cmd, "setSerialConfig") == 0) { uint32_t baud = doc["baudRate"] | 115200; uint8_t data = doc["dataBits"] | 8; uint8_t parity = doc["parity"] | 0; @@ -1130,7 +1354,7 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) Serial.printf("✓ Serial 설정 변경: %u-%u-%u-%u\n", baud, data, parity, stop); } - else if (strcmp(cmd, "getSerialConfig") == 0) { // Serial 설정 조회 + else if (strcmp(cmd, "getSerialConfig") == 0) { DynamicJsonDocument response(512); response["type"] = "serialConfig"; response["baudRate"] = serialSettings.baudRate; @@ -1185,6 +1409,40 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } } } + else if (strcmp(cmd, "syncTimeFromPhone") == 0) { + // 개별 시간 값으로 동기화 (년/월/일/시/분/초) + int year = doc["year"] | 2024; + int month = doc["month"] | 1; + int day = doc["day"] | 1; + int hour = doc["hour"] | 0; + int minute = doc["minute"] | 0; + int second = doc["second"] | 0; + + struct tm timeinfo; + timeinfo.tm_year = year - 1900; + timeinfo.tm_mon = month - 1; + timeinfo.tm_mday = day; + timeinfo.tm_hour = hour; + timeinfo.tm_min = minute; + timeinfo.tm_sec = second; + + time_t t = mktime(&timeinfo); + struct timeval tv = {t, 0}; + settimeofday(&tv, NULL); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = (uint64_t)t * 1000000ULL; + timeSyncStatus.syncCount++; + + if (timeSyncStatus.rtcAvailable) { + writeRTC(&timeinfo); + Serial.printf("✓ 시간 동기화 완료: %04d-%02d-%02d %02d:%02d:%02d (Phone → ESP32 → RTC)\n", + year, month, day, hour, minute, second); + } else { + Serial.printf("✓ 시간 동기화 완료: %04d-%02d-%02d %02d:%02d:%02d (Phone → ESP32)\n", + year, month, day, hour, minute, second); + } + } else if (strcmp(cmd, "getFiles") == 0) { if (sdCardReady) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { @@ -1405,6 +1663,11 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } } else if (strcmp(cmd, "sendOnce") == 0) { + // Transmit-Only 모드: 송신 전 Normal 모드로 전환 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + mcp2515.setNormalMode(); + } + struct can_frame frame; frame.can_id = strtoul(doc["id"], NULL, 16); @@ -1423,6 +1686,11 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) totalTxCount++; Serial.printf("✓ CAN 메시지 전송: ID=0x%X\n", frame.can_id & 0x1FFFFFFF); } + + // Transmit-Only 모드: 송신 후 Listen-Only로 복귀 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + mcp2515.setListenOnlyMode(); + } } else if (strcmp(cmd, "addSequence") == 0) { if (sequenceCount < MAX_SEQUENCES) { @@ -1521,36 +1789,50 @@ void webUpdateTask(void *parameter) { while (1) { webSocket.loop(); - // CAN 데이터 전송 if (webSocket.connectedClients() > 0) { - DynamicJsonDocument doc(3072); // 4096 → 3072로 축소 + DynamicJsonDocument doc(4096); // 3072 → 4096으로 증가 (Serial 메시지 포함) doc["type"] = "update"; doc["logging"] = loggingEnabled; - doc["serialLogging"] = serialLoggingEnabled; // Serial 로깅 상태 추가 + doc["serialLogging"] = serialLoggingEnabled; doc["sdReady"] = sdCardReady; doc["totalMsg"] = totalMsgCount; doc["msgPerSec"] = msgPerSecond; doc["totalTx"] = totalTxCount; - doc["totalSerialRx"] = totalSerialRxCount; // Serial RX 카운터 추가 - doc["totalSerialTx"] = totalSerialTxCount; // Serial TX 카운터 추가 + doc["totalSerialRx"] = totalSerialRxCount; + doc["totalSerialTx"] = totalSerialTxCount; doc["fileSize"] = currentFileSize; - doc["serialFileSize"] = currentSerialFileSize; // Serial 파일 크기 추가 + doc["serialFileSize"] = currentSerialFileSize; doc["queueUsed"] = uxQueueMessagesWaiting(canQueue); doc["queueSize"] = CAN_QUEUE_SIZE; - doc["serialQueueUsed"] = uxQueueMessagesWaiting(serialQueue); // Serial Queue 사용량 추가 + doc["serialQueueUsed"] = uxQueueMessagesWaiting(serialQueue); doc["serialQueueSize"] = SERIAL_QUEUE_SIZE; doc["timeSync"] = timeSyncStatus.synchronized; doc["rtcAvail"] = timeSyncStatus.rtcAvailable; doc["rtcSyncCnt"] = timeSyncStatus.rtcSyncCount; + doc["syncCount"] = timeSyncStatus.syncCount; // 전체 동기화 횟수 (NTP + 수동) doc["voltage"] = powerStatus.voltage; doc["minVoltage"] = powerStatus.minVoltage; doc["lowVoltage"] = powerStatus.lowVoltage; doc["mcpMode"] = (int)currentMcpMode; + // 현재 로깅 파일명 추가 + if (loggingEnabled && currentFilename[0] != '\0') { + doc["currentFile"] = String(currentFilename); + } else { + doc["currentFile"] = ""; + } + + if (serialLoggingEnabled && currentSerialFilename[0] != '\0') { + doc["currentSerialFile"] = String(currentSerialFilename); + } else { + doc["currentSerialFile"] = ""; + } + time_t now; time(&now); doc["timestamp"] = (uint64_t)now; + // CAN 메시지 배열 JsonArray messages = doc.createNestedArray("messages"); for (int i = 0; i < RECENT_MSG_COUNT; i++) { if (recentData[i].count > 0) { @@ -1566,23 +1848,72 @@ void webUpdateTask(void *parameter) { } } - // Serial 메시지 전송 (추가) + // Serial 메시지 배열 (Queue에서 읽기) SerialMessage serialMsg; JsonArray serialMessages = doc.createNestedArray("serialMessages"); int serialCount = 0; - while (serialCount < 5 && xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { // 10 → 5개로 축소 + // Queue에서 최대 10개의 Serial 메시지 읽기 + while (serialCount < 10 && xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { JsonObject serMsgObj = serialMessages.createNestedObject(); serMsgObj["timestamp"] = serialMsg.timestamp_us; serMsgObj["isTx"] = serialMsg.isTx; - // 데이터를 문자열로 변환 (printable characters) + // 데이터를 문자열로 변환 char dataStr[MAX_SERIAL_LINE_LEN + 1]; memcpy(dataStr, serialMsg.data, serialMsg.length); dataStr[serialMsg.length] = '\0'; serMsgObj["data"] = dataStr; serialCount++; + + // Serial 로깅이 활성화되어 있으면 SD 카드에 저장 + if (serialLoggingEnabled && sdCardReady) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + if (serialLogFormatCSV) { + // CSV 형식 로깅 (상대 시간 사용) + uint64_t relativeTime = serialMsg.timestamp_us - serialLogStartTime; + + char csvLine[256]; + int lineLen = snprintf(csvLine, sizeof(csvLine), + "%llu,%s,\"%s\"\n", + relativeTime, + serialMsg.isTx ? "TX" : "RX", + dataStr); + + // CSV 버퍼에 추가 + if (serialCsvIndex + lineLen < SERIAL_CSV_BUFFER_SIZE) { + memcpy(&serialCsvBuffer[serialCsvIndex], csvLine, lineLen); + serialCsvIndex += lineLen; + currentSerialFileSize += lineLen; + } + + // 버퍼가 가득 차면 파일에 쓰기 + if (serialCsvIndex >= SERIAL_CSV_BUFFER_SIZE - 256) { + if (serialLogFile) { + serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex); + serialLogFile.flush(); + serialCsvIndex = 0; + } + } + } else { + // BIN 형식 로깅 (기존 방식) + if (serialLogFile) { + serialLogFile.write((uint8_t*)&serialMsg, sizeof(SerialMessage)); + currentSerialFileSize += sizeof(SerialMessage); + + // 주기적으로 플러시 + static int binFlushCounter = 0; + if (++binFlushCounter >= 50) { + serialLogFile.flush(); + binFlushCounter = 0; + } + } + } + + xSemaphoreGive(sdMutex); + } + } } String json; @@ -1604,7 +1935,8 @@ void setup() { Serial.println("\n========================================"); Serial.println(" Byun CAN Logger + Serial Terminal"); - Serial.println(" Version 2.1 - ESP32-S3 Edition"); + Serial.println(" Version 2.2 - ESP32-S3 Edition"); + Serial.println(" Fixed: RTC Time Sync + Settings"); Serial.println("========================================\n"); loadSettings(); @@ -1620,7 +1952,7 @@ void setup() { // ADC 설정 analogSetAttenuation(ADC_11db); - // SPI 초기화 (먼저 완료) + // SPI 초기화 Serial.println("SPI 초기화 중..."); hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); hspi.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0)); @@ -1633,27 +1965,25 @@ void setup() { vspi.setFrequency(40000000); Serial.println("✓ SPI 초기화 완료"); - // Watchdog 완전 비활성화 (SPI 초기화 후) + // Watchdog 완전 비활성화 Serial.println("Watchdog 비활성화..."); esp_task_wdt_deinit(); Serial.println("✓ Watchdog 비활성화 완료"); - // MCP2515 초기화 (간소화) + // MCP2515 초기화 Serial.println("MCP2515 초기화 중..."); mcp2515.reset(); delay(50); - // Bitrate만 설정 (모드는 나중에) MCP2515::ERROR result = mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); if (result != MCP2515::ERROR_OK) { Serial.printf("! MCP2515 Bitrate 설정 실패: %d (계속 진행)\n", result); } - // Normal 모드로 직접 설정 (함수 호출 안함) mcp2515.setNormalMode(); Serial.println("✓ MCP2515 초기화 완료"); - // Serial 통신 초기화 (추가) + // Serial 통신 초기화 applySerialSettings(); Serial.println("✓ Serial 통신 초기화 완료 (UART1)"); @@ -1683,7 +2013,7 @@ void setup() { if (enableSTAMode && strlen(staSSID) > 0) { Serial.println("\n📶 WiFi APSTA 모드 시작..."); WiFi.mode(WIFI_AP_STA); - WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); // 채널1, 최대4개연결 + WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); Serial.print("✓ AP SSID: "); Serial.println(wifiSSID); Serial.print("✓ AP IP: "); @@ -1704,13 +2034,30 @@ void setup() { Serial.println("✓ WiFi 연결 성공!"); Serial.print("✓ STA IP: "); Serial.println(WiFi.localIP()); + + // NTP 시간 동기화 시작 + initNTP(); + + // NTP 동기화 대기 (최대 5초) + Serial.print("⏱ NTP 시간 동기화 대기 중"); + for (int i = 0; i < 10; i++) { + delay(500); + Serial.print("."); + if (timeSyncStatus.synchronized) { + Serial.println(" 완료!"); + break; + } + } + if (!timeSyncStatus.synchronized) { + Serial.println(" 시간 초과 (계속 진행)"); + } } else { Serial.println("✗ WiFi 연결 실패 (AP 모드는 정상 동작)"); } } else { Serial.println("\n📶 WiFi AP 모드 시작..."); WiFi.mode(WIFI_AP); - WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); // 채널1, 최대4개연결 + WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); Serial.print("✓ AP SSID: "); Serial.println(wifiSSID); Serial.print("✓ AP IP: "); @@ -1745,7 +2092,7 @@ void setup() { server.send_P(200, "text/html", settings_html); }); - server.on("/serial", HTTP_GET, []() { // Serial 페이지 추가 + server.on("/serial", HTTP_GET, []() { server.send_P(200, "text/html", serial_terminal_html); }); @@ -1815,7 +2162,7 @@ void setup() { // Queue 생성 canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); - serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage)); // Serial Queue 생성 + serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage)); if (canQueue == NULL || serialQueue == NULL) { Serial.println("✗ Queue 생성 실패!"); @@ -1825,14 +2172,14 @@ void setup() { // CAN 인터럽트 활성화 attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - // Task 생성 (메모리 최적화) - xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8096, NULL, 5, &canRxTaskHandle, 1); // 4096 → 3072 - xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 4072, NULL, 4, &serialRxTaskHandle, 1); // 3072 → 2560 - xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 15240, NULL, 3, &sdWriteTaskHandle, 1); // 10240 → 8192 - xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 5072, NULL, 1, NULL, 1); // 3072 → 2560 - xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0); // 8192 → 6144 - xTaskCreatePinnedToCore(txTask, "TX_TASK", 5072, NULL, 2, NULL, 1); // 3072 → 2560 - xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4072, NULL, 2, NULL, 1); // 3072 → 2560 + // Task 생성 + xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8096, NULL, 5, &canRxTaskHandle, 1); + xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 4072, NULL, 4, &serialRxTaskHandle, 0); + xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 15240, NULL, 3, &sdWriteTaskHandle, 1); + xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 5072, NULL, 1, NULL, 0); + xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0); + xTaskCreatePinnedToCore(txTask, "TX_TASK", 5072, NULL, 2, NULL, 0); + xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4072, NULL, 2, NULL, 1); // RTC 동기화 Task if (timeSyncStatus.rtcAvailable) { @@ -1858,7 +2205,7 @@ void setup() { Serial.println(" - Transmit : /transmit"); Serial.println(" - Graph : /graph"); Serial.println(" - Settings : /settings"); - Serial.println(" - Serial : /serial ← NEW!"); + Serial.println(" - Serial : /serial"); Serial.println("========================================\n"); } diff --git a/serial_terminal.h b/serial_terminal.h index b5b3dd6..63ce15f 100644 --- a/serial_terminal.h +++ b/serial_terminal.h @@ -279,6 +279,46 @@ const char serial_terminal_html[] PROGMEM = R"rawliteral( font-size: 0.8em; } } + + /* 파일 형식 선택 라디오 버튼 스타일 */ + .format-selector { + display: flex; + align-items: center; + gap: 15px; + background: #f8f9fa; + padding: 10px 15px; + border-radius: 8px; + border: 2px solid #667eea; + margin-bottom: 15px; + } + + .format-selector label { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + font-weight: 600; + color: #2c3e50; + font-size: 0.95em; + transition: all 0.3s; + } + + .format-selector label:hover { + color: #667eea; + } + + .format-selector input[type="radio"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #667eea; + } + + .format-info { + font-size: 0.8em; + color: #7f8c8d; + margin-left: 5px; + } @@ -361,6 +401,21 @@ const char serial_terminal_html[] PROGMEM = R"rawliteral( + +
+ + + +
+
@@ -484,18 +539,35 @@ const char serial_terminal_html[] PROGMEM = R"rawliteral( } } + function toggleSerialLogging() { if (ws && ws.readyState === WebSocket.OPEN) { if (serialLogging) { + // 로깅 중지 ws.send(JSON.stringify({cmd: 'stopSerialLogging'})); } else { - ws.send(JSON.stringify({cmd: 'startSerialLogging'})); + // 선택된 형식 가져오기 + let serialFormat = 'csv'; // 기본값 + const formatRadios = document.getElementsByName('serial-format'); + for (const radio of formatRadios) { + if (radio.checked) { + serialFormat = radio.value; + break; + } + } + + // 로깅 시작 명령 전송 + ws.send(JSON.stringify({ + cmd: 'startSerialLogging', + format: serialFormat + })); + + console.log('Start serial logging: format=' + serialFormat); } serialLogging = !serialLogging; updateLoggingUI(); } } - function updateLoggingUI() { const btn = document.getElementById('log-btn'); const indicator = btn.querySelector('.status-indicator');