diff --git a/a.ino b/a.ino new file mode 100644 index 0000000..602ed81 --- /dev/null +++ b/a.ino @@ -0,0 +1,3142 @@ +/* + * 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 mcp2515(HSPI_CS, 20000000, &hspi); +HardwareSerial SerialComm(1); // UART1 +HardwareSerial Serial2Comm(2); // ⭐ UART2 추가 +WebServer server(80); +WebSocketsServer webSocket = WebSocketsServer(81); +Preferences preferences; + +// FreeRTOS 핸들 +QueueHandle_t canQueue = NULL; +QueueHandle_t serialQueue = NULL; +QueueHandle_t serial2Queue = NULL; // ⭐ Serial2 추가 +SemaphoreHandle_t sdMutex = NULL; +SemaphoreHandle_t rtcMutex = NULL; +SemaphoreHandle_t serialMutex = NULL; +SemaphoreHandle_t serial2Mutex = NULL; // ⭐ Serial2 추가 +TaskHandle_t canRxTaskHandle = NULL; +TaskHandle_t sdWriteTaskHandle = NULL; +TaskHandle_t webTaskHandle = NULL; +TaskHandle_t rtcTaskHandle = NULL; +TaskHandle_t serialRxTaskHandle = NULL; +TaskHandle_t serial2RxTaskHandle = NULL; // ⭐ Serial2 추가 + +// 로깅 변수 +volatile bool loggingEnabled = false; + + +// ============================================ +// 🎯 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 형식 설정 +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 = MCP_MODE_LISTEN_ONLY; +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. MCP2515 하드 리셋 + digitalWrite(HSPI_CS, LOW); + delay(10); + digitalWrite(HSPI_CS, HIGH); + delay(50); + + // 4. MCP2515 재초기화 + mcp2515.reset(); + delay(100); + mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + delay(10); + mcp2515.setListenOnlyMode(); + //currentMcpMode = MCP_MODE_LISTEN_ONLY; + + + + // 5. 모드 설정 (Normal/Loopback/Listen Only) + if (currentMcpMode == MCP_MODE_NORMAL) { + mcp2515.setNormalMode(); + } else if (currentMcpMode == MCP_MODE_LOOPBACK) { + mcp2515.setLoopbackMode(); + } else { + mcp2515.setListenOnlyMode(); + } + + // 6. 버퍼 클리어 + struct can_frame dummyFrame; + while (mcp2515.readMessage(&dummyFrame) == MCP2515::ERROR_OK) { + // MCP2515 수신 버퍼 비우기 + } + mcp2515.clearRXnOVRFlags(); + + // 7. 통계 리셋 + totalMsgCount = 0; + lastMsgCount = 0; + msgPerSecond = 0; + + // 8. 최근 메시지 테이블 클리어 + 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); + if (savedMode >= 0 && savedMode <= 3) { + currentMcpMode = (MCP2515Mode)savedMode; + } + + 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); // 🆕 로그 형식 저장 + preferences.putString("start_logic", startLogicOp); + preferences.putString("stop_logic", stopLogicOp); + + 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); // 🆕 로그 형식 로드 + 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); + } + + 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); + saveSerialSettings(); + preferences.end(); +} + +// ======================================== +// RTC 함수 +// ======================================== +void initRTC() { + rtcWire.begin(); + rtcWire.setClock(100000); + + rtcWire.beginTransmission(DS3231_ADDRESS); + if (rtcWire.endTransmission() == 0) { + timeSyncStatus.rtcAvailable = true; + Serial.println("✓ RTC(DS3231) 감지됨"); + } else { + timeSyncStatus.rtcAvailable = false; + Serial.println("! RTC(DS3231) 없음"); + } +} + +uint8_t bcdToDec(uint8_t val) { + return (val >> 4) * 10 + (val & 0x0F); +} + +uint8_t decToBcd(uint8_t val) { + return ((val / 10) << 4) | (val % 10); +} + +bool readRTC(struct tm *timeinfo) { + if (!timeSyncStatus.rtcAvailable) return false; + if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false; + + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x00); + if (rtcWire.endTransmission() != 0) { + xSemaphoreGive(rtcMutex); + return false; + } + + if (rtcWire.requestFrom(DS3231_ADDRESS, 7) != 7) { + xSemaphoreGive(rtcMutex); + return false; + } + + uint8_t buffer[7]; + for (int i = 0; i < 7; i++) buffer[i] = rtcWire.read(); + xSemaphoreGive(rtcMutex); + + timeinfo->tm_sec = bcdToDec(buffer[0] & 0x7F); + timeinfo->tm_min = bcdToDec(buffer[1] & 0x7F); + timeinfo->tm_hour = bcdToDec(buffer[2] & 0x3F); + timeinfo->tm_wday = bcdToDec(buffer[3] & 0x07) - 1; + timeinfo->tm_mday = bcdToDec(buffer[4] & 0x3F); + timeinfo->tm_mon = bcdToDec(buffer[5] & 0x1F) - 1; + timeinfo->tm_year = bcdToDec(buffer[6]) + 100; + + return true; +} + +bool writeRTC(const struct tm *timeinfo) { + if (!timeSyncStatus.rtcAvailable) return false; + if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false; + + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x00); + rtcWire.write(decToBcd(timeinfo->tm_sec)); + rtcWire.write(decToBcd(timeinfo->tm_min)); + rtcWire.write(decToBcd(timeinfo->tm_hour)); + rtcWire.write(decToBcd(timeinfo->tm_wday + 1)); + rtcWire.write(decToBcd(timeinfo->tm_mday)); + rtcWire.write(decToBcd(timeinfo->tm_mon + 1)); + rtcWire.write(decToBcd(timeinfo->tm_year - 100)); + + bool success = (rtcWire.endTransmission() == 0); + xSemaphoreGive(rtcMutex); + return success; +} + +void timeSyncCallback(struct timeval *tv) { + Serial.println("✓ NTP 시간 동기화 완료"); + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = (uint64_t)tv->tv_sec * 1000000ULL + tv->tv_usec; + timeSyncStatus.syncCount++; + + if (timeSyncStatus.rtcAvailable) { + struct tm timeinfo; + time_t now = tv->tv_sec; + localtime_r(&now, &timeinfo); + if (writeRTC(&timeinfo)) { + timeSyncStatus.rtcSyncCount++; + } + } +} + +void initNTP() { + configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov"); + sntp_set_time_sync_notification_cb(timeSyncCallback); +} + +void rtcSyncTask(void *parameter) { + const TickType_t xDelay = pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS); + while (1) { + if (timeSyncStatus.rtcAvailable) { + struct tm timeinfo; + if (readRTC(&timeinfo)) { + time_t now = mktime(&timeinfo); + struct timeval tv = { .tv_sec = now, .tv_usec = 0 }; + settimeofday(&tv, NULL); + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = (uint64_t)now * 1000000ULL; + timeSyncStatus.rtcSyncCount++; + } + } + vTaskDelay(xDelay); + } +} + +// ======================================== +// MCP2515 모드 +// ======================================== +bool setMCP2515Mode(MCP2515Mode mode) { + const char* modeName; + MCP2515::ERROR result; + + switch (mode) { + case MCP_MODE_NORMAL: + result = mcp2515.setNormalMode(); + modeName = "Normal"; + break; + case MCP_MODE_LISTEN_ONLY: + result = mcp2515.setListenOnlyMode(); + modeName = "Listen-Only"; + break; + case MCP_MODE_LOOPBACK: + result = mcp2515.setLoopbackMode(); + modeName = "Loopback"; + break; + case MCP_MODE_TRANSMIT: + result = mcp2515.setListenOnlyMode(); + modeName = "Transmit-Only"; + break; + default: + return false; + } + + if (result == MCP2515::ERROR_OK) { + currentMcpMode = mode; + Serial.printf("✓ MCP2515 모드: %s\n", modeName); + return true; + } + return false; +} + +// ======================================== +// 인터럽트 및 Task +// ======================================== +void IRAM_ATTR canISR() { + BaseType_t xHigherPriorityTaskWoken = pdFALSE; + if (canRxTaskHandle != NULL) { + vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); + portYIELD_FROM_ISR(xHigherPriorityTaskWoken); + } +} + +void serialRxTask(void *parameter) { + SerialMessage serialMsg; + uint8_t lineBuffer[MAX_SERIAL_LINE_LEN]; + uint16_t lineIndex = 0; + uint32_t lastActivity = millis(); + + while (1) { + while (SerialComm.available()) { + uint8_t c = SerialComm.read(); + lineBuffer[lineIndex++] = c; + lastActivity = millis(); + + if (c == '\n' || c == '\r' || lineIndex >= MAX_SERIAL_LINE_LEN - 1) { + if (lineIndex > 0) { + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + serialMsg.length = lineIndex; + memcpy(serialMsg.data, lineBuffer, lineIndex); + serialMsg.isTx = false; + + if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { + totalSerialRxCount++; + } + lineIndex = 0; + } + } + + if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0; + } + + if (lineIndex > 0 && (millis() - lastActivity > 100)) { + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + serialMsg.length = lineIndex; + memcpy(serialMsg.data, lineBuffer, lineIndex); + serialMsg.isTx = false; + + if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { + totalSerialRxCount++; + } + lineIndex = 0; + } + + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + + +// ⭐ Serial2 RX Task (우선순위 5) +void serial2RxTask(void *parameter) { + SerialMessage serialMsg; + uint8_t lineBuffer[MAX_SERIAL_LINE_LEN]; + uint16_t lineIndex = 0; + uint32_t lastActivity = millis(); + + while (1) { + while (Serial2Comm.available()) { + uint8_t c = Serial2Comm.read(); + lineBuffer[lineIndex++] = c; + lastActivity = millis(); + + if (c == '\n' || c == '\r' || lineIndex >= MAX_SERIAL_LINE_LEN - 1) { + if (lineIndex > 0) { + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + serialMsg.length = lineIndex; + memcpy(serialMsg.data, lineBuffer, lineIndex); + serialMsg.isTx = false; + + if (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크 + totalSerial2RxCount++; + } + lineIndex = 0; + } + } + + if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0; + } + + if (lineIndex > 0 && (millis() - lastActivity > 100)) { + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + serialMsg.length = lineIndex; + memcpy(serialMsg.data, lineBuffer, lineIndex); + serialMsg.isTx = false; + + if (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크 + totalSerial2RxCount++; + } + lineIndex = 0; + } + + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + +void canRxTask(void *parameter) { + struct can_frame frame; + CANMessage msg; + + 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, portMAX_DELAY); + + while (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, pdMS_TO_TICKS(10)) == pdTRUE) { + totalMsgCount++; + } + } + } +} + +// ======================================== +// SD Write Task +// ======================================== +void sdWriteTask(void *parameter) { + CANMessage canMsg; + SerialMessage serialMsg; + + while (1) { + bool hasWork = false; + + // CAN 메시지 처리 + if (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) { + hasWork = true; + + // 실시간 모니터링 업데이트 + bool found = false; + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].msg.id == canMsg.id) { + recentData[i].msg = canMsg; + recentData[i].count++; + found = true; + break; + } + } + + if (!found) { + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].count == 0) { + recentData[i].msg = canMsg; + recentData[i].count = 1; + break; + } + } + } + + // CAN 로깅 + if (loggingEnabled && sdCardReady) { + // ⭐⭐⭐ 뮤텍스 타임아웃 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 >= 50) { // ⭐ 20 → 50 (너무 자주 플러시하면 느림) + logFile.flush(); + csvFlushCounter = 0; + } + + // ⭐⭐⭐ 500개마다 파일 재오픈 (핵심!) + if (++csvReopenCounter >= 500) { + logFile.close(); + logFile = SD_MMC.open(currentFilename, FILE_APPEND); + if (logFile) { + Serial.printf("✓ CSV 파일 재오픈: %s (%lu bytes)\n", currentFilename, currentFileSize); + } else { + Serial.println("✗ CSV 파일 재오픈 실패!"); + loggingEnabled = false; + } + csvReopenCounter = 0; + } + + } + } 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단계: 100개 메시지마다 주기적 플러시 + if (binMsgCounter % 100 == 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단계: 500개마다 파일 재오픈 (핵심!) + if (binReopenCounter >= 500) { + // 버퍼 먼저 플러시 + if (logFile && bufferIndex > 0) { + logFile.write(fileBuffer, bufferIndex); + logFile.flush(); + bufferIndex = 0; + } + + // 파일 닫고 다시 열기 + logFile.close(); + logFile = SD_MMC.open(currentFilename, FILE_APPEND); + if (logFile) { + Serial.printf("✓ BIN 파일 재오픈: %s (%lu bytes)\n", currentFilename, currentFileSize); + } else { + Serial.println("✗ BIN 파일 재오픈 실패!"); + loggingEnabled = false; + } + binReopenCounter = 0; + } + } + 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) { + 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_8MHZ); + // 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_8MHZ); + // 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); + } + 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(200); // ⭐ 100ms → 200ms (WiFi 안정성 향상) + + // 🆕 초기화 대기 (부팅 직후 안정화) + vTaskDelay(pdMS_TO_TICKS(2000)); + + 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; + 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; + + 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); + WiFi.setSleep(false); + 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 초기화..."); + + // ⭐⭐⭐ 하드 리셋 + digitalWrite(HSPI_CS, LOW); + delay(10); + digitalWrite(HSPI_CS, HIGH); + delay(50); + + mcp2515.reset(); + delay(100); + mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + delay(10); + mcp2515.setListenOnlyMode(); + //currentMcpMode = MCP_MODE_LISTEN_ONLY; + currentMcpMode =(MCP2515Mode)preferences.getInt("mcp_mode", 1); + + delay(50); + + // 버퍼 클리어 + Serial.println("CAN 버퍼 클리어..."); + 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); + } + mcp2515.clearRXnOVRFlags(); + + 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")) { + String filename = "/" + server.arg("file"); + + if (SD_MMC.exists(filename)) { + File file = SD_MMC.open(filename, FILE_READ); + if (file) { + String displayName = server.arg("file"); + server.sendHeader("Content-Disposition", + "attachment; filename=\"" + displayName + "\""); + server.sendHeader("Content-Type", "application/octet-stream"); + server.streamFile(file, "application/octet-stream"); + file.close(); + } else { + server.send(500, "text/plain", "Failed to open file"); + } + } else { + server.send(404, "text/plain", "File not found"); + } + } else { + server.send(400, "text/plain", "Bad request"); + } + }); + + server.begin(); + Serial.println("✓ 웹 서버 시작 완료"); + + // ★★★ Queue 생성 (PSRAM 사용) ★★★ + if (!createQueues()) { + Serial.println("✗ Queue 생성 실패!"); + while (1) delay(1000); + } + + // CAN 인터럽트 활성화 + attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); + + // Task 생성 + xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8192, NULL, configMAX_PRIORITIES - 1, &canRxTaskHandle, 1); // Core 0, Pri 24 + xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 18576, NULL, 6, &sdWriteTaskHandle, 0); + xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1); + xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 5, &serialRxTaskHandle, 0); + xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 5, &serial2RxTaskHandle, 0); // ⭐ Serial2 + xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1); + xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 10384, NULL, 2, &webTaskHandle, 0); // ⭐ 10240 → 16384 + xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 1); + + if (timeSyncStatus.rtcAvailable) { + xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0); + } + + Serial.println("✓ 모든 Task 시작 완료"); + Serial.println("\n========================================"); + Serial.println(" 접속 방법"); + Serial.println("========================================"); + Serial.printf(" WiFi SSID: %s\n", wifiSSID); + Serial.printf(" URL: http://%s\n", WiFi.softAPIP().toString().c_str()); + Serial.println("========================================"); + Serial.println(" PSRAM 상태"); + Serial.println("========================================"); + Serial.printf(" 여유 PSRAM: %d KB\n", ESP.getFreePsram() / 1024); + Serial.println("========================================\n"); +} + +// ======================================== +// Loop +// ======================================== +void loop() { + server.handleClient(); + vTaskDelay(pdMS_TO_TICKS(10)); + + static uint32_t lastPrint = 0; + if (millis() - lastPrint > 30000) { + Serial.printf("[상태] CAN: %d/%d | S1: %d/%d | S2: %d/%d | PSRAM: %d KB\n", + uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, + uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE, + uxQueueMessagesWaiting(serial2Queue), SERIAL2_QUEUE_SIZE, + ESP.getFreePsram() / 1024); + lastPrint = millis(); + } +} +