/* * 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 // ⭐ SDIO 4-bit #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 // ⭐⭐⭐ SDIO 4-bit Pins (ESP32-S3) // CLK: GPIO39, CMD: GPIO38, D0-D3: GPIO40,41,42,21 #define SDIO_CLK 39 #define SDIO_CMD 38 #define SDIO_D0 40 #define SDIO_D1 41 #define SDIO_D2 42 #define SDIO_D3 21 // ⭐ OPI PSRAM 호환 // 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); // ⭐ SDIO 사용으로 제거 // ======================================== // MCP2515 크리스탈 설정 // ======================================== // 8MHz 크리스탈: 500 Kbps까지 안정적 // 16MHz 크리스탈: 1 Mbps 고속 통신 권장 (고부하 대응) // // 사용법: // - 하드웨어에 16MHz 크리스탈 장착 시: MCP_16MHZ // - 하드웨어에 8MHz 크리스탈 장착 시: MCP_8MHZ // //#define MCP_CRYSTAL MCP_16MHZ // ⭐ 16MHz 크리스탈 (1 Mbps 권장) #define MCP_CRYSTAL MCP_8MHZ // 8MHz 크리스탈 (500 Kbps까지) // SPI 속도: 20MHz (MCP2515 최대 속도와 균형) // 10MHz: 안정적이지만 느림 // 20MHz: 권장 (고속 + 안정성) // 25MHz: 불안정할 수 있음 MCP2515 mcp2515(HSPI_CS, 40000000, &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; // ============================================ // 🎯 Auto Trigger 전역 변수 // ============================================ // Auto Trigger 조건 구조체 struct TriggerCondition { uint32_t canId; // CAN ID uint8_t startBit; // 시작 비트 (0-63) uint8_t bitLength; // 비트 길이 (1-64) char op[3]; // 연산자: "==", "!=", ">", "<", ">=", "<=" int64_t value; // 비교 값 bool enabled; // 조건 활성화 }; #define MAX_TRIGGERS 8 TriggerCondition startTriggers[MAX_TRIGGERS]; TriggerCondition stopTriggers[MAX_TRIGGERS]; int startTriggerCount = 0; int stopTriggerCount = 0; String startFormula = ""; // Start 조건 수식 String stopFormula = ""; // Stop 조건 수식 bool autoTriggerEnabled = false; char startLogicOp[4] = "OR"; char stopLogicOp[4] = "OR"; bool autoTriggerActive = false; bool autoTriggerLogCSV = false; // 🆕 Auto Trigger용 CSV 형식 설정 // 🆕 File Format 저장 변수 bool savedCanLogFormatCSV = false; // 저장된 CAN 로그 형식 (BIN=false, CSV=true) 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 추가 uint32_t bufferIndex = 0; // ⭐ uint16_t → uint32_t (오버플로우 방지) uint32_t serialCsvIndex = 0; // ⭐ uint16_t → uint32_t uint32_t serial2CsvIndex = 0; // ⭐ uint16_t → uint32_t // ⭐ 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; 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); void resetMCP2515(); void resetMCP2515() { Serial.println("🔄 MCP2515 리셋 시작..."); // 1. 로깅 중지 (진행 중이면) if (loggingEnabled) { // 버퍼 플러시 및 파일 닫기 } // 2. CAN 큐 비우기 CANMessage tempMsg; while (xQueueReceive(canQueue, &tempMsg, 0) == pdTRUE) { // 큐에서 모든 메시지 제거 } // 3. 하드웨어 리셋 (CS 토글) Serial.println(" 1. 하드웨어 리셋..."); digitalWrite(HSPI_CS, LOW); delayMicroseconds(100); digitalWrite(HSPI_CS, HIGH); delay(100); // 4. 소프트웨어 리셋 (Configuration 모드로 진입) Serial.println(" 2. 소프트웨어 리셋..."); mcp2515.reset(); delay(100); // ✅ Preferences에서 설정 불러오기 preferences.begin("can-logger", true); int speedIndex = preferences.getInt("can_speed", 3); if (speedIndex >= 0 && speedIndex < 4) { currentCanSpeed = canSpeedValues[speedIndex]; } int savedMode = preferences.getInt("mcp_mode", 1); if (savedMode >= 0 && savedMode <= 3) { currentMcpMode = (MCP2515Mode)savedMode; } preferences.end(); Serial.printf(" 3. Speed=%d, Mode=%d\n", (int)currentCanSpeed, (int)currentMcpMode); // 5. Bitrate 설정 (Configuration 모드에서) mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10); // 6. 필터/마스크 설정 (모든 메시지 수신) Serial.println(" 4. 필터 설정 (Standard + Extended ID 모두 수신)..."); mcp2515.setFilterMask(MCP2515::MASK0, true, 0x00000000); // ⭐ false → true (Extended ID 지원) mcp2515.setFilterMask(MCP2515::MASK1, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF0, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF1, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF2, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF3, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF4, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF5, true, 0x00000000); // ⭐ false → true delay(10); // 7. 모드 설정 (마지막에!) Serial.printf(" 5. 모드 설정: %d\n", (int)currentMcpMode); if (currentMcpMode == MCP_MODE_NORMAL) { mcp2515.setNormalMode(); } else if (currentMcpMode == MCP_MODE_LOOPBACK) { mcp2515.setLoopbackMode(); } else { mcp2515.setListenOnlyMode(); } delay(50); // 8. 버퍼 클리어 (모드 전환 후) Serial.println(" 6. 버퍼 클리어..."); struct can_frame dummyFrame; int clearCount = 0; while (mcp2515.readMessage(&dummyFrame) == MCP2515::ERROR_OK) { clearCount++; if (clearCount > 100) break; } if (clearCount > 0) { Serial.printf(" %d개 메시지 버림\n", clearCount); } // 9. 에러/오버플로우 플래그 클리어 mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); mcp2515.clearTXInterrupts(); mcp2515.clearMERR(); mcp2515.clearERRIF(); delay(10); // 10. 에러 상태 확인 uint8_t errorFlag = mcp2515.getErrorFlags(); uint8_t txErr = mcp2515.errorCountTX(); uint8_t rxErr = mcp2515.errorCountRX(); Serial.printf(" 7. 에러 상태: EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr); if (errorFlag != 0 || txErr > 0 || rxErr > 0) { Serial.println(" ⚠️ 에러 감지됨 - 추가 리셋 시도..."); // 에러가 있으면 완전 리셋 mcp2515.reset(); delay(50); mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10); if (currentMcpMode == MCP_MODE_NORMAL) { mcp2515.setNormalMode(); } else if (currentMcpMode == MCP_MODE_LOOPBACK) { mcp2515.setLoopbackMode(); } else { mcp2515.setListenOnlyMode(); } delay(50); mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); } // 11. 통계 리셋 totalMsgCount = 0; lastMsgCount = 0; msgPerSecond = 0; // 12. 최근 메시지 테이블 클리어 for (int i = 0; i < RECENT_MSG_COUNT; i++) { recentData[i].count = 0; } Serial.println("✅ MCP2515 리셋 완료!"); } // ======================================== // 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", 1); Serial.printf("📥 loadSettings: MCP Mode = %d\n", savedMode); if (savedMode >= 0 && savedMode <= 3) { currentMcpMode = (MCP2515Mode)savedMode; } // 🆕 File Format 불러오기 savedCanLogFormatCSV = preferences.getBool("can_format_csv", false); Serial.printf("📥 loadSettings: CAN Format = %s\n", savedCanLogFormatCSV ? "CSV" : "BIN"); loadSerialSettings(); preferences.end(); } // ============================================ // 🎯 Auto Trigger 함수 // ============================================ // 비트 추출 (Motorola/Big-endian) int64_t extractBits(uint8_t *data, uint8_t startBit, uint8_t bitLength) { if (bitLength == 0 || bitLength > 64 || startBit >= 64) return 0; int64_t result = 0; for (int i = 0; i < bitLength; i++) { uint8_t bitPos = startBit + i; uint8_t byteIdx = bitPos / 8; uint8_t bitIdx = 7 - (bitPos % 8); if (byteIdx < 8) { if (data[byteIdx] & (1 << bitIdx)) { result |= (1LL << (bitLength - 1 - i)); } } } return result; } // 조건 체크 bool checkCondition(TriggerCondition &trigger, uint8_t *data) { if (!trigger.enabled) return false; int64_t extractedValue = extractBits(data, trigger.startBit, trigger.bitLength); if (strcmp(trigger.op, "==") == 0) return extractedValue == trigger.value; else if (strcmp(trigger.op, "!=") == 0) return extractedValue != trigger.value; else if (strcmp(trigger.op, ">") == 0) return extractedValue > trigger.value; else if (strcmp(trigger.op, "<") == 0) return extractedValue < trigger.value; else if (strcmp(trigger.op, ">=") == 0) return extractedValue >= trigger.value; else if (strcmp(trigger.op, "<=") == 0) return extractedValue <= trigger.value; return false; } // Auto Trigger 체크 void checkAutoTriggers(struct can_frame &frame) { if (!autoTriggerEnabled || !sdCardReady) return; // 시작 조건 체크 if (!loggingEnabled && startTriggerCount > 0) { bool result = (strcmp(startLogicOp, "AND") == 0); bool anyMatch = false; for (int i = 0; i < startTriggerCount; i++) { if (startTriggers[i].canId == frame.can_id && startTriggers[i].enabled) { bool match = checkCondition(startTriggers[i], frame.data); anyMatch = true; if (strcmp(startLogicOp, "AND") == 0) { result = result && match; if (!match) break; } else { if (match) { result = true; break; } } } } if (anyMatch && result) { // 🎯 파일 생성 로직 추가 if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { time_t now; struct tm timeinfo; time(&now); localtime_r(&now, &timeinfo); struct timeval tv; gettimeofday(&tv, NULL); canLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; // 🆕 Auto Trigger 전용 형식 설정 적용 canLogFormatCSV = autoTriggerLogCSV; 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_MMC.open(currentFilename, FILE_WRITE); if (logFile) { if (canLogFormatCSV) { logFile.println("Time_us,CAN_ID,DLC,Data"); logFile.flush(); } logFile.close(); // APPEND 모드로 다시 열기 logFile = SD_MMC.open(currentFilename, FILE_APPEND); if (logFile) { loggingEnabled = true; autoTriggerActive = true; bufferIndex = 0; currentFileSize = logFile.size(); Serial.println("🎯 Auto Trigger: 로깅 시작!"); Serial.printf(" 조건: ID 0x%03X 만족\n", frame.can_id); Serial.printf(" 파일: %s\n", currentFilename); Serial.printf(" 형식: %s\n", canLogFormatCSV ? "CSV" : "BIN"); } else { Serial.println("✗ Auto Trigger: APPEND 모드 파일 열기 실패"); } } else { Serial.println("✗ Auto Trigger: 파일 생성 실패"); } xSemaphoreGive(sdMutex); } else { Serial.println("✗ Auto Trigger: sdMutex 획득 실패"); } } } // 중지 조건 체크 if (loggingEnabled && stopTriggerCount > 0) { bool result = (strcmp(stopLogicOp, "AND") == 0); bool anyMatch = false; for (int i = 0; i < stopTriggerCount; i++) { if (stopTriggers[i].canId == frame.can_id && stopTriggers[i].enabled) { bool match = checkCondition(stopTriggers[i], frame.data); anyMatch = true; if (strcmp(stopLogicOp, "AND") == 0) { result = result && match; if (!match) break; } else { if (match) { result = true; break; } } } } if (anyMatch && result) { // 🎯 파일 닫기 로직 추가 if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) { // BIN 형식: 버퍼에 남은 데이터 강제 플러시 if (bufferIndex > 0 && logFile) { size_t written = logFile.write(fileBuffer, bufferIndex); logFile.flush(); Serial.printf("✓ Auto Trigger 최종 플러시: %d bytes\n", written); bufferIndex = 0; } // CSV 형식: 최종 플러시 if (canLogFormatCSV && logFile) { logFile.flush(); } if (logFile) { size_t finalSize = logFile.size(); logFile.close(); Serial.printf("✓ Auto Trigger 파일 닫힘: %s (%lu bytes)\n", currentFilename, finalSize); } loggingEnabled = false; autoTriggerActive = false; currentFilename[0] = '\0'; bufferIndex = 0; Serial.println("🎯 Auto Trigger: 로깅 중지!"); Serial.printf(" 조건: ID 0x%03X 만족\n", frame.can_id); xSemaphoreGive(sdMutex); } } } } // Auto Trigger 설정 저장 void saveAutoTriggerSettings() { preferences.begin("autotrigger", false); preferences.putBool("enabled", autoTriggerEnabled); preferences.putBool("logCSV", autoTriggerLogCSV); // 🆕 로그 형식 저장 Serial.printf("💾 Save: autoTriggerLogCSV = %d\n", autoTriggerLogCSV); preferences.putString("start_logic", startLogicOp); preferences.putString("stop_logic", stopLogicOp); // 디버그: Start Triggers 저장 Serial.printf("💾 Save: startTriggerCount = %d\n", startTriggerCount); for (int i = 0; i < startTriggerCount; i++) { Serial.printf(" [%d] ID=0x%X, Bit=%d, Len=%d, Op=%s, Val=%ld, En=%d\n", i, startTriggers[i].canId, startTriggers[i].startBit, startTriggers[i].bitLength, startTriggers[i].op, startTriggers[i].value, startTriggers[i].enabled); } preferences.putInt("start_count", startTriggerCount); for (int i = 0; i < startTriggerCount; i++) { char key[32]; sprintf(key, "s%d_id", i); preferences.putUInt(key, startTriggers[i].canId); sprintf(key, "s%d_bit", i); preferences.putUChar(key, startTriggers[i].startBit); sprintf(key, "s%d_len", i); preferences.putUChar(key, startTriggers[i].bitLength); sprintf(key, "s%d_op", i); preferences.putString(key, startTriggers[i].op); sprintf(key, "s%d_val", i); preferences.putLong(key, startTriggers[i].value); sprintf(key, "s%d_en", i); preferences.putBool(key, startTriggers[i].enabled); } preferences.putInt("stop_count", stopTriggerCount); for (int i = 0; i < stopTriggerCount; i++) { char key[32]; sprintf(key, "p%d_id", i); preferences.putUInt(key, stopTriggers[i].canId); sprintf(key, "p%d_bit", i); preferences.putUChar(key, stopTriggers[i].startBit); sprintf(key, "p%d_len", i); preferences.putUChar(key, stopTriggers[i].bitLength); sprintf(key, "p%d_op", i); preferences.putString(key, stopTriggers[i].op); sprintf(key, "p%d_val", i); preferences.putLong(key, stopTriggers[i].value); sprintf(key, "p%d_en", i); preferences.putBool(key, stopTriggers[i].enabled); } // Formula 저장 preferences.putString("start_formula", startFormula); preferences.putString("stop_formula", stopFormula); preferences.end(); Serial.println("💾 Auto Trigger 설정 저장 완료"); } // Auto Trigger 설정 로드 void loadAutoTriggerSettings() { preferences.begin("autotrigger", true); autoTriggerEnabled = preferences.getBool("enabled", false); autoTriggerLogCSV = preferences.getBool("logCSV", false); // 🆕 로그 형식 로드 Serial.printf("📥 Load: autoTriggerLogCSV = %d\n", autoTriggerLogCSV); preferences.getString("start_logic", startLogicOp, sizeof(startLogicOp)); preferences.getString("stop_logic", stopLogicOp, sizeof(stopLogicOp)); // Formula 불러오기 startFormula = preferences.getString("start_formula", ""); stopFormula = preferences.getString("stop_formula", ""); if (strlen(startLogicOp) == 0) strcpy(startLogicOp, "OR"); if (strlen(stopLogicOp) == 0) strcpy(stopLogicOp, "OR"); startTriggerCount = preferences.getInt("start_count", 0); if (startTriggerCount > MAX_TRIGGERS) startTriggerCount = MAX_TRIGGERS; for (int i = 0; i < startTriggerCount; i++) { char key[32]; sprintf(key, "s%d_id", i); startTriggers[i].canId = preferences.getUInt(key, 0); sprintf(key, "s%d_bit", i); startTriggers[i].startBit = preferences.getUChar(key, 0); sprintf(key, "s%d_len", i); startTriggers[i].bitLength = preferences.getUChar(key, 8); sprintf(key, "s%d_op", i); preferences.getString(key, startTriggers[i].op, sizeof(startTriggers[i].op)); if (strlen(startTriggers[i].op) == 0) strcpy(startTriggers[i].op, "=="); sprintf(key, "s%d_val", i); startTriggers[i].value = preferences.getLong(key, 0); sprintf(key, "s%d_en", i); startTriggers[i].enabled = preferences.getBool(key, true); } // 디버그: Start Triggers 출력 Serial.printf("📥 Load: startTriggerCount = %d\n", startTriggerCount); for (int i = 0; i < startTriggerCount; i++) { Serial.printf(" [%d] ID=0x%X, Bit=%d, Len=%d, Op=%s, Val=%ld, En=%d\n", i, startTriggers[i].canId, startTriggers[i].startBit, startTriggers[i].bitLength, startTriggers[i].op, startTriggers[i].value, startTriggers[i].enabled); } stopTriggerCount = preferences.getInt("stop_count", 0); if (stopTriggerCount > MAX_TRIGGERS) stopTriggerCount = MAX_TRIGGERS; for (int i = 0; i < stopTriggerCount; i++) { char key[32]; sprintf(key, "p%d_id", i); stopTriggers[i].canId = preferences.getUInt(key, 0); sprintf(key, "p%d_bit", i); stopTriggers[i].startBit = preferences.getUChar(key, 0); sprintf(key, "p%d_len", i); stopTriggers[i].bitLength = preferences.getUChar(key, 8); sprintf(key, "p%d_op", i); preferences.getString(key, stopTriggers[i].op, sizeof(stopTriggers[i].op)); if (strlen(stopTriggers[i].op) == 0) strcpy(stopTriggers[i].op, "=="); sprintf(key, "p%d_val", i); stopTriggers[i].value = preferences.getLong(key, 0); sprintf(key, "p%d_en", i); stopTriggers[i].enabled = preferences.getBool(key, true); } preferences.end(); if (autoTriggerEnabled) { Serial.println("✓ Auto Trigger 설정 로드 완료"); Serial.printf(" 시작 조건: %d개 (%s)\n", startTriggerCount, startLogicOp); Serial.printf(" 중지 조건: %d개 (%s)\n", stopTriggerCount, stopLogicOp); } } 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); Serial.printf("💾 MCP Mode 저장: %d\n", (int)currentMcpMode); // 🆕 File Format 저장 preferences.putBool("can_format_csv", savedCanLogFormatCSV); Serial.printf("💾 CAN Format 저장: %s\n", savedCanLogFormatCSV ? "CSV" : "BIN"); 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) { Serial.printf("🔧 MCP Mode 변경 요청: %d → ", (int)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; uint32_t lastErrorCheck = 0; uint32_t errorRecoveryCount = 0; Serial.println("✓ CAN RX Task 시작 (Core 0, Priority 24 - 절대 최고!)"); // ⭐⭐⭐ 초기 버퍼 확인 if (digitalRead(CAN_INT_PIN) == LOW) { Serial.println("⚠️ 초기 CAN 인터럽트 핀 LOW - 버퍼 클리어"); int readCount = 0; while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK && readCount < 100) { 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++; readCount++; } } Serial.printf("✓ 초기 버퍼에서 %d개 읽음\n", readCount); } while (1) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); // 100ms 타임아웃으로 변경 // 🆕 주기적 에러 체크 (1초마다) uint32_t now = millis(); if (now - lastErrorCheck > 1000) { lastErrorCheck = now; uint8_t errorFlag = mcp2515.getErrorFlags(); uint8_t txErr = mcp2515.errorCountTX(); uint8_t rxErr = mcp2515.errorCountRX(); // 에러 감지 시 복구 시도 if (errorFlag & 0xC0) { // RX0OVR 또는 RX1OVR (오버플로우) Serial.printf("⚠️ CAN 오버플로우 감지! EFLG=0x%02X - 클리어 중...\n", errorFlag); mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); errorRecoveryCount++; } if (errorFlag & 0x20) { // TXBO (Bus-Off) Serial.printf("🚨 CAN Bus-Off 감지! EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr); // Bus-Off 복구: 완전 리셋 필요 Serial.println(" → 자동 복구 시도..."); // 1. 완전 리셋 (Configuration 모드로 진입 + 에러 카운터 자동 리셋) mcp2515.reset(); delay(100); // 2. Bitrate 재설정 mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10); // 3. 모든 에러 플래그 클리어 mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); mcp2515.clearTXInterrupts(); mcp2515.clearMERR(); mcp2515.clearERRIF(); delay(10); // 4. 모드 재설정 if (currentMcpMode == MCP_MODE_NORMAL) { mcp2515.setNormalMode(); } else if (currentMcpMode == MCP_MODE_LOOPBACK) { mcp2515.setLoopbackMode(); } else { mcp2515.setListenOnlyMode(); } delay(50); // 5. 버퍼 클리어 while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {} mcp2515.clearRXnOVRFlags(); errorRecoveryCount++; Serial.printf(" ✓ Bus-Off 복구 완료 (총 %lu회 복구)\n", errorRecoveryCount); } // Error Passive 상태 감지 if ((errorFlag & 0x18) && rxErr > 96) { Serial.printf("⚠️ CAN Error Passive! REC=%d - 주의 필요\n", rxErr); } } // 메시지 배치 읽기 (한 번에 최대 20개 처리) int batchCount = 0; while (batchCount < 20 && mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { struct timeval tv; // 🎯 Auto Trigger 체크 checkAutoTriggers(frame); 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) { // ⭐ 타임아웃 10ms → 0 (블로킹 제거) totalMsgCount++; } batchCount++; } // 배치 처리 후 짧은 yield (다른 Task에 CPU 양보) if (batchCount == 0) { vTaskDelay(pdMS_TO_TICKS(1)); } } } // ======================================== // 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) { // ⭐⭐⭐ 뮤텍스 타임아웃 1ms로 감소 (블로킹 방지) if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1)) == pdTRUE) { if (canLogFormatCSV) { char csvLine[128]; uint64_t relativeTime = canMsg.timestamp_us - canLogStartTime; char dataStr[32]; int dataLen = 0; static uint32_t csvReopenCounter = 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) { size_t written = logFile.write((uint8_t*)csvLine, lineLen); currentFileSize += lineLen; static int csvFlushCounter = 0; if (++csvFlushCounter >= 500) { // ⭐ 200 → 500 (최대 성능) logFile.flush(); csvFlushCounter = 0; } // ⭐⭐⭐ 파일 재오픈 제거 (고부하 환경 최적화) // if (++csvReopenCounter >= 2000) { ... } // 제거됨 } } else { // BIN 형식 static uint32_t binMsgCounter = 0; static uint32_t binReopenCounter = 0; // ⭐⭐⭐ 1단계: 버퍼 가득 찼으면 먼저 플러시 if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) { if (logFile) { size_t written = logFile.write(fileBuffer, bufferIndex); logFile.flush(); Serial.printf("✓ BIN 버퍼 플러시 (FULL): %d bytes\n", written); bufferIndex = 0; } } // ⭐⭐⭐ 2단계: 이제 공간 확보됨, 데이터 추가 memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage)); bufferIndex += sizeof(CANMessage); currentFileSize += sizeof(CANMessage); binMsgCounter++; binReopenCounter++; // ⭐⭐⭐ 3단계: 1000개 메시지마다 주기적 플러시 (500→1000, 최대 성능) if (binMsgCounter % 1000 == 0) { if (logFile && bufferIndex > 0) { size_t written = logFile.write(fileBuffer, bufferIndex); logFile.flush(); Serial.printf("✓ BIN 주기 플러시: %d bytes (메시지: %d)\n", written, binMsgCounter); bufferIndex = 0; } } // ⭐⭐⭐ 4단계: 파일 재오픈 제거 (고부하 환경 최적화) // 재오픈 시 SD 카드 블록으로 인한 오버플로우 방지 // 대신 주기적 flush로 데이터 안정성 확보 // if (binReopenCounter >= 2000) { ... } // 제거됨 } xSemaphoreGive(sdMutex); } } } if (!hasWork) { vTaskDelay(pdMS_TO_TICKS(1)); } } } void sdMonitorTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(500); 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_MMC.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_MMC.exists("/comments.dat")) { File commentFile = SD_MMC.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_MMC.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_MMC.exists("/sequences.dat")) { File seqFile = SD_MMC.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) { // 🆕 WebSocket 연결 시 현재 설정 전송 if (type == WStype_CONNECTED) { Serial.printf("[%u] ✅ WebSocket 연결됨\n", num); // ⭐ 모든 데이터를 하나의 JSON으로 통합 전송 (빠른 연결) DynamicJsonDocument allData(3072); // 통합 JSON allData["type"] = "initialData"; // 기본 설정 int speedIndex = 3; for (int i = 0; i < 4; i++) { if (canSpeedValues[i] == currentCanSpeed) { speedIndex = i; break; } } allData["canSpeed"] = speedIndex; allData["mcpMode"] = (int)currentMcpMode; allData["autoTriggerEnabled"] = autoTriggerEnabled; allData["autoTriggerLogCSV"] = autoTriggerLogCSV; // Auto Trigger 설정도 포함 if (autoTriggerEnabled) { allData["logFormat"] = autoTriggerLogCSV ? "csv" : "bin"; allData["startLogic"] = startLogicOp; allData["stopLogic"] = stopLogicOp; allData["startFormula"] = startFormula; allData["stopFormula"] = stopFormula; JsonArray startArray = allData.createNestedArray("startTriggers"); for (int i = 0; i < startTriggerCount; i++) { JsonObject t = startArray.createNestedObject(); char idStr[10]; sprintf(idStr, "0x%03X", startTriggers[i].canId); t["canId"] = idStr; t["startBit"] = startTriggers[i].startBit; t["bitLength"] = startTriggers[i].bitLength; t["op"] = startTriggers[i].op; t["value"] = (long)startTriggers[i].value; t["enabled"] = startTriggers[i].enabled; } JsonArray stopArray = allData.createNestedArray("stopTriggers"); for (int i = 0; i < stopTriggerCount; i++) { JsonObject t = stopArray.createNestedObject(); char idStr[10]; sprintf(idStr, "0x%03X", stopTriggers[i].canId); t["canId"] = idStr; t["startBit"] = stopTriggers[i].startBit; t["bitLength"] = stopTriggers[i].bitLength; t["op"] = stopTriggers[i].op; t["value"] = (long)stopTriggers[i].value; t["enabled"] = stopTriggers[i].enabled; } } // ⭐ 하나의 메시지로 전송 (delay 제거) String json; serializeJson(allData, json); webSocket.sendTXT(num, json); } else if (type == WStype_TEXT) { DynamicJsonDocument doc(44384); 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_MMC.open(currentFilename, FILE_WRITE); if (logFile) { if (canLogFormatCSV) { logFile.println("Time_us,CAN_ID,DLC,Data"); logFile.flush(); } logFile.close(); // ⭐ 헤더 쓰고 닫기 // ⭐⭐⭐ APPEND 모드로 다시 열기 logFile = SD_MMC.open(currentFilename, FILE_APPEND); if (logFile) { loggingEnabled = true; bufferIndex = 0; currentFileSize = logFile.size(); Serial.printf("✅ 로깅 시작!\n"); Serial.printf(" 파일: %s\n", currentFilename); Serial.printf(" 형식: %s\n", canLogFormatCSV ? "CSV" : "BIN"); Serial.printf(" 초기 크기: %lu bytes\n", currentFileSize); Serial.printf(" sdCardReady: %d\n", sdCardReady); } else { Serial.println("✗ APPEND 모드로 파일 열기 실패"); Serial.printf(" 파일명: %s\n", currentFilename); } } else { Serial.println("✗ 파일 생성 실패"); } xSemaphoreGive(sdMutex); } } } else if (strcmp(cmd, "stopLogging") == 0) { if (loggingEnabled) { Serial.println("🛑 로깅 중지 요청..."); if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) { // ⭐⭐⭐ BIN 형식: 버퍼에 남은 데이터 강제 플러시 if (bufferIndex > 0 && logFile) { size_t written = logFile.write(fileBuffer, bufferIndex); logFile.flush(); Serial.printf("✓ 최종 플러시: %d bytes\n", written); bufferIndex = 0; } // ⭐⭐⭐ CSV 형식: 최종 플러시 if (canLogFormatCSV && logFile) { logFile.flush(); Serial.println("✓ CSV 최종 플러시"); } if (logFile) { size_t finalSize = logFile.size(); logFile.close(); Serial.printf("✓ 파일 닫힘: %s (%lu bytes)\n", currentFilename, finalSize); } loggingEnabled = false; currentFilename[0] = '\0'; bufferIndex = 0; xSemaphoreGive(sdMutex); } else { Serial.println("✗ 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_MMC.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 리셋 제거! (CAN 수신 중단 방지) // 속도 변경은 로깅 중지 후 수동으로만 가능하도록 변경 // mcp2515.reset(); // mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); // setMCP2515Mode(currentMcpMode); saveSettings(); // 사용자에게 안내 메시지 StaticJsonDocument<256> response; response["type"] = "info"; response["message"] = "CAN speed saved. Stop logging and restart to apply."; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } 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_MMC.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 리셋 제거! (CAN 수신 중단 방지) // 속도 변경은 로깅 중지 후 수동으로만 가능하도록 변경 // mcp2515.reset(); // mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); // setMCP2515Mode(currentMcpMode); saveSettings(); // 사용자에게 안내 메시지 StaticJsonDocument<256> response; response["type"] = "info"; response["message"] = "CAN speed saved. Stop logging and restart to apply."; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } 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_MMC.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_MMC.exists(fullPath)) { if (SD_MMC.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); } } // 🎯 Auto Trigger 명령 추가 else if (strcmp(cmd, "setAutoTrigger") == 0) { autoTriggerEnabled = doc["enabled"] | false; // 🆕 로그 형식 설정 const char* logFormat = doc["logFormat"]; if (logFormat) { autoTriggerLogCSV = (strcmp(logFormat, "csv") == 0); } saveAutoTriggerSettings(); DynamicJsonDocument response(256); response["type"] = "autoTriggerSet"; response["enabled"] = autoTriggerEnabled; response["logFormat"] = autoTriggerLogCSV ? "csv" : "bin"; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } // 🆕 Auto Trigger 형식 설정 명령 else if (strcmp(cmd, "setAutoTriggerFormat") == 0) { const char* logFormat = doc["logFormat"]; if (logFormat) { autoTriggerLogCSV = (strcmp(logFormat, "csv") == 0); saveAutoTriggerSettings(); Serial.printf("🎯 Auto Trigger 형식 설정: %s\n", autoTriggerLogCSV ? "CSV" : "BIN"); } DynamicJsonDocument response(128); response["type"] = "autoTriggerFormatSet"; response["logFormat"] = autoTriggerLogCSV ? "csv" : "bin"; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } // 🆕 CAN File Format 저장 명령 (메인 페이지용) else if (strcmp(cmd, "saveCanFormat") == 0) { const char* format = doc["format"]; if (format) { savedCanLogFormatCSV = (strcmp(format, "csv") == 0); saveSettings(); Serial.printf("💾 CAN File Format 저장: %s\n", savedCanLogFormatCSV ? "CSV" : "BIN"); } DynamicJsonDocument response(128); response["type"] = "canFormatSaved"; response["format"] = savedCanLogFormatCSV ? "csv" : "bin"; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else if (strcmp(cmd, "setStartTriggers") == 0) { JsonArray triggers = doc["triggers"]; strcpy(startLogicOp, doc["logic"] | "OR"); startTriggerCount = 0; for (JsonObject t : triggers) { if (startTriggerCount >= MAX_TRIGGERS) break; String idStr = t["canId"] | "0x0"; startTriggers[startTriggerCount].canId = strtoul(idStr.c_str(), NULL, 16); startTriggers[startTriggerCount].startBit = t["startBit"] | 0; startTriggers[startTriggerCount].bitLength = t["bitLength"] | 8; strcpy(startTriggers[startTriggerCount].op, t["op"] | "=="); startTriggers[startTriggerCount].value = t["value"] | 0; startTriggers[startTriggerCount].enabled = t["enabled"] | true; startTriggerCount++; } saveAutoTriggerSettings(); DynamicJsonDocument response(256); response["type"] = "startTriggersSet"; response["count"] = startTriggerCount; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else if (strcmp(cmd, "setStopTriggers") == 0) { JsonArray triggers = doc["triggers"]; strcpy(stopLogicOp, doc["logic"] | "OR"); stopTriggerCount = 0; for (JsonObject t : triggers) { if (stopTriggerCount >= MAX_TRIGGERS) break; String idStr = t["canId"] | "0x0"; stopTriggers[stopTriggerCount].canId = strtoul(idStr.c_str(), NULL, 16); stopTriggers[stopTriggerCount].startBit = t["startBit"] | 0; stopTriggers[stopTriggerCount].bitLength = t["bitLength"] | 8; strcpy(stopTriggers[stopTriggerCount].op, t["op"] | "=="); stopTriggers[stopTriggerCount].value = t["value"] | 0; stopTriggers[stopTriggerCount].enabled = t["enabled"] | true; stopTriggerCount++; } saveAutoTriggerSettings(); DynamicJsonDocument response(256); response["type"] = "stopTriggersSet"; response["count"] = stopTriggerCount; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else if (strcmp(cmd, "setStartFormula") == 0) { const char* formula = doc["formula"]; if (formula) { startFormula = String(formula); saveAutoTriggerSettings(); Serial.println("📐 Start Formula 저장: " + startFormula); } } else if (strcmp(cmd, "setStopFormula") == 0) { const char* formula = doc["formula"]; if (formula) { stopFormula = String(formula); saveAutoTriggerSettings(); Serial.println("📐 Stop Formula 저장: " + stopFormula); } } else if (strcmp(cmd, "getAutoTriggers") == 0) { DynamicJsonDocument response(2048); response["type"] = "autoTriggers"; response["enabled"] = autoTriggerEnabled; response["logFormat"] = autoTriggerLogCSV ? "csv" : "bin"; // 🆕 로그 형식 전송 response["startLogic"] = startLogicOp; response["stopLogic"] = stopLogicOp; response["startFormula"] = startFormula; // Formula 전송 response["stopFormula"] = stopFormula; // Formula 전송 JsonArray startArray = response.createNestedArray("startTriggers"); for (int i = 0; i < startTriggerCount; i++) { JsonObject t = startArray.createNestedObject(); char idStr[10]; sprintf(idStr, "0x%03X", startTriggers[i].canId); t["canId"] = idStr; t["startBit"] = startTriggers[i].startBit; t["bitLength"] = startTriggers[i].bitLength; t["op"] = startTriggers[i].op; t["value"] = (long)startTriggers[i].value; t["enabled"] = startTriggers[i].enabled; } JsonArray stopArray = response.createNestedArray("stopTriggers"); for (int i = 0; i < stopTriggerCount; i++) { JsonObject t = stopArray.createNestedObject(); char idStr[10]; sprintf(idStr, "0x%03X", stopTriggers[i].canId); t["canId"] = idStr; t["startBit"] = stopTriggers[i].startBit; t["bitLength"] = stopTriggers[i].bitLength; t["op"] = stopTriggers[i].op; t["value"] = (long)stopTriggers[i].value; t["enabled"] = stopTriggers[i].enabled; } String json; serializeJson(response, json); webSocket.sendTXT(num, json); } 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, "addSequence") == 0) { // ⭐⭐⭐ Sequence 저장 기능 if (sequenceCount >= MAX_SEQUENCES) { Serial.println("✗ Sequence 저장 실패: 최대 개수 초과"); DynamicJsonDocument response(256); response["type"] = "error"; response["message"] = "Maximum sequences reached"; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else { const char* name = doc["name"]; int repeatMode = doc["repeatMode"] | 0; int repeatCount = doc["repeatCount"] | 1; JsonArray stepsArray = doc["steps"]; if (name && stepsArray.size() > 0) { CANSequence* seq = &sequences[sequenceCount]; // 이름 복사 strncpy(seq->name, name, sizeof(seq->name) - 1); seq->name[sizeof(seq->name) - 1] = '\0'; // Repeat 설정 seq->repeatMode = repeatMode; seq->repeatCount = repeatCount; // Steps 복사 seq->stepCount = 0; for (JsonObject stepObj : stepsArray) { if (seq->stepCount >= 20) break; // 최대 20개 SequenceStep* step = &seq->steps[seq->stepCount]; // ID 파싱 (0x 제거) const char* idStr = stepObj["id"]; if (idStr) { if (strncmp(idStr, "0x", 2) == 0 || strncmp(idStr, "0X", 2) == 0) { step->canId = strtoul(idStr + 2, NULL, 16); } else { step->canId = strtoul(idStr, NULL, 16); } } step->extended = stepObj["ext"] | false; step->dlc = stepObj["dlc"] | 8; // Data 배열 복사 JsonArray dataArray = stepObj["data"]; for (int i = 0; i < 8 && i < dataArray.size(); i++) { step->data[i] = dataArray[i]; } step->delayMs = stepObj["delay"] | 0; seq->stepCount++; } sequenceCount++; // SD 카드에 저장 if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { File seqFile = SD_MMC.open("/sequences.dat", FILE_WRITE); if (seqFile) { seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount)); seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount); seqFile.close(); Serial.printf("✓ Sequence 저장 완료: %s (Steps: %d)\n", name, seq->stepCount); } else { Serial.println("✗ sequences.dat 열기 실패"); } xSemaphoreGive(sdMutex); } // 성공 응답 DynamicJsonDocument response(256); response["type"] = "sequenceSaved"; response["name"] = name; response["steps"] = seq->stepCount; String json; serializeJson(response, json); webSocket.sendTXT(num, json); // Sequence 목록 다시 전송 delay(100); webSocket.sendTXT(num, "{\"cmd\":\"getSequences\"}"); } else { Serial.println("✗ Sequence 저장 실패: 잘못된 데이터"); } } } 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); } else if (strcmp(cmd, "startSequence") == 0) { // ⭐⭐⭐ Sequence 실행 int index = doc["index"] | -1; if (index >= 0 && index < sequenceCount) { seqRuntime.running = true; seqRuntime.activeSequenceIndex = index; seqRuntime.currentStep = 0; seqRuntime.currentRepeat = 0; seqRuntime.lastStepTime = millis(); Serial.printf("✓ Sequence 시작: %s (index: %d)\n", sequences[index].name, index); // 성공 응답 DynamicJsonDocument response(256); response["type"] = "sequenceStarted"; response["index"] = index; response["name"] = sequences[index].name; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } else { Serial.printf("✗ Sequence 시작 실패: 잘못된 index %d\n", index); } } else if (strcmp(cmd, "stopSequence") == 0) { // ⭐⭐⭐ Sequence 중지 if (seqRuntime.running) { Serial.println("✓ Sequence 중지됨"); seqRuntime.running = false; seqRuntime.activeSequenceIndex = -1; // 성공 응답 DynamicJsonDocument response(256); response["type"] = "sequenceStopped"; String json; serializeJson(response, json); webSocket.sendTXT(num, json); } } else if (strcmp(cmd, "removeSequence") == 0) { // ⭐⭐⭐ Sequence 삭제 int index = doc["index"] | -1; if (index >= 0 && index < sequenceCount) { Serial.printf("✓ Sequence 삭제: %s (index: %d)\n", sequences[index].name, index); // 배열에서 제거 (뒤의 항목들을 앞으로 이동) for (int i = index; i < sequenceCount - 1; i++) { memcpy(&sequences[i], &sequences[i + 1], sizeof(CANSequence)); } sequenceCount--; // SD 카드에 저장 if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { File seqFile = SD_MMC.open("/sequences.dat", FILE_WRITE); if (seqFile) { seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount)); seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount); seqFile.close(); Serial.printf("✓ SD 카드 업데이트: %d개 sequence\n", sequenceCount); } xSemaphoreGive(sdMutex); } // 성공 응답 DynamicJsonDocument response(256); response["type"] = "sequenceDeleted"; response["index"] = index; String json; serializeJson(response, json); webSocket.sendTXT(num, json); // Sequence 목록 업데이트 delay(100); webSocket.sendTXT(num, "{\"cmd\":\"getSequences\"}"); } else { Serial.printf("✗ Sequence 삭제 실패: 잘못된 index %d\n", index); } } else if (strcmp(cmd, "getSequenceDetail") == 0) { // ⭐⭐⭐ Sequence 상세 정보 (Edit용) int index = doc["index"] | -1; if (index >= 0 && index < sequenceCount) { DynamicJsonDocument response(4096); response["type"] = "sequenceDetail"; response["index"] = index; response["name"] = sequences[index].name; response["repeatMode"] = sequences[index].repeatMode; response["repeatCount"] = sequences[index].repeatCount; JsonArray stepsArray = response.createNestedArray("steps"); for (int i = 0; i < sequences[index].stepCount; i++) { SequenceStep* step = &sequences[index].steps[i]; JsonObject stepObj = stepsArray.createNestedObject(); char idStr[12]; sprintf(idStr, "0x%X", step->canId); stepObj["id"] = idStr; stepObj["ext"] = step->extended; stepObj["dlc"] = step->dlc; JsonArray dataArray = stepObj.createNestedArray("data"); for (int j = 0; j < 8; j++) { dataArray.add(step->data[j]); } stepObj["delay"] = step->delayMs; } String json; serializeJson(response, json); webSocket.sendTXT(num, json); Serial.printf("✓ Sequence 상세 전송: %s (index: %d)\n", sequences[index].name, index); } else { Serial.printf("✗ Sequence 상세 조회 실패: 잘못된 index %d\n", index); } }else if (strcmp(cmd, "hwReset") == 0) { // ⭐⭐⭐ ESP32 하드웨어 리셋 (재부팅) Serial.println("📨 하드웨어 리셋 요청 수신"); Serial.println("🔄 ESP32 재부팅 중..."); Serial.println(""); Serial.println("========================================"); Serial.println(" ESP32 REBOOTING..."); Serial.println("========================================"); // 응답 전송 DynamicJsonDocument response(256); response["type"] = "hwReset"; response["success"] = true; String json; serializeJson(response, json); webSocket.sendTXT(num, json); // 약간의 지연 후 재부팅 (응답 전송 시간 확보) delay(100); // ESP32 재부팅 ESP.restart(); } } } // ======================================== // Web Update Task // ======================================== void webUpdateTask(void *parameter) { const TickType_t xDelay = pdMS_TO_TICKS(500); // ⭐ 100ms → 500ms (CPU 부하 감소) // 🆕 초기화 대기 단축 (부팅 직후 안정화) vTaskDelay(pdMS_TO_TICKS(500)); // ⭐ 2000ms → 500ms (빠른 연결) while (1) { webSocket.loop(); if (webSocket.connectedClients() > 0) { // 🆕 큐가 초기화되었는지 확인 if (!canQueue || !serialQueue) { vTaskDelay(xDelay); continue; } DynamicJsonDocument doc(16384); // ⭐ 4096 → 8192로 증가 doc["type"] = "update"; doc["logging"] = loggingEnabled; // 🎯 Auto Trigger 상태 doc["autoTriggerEnabled"] = autoTriggerEnabled; doc["autoTriggerActive"] = autoTriggerActive; doc["startTriggerCount"] = startTriggerCount; doc["stopTriggerCount"] = stopTriggerCount; 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; // 🆕 CAN 버스 부하율 계산 // CAN 속도별 이론적 최대 메시지/초 (8바이트 데이터 기준) // 125 Kbps: ~1,000 msg/s // 250 Kbps: ~2,000 msg/s // 500 Kbps: ~4,000 msg/s // 1 Mbps: ~8,000 msg/s uint32_t maxMsgPerSec; switch(currentCanSpeed) { case CAN_125KBPS: maxMsgPerSec = 1000; break; case CAN_250KBPS: maxMsgPerSec = 2000; break; case CAN_500KBPS: maxMsgPerSec = 4000; break; case CAN_1000KBPS: default: maxMsgPerSec = 8000; break; } // 부하율 계산 (0~100%) float busLoad = (msgPerSecond * 100.0) / maxMsgPerSec; if (busLoad > 100.0) busLoad = 100.0; doc["busLoad"] = (int)busLoad; // 정수로 전송 doc["totalTx"] = totalTxCount; doc["totalSerialRx"] = totalSerialRxCount; doc["totalSerialTx"] = totalSerialTxCount; doc["fileSize"] = currentFileSize; doc["serialFileSize"] = currentSerialFileSize; doc["queueUsed"] = canQueue ? uxQueueMessagesWaiting(canQueue) : 0; // 🆕 NULL 체크 doc["queueSize"] = CAN_QUEUE_SIZE; doc["serialQueueUsed"] = serialQueue ? uxQueueMessagesWaiting(serialQueue) : 0; // 🆕 NULL 체크 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; // 🆕 저장된 File Format 전송 doc["savedCanFormat"] = savedCanLogFormatCSV ? "csv" : "bin"; 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); // ⭐ 리셋 원인 확인 (전원 부족 감지) esp_reset_reason_t reset_reason = esp_reset_reason(); if (reset_reason == ESP_RST_BROWNOUT) { Serial.println(""); Serial.println("╔════════════════════════════════════════════╗"); Serial.println("║ 🚨 브라운아웃 리셋 감지! ║"); Serial.println("║ ║"); Serial.println("║ 원인: 전원 공급 부족 ║"); Serial.println("║ ║"); Serial.println("║ 필요 전류: ║"); Serial.println("║ - 평균: 300 mA ║"); Serial.println("║ - 피크: 550 mA (WiFi TX + SD Write) ║"); Serial.println("║ ║"); Serial.println("║ 해결 방법: ║"); Serial.println("║ 1. 5V 1A USB 어댑터 사용 (권장) ║"); Serial.println("║ 2. USB 케이블 교체 (짧고 굵은 케이블) ║"); Serial.println("║ 3. USB 3.0 포트 사용 (900mA 지원) ║"); Serial.println("╚════════════════════════════════════════════╝"); Serial.println(""); delay(5000); // 경고 메시지 읽을 시간 } // ⭐ WiFi 전력 최적화 (전류 소비 감소) WiFi.setSleep(false); WiFi.setTxPower(WIFI_POWER_15dBm); // 19.5dBm → 15dBm (전류 절감) 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(); // 🎯 Auto Trigger 설정 로드 loadAutoTriggerSettings(); 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(); // ⭐ VSPI 제거: SDIO는 별도 SPI 초기화 불필요 Serial.println("✓ SPI 초기화 완료 (HSPI only)"); // Watchdog 비활성화 esp_task_wdt_deinit(); // ======================================== // MCP2515 초기화 (개선된 시퀀스) // ======================================== Serial.println("MCP2515 초기화..."); // 1. CS 핀 초기 상태 설정 pinMode(HSPI_CS, OUTPUT); digitalWrite(HSPI_CS, HIGH); delay(10); // 2. 하드웨어 리셋 (CS 토글) Serial.println(" 1. 하드웨어 리셋..."); digitalWrite(HSPI_CS, LOW); delayMicroseconds(100); digitalWrite(HSPI_CS, HIGH); delay(100); // 리셋 후 충분한 대기 // 3. 소프트웨어 리셋 (Configuration 모드로 진입) Serial.println(" 2. 소프트웨어 리셋..."); mcp2515.reset(); delay(100); // Configuration 모드 안정화 대기 // 4. Configuration 모드에서 설정 (중요: 이 순서대로!) Serial.println(" 3. Bitrate 설정..."); mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10); // 5. 필터/마스크 설정 (모든 메시지 수신) Serial.println(" 4. 필터 설정 (Standard + Extended ID 모두 수신)..."); mcp2515.setFilterMask(MCP2515::MASK0, true, 0x00000000); // ⭐ false → true (Extended ID 지원) mcp2515.setFilterMask(MCP2515::MASK1, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF0, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF1, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF2, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF3, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF4, true, 0x00000000); // ⭐ false → true mcp2515.setFilter(MCP2515::RXF5, true, 0x00000000); // ⭐ false → true delay(10); // 6. 수신 버퍼 비우기 (Configuration 모드에서) Serial.println(" 5. 수신 버퍼 클리어..."); struct can_frame dummyFrame; int clearCount = 0; while (clearCount < 10) { if (mcp2515.readMessage(&dummyFrame) != MCP2515::ERROR_OK) break; clearCount++; } if (clearCount > 0) { Serial.printf(" %d개 메시지 버림\n", clearCount); } // 7. 에러/오버플로우 플래그 클리어 Serial.println(" 6. 에러 플래그 클리어..."); mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); mcp2515.clearTXInterrupts(); mcp2515.clearMERR(); mcp2515.clearERRIF(); delay(10); // 8. 모드 전환 (마지막에!) Serial.printf(" 7. 모드 설정: %d\n", (int)currentMcpMode); if (currentMcpMode == MCP_MODE_NORMAL) { mcp2515.setNormalMode(); Serial.println(" → Normal Mode"); } else if (currentMcpMode == MCP_MODE_LOOPBACK) { mcp2515.setLoopbackMode(); Serial.println(" → Loopback Mode"); } else if (currentMcpMode == MCP_MODE_LISTEN_ONLY) { mcp2515.setListenOnlyMode(); Serial.println(" → Listen-Only Mode"); } else { // TRANSMIT 모드는 Listen-Only로 시작 (TX 시에만 Normal로 전환) mcp2515.setListenOnlyMode(); Serial.println(" → Listen-Only Mode (TX mode)"); } delay(50); // 모드 전환 안정화 // 9. 최종 버퍼 클리어 (모드 전환 후) Serial.println(" 8. 최종 버퍼 클리어..."); clearCount = 0; while (mcp2515.readMessage(&dummyFrame) == MCP2515::ERROR_OK) { clearCount++; if (clearCount > 100) break; } if (clearCount > 0) { Serial.printf(" %d개 메시지 버림\n", clearCount); } mcp2515.clearRXnOVRFlags(); // 10. 에러 상태 확인 uint8_t errorFlag = mcp2515.getErrorFlags(); uint8_t txErr = mcp2515.errorCountTX(); uint8_t rxErr = mcp2515.errorCountRX(); Serial.printf(" 9. 에러 상태: EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr); if (errorFlag != 0 || txErr > 0 || rxErr > 0) { Serial.println(" ⚠️ 에러 감지됨 - 추가 리셋 시도..."); // 에러가 있으면 완전 리셋 재시도 mcp2515.reset(); delay(50); mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10); mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); if (currentMcpMode == MCP_MODE_NORMAL) { mcp2515.setNormalMode(); } else if (currentMcpMode == MCP_MODE_LOOPBACK) { mcp2515.setLoopbackMode(); } else { mcp2515.setListenOnlyMode(); } delay(50); } 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 카드 초기화 (SDIO 4-bit Mode) Serial.println("SD 카드 초기화 (SDIO 4-bit)..."); // setPins() 호출: clk, cmd, d0, d1, d2, d3 if (!SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3)) { Serial.println("✗ SD_MMC.setPins() 실패!"); sdCardReady = false; } else if (SD_MMC.begin("/sdcard", false)) { // false = 4-bit mode sdCardReady = true; Serial.println("✓ SD 카드 초기화 완료 (SDIO 4-bit)"); Serial.printf(" 카드 크기: %llu MB\n", SD_MMC.cardSize() / (1024 * 1024)); Serial.printf(" 핀: CLK=%d, CMD=%d, D0=%d, D1=%d, D2=%d, D3=%d\n", SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3); loadFileComments(); loadSequences(); } else { sdCardReady = false; Serial.println("✗ SD 카드 초기화 실패"); Serial.println(" 확인 사항:"); Serial.println(" - 배선: CLK=39, CMD=38, D0=40, D1=41, D2=42, D3=21"); Serial.println(" - 10kΩ 풀업 저항 확인 (CMD, D0-D3)"); Serial.println(" - SD 카드 포맷 (FAT32)"); Serial.println(" - SDIO 지원 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")) { server.send(400, "text/plain", "Bad request"); return; } String filename = "/" + server.arg("file"); Serial.println("\n========================================"); Serial.println("📥 다운로드 준비"); Serial.println("========================================"); // ⭐ 1단계: 모든 로깅 완전 중지 bool wasLogging = loggingEnabled; if (wasLogging) { loggingEnabled = false; delay(200); Serial.println("⏸ 모든 로깅 중지"); } // ⭐ 2단계: 모든 SD 관련 Task 중단 if (sdWriteTaskHandle != NULL) { vTaskSuspend(sdWriteTaskHandle); delay(100); Serial.println("⏸ SD 쓰기 Task 중단"); } if (webTaskHandle != NULL) { vTaskSuspend(webTaskHandle); delay(50); Serial.println("⏸ 웹 업데이트 Task 중단"); } // ⭐ 3단계: SD Mutex 획득 if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10000)) != pdTRUE) { if (webTaskHandle != NULL) vTaskResume(webTaskHandle); if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; server.send(503, "text/plain", "SD card busy"); Serial.println("✗ SD Mutex 획득 실패"); return; } // ⭐ 4단계: SD 카드 재마운트 (1-bit 모드로) Serial.println("🔄 SD 카드 재마운트 중..."); SD_MMC.end(); delay(200); // 1-bit 모드로 재시작 (더 안정적) if (!SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0)) { Serial.println("✗ SD_MMC.setPins() 실패"); xSemaphoreGive(sdMutex); if (webTaskHandle != NULL) vTaskResume(webTaskHandle); if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; server.send(500, "text/plain", "SD remount failed"); return; } if (!SD_MMC.begin("/sdcard", true)) { // true = 1-bit mode Serial.println("✗ SD 카드 1-bit 재마운트 실패"); xSemaphoreGive(sdMutex); if (webTaskHandle != NULL) vTaskResume(webTaskHandle); if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; server.send(500, "text/plain", "SD 1-bit mode failed"); return; } Serial.println("✓ SD 카드 1-bit 모드 활성화"); delay(100); // 파일 존재 확인 if (!SD_MMC.exists(filename)) { Serial.println("✗ 파일 없음"); // 4-bit 모드로 복구 SD_MMC.end(); delay(100); SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3); SD_MMC.begin("/sdcard", false); xSemaphoreGive(sdMutex); if (webTaskHandle != NULL) vTaskResume(webTaskHandle); if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; server.send(404, "text/plain", "File not found"); return; } File file = SD_MMC.open(filename, FILE_READ); if (!file) { Serial.println("✗ 파일 열기 실패"); // 4-bit 모드로 복구 SD_MMC.end(); delay(100); SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3); SD_MMC.begin("/sdcard", false); xSemaphoreGive(sdMutex); if (webTaskHandle != NULL) vTaskResume(webTaskHandle); if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; server.send(500, "text/plain", "Failed to open file"); return; } size_t fileSize = file.size(); String displayName = server.arg("file"); Serial.printf("📥 다운로드 시작: %s (%u bytes)\n", displayName.c_str(), fileSize); // 헤더 전송 server.setContentLength(fileSize); server.sendHeader("Content-Disposition", "attachment; filename=\"" + displayName + "\""); server.sendHeader("Content-Type", "application/octet-stream"); server.sendHeader("Connection", "close"); server.send(200, "application/octet-stream", ""); // 512바이트 섹터 단위 전송 const size_t CHUNK_SIZE = 512; uint8_t *buffer = (uint8_t*)heap_caps_aligned_alloc(32, CHUNK_SIZE, MALLOC_CAP_DMA); if (!buffer) { buffer = (uint8_t*)malloc(CHUNK_SIZE); } if (!buffer) { Serial.println("✗ 버퍼 할당 실패"); file.close(); // 4-bit 모드로 복구 SD_MMC.end(); delay(100); SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3); SD_MMC.begin("/sdcard", false); xSemaphoreGive(sdMutex); if (webTaskHandle != NULL) vTaskResume(webTaskHandle); if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; return; } size_t totalSent = 0; bool downloadSuccess = true; WiFiClient client = server.client(); unsigned long lastPrint = millis(); while (file.available() && totalSent < fileSize && downloadSuccess) { // SD 읽기 size_t bytesRead = file.read(buffer, CHUNK_SIZE); if (bytesRead == 0) { Serial.printf("✗ 읽기 실패 (위치: %u)\n", totalSent); downloadSuccess = false; break; } // WiFi 전송 if (!client.connected()) { Serial.println("✗ 클라이언트 연결 끊김"); downloadSuccess = false; break; } size_t totalWritten = 0; while (totalWritten < bytesRead && client.connected()) { size_t written = client.write(buffer + totalWritten, bytesRead - totalWritten); totalWritten += written; if (written == 0) delay(5); } totalSent += bytesRead; // 진행상황 (1초마다) if (millis() - lastPrint > 1000) { float percent = (totalSent * 100.0) / fileSize; Serial.printf("📤 %.1f%% (%u/%u)\n", percent, totalSent, fileSize); lastPrint = millis(); } yield(); } free(buffer); file.close(); Serial.println("========================================"); if (downloadSuccess && totalSent == fileSize) { Serial.printf("✓ 완료: %u bytes (100.0%%)\n", totalSent); } else { Serial.printf("⚠ 불완전: %u/%u bytes (%.1f%%)\n", totalSent, fileSize, (totalSent * 100.0) / fileSize); } Serial.println("========================================"); // ⭐ SD 카드를 4-bit 모드로 복구 Serial.println("🔄 SD 카드 4-bit 모드로 복구..."); SD_MMC.end(); delay(200); if (SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3)) { if (SD_MMC.begin("/sdcard", false)) { // false = 4-bit mode Serial.println("✓ SD 카드 4-bit 모드 복구 완료"); } else { Serial.println("⚠ 4-bit 모드 복구 실패, 1-bit 유지"); SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0); SD_MMC.begin("/sdcard", true); } } xSemaphoreGive(sdMutex); // Task 재개 if (webTaskHandle != NULL) { vTaskResume(webTaskHandle); Serial.println("▶ 웹 업데이트 Task 재개"); } if (sdWriteTaskHandle != NULL) { vTaskResume(sdWriteTaskHandle); Serial.println("▶ SD 쓰기 Task 재개"); } // 로깅 재개 if (wasLogging) { loggingEnabled = true; Serial.println("▶ 로깅 재개"); } Serial.println("\n"); }); server.begin(); Serial.println("✓ 웹 서버 시작 완료"); // ★★★ Queue 생성 (PSRAM 사용) ★★★ if (!createQueues()) { Serial.println("✗ Queue 생성 실패!"); while (1) delay(1000); } // CAN 인터럽트 활성화 attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); // ======================================== // Task 생성 (최적화 버전) // ======================================== Serial.println("\nTask 생성 중..."); // Core 1: 실시간 처리 + 웹 서비스 // - CAN RX: 최고 우선순위로 메시지 수신 // - WEB: Core 0의 SD 쓰기와 분리하여 응답성 확보 // - TX: CAN 전송 // - SEQ: 시퀀스 재생 // - MONITOR: 상태 모니터링 xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 12288, NULL, 20, &canRxTaskHandle, 1); // ⭐ 8KB → 12KB, Pri 24 (최고) xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 12288, NULL, 4, &webTaskHandle, 1); // ⭐ Core 0 → 1, Pri 4 (SD와 분리) xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1); // Pri 3 xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1); // Pri 2 xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 1); // Pri 1 // Core 0: I/O 전용 (SD, Serial, RTC) // - SD 쓰기와 Serial 수신만 처리 // - 웹은 Core 1에서 처리하므로 SD 지연 영향 없음 xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 18576, NULL, 8, &sdWriteTaskHandle, 0); // Pri 8 (최우선) xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 6, &serialRxTaskHandle, 0); // Pri 6 xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 6, &serial2RxTaskHandle,0); // Pri 6 if (timeSyncStatus.rtcAvailable) { xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0); // Pri 0 (최저) } Serial.println("✓ 모든 Task 시작 완료"); Serial.println("\n========================================"); Serial.println(" Task 구성 (Core 분리 전략)"); Serial.println("========================================"); Serial.println("Core 1 (실시간 + 웹 서비스):"); Serial.println(" - CAN_RX: 12KB, Pri 24 (최고)"); Serial.println(" - WEB_UPDATE: 12KB, Pri 4 ⭐ SD와 분리"); Serial.println(" - TX: 4KB, Pri 3"); Serial.println(" - SEQ: 4KB, Pri 2"); Serial.println(" - SD_MONITOR: 4KB, Pri 1"); Serial.println("Core 0 (I/O 전용):"); Serial.println(" - SD_WRITE: 18KB, Pri 8 (최우선)"); Serial.println(" - SERIAL_RX: 6KB, Pri 6"); Serial.println(" - SERIAL2_RX: 6KB, Pri 6"); if (timeSyncStatus.rtcAvailable) { Serial.println(" - RTC_SYNC: 3KB, Pri 0"); } Serial.println("========================================"); Serial.println("📌 웹을 Core 1로 배치 → SD 쓰기 지연 영향 없음"); Serial.println("========================================"); 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)); // 주기적 상태 출력 (30초) 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(); } // 스택 사용량 모니터링 (5분마다) static uint32_t lastStackCheck = 0; if (millis() - lastStackCheck > 300000) { Serial.println("\n========== Task Stack Usage =========="); if (canRxTaskHandle) { UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(canRxTaskHandle); Serial.printf("CAN_RX: %5u bytes free (alloc: 12288)\n", stackLeft * 4); if (stackLeft * 4 < 2000) Serial.println(" ⚠️ 스택 부족 위험!"); } if (sdWriteTaskHandle) { UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(sdWriteTaskHandle); Serial.printf("SD_WRITE: %5u bytes free (alloc: 18576)\n", stackLeft * 4); if (stackLeft * 4 < 3000) Serial.println(" ⚠️ 스택 부족 위험!"); } if (webTaskHandle) { UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(webTaskHandle); Serial.printf("WEB_UPDATE: %5u bytes free (alloc: 12288)\n", stackLeft * 4); if (stackLeft * 4 < 2000) Serial.println(" ⚠️ 스택 부족 위험!"); } if (serialRxTaskHandle) { UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(serialRxTaskHandle); Serial.printf("SERIAL_RX: %5u bytes free (alloc: 6144)\n", stackLeft * 4); } if (serial2RxTaskHandle) { UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(serial2RxTaskHandle); Serial.printf("SERIAL2_RX: %5u bytes free (alloc: 6144)\n", stackLeft * 4); } Serial.printf("Free Heap: %u bytes\n", ESP.getFreeHeap()); Serial.printf("Free PSRAM: %u KB\n", ESP.getFreePsram() / 1024); Serial.println("======================================\n"); lastStackCheck = millis(); } }