/* * ============================================================================ * Byun CAN Logger v2.5 PSRAM Edition - Final Release * ============================================================================ * * ESP32-S3 기반 고성능 CAN Bus & Serial 데이터 로거 * * 주요 기능: * - 1Mbps CAN Bus 통신 (MCP2515) * - RS232/UART Serial 통신 * - PSRAM 활용 대용량 큐 (CAN: 10,000개, Serial: 2,000개) * - 실시간 웹 모니터링 (WebSocket) * - SD 카드 로깅 (CSV/BIN 형식) * - CAN 시퀀스 전송 * - DBC 기반 실시간 그래프 * - WiFi AP/APSTA 모드 * - NTP 시간 동기화 * * 하드웨어: * - ESP32-S3 (PSRAM 8MB) * - MCP2515 CAN Controller * - SD Card Module * - RS232 Transceiver (Optional) * * 작성자: Byun * 버전: 2.5 Final * 날짜: 2024 * ============================================================================ */ #include #include #include #include #include #include #include #include #include // ============================================================================ // HTML 페이지 포함 // ============================================================================ #include "index.h" #include "graph.h" #include "graph_viewer.h" #include "transmit.h" #include "serial_terminal.h" #include "settings.h" // ============================================================================ // 핀 정의 // ============================================================================ #define CAN_CS_PIN 5 // MCP2515 CS #define SD_CS_PIN 15 // SD Card CS #define VOLTAGE_PIN 4 // 전압 측정 (ADC) // Serial2 핀 정의 (ESP32-S3) #define SERIAL2_RX 16 #define SERIAL2_TX 17 // ============================================================================ // 상수 정의 // ============================================================================ #define RECENT_MSG_COUNT 100 // 최근 메시지 추적 개수 #define MAX_SERIAL_LINE_LEN 256 // Serial 라인 최대 길이 #define SEQUENCE_MAX_STEPS 50 // 시퀀스 최대 스텝 #define MAX_SEQUENCES 10 // 최대 시퀀스 개수 // PSRAM 기반 대용량 큐 #define CAN_QUEUE_SIZE 10000 // CAN 메시지 큐 (PSRAM) #define SERIAL_QUEUE_SIZE 2000 // Serial 메시지 큐 (PSRAM) // 전압 관련 #define LOW_VOLTAGE_THRESHOLD 11.0 // 저전압 경고 (12V 기준) #define VOLTAGE_SAMPLES 10 // 전압 측정 샘플 수 // WebSocket 업데이트 주기 #define WEB_UPDATE_INTERVAL 500 // 500ms // ============================================================================ // 구조체 정의 // ============================================================================ // CAN 메시지 구조체 struct CANMessage { uint32_t id; uint8_t dlc; uint8_t data[8]; uint32_t timestamp_ms; bool extended; }; // CAN 데이터 추적 구조체 struct CANData { struct can_frame msg; uint32_t count; uint32_t lastUpdate; }; // Serial 메시지 구조체 struct SerialMessage { uint64_t timestamp_us; char data[MAX_SERIAL_LINE_LEN]; uint16_t length; bool isTx; }; // CAN 시퀀스 스텝 struct SequenceStep { uint32_t canId; bool extended; uint8_t dlc; uint8_t data[8]; uint32_t delayMs; }; // CAN 시퀀스 struct CANSequence { char name[32]; uint8_t repeatMode; // 0=Once, 1=Count, 2=Infinite uint16_t repeatCount; uint8_t stepCount; SequenceStep steps[SEQUENCE_MAX_STEPS]; }; // 시퀀스 실행 상태 struct SequenceRuntime { bool running; int8_t activeSequenceIndex; uint8_t currentStep; uint16_t currentRepeat; uint32_t lastStepTime; }; // WiFi 설정 struct WiFiSettings { char ssid[32]; char password[64]; bool staEnable; char staSSID[32]; char staPassword[64]; }; // Serial 설정 struct SerialConfig { uint32_t baudRate; uint8_t dataBits; uint8_t parity; // 0=None, 1=Even, 2=Odd uint8_t stopBits; }; // ============================================================================ // 전역 변수 // ============================================================================ // 하드웨어 객체 MCP2515 mcp2515(CAN_CS_PIN); AsyncWebServer server(80); WebSocketsServer webSocket(81); Preferences preferences; // FreeRTOS 큐 (PSRAM 할당) QueueHandle_t canQueue = nullptr; QueueHandle_t serialQueue = nullptr; // FreeRTOS 태스크 핸들 TaskHandle_t canRxTaskHandle = nullptr; TaskHandle_t canTxTaskHandle = nullptr; TaskHandle_t serialRxTaskHandle = nullptr; TaskHandle_t loggingTaskHandle = nullptr; TaskHandle_t webUpdateTaskHandle = nullptr; // CAN 데이터 CANData recentData[RECENT_MSG_COUNT]; // CAN 통계 uint32_t totalCanRxCount = 0; uint32_t totalCanTxCount = 0; uint32_t canMessagesPerSecond = 0; uint32_t lastCanCountTime = 0; uint32_t lastCanCount = 0; // Serial 통계 uint32_t totalSerialRxCount = 0; uint32_t totalSerialTxCount = 0; // 로깅 상태 volatile bool canLoggingEnabled = false; volatile bool serialLoggingEnabled = false; File canLogFile; File serialLogFile; char currentCanFileName64 = {0}; char currentSerialFileName[64] = {0}; uint32_t currentCanFileSize = 0; uint32_t currentSerialFileSize = 0; String canLogFormat = "bin"; // "bin" or "csv" String serialLogFormat = "csv"; // "csv" or "bin" // SD 카드 상태 bool sdCardReady = false; // 시간 동기화 bool timeSynced = false; uint32_t rtcSyncCount = 0; struct tm timeinfo; // 전압 모니터링 float currentVoltage = 0.0; float minVoltage1s = 99.0; bool lowVoltageDetected = false; uint32_t lastVoltageResetTime = 0; // CAN 시퀀스 CANSequence sequences[MAX_SEQUENCES]; uint8_t sequenceCount = 0; SequenceRuntime seqRuntime = {false, -1, 0, 0, 0}; // WiFi 설정 WiFiSettings wifiSettings; // Serial 설정 SerialConfig serialConfig = {115200, 8, 0, 1}; // MCP2515 모드 uint8_t currentMcpMode = 0; // 0=Normal, 1=Listen-Only, 2=Loopback, 3=TX-Only // WebSocket 클라이언트 수 uint8_t wsClientCount = 0; // ============================================================================ // 함수 선언 // ============================================================================ // 초기화 void initSPIBus(); void initSDCard(); void initMCP2515(); void initSerial2(); void loadSettings(); void saveSettings(); // FreeRTOS 태스크 void canRxTask(void* parameter); void canTxTask(void* parameter); void serialRxTask(void* parameter); void loggingTask(void* parameter); void webUpdateTask(void* parameter); // CAN 기능 void setCANSpeed(uint8_t speed); void setMCPMode(uint8_t mode); bool sendCANMessage(uint32_t id, bool ext, uint8_t dlc, const uint8_t* data); // 로깅 void startCANLogging(const String& format); void stopCANLogging(); void startSerialLogging(const String& format); void stopSerialLogging(); void writeCANMessageToFile(const CANMessage& msg); void writeSerialMessageToFile(const SerialMessage& msg); // 시퀀스 void loadSequences(); void saveSequences(); void startSequence(uint8_t index); void stopSequence(); void processSequence(); // 시간 void syncTimeFromPhone(int year, int month, int day, int hour, int minute, int second); String getTimestamp(); // 전압 void updateVoltage(); // WebSocket void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); void sendWebUpdate(); void handleWebSocketCommand(uint8_t num, const char* payload); // 파일 관리 void sendFileList(uint8_t clientNum); void deleteFile(const String& filename, uint8_t clientNum); void addFileComment(const String& filename, const String& comment); String getFileComment(const String& filename); // 유틸리티 String formatBytes(uint32_t bytes); void printSystemInfo(); // ============================================================================ // setup() // ============================================================================ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n\n"); Serial.println("============================================================================"); Serial.println(" Byun CAN Logger v2.5 PSRAM Edition - Final Release"); Serial.println("============================================================================"); // PSRAM 확인 if (psramFound()) { Serial.printf("✓ PSRAM detected: %d KB\n", ESP.getPsramSize() / 1024); Serial.printf(" Free PSRAM: %d KB\n", ESP.getFreePsram() / 1024); } else { Serial.println("✗ WARNING: PSRAM not found!"); } // 설정 로드 preferences.begin("can-logger", false); loadSettings(); // SPI 초기화 initSPIBus(); // SD 카드 초기화 initSDCard(); // MCP2515 초기화 initMCP2515(); // Serial2 초기화 initSerial2(); // 시퀀스 로드 loadSequences(); // PSRAM 기반 큐 생성 Serial.println("\n[Queue Creation]"); canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); if (canQueue == nullptr) { Serial.println("✗ Failed to create CAN queue!"); } else { Serial.printf("✓ CAN Queue created: %d slots\n", CAN_QUEUE_SIZE); } serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage)); if (serialQueue == nullptr) { Serial.println("✗ Failed to create Serial queue!"); } else { Serial.printf("✓ Serial Queue created: %d slots\n", SERIAL_QUEUE_SIZE); } // FreeRTOS 태스크 생성 Serial.println("\n[Task Creation]"); xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 5, &canRxTaskHandle, 0); Serial.println("✓ CAN RX Task created (Core 0)"); xTaskCreatePinnedToCore(canTxTask, "CAN_TX", 4096, NULL, 4, &canTxTaskHandle, 0); Serial.println("✓ CAN TX Task created (Core 0)"); xTaskCreatePinnedToCore(serialRxTask, "Serial_RX", 4096, NULL, 3, &serialRxTaskHandle, 1); Serial.println("✓ Serial RX Task created (Core 1)"); xTaskCreatePinnedToCore(loggingTask, "Logging", 8192, NULL, 2, &loggingTaskHandle, 1); Serial.println("✓ Logging Task created (Core 1)"); xTaskCreatePinnedToCore(webUpdateTask, "Web_Update", 8192, NULL, 1, &webUpdateTaskHandle, 1); Serial.println("✓ Web Update Task created (Core 1)"); // WiFi 시작 Serial.println("\n[WiFi Configuration]"); // AP 모드 시작 WiFi.mode(WIFI_AP_STA); // 항상 APSTA 모드로 시작 WiFi.softAP(wifiSettings.ssid, wifiSettings.password); Serial.printf("✓ AP Mode started\n"); Serial.printf(" SSID: %s\n", wifiSettings.ssid); Serial.printf(" IP: %s\n", WiFi.softAPIP().toString().c_str()); // Station 모드 (옵션) if (wifiSettings.staEnable && strlen(wifiSettings.staSSID) > 0) { Serial.printf(" Connecting to WiFi: %s\n", wifiSettings.staSSID); WiFi.begin(wifiSettings.staSSID, wifiSettings.staPassword); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 20) { delay(500); Serial.print("."); attempts++; } if (WiFi.status() == WL_CONNECTED) { Serial.printf("\n✓ Station connected\n"); Serial.printf(" IP: %s\n", WiFi.localIP().toString().c_str()); // NTP 시간 동기화 configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov"); if (getLocalTime(&timeinfo)) { timeSynced = true; Serial.println("✓ NTP time synchronized"); } } else { Serial.println("\n✗ Station connection failed (AP mode still active)"); } } // 웹 서버 라우트 설정 Serial.println("\n[Web Server Setup]"); server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", index_html); }); server.on("/graph", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", graph_html); }); server.on("/graph-view", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", graph_viewer_html); }); server.on("/transmit", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", transmit_html); }); server.on("/serial", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", serial_terminal_html); }); server.on("/settings", HTTP_GET, [](AsyncWebServerRequest* request) { request->send_P(200, "text/html", settings_html); }); // 파일 다운로드 server.on("/download", HTTP_GET, [](AsyncWebServerRequest* request) { if (request->hasParam("file")) { String filename = "/" + request->getParam("file")->value(); if (SD.exists(filename)) { request->send(SD, filename, "application/octet-stream"); } else { request->send(404, "text/plain", "File not found"); } } else { request->send(400, "text/plain", "Missing file parameter"); } }); server.begin(); Serial.println("✓ HTTP Server started on port 80"); // WebSocket 시작 webSocket.begin(); webSocket.onEvent(webSocketEvent); Serial.println("✓ WebSocket Server started on port 81"); // 시스템 정보 출력 printSystemInfo(); Serial.println("\n============================================================================"); Serial.println(" System Ready!"); Serial.println(" Connect to WiFi and access: http://192.168.4.1"); Serial.println("============================================================================\n"); } // ============================================================================ // loop() // ============================================================================ void loop() { webSocket.loop(); // 전압 업데이트 (1초마다) static uint32_t lastVoltageUpdate = 0; if (millis() - lastVoltageUpdate > 1000) { updateVoltage(); lastVoltageUpdate = millis(); } // 시퀀스 처리 if (seqRuntime.running) { processSequence(); } // CAN 메시지 속도 계산 (1초마다) if (millis() - lastCanCountTime >= 1000) { canMessagesPerSecond = totalCanRxCount - lastCanCount; lastCanCount = totalCanRxCount; lastCanCountTime = millis(); // 1초마다 최소 전압 리셋 if (millis() - lastVoltageResetTime >= 1000) { minVoltage1s = currentVoltage; lastVoltageResetTime = millis(); } } delay(10); } // ============================================================================ // 초기화 함수들 // ============================================================================ void initSPIBus() { Serial.println("\n[SPI Bus Initialization]"); SPI.begin(); Serial.println("✓ SPI Bus initialized"); } void initSDCard() { Serial.println("\n[SD Card Initialization]"); if (!SD.begin(SD_CS_PIN)) { Serial.println("✗ SD Card initialization failed!"); sdCardReady = false; return; } uint64_t cardSize = SD.cardSize() / (1024 * 1024); Serial.printf("✓ SD Card detected: %llu MB\n", cardSize); uint64_t usedBytes = SD.usedBytes() / (1024 * 1024); uint64_t totalBytes = SD.totalBytes() / (1024 * 1024); Serial.printf(" Used: %llu MB / %llu MB\n", usedBytes, totalBytes); sdCardReady = true; } void initMCP2515() { Serial.println("\n[MCP2515 Initialization]"); mcp2515.reset(); mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); mcp2515.setNormalMode(); Serial.println("✓ MCP2515 initialized"); Serial.println(" Speed: 1 Mbps"); Serial.println(" Mode: Normal"); currentMcpMode = 0; } void initSerial2() { Serial.println("\n[Serial2 Initialization]"); Serial2.begin(serialConfig.baudRate, SERIAL_8N1, SERIAL2_RX, SERIAL2_TX); Serial.printf("✓ Serial2 initialized\n"); Serial.printf(" Baud: %d\n", serialConfig.baudRate); Serial.printf(" Config: 8N1\n"); Serial.printf(" RX: GPIO%d, TX: GPIO%d\n", SERIAL2_RX, SERIAL2_TX); } void loadSettings() { Serial.println("\n[Loading Settings]"); // WiFi 설정 preferences.getString("ssid", wifiSettings.ssid, sizeof(wifiSettings.ssid)); preferences.getString("password", wifiSettings.password, sizeof(wifiSettings.password)); wifiSettings.staEnable = preferences.getBool("staEnable", false); preferences.getString("staSSID", wifiSettings.staSSID, sizeof(wifiSettings.staSSID)); preferences.getString("staPassword", wifiSettings.staPassword, sizeof(wifiSettings.staPassword)); // 기본값 설정 if (strlen(wifiSettings.ssid) == 0) { strcpy(wifiSettings.ssid, "Byun_CAN_Logger"); strcpy(wifiSettings.password, "12345678"); } Serial.printf("✓ WiFi AP: %s\n", wifiSettings.ssid); if (wifiSettings.staEnable) { Serial.printf("✓ WiFi STA enabled: %s\n", wifiSettings.staSSID); } // Serial 설정 serialConfig.baudRate = preferences.getUInt("serialBaud", 115200); serialConfig.dataBits = preferences.getUChar("serialData", 8); serialConfig.parity = preferences.getUChar("serialParity", 0); serialConfig.stopBits = preferences.getUChar("serialStop", 1); Serial.printf("✓ Serial: %d 8N1\n", serialConfig.baudRate); } void saveSettings() { Serial.println("\n[Saving Settings]"); preferences.putString("ssid", wifiSettings.ssid); preferences.putString("password", wifiSettings.password); preferences.putBool("staEnable", wifiSettings.staEnable); preferences.putString("staSSID", wifiSettings.staSSID); preferences.putString("staPassword", wifiSettings.staPassword); preferences.putUInt("serialBaud", serialConfig.baudRate); preferences.putUChar("serialData", serialConfig.dataBits); preferences.putUChar("serialParity", serialConfig.parity); preferences.putUChar("serialStop", serialConfig.stopBits); Serial.println("✓ Settings saved to NVS"); } // ============================================================================ // FreeRTOS 태스크들 // ============================================================================ void canRxTask(void* parameter) { struct can_frame frame; CANMessage msg; Serial.println("[CAN RX Task] Started"); while (true) { if (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { // CAN 메시지를 큐에 추가 msg.id = frame.can_id & 0x1FFFFFFF; // Extended ID 비트 제거 msg.extended = (frame.can_id & CAN_EFF_FLAG) ? true : false; msg.dlc = frame.can_dlc; memcpy(msg.data, frame.data, 8); msg.timestamp_ms = millis(); if (xQueueSend(canQueue, &msg, 0) != pdTRUE) { // 큐가 가득 찬 경우 (드롭) } // 최근 메시지 업데이트 bool found = false; for (int i = 0; i < RECENT_MSG_COUNT; i++) { if (recentData[i].msg.can_id == frame.can_id) { memcpy(&recentData[i].msg, &frame, sizeof(struct can_frame)); recentData[i].count++; recentData[i].lastUpdate = millis(); found = true; break; } } if (!found) { // 빈 슬롯 찾기 for (int i = 0; i < RECENT_MSG_COUNT; i++) { if (recentData[i].count == 0) { memcpy(&recentData[i].msg, &frame, sizeof(struct can_frame)); recentData[i].count = 1; recentData[i].lastUpdate = millis(); break; } } } totalCanRxCount++; } vTaskDelay(1 / portTICK_PERIOD_MS); // 1ms 대기 } } void canTxTask(void* parameter) { Serial.println("[CAN TX Task] Started"); while (true) { // 현재는 시퀀스에서만 TX 사용 vTaskDelay(10 / portTICK_PERIOD_MS); } } void serialRxTask(void* parameter) { static char lineBuffer[MAX_SERIAL_LINE_LEN]; static uint16_t bufferIndex = 0; SerialMessage msg; Serial.println("[Serial RX Task] Started"); while (true) { while (Serial2.available()) { char c = Serial2.read(); if (c == '\n' || c == '\r') { if (bufferIndex > 0) { // 라인 완성 msg.timestamp_us = esp_timer_get_time(); msg.isTx = false; msg.length = bufferIndex; memcpy(msg.data, lineBuffer, bufferIndex); msg.data[bufferIndex] = '\0'; xQueueSend(serialQueue, &msg, 0); totalSerialRxCount++; bufferIndex = 0; } } else { if (bufferIndex < MAX_SERIAL_LINE_LEN - 1) { lineBuffer[bufferIndex++] = c; } } } vTaskDelay(10 / portTICK_PERIOD_MS); } } void loggingTask(void* parameter) { CANMessage canMsg; SerialMessage serialMsg; Serial.println("[Logging Task] Started"); while (true) { // CAN 로깅 if (canLoggingEnabled && canLogFile) { while (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) { writeCANMessageToFile(canMsg); } } // Serial 로깅 if (serialLoggingEnabled && serialLogFile) { while (xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { writeSerialMessageToFile(serialMsg); } } vTaskDelay(100 / portTICK_PERIOD_MS); } } void webUpdateTask(void* parameter) { Serial.println("[Web Update Task] Started"); while (true) { if (wsClientCount > 0) { sendWebUpdate(); } vTaskDelay(WEB_UPDATE_INTERVAL / portTICK_PERIOD_MS); } } // ============================================================================ // CAN 기능 // ============================================================================ void setCANSpeed(uint8_t speed) { mcp2515.reset(); switch (speed) { case 0: mcp2515.setBitrate(CAN_125KBPS, MCP_8MHZ); break; case 1: mcp2515.setBitrate(CAN_250KBPS, MCP_8MHZ); break; case 2: mcp2515.setBitrate(CAN_500KBPS, MCP_8MHZ); break; case 3: mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); break; default: mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); break; } // 모드 복원 setMCPMode(currentMcpMode); Serial.printf("CAN speed changed to: %d\n", speed); } void setMCPMode(uint8_t mode) { currentMcpMode = mode; switch (mode) { case 0: mcp2515.setNormalMode(); break; case 1: mcp2515.setListenOnlyMode(); break; case 2: mcp2515.setLoopbackMode(); break; case 3: mcp2515.setNormalMode(); break; // TX-Only는 Normal 사용 default: mcp2515.setNormalMode(); break; } Serial.printf("MCP2515 mode changed to: %d\n", mode); } bool sendCANMessage(uint32_t id, bool ext, uint8_t dlc, const uint8_t* data) { struct can_frame frame; frame.can_id = id; if (ext) { frame.can_id |= CAN_EFF_FLAG; } frame.can_dlc = dlc; memcpy(frame.data, data, dlc); if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { totalCanTxCount++; return true; } return false; } // ============================================================================ // 로깅 기능 // ============================================================================ void startCANLogging(const String& format) { if (canLoggingEnabled) { stopCANLogging(); } if (!sdCardReady) { Serial.println("Cannot start CAN logging: SD card not ready"); return; } canLogFormat = format; // 파일명 생성 String timestamp = getTimestamp(); String extension = (format == "csv") ? ".csv" : ".bin"; snprintf(currentCanFileName, sizeof(currentCanFileName), "/CAN_%s%s", timestamp.c_str(), extension.c_str()); canLogFile = SD.open(currentCanFileName, FILE_WRITE); if (!canLogFile) { Serial.println("Failed to create CAN log file"); return; } // CSV 헤더 if (format == "csv") { canLogFile.println("Timestamp_ms,ID,Extended,DLC,Data"); } canLoggingEnabled = true; currentCanFileSize = 0; Serial.printf("CAN logging started: %s (format: %s)\n", currentCanFileName, format.c_str()); } void stopCANLogging() { if (!canLoggingEnabled) return; canLoggingEnabled = false; if (canLogFile) { canLogFile.flush(); canLogFile.close(); } Serial.printf("CAN logging stopped: %s (%d bytes)\n", currentCanFileName, currentCanFileSize); currentCanFileName[0] = '\0'; currentCanFileSize = 0; } void startSerialLogging(const String& format) { if (serialLoggingEnabled) { stopSerialLogging(); } if (!sdCardReady) { Serial.println("Cannot start Serial logging: SD card not ready"); return; } serialLogFormat = format; String timestamp = getTimestamp(); String extension = (format == "csv") ? ".csv" : ".bin"; snprintf(currentSerialFileName, sizeof(currentSerialFileName), "/SER_%s%s", timestamp.c_str(), extension.c_str()); serialLogFile = SD.open(currentSerialFileName, FILE_WRITE); if (!serialLogFile) { Serial.println("Failed to create Serial log file"); return; } if (format == "csv") { serialLogFile.println("Timestamp_us,Direction,Data"); } serialLoggingEnabled = true; currentSerialFileSize = 0; Serial.printf("Serial logging started: %s (format: %s)\n", currentSerialFileName, format.c_str()); } void stopSerialLogging() { if (!serialLoggingEnabled) return; serialLoggingEnabled = false; if (serialLogFile) { serialLogFile.flush(); serialLogFile.close(); } Serial.printf("Serial logging stopped: %s (%d bytes)\n", currentSerialFileName, currentSerialFileSize); currentSerialFileName[0] = '\0'; currentSerialFileSize = 0; } void writeCANMessageToFile(const CANMessage& msg) { if (!canLogFile) return; if (canLogFormat == "csv") { // CSV 형식 char line[128]; char dataStr[32]; for (int i = 0; i < msg.dlc; i++) { sprintf(dataStr + i * 3, "%02X ", msg.data[i]); } sprintf(line, "%u,0x%X,%d,%d,%s\n", msg.timestamp_ms, msg.id, msg.extended ? 1 : 0, msg.dlc, dataStr); canLogFile.print(line); currentCanFileSize += strlen(line); } else { // Binary 형식 canLogFile.write((uint8_t*)&msg, sizeof(CANMessage)); currentCanFileSize += sizeof(CANMessage); } // 주기적으로 flush (100개마다) static uint32_t writeCount = 0; if (++writeCount % 100 == 0) { canLogFile.flush(); } } void writeSerialMessageToFile(const SerialMessage& msg) { if (!serialLogFile) return; if (serialLogFormat == "csv") { char line[MAX_SERIAL_LINE_LEN + 64]; sprintf(line, "%llu,%s,%s\n", msg.timestamp_us, msg.isTx ? "TX" : "RX", msg.data); serialLogFile.print(line); currentSerialFileSize += strlen(line); } else { serialLogFile.write((uint8_t*)&msg, sizeof(SerialMessage)); currentSerialFileSize += sizeof(SerialMessage); } static uint32_t writeCount = 0; if (++writeCount % 50 == 0) { serialLogFile.flush(); } } // ============================================================================ // 시퀀스 기능 // ============================================================================ void loadSequences() { // Preferences에서 시퀀스 로드 (간단한 구현) sequenceCount = preferences.getUChar("seqCount", 0); Serial.printf("Loaded %d sequences from NVS\n", sequenceCount); } void saveSequences() { preferences.putUChar("seqCount", sequenceCount); Serial.printf("Saved %d sequences to NVS\n", sequenceCount); } void startSequence(uint8_t index) { if (index >= sequenceCount) return; seqRuntime.running = true; seqRuntime.activeSequenceIndex = index; seqRuntime.currentStep = 0; seqRuntime.currentRepeat = 0; seqRuntime.lastStepTime = millis(); Serial.printf("Sequence started: %s\n", sequences[index].name); } void stopSequence() { if (!seqRuntime.running) return; Serial.printf("Sequence stopped: %s\n", sequences[seqRuntime.activeSequenceIndex].name); seqRuntime.running = false; seqRuntime.activeSequenceIndex = -1; } void processSequence() { if (!seqRuntime.running || seqRuntime.activeSequenceIndex < 0) return; CANSequence& seq = sequences[seqRuntime.activeSequenceIndex]; if (seqRuntime.currentStep >= seq.stepCount) { // 반복 처리 seqRuntime.currentRepeat++; if (seq.repeatMode == 0) { // Once stopSequence(); return; } else if (seq.repeatMode == 1) { // Count if (seqRuntime.currentRepeat >= seq.repeatCount) { stopSequence(); return; } } // Infinite: 계속 실행 seqRuntime.currentStep = 0; } SequenceStep& step = seq.steps[seqRuntime.currentStep]; if (millis() - seqRuntime.lastStepTime >= step.delayMs) { // CAN 메시지 전송 sendCANMessage(step.canId, step.extended, step.dlc, step.data); seqRuntime.currentStep++; seqRuntime.lastStepTime = millis(); } } // ============================================================================ // 시간 함수 // ============================================================================ void syncTimeFromPhone(int year, int month, int day, int hour, int minute, int second) { 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 now = { .tv_sec = t }; settimeofday(&now, NULL); timeSynced = true; rtcSyncCount++; Serial.printf("Time synced from phone: %04d-%02d-%02d %02d:%02d:%02d\n", year, month, day, hour, minute, second); } String getTimestamp() { if (timeSynced) { time_t now; time(&now); struct tm* tm_now = localtime(&now); char buf[32]; sprintf(buf, "%04d%02d%02d_%02d%02d%02d", tm_now->tm_year + 1900, tm_now->tm_mon + 1, tm_now->tm_mday, tm_now->tm_hour, tm_now->tm_min, tm_now->tm_sec); return String(buf); } else { char buf[32]; sprintf(buf, "%010lu", millis()); return String(buf); } } // ============================================================================ // 전압 모니터링 // ============================================================================ void updateVoltage() { float sum = 0; for (int i = 0; i < VOLTAGE_SAMPLES; i++) { int rawValue = analogRead(VOLTAGE_PIN); float voltage = (rawValue / 4095.0) * 3.3 * (12.0 / 3.3); // 분압비 가정 sum += voltage; delay(1); } currentVoltage = sum / VOLTAGE_SAMPLES; if (currentVoltage < minVoltage1s) { minVoltage1s = currentVoltage; } lowVoltageDetected = (currentVoltage < LOW_VOLTAGE_THRESHOLD); } // ============================================================================ // WebSocket 함수 // ============================================================================ void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { switch (type) { case WStype_DISCONNECTED: Serial.printf("[WS] Client #%u disconnected\n", num); if (wsClientCount > 0) wsClientCount--; break; case WStype_CONNECTED: { IPAddress ip = webSocket.remoteIP(num); Serial.printf("[WS] Client #%u connected from %s\n", num, ip.toString().c_str()); wsClientCount++; } break; case WStype_TEXT: Serial.printf("[WS] Client #%u: %s\n", num, payload); handleWebSocketCommand(num, (char*)payload); break; default: break; } } void handleWebSocketCommand(uint8_t num, const char* payload) { DynamicJsonDocument doc(2048); DeserializationError error = deserializeJson(doc, payload); if (error) { Serial.printf("JSON parse error: %s\n", error.c_str()); return; } const char* cmd = doc["cmd"]; if (strcmp(cmd, "setSpeed") == 0) { uint8_t speed = doc["speed"]; setCANSpeed(speed); } else if (strcmp(cmd, "setMcpMode") == 0) { uint8_t mode = doc["mode"]; setMCPMode(mode); } else if (strcmp(cmd, "startLogging") == 0) { String format = doc["format"] | "bin"; startCANLogging(format); } else if (strcmp(cmd, "stopLogging") == 0) { stopCANLogging(); } else if (strcmp(cmd, "startSerialLogging") == 0) { String format = doc["format"] | "csv"; startSerialLogging(format); } else if (strcmp(cmd, "stopSerialLogging") == 0) { stopSerialLogging(); } else if (strcmp(cmd, "getFiles") == 0) { sendFileList(num); } else if (strcmp(cmd, "deleteFile") == 0) { String filename = "/" + String((const char*)doc["filename"]); deleteFile(filename, num); } else if (strcmp(cmd, "syncTimeFromPhone") == 0) { int year = doc["year"]; int month = doc["month"]; int day = doc["day"]; int hour = doc["hour"]; int minute = doc["minute"]; int second = doc["second"]; syncTimeFromPhone(year, month, day, hour, minute, second); } else if (strcmp(cmd, "getSettings") == 0) { DynamicJsonDocument response(1024); response["type"] = "settings"; response["ssid"] = wifiSettings.ssid; response["password"] = wifiSettings.password; response["staEnable"] = wifiSettings.staEnable; response["staSSID"] = wifiSettings.staSSID; response["staPassword"] = wifiSettings.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) { strcpy(wifiSettings.ssid, doc["ssid"]); strcpy(wifiSettings.password, doc["password"]); wifiSettings.staEnable = doc["staEnable"]; strcpy(wifiSettings.staSSID, doc["staSSID"]); strcpy(wifiSettings.staPassword, doc["staPassword"]); saveSettings(); DynamicJsonDocument response(256); response["type"] = "settingsSaved"; response["success"] = true; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else if (strcmp(cmd, "reboot") == 0) { Serial.println("Rebooting by user request..."); delay(500); ESP.restart(); } else if (strcmp(cmd, "getSequences") == 0) { DynamicJsonDocument response(4096); response["type"] = "sequences"; JsonArray list = response.createNestedArray("list"); for (int i = 0; i < sequenceCount; i++) { JsonObject seq = list.createNestedObject(); seq["name"] = sequences[i].name; seq["index"] = i; seq["mode"] = sequences[i].repeatMode; seq["count"] = sequences[i].repeatCount; seq["stepCount"] = sequences[i].stepCount; seq["running"] = (seqRuntime.running && seqRuntime.activeSequenceIndex == i); } String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else if (strcmp(cmd, "startSequence") == 0) { uint8_t index = doc["index"]; startSequence(index); } else if (strcmp(cmd, "stopSequence") == 0) { stopSequence(); } else if (strcmp(cmd, "addSequence") == 0) { if (sequenceCount < MAX_SEQUENCES) { CANSequence& seq = sequences[sequenceCount]; strcpy(seq.name, doc["name"]); seq.repeatMode = doc["repeatMode"]; seq.repeatCount = doc["repeatCount"]; JsonArray steps = doc["steps"]; seq.stepCount = 0; for (JsonObject step : steps) { if (seq.stepCount >= SEQUENCE_MAX_STEPS) break; SequenceStep& s = seq.steps[seq.stepCount]; String idStr = step["id"]; s.canId = strtoul(idStr.c_str(), NULL, 16); s.extended = step["ext"]; s.dlc = step["dlc"]; JsonArray data = step["data"]; for (int i = 0; i < 8; i++) { s.data[i] = data[i]; } s.delayMs = step["delay"]; seq.stepCount++; } sequenceCount++; saveSequences(); DynamicJsonDocument response(256); response["type"] = "sequenceSaved"; response["success"] = true; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } else if (strcmp(cmd, "removeSequence") == 0) { uint8_t index = doc["index"]; if (index < sequenceCount) { // 시퀀스 삭제 (배열 이동) for (int i = index; i < sequenceCount - 1; i++) { sequences[i] = sequences[i + 1]; } sequenceCount--; saveSequences(); DynamicJsonDocument response(256); response["type"] = "sequenceDeleted"; response["success"] = true; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } } void sendWebUpdate() { DynamicJsonDocument doc(8192); doc["type"] = "update"; doc["logging"] = canLoggingEnabled; doc["serialLogging"] = serialLoggingEnabled; doc["sdReady"] = sdCardReady; doc["totalMsg"] = totalCanRxCount; doc["msgPerSec"] = canMessagesPerSecond; doc["totalTx"] = totalCanTxCount; doc["timeSync"] = timeSynced; doc["syncCount"] = rtcSyncCount; doc["mcpMode"] = currentMcpMode; doc["currentFile"] = String(currentCanFileName); doc["fileSize"] = currentCanFileSize; doc["voltage"] = currentVoltage; doc["minVoltage"] = minVoltage1s; doc["lowVoltage"] = lowVoltageDetected; // 큐 상태 doc["queueUsed"] = uxQueueMessagesWaiting(canQueue); doc["queueSize"] = CAN_QUEUE_SIZE; doc["serialQueueUsed"] = uxQueueMessagesWaiting(serialQueue); doc["serialQueueSize"] = SERIAL_QUEUE_SIZE; // PSRAM 상태 doc["psramFree"] = ESP.getFreePsram(); // Serial 통계 doc["totalSerialRx"] = totalSerialRxCount; doc["totalSerialTx"] = totalSerialTxCount; doc["serialFileSize"] = currentSerialFileSize; doc["currentSerialFile"] = String(currentSerialFileName); // 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.can_id & 0x1FFFFFFF; msgObj["dlc"] = recentData[i].msg.can_dlc; msgObj["count"] = recentData[i].count; JsonArray dataArray = msgObj.createNestedArray("data"); for (int j = 0; j < recentData[i].msg.can_dlc; j++) { dataArray.add(recentData[i].msg.data[j]); } } } String json; serializeJson(doc, json); webSocket.broadcastTXT(json); } // ============================================================================ // 파일 관리 // ============================================================================ void sendFileList(uint8_t clientNum) { if (!sdCardReady) return; DynamicJsonDocument doc(8192); doc["type"] = "files"; JsonArray list = doc.createNestedArray("list"); File root = SD.open("/"); File file = root.openNextFile(); while (file) { if (!file.isDirectory()) { String filename = file.name(); // '/' 제거 if (filename.startsWith("/")) { filename = filename.substring(1); } JsonObject fileObj = list.createNestedObject(); fileObj["name"] = filename; fileObj["size"] = file.size(); String comment = getFileComment(filename); if (comment.length() > 0) { fileObj["comment"] = comment; } } file = root.openNextFile(); } String json; serializeJson(doc, json); webSocket.sendTXT(clientNum, json); } void deleteFile(const String& filename, uint8_t clientNum) { bool success = false; String message = "Unknown error"; if (sdCardReady) { if (SD.exists(filename)) { if (SD.remove(filename)) { success = true; message = "File deleted"; Serial.printf("File deleted: %s\n", filename.c_str()); } else { message = "Failed to delete file"; } } else { message = "File not found"; } } else { message = "SD card not ready"; } DynamicJsonDocument response(256); response["type"] = "deleteResult"; response["success"] = success; response["message"] = message; String json; serializeJson(response, json); webSocket.sendTXT(clientNum, json); } void addFileComment(const String& filename, const String& comment) { String key = "cmt_" + filename; key.replace("/", "_"); key.replace(".", "_"); preferences.putString(key.c_str(), comment); Serial.printf("Comment added: %s = %s\n", filename.c_str(), comment.c_str()); } String getFileComment(const String& filename) { String key = "cmt_" + filename; key.replace("/", "_"); key.replace(".", "_"); return preferences.getString(key.c_str(), ""); } // ============================================================================ // 유틸리티 // ============================================================================ String formatBytes(uint32_t bytes) { if (bytes < 1024) return String(bytes) + " B"; if (bytes < 1024 * 1024) return String(bytes / 1024.0, 2) + " KB"; return String(bytes / 1024.0 / 1024.0, 2) + " MB"; } void printSystemInfo() { Serial.println("\n[System Information]"); Serial.printf("Chip Model: %s\n", ESP.getChipModel()); Serial.printf("Chip Revision: %d\n", ESP.getChipRevision()); Serial.printf("CPU Cores: %d\n", ESP.getChipCores()); Serial.printf("CPU Frequency: %d MHz\n", ESP.getCpuFreqMHz()); Serial.printf("Flash Size: %d MB\n", ESP.getFlashChipSize() / (1024 * 1024)); Serial.printf("Free Heap: %d KB\n", ESP.getFreeHeap() / 1024); if (psramFound()) { Serial.printf("PSRAM Size: %d KB\n", ESP.getPsramSize() / 1024); Serial.printf("Free PSRAM: %d KB\n", ESP.getFreePsram() / 1024); } Serial.printf("SDK Version: %s\n", ESP.getSdkVersion()); }