/* * Byun CAN Logger with Web Interface + Serial Terminal * Version: 2.3 - PSRAM Optimized (Complete Version) * * PSRAM 최적화 완전판: * - 원본 기능 100% 유지 * - 대용량 버퍼/Queue를 PSRAM에 할당 * - 웹서버, WebSocket, 모든 Task 포함 * * Arduino IDE 설정: * - Board: ESP32S3 Dev Module * - PSRAM: OPI PSRAM ⭐ 필수! * - Flash Size: 16MB (128Mb) * - Partition: 16MB Flash (3MB APP/9.9MB FATFS) */ #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" #include "serial2_terminal.h" // ⭐ Serial2 페이지 추가 // GPIO 핀 정의 #define CAN_INT_PIN 4 #define SERIAL_TX_PIN 17 #define SERIAL_RX_PIN 18 // UART2 (Serial Logger 2) ⭐ 추가 #define SERIAL2_TX_PIN 6 #define SERIAL2_RX_PIN 7 // 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) #define RTC_SDA 8 #define RTC_SCL 9 #define DS3231_ADDRESS 0x68 // ======================================== // PSRAM 최적화 설정 // ======================================== #define CAN_QUEUE_SIZE 6000 // 1000 → 6000 (PSRAM 사용) #define FILE_BUFFER_SIZE 65536 // 16KB → 64KB (PSRAM 사용) #define SERIAL_QUEUE_SIZE 1200 // 200 → 1200 (PSRAM 사용) #define SERIAL_CSV_BUFFER_SIZE 32768 #define SERIAL2_QUEUE_SIZE 1200 // ⭐ Serial2 추가 #define SERIAL2_CSV_BUFFER_SIZE 32768 // ⭐ Serial2 추가 // 8KB → 32KB (PSRAM 사용) #define MAX_FILENAME_LEN 64 #define RECENT_MSG_COUNT 100 #define MAX_TX_MESSAGES 20 #define MAX_COMMENT_LEN 128 #define MAX_SERIAL_LINE_LEN 64 #define RTC_SYNC_INTERVAL_MS 60000 #define VOLTAGE_CHECK_INTERVAL_MS 5000 #define LOW_VOLTAGE_THRESHOLD 3.0 #define MONITORING_VOLT 5 #define MAX_GRAPH_SIGNALS 20 #define MAX_SEQUENCES 10 #define MAX_FILE_COMMENTS 50 // ======================================== // 구조체 정의 // ======================================== struct CANMessage { uint64_t timestamp_us; uint32_t id; uint8_t dlc; uint8_t data[8]; } __attribute__((packed)); struct SerialMessage { uint64_t timestamp_us; uint16_t length; uint8_t data[MAX_SERIAL_LINE_LEN]; bool isTx; } __attribute__((packed)); struct SerialSettings { uint32_t baudRate; uint8_t dataBits; uint8_t parity; uint8_t stopBits; }; struct RecentCANData { CANMessage msg; uint32_t count; }; struct TxMessage { uint32_t id; bool extended; uint8_t dlc; uint8_t data[8]; uint32_t interval; uint32_t lastSent; bool active; }; struct SequenceStep { uint32_t canId; bool extended; uint8_t dlc; uint8_t data[8]; uint32_t delayMs; }; 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}; enum MCP2515Mode { MCP_MODE_NORMAL = 0, MCP_MODE_LISTEN_ONLY = 1, MCP_MODE_LOOPBACK = 2, MCP_MODE_TRANSMIT = 3 }; // ======================================== // PSRAM 할당 변수 (포인터로 선언) // ======================================== uint8_t *fileBuffer = nullptr; char *serialCsvBuffer = nullptr; char *serial2CsvBuffer = nullptr; // ⭐ Serial2 추가 RecentCANData *recentData = nullptr; TxMessage *txMessages = nullptr; CANSequence *sequences = nullptr; FileComment *fileComments = nullptr; // Queue 저장소 (PSRAM) StaticQueue_t *canQueueBuffer = nullptr; StaticQueue_t *serialQueueBuffer = nullptr; StaticQueue_t *serial2QueueBuffer = nullptr; // ⭐ Serial2 uint8_t *canQueueStorage = nullptr; uint8_t *serialQueueStorage = nullptr; uint8_t *serial2QueueStorage = nullptr; // ⭐ Serial2 // WiFi 설정 (내부 SRAM) char wifiSSID[32] = "Byun_CAN_Logger"; char wifiPassword[64] = "12345678"; bool enableSTAMode = false; char staSSID[32] = ""; char staPassword[64] = ""; // ======================================== // Serial 설정 (2개) // ======================================== SerialSettings serialSettings = {115200, 8, 0, 1}; // Serial1 SerialSettings serial2Settings = {115200, 8, 0, 1}; // ⭐ Serial2 추가 // 전역 객체 (내부 SRAM) SPIClass hspi(HSPI); SPIClass vspi(FSPI); MCP2515 mcp2515(HSPI_CS, 20000000, &hspi); HardwareSerial SerialComm(1); // UART1 HardwareSerial Serial2Comm(2); // ⭐ UART2 추가 WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(81); Preferences preferences; // FreeRTOS 핸들 QueueHandle_t canQueue = NULL; QueueHandle_t serialQueue = NULL; QueueHandle_t serial2Queue = NULL; // ⭐ Serial2 추가 SemaphoreHandle_t sdMutex = NULL; SemaphoreHandle_t rtcMutex = NULL; SemaphoreHandle_t serialMutex = NULL; SemaphoreHandle_t serial2Mutex = NULL; // ⭐ Serial2 추가 TaskHandle_t canRxTaskHandle = NULL; TaskHandle_t sdWriteTaskHandle = NULL; TaskHandle_t webTaskHandle = NULL; TaskHandle_t rtcTaskHandle = NULL; TaskHandle_t serialRxTaskHandle = NULL; TaskHandle_t serial2RxTaskHandle = NULL; // ⭐ Serial2 추가 // 로깅 변수 volatile bool loggingEnabled = false; volatile bool serialLoggingEnabled = false; volatile bool serial2LoggingEnabled = false; // ⭐ Serial2 추가 volatile bool sdCardReady = false; File logFile; File serialLogFile; File serial2LogFile; // ⭐ Serial2 추가 char currentFilename[MAX_FILENAME_LEN]; char currentSerialFilename[MAX_FILENAME_LEN]; char currentSerial2Filename[MAX_FILENAME_LEN]; // ⭐ Serial2 추가 uint16_t bufferIndex = 0; uint16_t serialCsvIndex = 0; uint16_t serial2CsvIndex = 0; // ⭐ Serial2 추가 volatile uint32_t currentFileSize = 0; volatile uint32_t currentSerialFileSize = 0; volatile uint32_t currentSerial2FileSize = 0; // ⭐ Serial2 추가 volatile bool canLogFormatCSV = false; volatile bool serialLogFormatCSV = true; volatile bool serial2LogFormatCSV = true; // ⭐ Serial2 추가 volatile uint64_t canLogStartTime = 0; volatile uint64_t serialLogStartTime = 0; volatile uint64_t serial2LogStartTime = 0; // ⭐ Serial2 추가 // 기타 전역 변수 MCP2515Mode currentMcpMode = MCP_MODE_NORMAL; SoftWire rtcWire(RTC_SDA, RTC_SCL); char rtcSyncBuffer[20]; CAN_SPEED currentCanSpeed = CAN_1000KBPS; const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; uint32_t totalMsgCount = 0; uint32_t msgPerSecond = 0; uint32_t lastMsgCountTime = 0; uint32_t lastMsgCount = 0; volatile uint32_t totalSerialRxCount = 0; volatile uint32_t totalSerialTxCount = 0; volatile uint32_t totalSerial2RxCount = 0; // ⭐ Serial2 추가 volatile uint32_t totalSerial2TxCount = 0; // ⭐ Serial2 추가 uint32_t totalTxCount = 0; uint8_t sequenceCount = 0; SequenceRuntime seqRuntime = {false, 0, 0, 0, -1}; int commentCount = 0; // Forward declarations void IRAM_ATTR canISR(); void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length); // ======================================== // PSRAM 초기화 함수 // ======================================== bool initPSRAM() { Serial.println("\n========================================"); Serial.println(" PSRAM 메모리 할당"); Serial.println("========================================"); if (!psramFound()) { Serial.println("✗ PSRAM을 찾을 수 없습니다!"); Serial.println("✗ Arduino IDE 설정:"); Serial.println(" Tools → PSRAM → OPI PSRAM"); return false; } Serial.printf("✓ PSRAM 총 용량: %d MB\n", ESP.getPsramSize() / 1024 / 1024); Serial.printf("✓ PSRAM 여유: %d KB\n\n", ESP.getFreePsram() / 1024); // 버퍼 할당 fileBuffer = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE); if (!fileBuffer) { Serial.println("✗ fileBuffer 할당 실패"); return false; } Serial.printf("✓ fileBuffer: %d KB\n", FILE_BUFFER_SIZE / 1024); serialCsvBuffer = (char*)ps_malloc(SERIAL_CSV_BUFFER_SIZE); if (!serialCsvBuffer) { Serial.println("✗ serialCsvBuffer 할당 실패"); return false; } Serial.printf("✓ serialCsvBuffer: %d KB\n", SERIAL_CSV_BUFFER_SIZE / 1024); // ⭐ Serial2 CSV Buffer serial2CsvBuffer = (char*)ps_malloc(SERIAL2_CSV_BUFFER_SIZE); if (!serial2CsvBuffer) { Serial.println("✗ serial2CsvBuffer 할당 실패"); return false; } Serial.printf("✓ serial2CsvBuffer: %d KB\n", SERIAL2_CSV_BUFFER_SIZE / 1024); recentData = (RecentCANData*)ps_calloc(RECENT_MSG_COUNT, sizeof(RecentCANData)); if (!recentData) { Serial.println("✗ recentData 할당 실패"); return false; } Serial.printf("✓ recentData: %.2f KB\n", (float)(RECENT_MSG_COUNT * sizeof(RecentCANData)) / 1024.0); txMessages = (TxMessage*)ps_calloc(MAX_TX_MESSAGES, sizeof(TxMessage)); if (!txMessages) { Serial.println("✗ txMessages 할당 실패"); return false; } Serial.printf("✓ txMessages: %.2f KB\n", (float)(MAX_TX_MESSAGES * sizeof(TxMessage)) / 1024.0); sequences = (CANSequence*)ps_calloc(MAX_SEQUENCES, sizeof(CANSequence)); if (!sequences) { Serial.println("✗ sequences 할당 실패"); return false; } Serial.printf("✓ sequences: %.2f KB\n", (float)(MAX_SEQUENCES * sizeof(CANSequence)) / 1024.0); fileComments = (FileComment*)ps_calloc(MAX_FILE_COMMENTS, sizeof(FileComment)); if (!fileComments) { Serial.println("✗ fileComments 할당 실패"); return false; } Serial.printf("✓ fileComments: %.2f KB\n", (float)(MAX_FILE_COMMENTS * sizeof(FileComment)) / 1024.0); // Queue 저장소 할당 Serial.println("\n📦 Queue 저장소 할당..."); canQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t)); canQueueStorage = (uint8_t*)ps_malloc(CAN_QUEUE_SIZE * sizeof(CANMessage)); if (!canQueueBuffer || !canQueueStorage) { Serial.println("✗ CAN Queue 저장소 할당 실패"); return false; } Serial.printf("✓ CAN Queue: %d 개 × %d bytes = %.2f KB\n", CAN_QUEUE_SIZE, sizeof(CANMessage), (float)(CAN_QUEUE_SIZE * sizeof(CANMessage)) / 1024.0); serialQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t)); serialQueueStorage = (uint8_t*)ps_malloc(SERIAL_QUEUE_SIZE * sizeof(SerialMessage)); if (!serialQueueBuffer || !serialQueueStorage) { Serial.println("✗ Serial Queue 저장소 할당 실패"); return false; } Serial.printf("✓ Serial Queue: %d 개 × %d bytes = %.2f KB\n", SERIAL_QUEUE_SIZE, sizeof(SerialMessage), (float)(SERIAL_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0); // ⭐ Serial2 Queue serial2QueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t)); serial2QueueStorage = (uint8_t*)ps_malloc(SERIAL2_QUEUE_SIZE * sizeof(SerialMessage)); if (!serial2QueueBuffer || !serial2QueueStorage) { Serial.println("✗ Serial2 Queue 저장소 할당 실패"); return false; } Serial.printf("✓ Serial2 Queue: %d 개 × %d bytes = %.2f KB\n", SERIAL2_QUEUE_SIZE, sizeof(SerialMessage), (float)(SERIAL2_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0); Serial.println("========================================"); Serial.printf("✓ PSRAM 남은 용량: %.2f KB\n", (float)ESP.getFreePsram() / 1024.0); Serial.println("========================================\n"); return true; } bool createQueues() { Serial.println("Queue 생성 (PSRAM 사용)..."); canQueue = xQueueCreateStatic( CAN_QUEUE_SIZE, sizeof(CANMessage), canQueueStorage, canQueueBuffer ); if (canQueue == NULL) { Serial.println("✗ CAN Queue 생성 실패"); return false; } Serial.printf("✓ CAN Queue: %d 개\n", CAN_QUEUE_SIZE); serialQueue = xQueueCreateStatic( SERIAL_QUEUE_SIZE, sizeof(SerialMessage), serialQueueStorage, serialQueueBuffer ); if (serialQueue == NULL) { Serial.println("✗ Serial Queue 생성 실패"); return false; } Serial.printf("✓ Serial Queue: %d 개\n", SERIAL_QUEUE_SIZE); // ⭐ Serial2 Queue 생성 (중요!) serial2Queue = xQueueCreateStatic( SERIAL2_QUEUE_SIZE, sizeof(SerialMessage), serial2QueueStorage, serial2QueueBuffer ); if (serial2Queue == NULL) { Serial.println("✗ Serial2 Queue 생성 실패"); return false; } Serial.printf("✓ Serial2 Queue: %d 개\n\n", SERIAL2_QUEUE_SIZE); return true; } // ======================================== // 설정 저장/로드 함수 // ======================================== 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); // ⭐ Serial2 serial2Settings.baudRate = preferences.getUInt("ser2_baud", 115200); serial2Settings.dataBits = preferences.getUChar("ser2_data", 8); serial2Settings.parity = preferences.getUChar("ser2_parity", 0); serial2Settings.stopBits = preferences.getUChar("ser2_stop", 1); } 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); // ⭐ Serial2 preferences.putUInt("ser2_baud", serial2Settings.baudRate); preferences.putUChar("ser2_data", serial2Settings.dataBits); preferences.putUChar("ser2_parity", serial2Settings.parity); preferences.putUChar("ser2_stop", serial2Settings.stopBits); } void applySerialSettings() { uint32_t config = SERIAL_8N1; 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 { if (serialSettings.parity == 0) config = SERIAL_8N1; else if (serialSettings.parity == 1) config = SERIAL_8E1; else if (serialSettings.parity == 2) config = SERIAL_8O1; } if (serialSettings.stopBits == 2) { config |= 0x3000; } SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN); SerialComm.setRxBufferSize(2048); // ⭐ Serial2 설정 uint32_t config2 = SERIAL_8N1; if (serial2Settings.dataBits == 5) { if (serial2Settings.parity == 0) config2 = SERIAL_5N1; else if (serial2Settings.parity == 1) config2 = SERIAL_5E1; else if (serial2Settings.parity == 2) config2 = SERIAL_5O1; } else if (serial2Settings.dataBits == 6) { if (serial2Settings.parity == 0) config2 = SERIAL_6N1; else if (serial2Settings.parity == 1) config2 = SERIAL_6E1; else if (serial2Settings.parity == 2) config2 = SERIAL_6O1; } else if (serial2Settings.dataBits == 7) { if (serial2Settings.parity == 0) config2 = SERIAL_7N1; else if (serial2Settings.parity == 1) config2 = SERIAL_7E1; else if (serial2Settings.parity == 2) config2 = SERIAL_7O1; } else { if (serial2Settings.parity == 0) config2 = SERIAL_8N1; else if (serial2Settings.parity == 1) config2 = SERIAL_8E1; else if (serial2Settings.parity == 2) config2 = SERIAL_8O1; } if (serial2Settings.stopBits == 2) config2 |= 0x3000; Serial2Comm.begin(serial2Settings.baudRate, config2, SERIAL2_RX_PIN, SERIAL2_TX_PIN); Serial2Comm.setRxBufferSize(2048); } void loadSettings() { preferences.begin("can-logger", false); preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); 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"); int speedIndex = preferences.getInt("can_speed", 3); if (speedIndex >= 0 && speedIndex < 4) { currentCanSpeed = canSpeedValues[speedIndex]; } int savedMode = preferences.getInt("mcp_mode", 0); if (savedMode >= 0 && savedMode <= 3) { currentMcpMode = (MCP2515Mode)savedMode; } 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); for (int i = 0; i < 4; i++) { if (canSpeedValues[i] == currentCanSpeed) { preferences.putInt("can_speed", i); break; } } preferences.putInt("mcp_mode", (int)currentMcpMode); 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; } void timeSyncCallback(struct timeval *tv) { Serial.println("✓ NTP 시간 동기화 완료"); timeSyncStatus.synchronized = true; timeSyncStatus.lastSyncTime = (uint64_t)tv->tv_sec * 1000000ULL + tv->tv_usec; timeSyncStatus.syncCount++; if (timeSyncStatus.rtcAvailable) { struct tm timeinfo; time_t now = tv->tv_sec; localtime_r(&now, &timeinfo); if (writeRTC(&timeinfo)) { timeSyncStatus.rtcSyncCount++; } } } void initNTP() { configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov"); sntp_set_time_sync_notification_cb(timeSyncCallback); } 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(); modeName = "Transmit-Only"; break; default: return false; } if (result == MCP2515::ERROR_OK) { currentMcpMode = mode; Serial.printf("✓ MCP2515 모드: %s\n", modeName); return true; } return false; } // ======================================== // 인터럽트 및 Task // ======================================== void IRAM_ATTR canISR() { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if (canRxTaskHandle != NULL) { vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } void serialRxTask(void *parameter) { SerialMessage serialMsg; uint8_t lineBuffer[MAX_SERIAL_LINE_LEN]; uint16_t lineIndex = 0; uint32_t lastActivity = millis(); while (1) { while (SerialComm.available()) { 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++; } lineIndex = 0; } } if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0; } 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)); } } // ⭐ Serial2 RX Task (우선순위 5) void serial2RxTask(void *parameter) { SerialMessage serialMsg; uint8_t lineBuffer[MAX_SERIAL_LINE_LEN]; uint16_t lineIndex = 0; uint32_t lastActivity = millis(); while (1) { while (Serial2Comm.available()) { uint8_t c = Serial2Comm.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 (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크 totalSerial2RxCount++; } lineIndex = 0; } } if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0; } 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 (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크 totalSerial2RxCount++; } lineIndex = 0; } vTaskDelay(pdMS_TO_TICKS(1)); } } void canRxTask(void *parameter) { struct can_frame frame; CANMessage msg; 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 Write Task // ======================================== void sdWriteTask(void *parameter) { CANMessage canMsg; SerialMessage serialMsg; 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) { char csvLine[128]; uint64_t relativeTime = canMsg.timestamp_us - canLogStartTime; 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; 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); } } } if (!hasWork) { vTaskDelay(pdMS_TO_TICKS(1)); } } } void sdMonitorTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(1000); 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; } 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(); } 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(); } } 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(); } } // ======================================== // 시퀀스 관리 // ======================================== 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(); } 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(); } } xSemaphoreGive(sdMutex); } } // ======================================== // TX Task // ======================================== void txTask(void *parameter) { struct can_frame frame; 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) { 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; } if (currentMcpMode == MCP_MODE_TRANSMIT) { mcp2515.setListenOnlyMode(); } } } } vTaskDelay(anyActive ? pdMS_TO_TICKS(1) : pdMS_TO_TICKS(10)); } } // ======================================== // Sequence Task // ======================================== void sequenceTask(void *parameter) { 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) { 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++; } 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 이벤트 처리 (중요!) // ======================================== 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) return; const char* cmd = doc["cmd"]; 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); } else if (strcmp(cmd, "saveSettings") == 0) { 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); } 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) { if (canLogFormatCSV) { logFile.println("Time_us,CAN_ID,DLC,Data"); } loggingEnabled = true; bufferIndex = 0; currentFileSize = logFile.size(); } 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; 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; } 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) { if (serialLogFormatCSV) { serialLogFile.println("Time_us,Direction,Data"); } serialLoggingEnabled = true; serialCsvIndex = 0; currentSerialFileSize = serialLogFile.size(); } xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "stopSerialLogging") == 0) { if (serialLoggingEnabled) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (serialCsvIndex > 0 && serialLogFile) { serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex); serialCsvIndex = 0; } if (serialLogFile) { serialLogFile.close(); } serialLoggingEnabled = false; xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "sendSerial") == 0) { const char* data = doc["data"]; if (data && strlen(data) > 0) { SerialComm.println(data); 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; 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++; } } } 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(); } 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(); } } else if (strcmp(cmd, "startSerial2Logging") == 0) { if (!serial2LoggingEnabled && sdCardReady) { const char* format = doc["format"]; if (format && strcmp(format, "bin") == 0) { serial2LogFormatCSV = false; } else { serial2LogFormatCSV = true; } 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); serial2LogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; const char* ext = serial2LogFormatCSV ? "csv" : "bin"; snprintf(currentSerial2Filename, sizeof(currentSerial2Filename), "/SER2_%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); serial2LogFile = SD.open(currentSerial2Filename, FILE_WRITE); if (serial2LogFile) { if (serial2LogFormatCSV) { serial2LogFile.println("Time_us,Direction,Data"); } serial2LoggingEnabled = true; serial2CsvIndex = 0; currentSerial2FileSize = serial2LogFile.size(); } xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "stopSerial2Logging") == 0) { if (serial2LoggingEnabled) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (serial2CsvIndex > 0 && serial2LogFile) { serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex); serial2CsvIndex = 0; } if (serial2LogFile) { serial2LogFile.close(); } serial2LoggingEnabled = false; xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "sendSerial2") == 0) { const char* data = doc["data"]; if (data && strlen(data) > 0) { Serial2Comm.println(data); 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; 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 (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크 totalSerial2TxCount++; } } } else if (strcmp(cmd, "setSerial2Config") == 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; serial2Settings.baudRate = baud; serial2Settings.dataBits = data; serial2Settings.parity = parity; serial2Settings.stopBits = stop; saveSerialSettings(); applySerialSettings(); } else if (strcmp(cmd, "getSerial2Config") == 0) { DynamicJsonDocument response(512); response["type"] = "serial2Config"; response["baudRate"] = serial2Settings.baudRate; response["dataBits"] = serial2Settings.dataBits; response["parity"] = serial2Settings.parity; response["stopBits"] = serial2Settings.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(); } } else if (strcmp(cmd, "setMcpMode") == 0) { int mode = doc["mode"]; if (mode >= 0 && mode <= 3) { setMCP2515Mode((MCP2515Mode)mode); saveSettings(); } } 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); } } 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("files"); File root = SD.open("/"); if (root) { File file = root.openNextFile(); int fileCount = 0; while (file && fileCount < 50) { if (!file.isDirectory()) { const char* filename = file.name(); // ⭐ 파일명이 '/'로 시작하면 건너뛰기 if (filename[0] == '/') { filename++; // 슬래시 제거 } // 숨김 파일과 시스템 폴더 제외 if (filename[0] != '.' && strcmp(filename, "System Volume Information") != 0 && strlen(filename) > 0) { JsonObject fileObj = files.createNestedObject(); fileObj["name"] = filename; fileObj["size"] = file.size(); const char* comment = getFileComment(filename); if (strlen(comment) > 0) { fileObj["comment"] = comment; } fileCount++; } } file.close(); file = root.openNextFile(); } root.close(); // ⭐ 디버그 로그 Serial.printf("getFiles: Found %d files\n", fileCount); } else { Serial.println("getFiles: Failed to open root directory"); } xSemaphoreGive(sdMutex); String json; size_t jsonSize = serializeJson(response, json); Serial.printf("getFiles: JSON size = %d bytes\n", jsonSize); webSocket.sendTXT(num, json); } else { Serial.println("getFiles: Failed to acquire sdMutex"); // Mutex 실패 시에도 응답 전송 DynamicJsonDocument response(256); response["type"] = "files"; response["error"] = "SD busy"; JsonArray files = response.createNestedArray("files"); String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } else { Serial.println("getFiles: SD card not ready"); // SD 카드 없을 때 빈 목록 전송 DynamicJsonDocument response(256); response["type"] = "files"; JsonArray files = response.createNestedArray("files"); 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) { String fullPath = "/" + String(filename); if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { bool success = false; if (SD.exists(fullPath)) { if (SD.remove(fullPath)) { success = true; } } xSemaphoreGive(sdMutex); DynamicJsonDocument response(256); response["type"] = "deleteResult"; response["success"] = success; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } } else if (strcmp(cmd, "addComment") == 0) { const char* filename = doc["filename"]; const char* comment = doc["comment"]; if (filename && comment) { addFileComment(filename, comment); } } else if (strcmp(cmd, "sendOnce") == 0) { 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++; } if (currentMcpMode == MCP_MODE_TRANSMIT) { mcp2515.setListenOnlyMode(); } } 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); } } } // ======================================== // Web Update Task // ======================================== void webUpdateTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(200); // ⭐ 100ms → 200ms (WiFi 안정성 향상) while (1) { webSocket.loop(); if (webSocket.connectedClients() > 0) { DynamicJsonDocument doc(8192); // ⭐ 4096 → 8192로 증가 doc["type"] = "update"; doc["logging"] = loggingEnabled; doc["serialLogging"] = serialLoggingEnabled; doc["serial2Logging"] = serial2LoggingEnabled; doc["totalSerial2Rx"] = totalSerial2RxCount; doc["totalSerial2Tx"] = totalSerial2TxCount; doc["serial2QueueUsed"] = serial2Queue ? uxQueueMessagesWaiting(serial2Queue) : 0; // ⭐ NULL 체크 doc["serial2QueueSize"] = SERIAL2_QUEUE_SIZE; doc["serial2FileSize"] = currentSerial2FileSize; if (serial2LoggingEnabled && currentSerial2Filename[0] != '\0') { doc["currentSerial2File"] = String(currentSerial2Filename); } else { doc["currentSerial2File"] = ""; } 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; 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 메시지 배열 (최근 20개만 전송) JsonArray messages = doc.createNestedArray("messages"); int msgCount = 0; for (int i = 0; i < RECENT_MSG_COUNT && msgCount < 20; i++) { // ⭐ 최대 20개 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]); } msgCount++; } } // Serial 메시지 배열 SerialMessage serialMsg; JsonArray serialMessages = doc.createNestedArray("serialMessages"); int serialCount = 0; 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 로깅 if (serialLoggingEnabled && sdCardReady) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) { if (serialLogFormatCSV) { 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); 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 { 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); } } } // ⭐ Serial2 메시지 배열 처리 SerialMessage serial2Msg; JsonArray serial2Messages = doc.createNestedArray("serial2Messages"); int serial2Count = 0; while (serial2Queue && serial2Count < 10 && xQueueReceive(serial2Queue, &serial2Msg, 0) == pdTRUE) { // ⭐ NULL 체크 JsonObject serMsgObj = serial2Messages.createNestedObject(); serMsgObj["timestamp"] = serial2Msg.timestamp_us; serMsgObj["isTx"] = serial2Msg.isTx; char dataStr[MAX_SERIAL_LINE_LEN + 1]; memcpy(dataStr, serial2Msg.data, serial2Msg.length); dataStr[serial2Msg.length] = '\0'; serMsgObj["data"] = dataStr; serial2Count++; // Serial2 로깅 if (serial2LoggingEnabled && sdCardReady) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) { if (serial2LogFormatCSV) { uint64_t relativeTime = serial2Msg.timestamp_us - serial2LogStartTime; char csvLine[256]; int lineLen = snprintf(csvLine, sizeof(csvLine), "%llu,%s,\"%s\"\n", relativeTime, serial2Msg.isTx ? "TX" : "RX", dataStr); if (serial2CsvIndex + lineLen < SERIAL2_CSV_BUFFER_SIZE) { memcpy(&serial2CsvBuffer[serial2CsvIndex], csvLine, lineLen); serial2CsvIndex += lineLen; currentSerial2FileSize += lineLen; } if (serial2CsvIndex >= SERIAL2_CSV_BUFFER_SIZE - 256) { if (serial2LogFile) { serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex); serial2LogFile.flush(); serial2CsvIndex = 0; } } } else { // BIN 형식 if (serial2LogFile) { serial2LogFile.write((uint8_t*)&serial2Msg, sizeof(SerialMessage)); currentSerial2FileSize += sizeof(SerialMessage); static int binFlushCounter2 = 0; if (++binFlushCounter2 >= 50) { serial2LogFile.flush(); binFlushCounter2 = 0; } } } xSemaphoreGive(sdMutex); } } } String json; size_t jsonSize = serializeJson(doc, json); // JSON 크기 확인 (8KB 이하만 전송) if (jsonSize > 0 && jsonSize < 8192) { webSocket.broadcastTXT(json); } else { Serial.printf("! JSON 크기 초과: %d bytes\n", jsonSize); } } vTaskDelay(xDelay); } } // ======================================== // Setup // ======================================== void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); Serial.println(" Byun CAN Logger + Serial Terminal"); Serial.println(" Version 2.3 - PSRAM Optimized"); Serial.println(" ESP32-S3 Complete Edition"); Serial.println("========================================\n"); // ★★★ PSRAM 초기화 (가장 먼저!) ★★★ if (!initPSRAM()) { Serial.println("\n✗ PSRAM 초기화 실패!"); Serial.println("✗ Arduino IDE 설정:"); Serial.println(" Tools → PSRAM → OPI PSRAM"); while (1) { delay(1000); Serial.println("✗ 설정 후 재업로드 필요!"); } } loadSettings(); analogSetPinAttenuation(MONITORING_VOLT, ADC_11db); pinMode(CAN_INT_PIN, INPUT_PULLUP); 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 비활성화 esp_task_wdt_deinit(); // MCP2515 초기화 Serial.println("MCP2515 초기화..."); mcp2515.reset(); delay(50); mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); mcp2515.setNormalMode(); Serial.println("✓ MCP2515 초기화 완료"); // Serial 통신 초기화 applySerialSettings(); Serial.println("✓ Serial1 통신 초기화 (GPIO 17/18)"); Serial.println("✓ Serial2 통신 초기화 (GPIO 6/7)"); // ⭐ Serial2 // Mutex 생성 sdMutex = xSemaphoreCreateMutex(); rtcMutex = xSemaphoreCreateMutex(); serialMutex = xSemaphoreCreateMutex(); serial2Mutex = xSemaphoreCreateMutex(); // ⭐ Serial2 if (!sdMutex || !rtcMutex || !serialMutex) { Serial.println("✗ Mutex 생성 실패!"); while (1) delay(1000); } // RTC 초기화 initRTC(); // SD 카드 초기화 if (SD.begin(VSPI_CS, vspi)) { sdCardReady = true; Serial.println("✓ SD 카드 초기화 완료"); loadFileComments(); loadSequences(); } else { Serial.println("✗ SD 카드 초기화 실패"); } // WiFi 설정 WiFi.setSleep(false); // ⭐ WiFi 절전 모드 비활성화 (연결 안정성 향상) if (enableSTAMode && strlen(staSSID) > 0) { Serial.println("\n📶 WiFi APSTA 모드..."); WiFi.mode(WIFI_AP_STA); WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); Serial.printf("✓ AP: %s\n", wifiSSID); Serial.printf("✓ AP IP: %s\n", WiFi.softAPIP().toString().c_str()); 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.printf("✓ STA IP: %s\n", WiFi.localIP().toString().c_str()); initNTP(); } } else { Serial.println("\n📶 WiFi AP 모드..."); WiFi.mode(WIFI_AP); WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); Serial.printf("✓ AP: %s\n", wifiSSID); Serial.printf("✓ AP IP: %s\n", WiFi.softAPIP().toString().c_str()); } 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("/serial2", HTTP_GET, []() { server.send_P(200,"text/html", serial2_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.begin(); Serial.println("✓ 웹 서버 시작 완료"); // ★★★ Queue 생성 (PSRAM 사용) ★★★ if (!createQueues()) { Serial.println("✗ Queue 생성 실패!"); while (1) delay(1000); } // CAN 인터럽트 활성화 attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); // Task 생성 xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 6, &canRxTaskHandle, 1); xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 24576, NULL, 4, &sdWriteTaskHandle, 1); xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1); xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 5, &serialRxTaskHandle, 0); xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 5, &serial2RxTaskHandle, 0); // ⭐ Serial2 xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 0); xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 16384, NULL, 2, &webTaskHandle, 0); // ⭐ 10240 → 16384 xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0); if (timeSyncStatus.rtcAvailable) { xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0); } Serial.println("✓ 모든 Task 시작 완료"); Serial.println("\n========================================"); Serial.println(" 접속 방법"); Serial.println("========================================"); Serial.printf(" WiFi SSID: %s\n", wifiSSID); Serial.printf(" URL: http://%s\n", WiFi.softAPIP().toString().c_str()); Serial.println("========================================"); Serial.println(" PSRAM 상태"); Serial.println("========================================"); Serial.printf(" 여유 PSRAM: %d KB\n", ESP.getFreePsram() / 1024); Serial.println("========================================\n"); } // ======================================== // Loop // ======================================== void loop() { server.handleClient(); vTaskDelay(pdMS_TO_TICKS(10)); static uint32_t lastPrint = 0; if (millis() - lastPrint > 30000) { Serial.printf("[상태] CAN: %d/%d | S1: %d/%d | S2: %d/%d | PSRAM: %d KB\n", uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE, uxQueueMessagesWaiting(serial2Queue), SERIAL2_QUEUE_SIZE, ESP.getFreePsram() / 1024); lastPrint = millis(); } }