/* * Byun CAN Logger with Web Interface + Serial Terminal * Version: 2.2 - RTC Time Sync & Settings Page Fixed * Fixed: RTC synchronization with WiFi time + Settings page loading */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "index.h" #include "transmit.h" #include "graph.h" #include "graph_viewer.h" #include "settings.h" #include "serial_terminal.h" // GPIO 핀 정의 #define CAN_INT_PIN 4 // Serial 통신 핀 (추가) #define SERIAL_TX_PIN 17 #define SERIAL_RX_PIN 18 // HSPI 핀 (CAN) #define HSPI_MISO 13 #define HSPI_MOSI 11 #define HSPI_SCLK 12 #define HSPI_CS 10 // VSPI 핀 (SD Card) #define VSPI_MISO 41 #define VSPI_MOSI 40 #define VSPI_SCLK 39 #define VSPI_CS 42 // I2C2 핀 (RTC DS3231) - SoftWire 사용 #define RTC_SDA 8 #define RTC_SCL 9 #define DS3231_ADDRESS 0x68 // 버퍼 설정 #define CAN_QUEUE_SIZE 2000 #define FILE_BUFFER_SIZE 16384 #define MAX_FILENAME_LEN 64 #define RECENT_MSG_COUNT 100 #define MAX_TX_MESSAGES 20 #define MAX_COMMENT_LEN 128 // Serial 버퍼 설정 (추가) #define SERIAL_QUEUE_SIZE 200 #define SERIAL_CSV_BUFFER_SIZE 8192 // CSV 텍스트 버퍼 #define MAX_SERIAL_LINE_LEN 128 // RTC 동기화 설정 #define RTC_SYNC_INTERVAL_MS 60000 // 전력 모니터링 설정 #define VOLTAGE_CHECK_INTERVAL_MS 5000 #define LOW_VOLTAGE_THRESHOLD 3.0 #define MONITORING_VOLT 5 // CAN 메시지 구조체 struct CANMessage { uint64_t timestamp_us; uint32_t id; uint8_t dlc; uint8_t data[8]; } __attribute__((packed)); // Serial 메시지 구조체 (추가) struct SerialMessage { uint64_t timestamp_us; uint16_t length; uint8_t data[MAX_SERIAL_LINE_LEN]; bool isTx; // true=송신, false=수신 } __attribute__((packed)); // Serial 설정 구조체 (추가) struct SerialSettings { uint32_t baudRate; uint8_t dataBits; // 5, 6, 7, 8 uint8_t parity; // 0=None, 1=Even, 2=Odd uint8_t stopBits; // 1, 2 } serialSettings = {115200, 8, 0, 1}; // 실시간 모니터링용 구조체 struct RecentCANData { CANMessage msg; uint32_t count; }; // CAN 송신용 구조체 struct TxMessage { uint32_t id; bool extended; uint8_t dlc; uint8_t data[8]; uint32_t interval; uint32_t lastSent; bool active; }; // CAN 시퀀스 스텝 구조체 struct SequenceStep { uint32_t canId; bool extended; uint8_t dlc; uint8_t data[8]; uint32_t delayMs; }; // CAN 시퀀스 구조체 struct CANSequence { char name[32]; SequenceStep steps[20]; uint8_t stepCount; uint8_t repeatMode; uint32_t repeatCount; }; // 시퀀스 실행 상태 struct SequenceRuntime { bool running; uint8_t currentStep; uint32_t currentRepeat; uint32_t lastStepTime; int8_t activeSequenceIndex; }; // 파일 커멘트 구조체 struct FileComment { char filename[MAX_FILENAME_LEN]; char comment[MAX_COMMENT_LEN]; }; // 시간 동기화 상태 struct TimeSyncStatus { bool synchronized; uint64_t lastSyncTime; int32_t offsetUs; uint32_t syncCount; bool rtcAvailable; uint32_t rtcSyncCount; } timeSyncStatus = {false, 0, 0, 0, false, 0}; // 전력 모니터링 상태 struct PowerStatus { float voltage; float minVoltage; bool lowVoltage; uint32_t lastCheck; uint32_t lastMinReset; } powerStatus = {0.0, 999.9, false, 0, 0}; // MCP2515 레지스터 주소 정의 #ifndef MCP_CANCTRL #define MCP_CANCTRL 0x0F #endif #ifndef MCP_CANSTAT #define MCP_CANSTAT 0x0E #endif // MCP2515 모드 정의 enum MCP2515Mode { MCP_MODE_NORMAL = 0, MCP_MODE_LISTEN_ONLY = 1, MCP_MODE_LOOPBACK = 2, MCP_MODE_TRANSMIT = 3 }; // WiFi AP 기본 설정 char wifiSSID[32] = "Byun_CAN_Logger"; char wifiPassword[64] = "12345678"; // WiFi Station 모드 설정 bool enableSTAMode = false; char staSSID[32] = ""; char staPassword[64] = ""; // 전역 변수 SPIClass hspi(HSPI); SPIClass vspi(FSPI); MCP2515 mcp2515(HSPI_CS, 20000000, &hspi); // Serial 통신용 (추가) HardwareSerial SerialComm(1); // UART1 사용 WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(81); Preferences preferences; // Forward declaration void IRAM_ATTR canISR(); QueueHandle_t canQueue; QueueHandle_t serialQueue; SemaphoreHandle_t sdMutex; SemaphoreHandle_t rtcMutex; SemaphoreHandle_t serialMutex; TaskHandle_t canRxTaskHandle = NULL; TaskHandle_t sdWriteTaskHandle = NULL; TaskHandle_t webTaskHandle = NULL; TaskHandle_t rtcTaskHandle = NULL; TaskHandle_t serialRxTaskHandle = NULL; volatile bool loggingEnabled = false; volatile bool serialLoggingEnabled = false; volatile bool sdCardReady = false; File logFile; File serialLogFile; char currentFilename[MAX_FILENAME_LEN]; char currentSerialFilename[MAX_FILENAME_LEN]; uint8_t fileBuffer[FILE_BUFFER_SIZE]; char serialCsvBuffer[SERIAL_CSV_BUFFER_SIZE]; // CSV 텍스트 버퍼 uint16_t bufferIndex = 0; uint16_t serialCsvIndex = 0; // CSV 버퍼 인덱스 // 로깅 파일 크기 추적 volatile uint32_t currentFileSize = 0; 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; // RTC 관련 SoftWire rtcWire(RTC_SDA, RTC_SCL); char rtcSyncBuffer[20]; // CAN 속도 설정 CAN_SPEED currentCanSpeed = CAN_1000KBPS; const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; // 실시간 모니터링용 RecentCANData recentData[RECENT_MSG_COUNT]; uint32_t totalMsgCount = 0; uint32_t msgPerSecond = 0; uint32_t lastMsgCountTime = 0; uint32_t lastMsgCount = 0; // Serial 통신 카운터 (추가) volatile uint32_t totalSerialRxCount = 0; volatile uint32_t totalSerialTxCount = 0; // 그래프 최대 개수 #define MAX_GRAPH_SIGNALS 20 // CAN 송신용 TxMessage txMessages[MAX_TX_MESSAGES]; uint32_t totalTxCount = 0; // CAN 시퀀스 #define MAX_SEQUENCES 10 CANSequence sequences[MAX_SEQUENCES]; uint8_t sequenceCount = 0; SequenceRuntime seqRuntime = {false, 0, 0, 0, -1}; // 파일 커멘트 저장 #define MAX_FILE_COMMENTS 50 FileComment fileComments[MAX_FILE_COMMENTS]; int commentCount = 0; // ======================================== // Serial 설정 저장/로드 함수 (추가) // ======================================== void loadSerialSettings() { serialSettings.baudRate = preferences.getUInt("ser_baud", 115200); serialSettings.dataBits = preferences.getUChar("ser_data", 8); serialSettings.parity = preferences.getUChar("ser_parity", 0); serialSettings.stopBits = preferences.getUChar("ser_stop", 1); Serial.printf("✓ Serial 설정 로드: %u-%u-%u-%u\n", serialSettings.baudRate, serialSettings.dataBits, serialSettings.parity, serialSettings.stopBits); } void saveSerialSettings() { preferences.putUInt("ser_baud", serialSettings.baudRate); preferences.putUChar("ser_data", serialSettings.dataBits); preferences.putUChar("ser_parity", serialSettings.parity); preferences.putUChar("ser_stop", serialSettings.stopBits); Serial.printf("✓ Serial 설정 저장: %u-%u-%u-%u\n", serialSettings.baudRate, serialSettings.dataBits, serialSettings.parity, serialSettings.stopBits); } void applySerialSettings() { uint32_t config = SERIAL_8N1; // Data bits + Parity 설정 if (serialSettings.dataBits == 5) { if (serialSettings.parity == 0) config = SERIAL_5N1; else if (serialSettings.parity == 1) config = SERIAL_5E1; else if (serialSettings.parity == 2) config = SERIAL_5O1; } else if (serialSettings.dataBits == 6) { if (serialSettings.parity == 0) config = SERIAL_6N1; else if (serialSettings.parity == 1) config = SERIAL_6E1; else if (serialSettings.parity == 2) config = SERIAL_6O1; } else if (serialSettings.dataBits == 7) { if (serialSettings.parity == 0) config = SERIAL_7N1; else if (serialSettings.parity == 1) config = SERIAL_7E1; else if (serialSettings.parity == 2) config = SERIAL_7O1; } else { // 8 bits if (serialSettings.parity == 0) config = SERIAL_8N1; else if (serialSettings.parity == 1) config = SERIAL_8E1; else if (serialSettings.parity == 2) config = SERIAL_8O1; } // Stop bits 설정 if (serialSettings.stopBits == 2) { config |= 0x3000; // 2 stop bits } SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN); SerialComm.setRxBufferSize(2048); Serial.printf("✓ Serial 설정 적용: %u baud, config=0x%X\n", serialSettings.baudRate, config); } // ======================================== // 설정 저장/로드 함수 // ======================================== void loadSettings() { preferences.begin("can-logger", false); // WiFi AP 설정 로드 preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); // WiFi STA 모드 설정 로드 enableSTAMode = preferences.getBool("sta_enable", false); preferences.getString("sta_ssid", staSSID, sizeof(staSSID)); preferences.getString("sta_pass", staPassword, sizeof(staPassword)); // 설정이 없으면 기본값 사용 if (strlen(wifiSSID) == 0) { strcpy(wifiSSID, "Byun_CAN_Logger"); } if (strlen(wifiPassword) == 0) { strcpy(wifiPassword, "12345678"); } // CAN 속도 로드 int speedIndex = preferences.getInt("can_speed", 3); if (speedIndex >= 0 && speedIndex < 4) { currentCanSpeed = canSpeedValues[speedIndex]; Serial.printf("✓ 저장된 CAN 속도 로드: %s\n", canSpeedNames[speedIndex]); } // MCP2515 모드 로드 int savedMode = preferences.getInt("mcp_mode", 0); if (savedMode >= 0 && savedMode <= 3) { currentMcpMode = (MCP2515Mode)savedMode; Serial.printf("✓ 저장된 MCP 모드 로드: %d\n", savedMode); } // Serial 설정 로드 (추가) loadSerialSettings(); preferences.end(); } void saveSettings() { preferences.begin("can-logger", false); preferences.putString("wifi_ssid", wifiSSID); preferences.putString("wifi_pass", wifiPassword); preferences.putBool("sta_enable", enableSTAMode); preferences.putString("sta_ssid", staSSID); preferences.putString("sta_pass", staPassword); // CAN 속도 저장 for (int i = 0; i < 4; i++) { if (canSpeedValues[i] == currentCanSpeed) { preferences.putInt("can_speed", i); Serial.printf("✓ CAN 속도 저장: %s (인덱스 %d)\n", canSpeedNames[i], i); break; } } // MCP2515 모드 저장 preferences.putInt("mcp_mode", (int)currentMcpMode); Serial.printf("✓ MCP 모드 저장: %d\n", (int)currentMcpMode); // Serial 설정 저장 (추가) saveSerialSettings(); preferences.end(); } // ======================================== // RTC 함수 // ======================================== void initRTC() { rtcWire.begin(); rtcWire.setClock(100000); rtcWire.beginTransmission(DS3231_ADDRESS); if (rtcWire.endTransmission() == 0) { timeSyncStatus.rtcAvailable = true; Serial.println("✓ RTC(DS3231) 감지됨"); } else { timeSyncStatus.rtcAvailable = false; Serial.println("! RTC(DS3231) 없음 - 시간 동기화 필요"); } } uint8_t bcdToDec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0F); } uint8_t decToBcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } bool readRTC(struct tm *timeinfo) { if (!timeSyncStatus.rtcAvailable) return false; if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) { return false; } rtcWire.beginTransmission(DS3231_ADDRESS); rtcWire.write(0x00); if (rtcWire.endTransmission() != 0) { xSemaphoreGive(rtcMutex); return false; } if (rtcWire.requestFrom(DS3231_ADDRESS, 7) != 7) { xSemaphoreGive(rtcMutex); return false; } uint8_t buffer[7]; for (int i = 0; i < 7; i++) { buffer[i] = rtcWire.read(); } xSemaphoreGive(rtcMutex); timeinfo->tm_sec = bcdToDec(buffer[0] & 0x7F); timeinfo->tm_min = bcdToDec(buffer[1] & 0x7F); timeinfo->tm_hour = bcdToDec(buffer[2] & 0x3F); timeinfo->tm_wday = bcdToDec(buffer[3] & 0x07) - 1; timeinfo->tm_mday = bcdToDec(buffer[4] & 0x3F); timeinfo->tm_mon = bcdToDec(buffer[5] & 0x1F) - 1; timeinfo->tm_year = bcdToDec(buffer[6]) + 100; return true; } bool writeRTC(const struct tm *timeinfo) { if (!timeSyncStatus.rtcAvailable) return false; if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) { return false; } rtcWire.beginTransmission(DS3231_ADDRESS); rtcWire.write(0x00); rtcWire.write(decToBcd(timeinfo->tm_sec)); rtcWire.write(decToBcd(timeinfo->tm_min)); rtcWire.write(decToBcd(timeinfo->tm_hour)); rtcWire.write(decToBcd(timeinfo->tm_wday + 1)); rtcWire.write(decToBcd(timeinfo->tm_mday)); rtcWire.write(decToBcd(timeinfo->tm_mon + 1)); rtcWire.write(decToBcd(timeinfo->tm_year - 100)); bool success = (rtcWire.endTransmission() == 0); xSemaphoreGive(rtcMutex); 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); while (1) { if (timeSyncStatus.rtcAvailable) { struct tm timeinfo; if (readRTC(&timeinfo)) { time_t now = mktime(&timeinfo); struct timeval tv = { .tv_sec = now, .tv_usec = 0 }; settimeofday(&tv, NULL); timeSyncStatus.synchronized = true; timeSyncStatus.lastSyncTime = (uint64_t)now * 1000000ULL; timeSyncStatus.rtcSyncCount++; } } vTaskDelay(xDelay); } } // ======================================== // MCP2515 모드 설정 // ======================================== bool setMCP2515Mode(MCP2515Mode mode) { const char* modeName; MCP2515::ERROR result; switch (mode) { case MCP_MODE_NORMAL: result = mcp2515.setNormalMode(); modeName = "Normal"; break; case MCP_MODE_LISTEN_ONLY: result = mcp2515.setListenOnlyMode(); modeName = "Listen-Only"; break; case MCP_MODE_LOOPBACK: result = mcp2515.setLoopbackMode(); modeName = "Loopback"; break; case MCP_MODE_TRANSMIT: result = mcp2515.setListenOnlyMode(); // Listen-Only 기본 상태 modeName = "Transmit-Only (Listen base)"; break; default: return false; } if (result == MCP2515::ERROR_OK) { currentMcpMode = mode; Serial.printf("✓ MCP2515 모드 변경: %s\n", modeName); return true; } else { Serial.printf("✗ MCP2515 모드 변경 실패: %s (error=%d)\n", modeName, result); return false; } } // ======================================== // CAN 인터럽트 핸들러 // ======================================== void IRAM_ATTR canISR() { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if (canRxTaskHandle != NULL) { vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } // ======================================== // Serial 수신 Task (추가) // ======================================== 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) { 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; 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; } } // 타임아웃: 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)); } } // ======================================== // CAN 수신 Task // ======================================== void canRxTask(void *parameter) { struct can_frame frame; CANMessage msg; Serial.println("✓ CAN RX Task 시작"); while (1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { struct timeval tv; gettimeofday(&tv, NULL); msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; msg.id = frame.can_id & 0x1FFFFFFF; msg.dlc = frame.can_dlc; memcpy(msg.data, frame.data, 8); if (xQueueSend(canQueue, &msg, 0) == pdTRUE) { totalMsgCount++; } } } } // ======================================== // SD 쓰기 Task (CAN + Serial 동시 지원) // ======================================== void sdWriteTask(void *parameter) { CANMessage canMsg; SerialMessage serialMsg; Serial.println("✓ SD Write Task 시작"); while (1) { bool hasWork = false; // CAN 메시지 처리 if (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) { hasWork = true; // 실시간 모니터링 업데이트 bool found = false; for (int i = 0; i < RECENT_MSG_COUNT; i++) { if (recentData[i].msg.id == canMsg.id) { recentData[i].msg = canMsg; recentData[i].count++; found = true; break; } } if (!found) { for (int i = 0; i < RECENT_MSG_COUNT; i++) { if (recentData[i].count == 0) { recentData[i].msg = canMsg; recentData[i].count = 1; break; } } } // CAN 로깅 if (loggingEnabled && sdCardReady) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { 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((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; } } } xSemaphoreGive(sdMutex); } } } // Serial 메시지 처리 - Queue에서는 항상 빼내고, 로깅이 활성화된 경우에만 저장 // 이렇게 해야 webUpdateTask에서 메시지를 받을 수 있음 // NOTE: Serial Queue는 webUpdateTask에서도 읽으므로 여기서는 로깅만 처리 if (!hasWork) { vTaskDelay(pdMS_TO_TICKS(1)); } } } // ======================================== // 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) { uint32_t currentTime = millis(); // 메시지/초 계산 if (currentTime - lastMsgCountTime >= 1000) { msgPerSecond = totalMsgCount - lastMsgCount; lastMsgCount = totalMsgCount; lastMsgCountTime = currentTime; } // 전압 체크 if (currentTime - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { float rawVoltage = analogRead(MONITORING_VOLT) * (3.3 / 4095.0); powerStatus.voltage = rawVoltage * 1.0; if (currentTime - powerStatus.lastMinReset >= 1000) { powerStatus.minVoltage = powerStatus.voltage; powerStatus.lastMinReset = currentTime; } else { if (powerStatus.voltage < powerStatus.minVoltage) { powerStatus.minVoltage = powerStatus.voltage; } } powerStatus.lowVoltage = (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD); 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); } } // ======================================== // 파일 커멘트 관리 // ======================================== void saveFileComments() { if (!sdCardReady) return; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { File commentFile = SD.open("/comments.dat", FILE_WRITE); if (commentFile) { commentFile.write((uint8_t*)&commentCount, sizeof(commentCount)); commentFile.write((uint8_t*)fileComments, sizeof(FileComment) * commentCount); commentFile.close(); Serial.println("✓ 파일 커멘트 저장 완료"); } xSemaphoreGive(sdMutex); } } void loadFileComments() { if (!sdCardReady) return; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (SD.exists("/comments.dat")) { File commentFile = SD.open("/comments.dat", FILE_READ); if (commentFile) { commentFile.read((uint8_t*)&commentCount, sizeof(commentCount)); if (commentCount > MAX_FILE_COMMENTS) commentCount = MAX_FILE_COMMENTS; commentFile.read((uint8_t*)fileComments, sizeof(FileComment) * commentCount); commentFile.close(); Serial.printf("✓ 파일 커멘트 %d개 로드 완료\n", commentCount); } } xSemaphoreGive(sdMutex); } } const char* getFileComment(const char* filename) { for (int i = 0; i < commentCount; i++) { if (strcmp(fileComments[i].filename, filename) == 0) { return fileComments[i].comment; } } return ""; } void addFileComment(const char* filename, const char* comment) { for (int i = 0; i < commentCount; i++) { if (strcmp(fileComments[i].filename, filename) == 0) { strncpy(fileComments[i].comment, comment, MAX_COMMENT_LEN - 1); fileComments[i].comment[MAX_COMMENT_LEN - 1] = '\0'; saveFileComments(); return; } } if (commentCount < MAX_FILE_COMMENTS) { strncpy(fileComments[commentCount].filename, filename, MAX_FILENAME_LEN - 1); fileComments[commentCount].filename[MAX_FILENAME_LEN - 1] = '\0'; strncpy(fileComments[commentCount].comment, comment, MAX_COMMENT_LEN - 1); fileComments[commentCount].comment[MAX_COMMENT_LEN - 1] = '\0'; commentCount++; saveFileComments(); } } // ======================================== // CAN 시퀀스 관리 // ======================================== void saveSequences() { if (!sdCardReady) return; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { File seqFile = SD.open("/sequences.dat", FILE_WRITE); if (seqFile) { seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount)); seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount); seqFile.close(); Serial.println("✓ 시퀀스 저장 완료"); } xSemaphoreGive(sdMutex); } } void loadSequences() { if (!sdCardReady) return; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (SD.exists("/sequences.dat")) { File seqFile = SD.open("/sequences.dat", FILE_READ); if (seqFile) { seqFile.read((uint8_t*)&sequenceCount, sizeof(sequenceCount)); if (sequenceCount > MAX_SEQUENCES) sequenceCount = MAX_SEQUENCES; seqFile.read((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount); seqFile.close(); Serial.printf("✓ 시퀀스 %d개 로드 완료\n", sequenceCount); } } xSemaphoreGive(sdMutex); } } // ======================================== // CAN TX Task // ======================================== void txTask(void *parameter) { struct can_frame frame; Serial.println("✓ TX Task 시작"); while (1) { uint32_t now = millis(); bool anyActive = false; for (int i = 0; i < MAX_TX_MESSAGES; i++) { if (txMessages[i].active && txMessages[i].interval > 0) { 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; } frame.can_dlc = txMessages[i].dlc; memcpy(frame.data, txMessages[i].data, 8); if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { totalTxCount++; txMessages[i].lastSent = now; } // Transmit-Only 모드: 송신 후 Listen-Only로 복귀 if (currentMcpMode == MCP_MODE_TRANSMIT) { mcp2515.setListenOnlyMode(); } } } } if (anyActive) { vTaskDelay(pdMS_TO_TICKS(1)); } else { vTaskDelay(pdMS_TO_TICKS(10)); } } } // ======================================== // 시퀀스 Task // ======================================== void sequenceTask(void *parameter) { Serial.println("✓ Sequence Task 시작"); while (1) { if (seqRuntime.running && seqRuntime.activeSequenceIndex >= 0 && seqRuntime.activeSequenceIndex < sequenceCount) { CANSequence* seq = &sequences[seqRuntime.activeSequenceIndex]; uint32_t now = millis(); if (seqRuntime.currentStep < seq->stepCount) { 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) { frame.can_id |= CAN_EFF_FLAG; } frame.can_dlc = step->dlc; memcpy(frame.data, step->data, 8); if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { totalTxCount++; } // Transmit-Only 모드: 송신 후 Listen-Only로 복귀 if (currentMcpMode == MCP_MODE_TRANSMIT) { mcp2515.setListenOnlyMode(); } seqRuntime.currentStep++; seqRuntime.lastStepTime = now; } } else { if (seq->repeatMode == 0) { seqRuntime.running = false; } else if (seq->repeatMode == 1) { seqRuntime.currentRepeat++; if (seqRuntime.currentRepeat >= seq->repeatCount) { seqRuntime.running = false; } else { seqRuntime.currentStep = 0; seqRuntime.lastStepTime = now; } } else if (seq->repeatMode == 2) { seqRuntime.currentStep = 0; seqRuntime.lastStepTime = now; } } vTaskDelay(pdMS_TO_TICKS(1)); } else { vTaskDelay(pdMS_TO_TICKS(10)); } } } // ======================================== // WebSocket 이벤트 처리 (Settings 명령 추가) // ======================================== void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { if (type == WStype_TEXT) { DynamicJsonDocument doc(2048); DeserializationError error = deserializeJson(doc, payload); if (error) { Serial.print("✗ JSON 파싱 실패: "); Serial.println(error.c_str()); return; } const char* cmd = doc["cmd"]; // ======================================== // 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.%s", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 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 = logFile.size(); Serial.printf("✓ CAN 로깅 시작: %s (%s)\n", currentFilename, canLogFormatCSV ? "CSV" : "BIN"); } else { Serial.println("✗ 파일 생성 실패"); } xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "stopLogging") == 0) { if (loggingEnabled) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (bufferIndex > 0 && logFile) { logFile.write(fileBuffer, bufferIndex); bufferIndex = 0; } if (logFile) { logFile.close(); } loggingEnabled = false; Serial.printf("✓ CAN 로깅 종료: %s (%u bytes)\n", currentFilename, currentFileSize); xSemaphoreGive(sdMutex); } } } 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.%s", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 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; serialCsvIndex = 0; currentSerialFileSize = serialLogFile.size(); Serial.printf("✓ Serial 로깅 시작: %s (%s)\n", currentSerialFilename, serialLogFormatCSV ? "CSV" : "BIN"); } else { Serial.println("✗ Serial 파일 생성 실패"); } xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "stopSerialLogging") == 0) { if (serialLoggingEnabled) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { // 남은 CSV 버퍼 내용 쓰기 if (serialCsvIndex > 0 && serialLogFile) { serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex); serialCsvIndex = 0; } if (serialLogFile) { serialLogFile.close(); } serialLoggingEnabled = false; Serial.printf("✓ Serial 로깅 종료: %s (%u bytes)\n", currentSerialFilename, currentSerialFileSize); xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "sendSerial") == 0) { const char* data = doc["data"]; if (data && strlen(data) > 0) { // UART로 데이터 전송 SerialComm.println(data); // 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) + 2; // \r\n 포함 if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) { serialMsg.length = MAX_SERIAL_LINE_LEN - 1; } // 데이터 복사 및 개행 문자 추가 snprintf((char*)serialMsg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data); serialMsg.isTx = true; 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) { uint32_t baud = doc["baudRate"] | 115200; uint8_t data = doc["dataBits"] | 8; uint8_t parity = doc["parity"] | 0; uint8_t stop = doc["stopBits"] | 1; serialSettings.baudRate = baud; serialSettings.dataBits = data; serialSettings.parity = parity; serialSettings.stopBits = stop; saveSerialSettings(); applySerialSettings(); Serial.printf("✓ Serial 설정 변경: %u-%u-%u-%u\n", baud, data, parity, stop); } else if (strcmp(cmd, "getSerialConfig") == 0) { DynamicJsonDocument response(512); response["type"] = "serialConfig"; response["baudRate"] = serialSettings.baudRate; response["dataBits"] = serialSettings.dataBits; response["parity"] = serialSettings.parity; response["stopBits"] = serialSettings.stopBits; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else if (strcmp(cmd, "setSpeed") == 0) { int speedIndex = doc["speed"]; if (speedIndex >= 0 && speedIndex < 4) { currentCanSpeed = canSpeedValues[speedIndex]; mcp2515.reset(); mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); setMCP2515Mode(currentMcpMode); saveSettings(); Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedIndex]); } } else if (strcmp(cmd, "setMcpMode") == 0) { int mode = doc["mode"]; if (mode >= 0 && mode <= 3) { setMCP2515Mode((MCP2515Mode)mode); saveSettings(); } } else if (strcmp(cmd, "syncTime") == 0) { uint64_t phoneTime = doc["time"]; if (phoneTime > 0) { time_t seconds = phoneTime / 1000; suseconds_t microseconds = (phoneTime % 1000) * 1000; struct timeval tv = {seconds, microseconds}; settimeofday(&tv, NULL); timeSyncStatus.synchronized = true; timeSyncStatus.lastSyncTime = phoneTime * 1000; timeSyncStatus.syncCount++; if (timeSyncStatus.rtcAvailable) { struct tm timeinfo; localtime_r(&seconds, &timeinfo); writeRTC(&timeinfo); Serial.println("✓ 시간 동기화 완료 (Phone → ESP32 → RTC)"); } else { Serial.println("✓ 시간 동기화 완료 (Phone → ESP32)"); } } } 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) { DynamicJsonDocument response(6144); response["type"] = "files"; JsonArray files = response.createNestedArray("list"); File root = SD.open("/"); File file = root.openNextFile(); while (file) { if (!file.isDirectory()) { const char* filename = file.name(); if (filename[0] != '.' && strcmp(filename, "System Volume Information") != 0) { JsonObject fileObj = files.createNestedObject(); fileObj["name"] = filename; fileObj["size"] = file.size(); const char* comment = getFileComment(filename); if (strlen(comment) > 0) { fileObj["comment"] = comment; } } } file = root.openNextFile(); } xSemaphoreGive(sdMutex); String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } } else if (strcmp(cmd, "deleteFile") == 0) { const char* filename = doc["filename"]; if (filename && strlen(filename) > 0) { if (loggingEnabled && currentFilename[0] != '\0') { String currentFileStr = String(currentFilename); if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); if (strcmp(filename, currentFileStr.c_str()) == 0) { DynamicJsonDocument response(256); response["type"] = "deleteResult"; response["success"] = false; response["message"] = "Cannot delete file currently being logged"; String json; serializeJson(response, json); webSocket.sendTXT(num, json); return; } } String fullPath = "/" + String(filename); if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { bool success = false; String message = ""; if (SD.exists(fullPath)) { if (SD.remove(fullPath)) { success = true; message = "File deleted successfully"; Serial.printf("✓ 파일 삭제: %s\n", filename); } else { message = "Failed to delete file"; } } else { message = "File not found"; } xSemaphoreGive(sdMutex); DynamicJsonDocument response(256); response["type"] = "deleteResult"; response["success"] = success; response["message"] = message; String json; serializeJson(response, json); webSocket.sendTXT(num, json); if (success) { vTaskDelay(pdMS_TO_TICKS(100)); DynamicJsonDocument filesDoc(6144); filesDoc["type"] = "files"; JsonArray files = filesDoc.createNestedArray("list"); if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { File root = SD.open("/"); File file = root.openNextFile(); while (file) { if (!file.isDirectory()) { const char* fname = file.name(); if (fname[0] != '.' && strcmp(fname, "System Volume Information") != 0) { JsonObject fileObj = files.createNestedObject(); fileObj["name"] = fname; fileObj["size"] = file.size(); const char* comment = getFileComment(fname); if (strlen(comment) > 0) { fileObj["comment"] = comment; } } } file = root.openNextFile(); } xSemaphoreGive(sdMutex); } String filesJson; serializeJson(filesDoc, filesJson); webSocket.sendTXT(num, filesJson); } } } } else if (strcmp(cmd, "addComment") == 0) { const char* filename = doc["filename"]; const char* comment = doc["comment"]; if (filename && comment) { addFileComment(filename, comment); Serial.printf("✓ 커멘트 추가: %s\n", filename); vTaskDelay(pdMS_TO_TICKS(100)); if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { DynamicJsonDocument response(6144); response["type"] = "files"; JsonArray files = response.createNestedArray("list"); File root = SD.open("/"); File file = root.openNextFile(); while (file) { if (!file.isDirectory()) { const char* fname = file.name(); if (fname[0] != '.' && strcmp(fname, "System Volume Information") != 0) { JsonObject fileObj = files.createNestedObject(); fileObj["name"] = fname; fileObj["size"] = file.size(); const char* fcomment = getFileComment(fname); if (strlen(fcomment) > 0) { fileObj["comment"] = fcomment; } } } file = root.openNextFile(); } xSemaphoreGive(sdMutex); String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } } else if (strcmp(cmd, "addTx") == 0) { for (int i = 0; i < MAX_TX_MESSAGES; i++) { if (!txMessages[i].active) { txMessages[i].id = strtoul(doc["id"], NULL, 16); txMessages[i].extended = doc["ext"] | false; txMessages[i].dlc = doc["dlc"] | 8; JsonArray dataArray = doc["data"]; for (int j = 0; j < 8; j++) { txMessages[i].data[j] = dataArray[j] | 0; } txMessages[i].interval = doc["interval"] | 1000; txMessages[i].active = true; txMessages[i].lastSent = 0; Serial.printf("✓ TX 메시지 추가: ID=0x%X\n", txMessages[i].id); break; } } } else if (strcmp(cmd, "removeTx") == 0) { uint32_t id = strtoul(doc["id"], NULL, 16); for (int i = 0; i < MAX_TX_MESSAGES; i++) { if (txMessages[i].active && txMessages[i].id == id) { txMessages[i].active = false; Serial.printf("✓ TX 메시지 제거: ID=0x%X\n", id); break; } } } else if (strcmp(cmd, "updateTx") == 0) { uint32_t id = strtoul(doc["id"], NULL, 16); for (int i = 0; i < MAX_TX_MESSAGES; i++) { if (txMessages[i].active && txMessages[i].id == id) { txMessages[i].interval = doc["interval"]; Serial.printf("✓ TX 주기 변경: ID=0x%X, Interval=%u\n", id, txMessages[i].interval); break; } } } 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); if (doc["ext"] | false) { frame.can_id |= CAN_EFF_FLAG; } frame.can_dlc = doc["dlc"] | 8; JsonArray dataArray = doc["data"]; for (int i = 0; i < 8; i++) { frame.data[i] = dataArray[i] | 0; } if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { 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) { CANSequence* seq = &sequences[sequenceCount]; strncpy(seq->name, doc["name"] | "Unnamed", sizeof(seq->name) - 1); seq->name[sizeof(seq->name) - 1] = '\0'; JsonArray stepsArray = doc["steps"]; seq->stepCount = min((int)stepsArray.size(), 20); for (int i = 0; i < seq->stepCount; i++) { JsonObject stepObj = stepsArray[i]; seq->steps[i].canId = strtoul(stepObj["id"], NULL, 16); seq->steps[i].extended = stepObj["ext"] | false; seq->steps[i].dlc = stepObj["dlc"] | 8; JsonArray dataArray = stepObj["data"]; for (int j = 0; j < 8; j++) { seq->steps[i].data[j] = dataArray[j] | 0; } seq->steps[i].delayMs = stepObj["delay"] | 0; } seq->repeatMode = doc["repeatMode"] | 0; seq->repeatCount = doc["repeatCount"] | 1; sequenceCount++; saveSequences(); Serial.printf("✓ 시퀀스 추가: %s (%d steps)\n", seq->name, seq->stepCount); } } else if (strcmp(cmd, "removeSequence") == 0) { int index = doc["index"]; if (index >= 0 && index < sequenceCount) { for (int i = index; i < sequenceCount - 1; i++) { sequences[i] = sequences[i + 1]; } sequenceCount--; saveSequences(); Serial.printf("✓ 시퀀스 삭제: index=%d\n", index); } } else if (strcmp(cmd, "startSequence") == 0) { int index = doc["index"]; if (index >= 0 && index < sequenceCount && !seqRuntime.running) { seqRuntime.running = true; seqRuntime.currentStep = 0; seqRuntime.currentRepeat = 0; seqRuntime.lastStepTime = millis(); seqRuntime.activeSequenceIndex = index; Serial.printf("✓ 시퀀스 시작: %s\n", sequences[index].name); } } else if (strcmp(cmd, "stopSequence") == 0) { if (seqRuntime.running) { seqRuntime.running = false; Serial.println("✓ 시퀀스 중지"); } } else if (strcmp(cmd, "getSequences") == 0) { DynamicJsonDocument response(3072); response["type"] = "sequences"; JsonArray seqArray = response.createNestedArray("list"); for (int i = 0; i < sequenceCount; i++) { JsonObject seqObj = seqArray.createNestedObject(); seqObj["name"] = sequences[i].name; seqObj["steps"] = sequences[i].stepCount; seqObj["repeatMode"] = sequences[i].repeatMode; seqObj["repeatCount"] = sequences[i].repeatCount; } String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } } // ======================================== // 웹 업데이트 Task (Serial 데이터 전송 추가) // ======================================== void webUpdateTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(100); Serial.println("✓ Web Update Task 시작"); while (1) { webSocket.loop(); if (webSocket.connectedClients() > 0) { DynamicJsonDocument doc(4096); // 3072 → 4096으로 증가 (Serial 메시지 포함) doc["type"] = "update"; doc["logging"] = loggingEnabled; doc["serialLogging"] = serialLoggingEnabled; doc["sdReady"] = sdCardReady; doc["totalMsg"] = totalMsgCount; doc["msgPerSec"] = msgPerSecond; doc["totalTx"] = totalTxCount; doc["totalSerialRx"] = totalSerialRxCount; doc["totalSerialTx"] = totalSerialTxCount; doc["fileSize"] = currentFileSize; doc["serialFileSize"] = currentSerialFileSize; doc["queueUsed"] = uxQueueMessagesWaiting(canQueue); doc["queueSize"] = CAN_QUEUE_SIZE; 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) { JsonObject msgObj = messages.createNestedObject(); msgObj["id"] = recentData[i].msg.id; msgObj["dlc"] = recentData[i].msg.dlc; msgObj["count"] = recentData[i].count; JsonArray dataArray = msgObj.createNestedArray("data"); for (int j = 0; j < recentData[i].msg.dlc; j++) { dataArray.add(recentData[i].msg.data[j]); } } } // Serial 메시지 배열 (Queue에서 읽기) SerialMessage serialMsg; JsonArray serialMessages = doc.createNestedArray("serialMessages"); int serialCount = 0; // Queue에서 최대 10개의 Serial 메시지 읽기 while (serialCount < 10 && xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { JsonObject serMsgObj = serialMessages.createNestedObject(); serMsgObj["timestamp"] = serialMsg.timestamp_us; serMsgObj["isTx"] = serialMsg.isTx; // 데이터를 문자열로 변환 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; serializeJson(doc, json); webSocket.broadcastTXT(json); } vTaskDelay(xDelay); } } // ======================================== // Setup // ======================================== void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); Serial.println(" Byun CAN Logger + Serial Terminal"); Serial.println(" Version 2.2 - ESP32-S3 Edition"); Serial.println(" Fixed: RTC Time Sync + Settings"); Serial.println("========================================\n"); loadSettings(); analogSetPinAttenuation(MONITORING_VOLT, ADC_11db); Serial.println("💡 설정 변경: http://[IP]/settings\n"); memset(recentData, 0, sizeof(recentData)); memset(txMessages, 0, sizeof(txMessages)); memset(fileComments, 0, sizeof(fileComments)); pinMode(CAN_INT_PIN, INPUT_PULLUP); // ADC 설정 analogSetAttenuation(ADC_11db); // SPI 초기화 Serial.println("SPI 초기화 중..."); hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); hspi.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0)); hspi.endTransaction(); pinMode(VSPI_CS, OUTPUT); digitalWrite(VSPI_CS, HIGH); delay(100); vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); vspi.setFrequency(40000000); Serial.println("✓ SPI 초기화 완료"); // Watchdog 완전 비활성화 Serial.println("Watchdog 비활성화..."); esp_task_wdt_deinit(); Serial.println("✓ Watchdog 비활성화 완료"); // MCP2515 초기화 Serial.println("MCP2515 초기화 중..."); mcp2515.reset(); delay(50); MCP2515::ERROR result = mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); if (result != MCP2515::ERROR_OK) { Serial.printf("! MCP2515 Bitrate 설정 실패: %d (계속 진행)\n", result); } mcp2515.setNormalMode(); Serial.println("✓ MCP2515 초기화 완료"); // Serial 통신 초기화 applySerialSettings(); Serial.println("✓ Serial 통신 초기화 완료 (UART1)"); // Mutex 생성 sdMutex = xSemaphoreCreateMutex(); rtcMutex = xSemaphoreCreateMutex(); serialMutex = xSemaphoreCreateMutex(); if (sdMutex == NULL || rtcMutex == NULL || serialMutex == NULL) { Serial.println("✗ Mutex 생성 실패!"); while (1) delay(1000); } // RTC 초기화 initRTC(); // SD 카드 초기화 if (SD.begin(VSPI_CS, vspi)) { sdCardReady = true; Serial.println("✓ SD 카드 초기화 완료"); loadFileComments(); } else { Serial.println("✗ SD 카드 초기화 실패"); } // WiFi 설정 if (enableSTAMode && strlen(staSSID) > 0) { Serial.println("\n📶 WiFi APSTA 모드 시작..."); WiFi.mode(WIFI_AP_STA); WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); Serial.print("✓ AP SSID: "); Serial.println(wifiSSID); Serial.print("✓ AP IP: "); Serial.println(WiFi.softAPIP()); Serial.printf("📡 WiFi 연결 시도: %s\n", staSSID); WiFi.begin(staSSID, staPassword); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 20) { delay(500); Serial.print("."); attempts++; } Serial.println(); if (WiFi.status() == WL_CONNECTED) { 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); Serial.print("✓ AP SSID: "); Serial.println(wifiSSID); Serial.print("✓ AP IP: "); Serial.println(WiFi.softAPIP()); } WiFi.setSleep(false); esp_wifi_set_max_tx_power(84); // WebSocket 시작 webSocket.begin(); webSocket.onEvent(webSocketEvent); // 웹 서버 라우팅 server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", index_html); }); server.on("/transmit", HTTP_GET, []() { server.send_P(200, "text/html", transmit_html); }); server.on("/graph", HTTP_GET, []() { server.send_P(200, "text/html", graph_html); }); server.on("/graph-view", HTTP_GET, []() { server.send_P(200, "text/html", graph_viewer_html); }); server.on("/settings", HTTP_GET, []() { server.send_P(200, "text/html", settings_html); }); server.on("/serial", HTTP_GET, []() { server.send_P(200, "text/html", serial_terminal_html); }); server.on("/download", HTTP_GET, []() { if (server.hasArg("file")) { String filename = "/" + server.arg("file"); if (SD.exists(filename)) { File file = SD.open(filename, FILE_READ); if (file) { String displayName = server.arg("file"); server.sendHeader("Content-Disposition", "attachment; filename=\"" + displayName + "\""); server.sendHeader("Content-Type", "application/octet-stream"); server.streamFile(file, "application/octet-stream"); file.close(); } else { server.send(500, "text/plain", "Failed to open file"); } } else { server.send(404, "text/plain", "File not found"); } } else { server.send(400, "text/plain", "Bad request"); } }); server.on("/delete", HTTP_GET, []() { if (server.hasArg("file")) { String filename = server.arg("file"); if (loggingEnabled && currentFilename[0] != '\0') { String currentFileStr = String(currentFilename); if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); if (filename == currentFileStr) { server.send(403, "text/plain", "Cannot delete file currently being logged"); return; } } String fullPath = "/" + filename; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (SD.exists(fullPath)) { if (SD.remove(fullPath)) { xSemaphoreGive(sdMutex); server.send(200, "text/plain", "File deleted successfully"); Serial.printf("✓ HTTP 파일 삭제: %s\n", filename.c_str()); } else { xSemaphoreGive(sdMutex); server.send(500, "text/plain", "Failed to delete file"); } } else { xSemaphoreGive(sdMutex); server.send(404, "text/plain", "File not found"); } } else { server.send(503, "text/plain", "SD card busy"); } } else { server.send(400, "text/plain", "Bad request"); } }); server.begin(); // Queue 생성 canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage)); if (canQueue == NULL || serialQueue == NULL) { Serial.println("✗ Queue 생성 실패!"); while (1) delay(1000); } // CAN 인터럽트 활성화 attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); // 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) { xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 4096, NULL, 0, &rtcTaskHandle, 0); Serial.println("✓ RTC 자동 동기화 Task 시작"); } // 시퀀스 로드 loadSequences(); Serial.println("✓ 모든 태스크 시작 완료"); Serial.println("\n========================================"); Serial.println(" 웹 인터페이스 접속 방법"); Serial.println("========================================"); Serial.printf(" 1. WiFi 연결\n"); Serial.printf(" - SSID : %s\n", wifiSSID); Serial.printf(" - Password : %s\n", wifiPassword); Serial.printf(" 2. 웹 브라우저에서 접속\n"); Serial.print(" - URL : http://"); Serial.println(WiFi.softAPIP()); Serial.println(" 3. 페이지 메뉴:"); Serial.println(" - Monitor : /"); Serial.println(" - Transmit : /transmit"); Serial.println(" - Graph : /graph"); Serial.println(" - Settings : /settings"); Serial.println(" - Serial : /serial"); Serial.println("========================================\n"); } void loop() { server.handleClient(); vTaskDelay(pdMS_TO_TICKS(10)); static uint32_t lastPrint = 0; if (millis() - lastPrint > 30000) { Serial.printf("[상태] CAN큐: %d/%d | Serial큐: %d/%d | CAN로깅: %s | Serial로깅: %s | SD: %s | CAN RX: %lu | CAN TX: %lu | Serial RX: %lu | Serial TX: %lu | 모드: %d\n", uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE, loggingEnabled ? "ON " : "OFF", serialLoggingEnabled ? "ON " : "OFF", sdCardReady ? "OK" : "NO", totalMsgCount, totalTxCount, totalSerialRxCount, totalSerialTxCount, currentMcpMode); lastPrint = millis(); } }