diff --git a/test_i2c_reset/ESP32_CAN_Logger.ino b/test_i2c_reset/ESP32_CAN_Logger.ino index 6652b3c..5ed794c 100644 --- a/test_i2c_reset/ESP32_CAN_Logger.ino +++ b/test_i2c_reset/ESP32_CAN_Logger.ino @@ -1,2353 +1,1905 @@ -/* - * Byun CAN Logger with Web Interface + RTC Time Synchronization - * Version: 3.0 - * Added: Phone time sync to RTC, File comments, MCP2515 mode control, Serial Terminal - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "index.h" -#include "transmit.h" -#include "graph.h" -#include "graph_viewer.h" -#include "settings.h" -#include "serial_terminal.h" - -// GPIO 핀 정의 -#define CAN_INT_PIN 27 - -// Serial2 핀 정의 (RS232 통신용) -#define SERIAL_RX_PIN 16 -#define SERIAL_TX_PIN 17 - -// HSPI 핀 (CAN) -#define HSPI_MISO 12 -#define HSPI_MOSI 13 -#define HSPI_SCLK 14 -#define HSPI_CS 15 - -// VSPI 핀 (SD Card) -#define VSPI_MISO 19 -#define VSPI_MOSI 23 -#define VSPI_SCLK 18 -#define VSPI_CS 5 - -// I2C2 핀 (RTC DS3231) - SoftWire 사용 -#define RTC_SDA 25 -#define RTC_SCL 26 -#define DS3231_ADDRESS 0x68 - -// 버퍼 설정 -#define CAN_QUEUE_SIZE 2000 // 1000 → 2000으로 증가 -#define FILE_BUFFER_SIZE 16384 // 8192 → 16384 (16KB)로 증가 -#define MAX_FILENAME_LEN 64 -#define RECENT_MSG_COUNT 100 -#define MAX_TX_MESSAGES 20 -#define MAX_COMMENT_LEN 128 - -// Serial 버퍼 설정 -#define SERIAL_RX_BUFFER_SIZE 4096 -#define SERIAL_TX_QUEUE_SIZE 100 -#define SERIAL_LOG_BUFFER_SIZE 8192 - -// RTC 동기화 설정 -#define RTC_SYNC_INTERVAL_MS 60000 // 1분마다 RTC와 동기화 - -// 전력 모니터링 설정 -#define VOLTAGE_CHECK_INTERVAL_MS 5000 // 5초마다 전압 체크 -#define LOW_VOLTAGE_THRESHOLD 3.0 // 3.0V 이하이면 경고 - -// CAN 메시지 구조체 - 마이크로초 단위 타임스탬프 -struct CANMessage { - uint64_t timestamp_us; // 마이크로초 단위 Unix timestamp - uint32_t id; - uint8_t dlc; - uint8_t data[8]; -} __attribute__((packed)); - -// 실시간 모니터링용 구조체 -struct RecentCANData { - CANMessage msg; - uint32_t count; -}; - -// CAN 송신용 구조체 -struct TxMessage { - uint32_t id; - bool extended; - uint8_t dlc; - uint8_t data[8]; - uint32_t interval; - uint32_t lastSent; - bool active; -}; - -// CAN 시퀀스 스텝 구조체 -struct SequenceStep { - uint32_t canId; - bool extended; - uint8_t dlc; - uint8_t data[8]; - uint32_t delayMs; // 이 스텝 실행 후 대기 시간 (ms) -}; - -// CAN 시퀀스 구조체 -struct CANSequence { - char name[32]; - SequenceStep steps[20]; // 최대 20개 스텝 - uint8_t stepCount; - uint8_t repeatMode; // 0=한번, 1=특정횟수, 2=무한 - uint32_t repeatCount; // repeatMode=1일 때 반복 횟수 -}; - -// 시퀀스 실행 상태 -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; // 1초 단위 최소 전압 - bool lowVoltage; - uint32_t lastCheck; - uint32_t lastMinReset; // 최소값 리셋 시간 -} powerStatus = {0.0, 999.9, false, 0, 0}; - -// ======================================== -// Serial 통신 관련 구조체 -// ======================================== - -// Serial 설정 구조체 -struct SerialConfig { - uint32_t baudRate; - uint8_t dataBits; // 5, 6, 7, 8 - uint8_t parity; // 0=None, 1=Even, 2=Odd - uint8_t stopBits; // 1, 2 - bool flowControl; // RTS/CTS -} serialConfig = {115200, 8, 0, 1, false}; - -// Serial 데이터 구조체 (로깅용) -struct SerialLogData { - uint64_t timestamp_us; - bool isTx; // true=송신, false=수신 - uint16_t length; - uint8_t data[256]; -} __attribute__((packed)); - -// Serial 상태 -struct SerialStatus { - bool logging; - uint32_t rxCount; - uint32_t txCount; - uint32_t rxBytesPerSec; - uint32_t txBytesPerSec; - uint32_t lastRxBytes; - uint32_t lastTxBytes; - uint32_t lastStatTime; -} serialStatus = {false, 0, 0, 0, 0, 0, 0, 0}; - - -// MCP2515 레지스터 주소 정의 (라이브러리에 없는 경우) -#ifndef MCP_CANCTRL -#define MCP_CANCTRL 0x0F -#endif - -#ifndef MCP_CANSTAT -#define MCP_CANSTAT 0x0E -#endif - -// MCP2515 모드 정의 -enum MCP2515Mode { - MCP_MODE_NORMAL = 0, - MCP_MODE_LISTEN_ONLY = 1, - MCP_MODE_LOOPBACK = 2, - MCP_MODE_TRANSMIT = 3 // 송신만 (ACK 없음) -}; - -// WiFi AP 기본 설정 -char wifiSSID[32] = "Byun_CAN_Logger"; -char wifiPassword[64] = "12345678"; - -// WiFi Station 모드 설정 (추가) -bool enableSTAMode = false; // APSTA 모드 활성화 여부 -char staSSID[32] = ""; // 연결할 WiFi SSID -char staPassword[64] = ""; // 연결할 WiFi 비밀번호 - -// 전역 변수 -SPIClass hspi(HSPI); -SPIClass vspi(VSPI); -MCP2515 mcp2515(HSPI_CS, 20000000, &hspi); // 20MHz로 증가 (10MHz → 20MHz) - -WebServer server(80); -WebSocketsServer webSocket = WebSocketsServer(81); -WebSocketsServer serialWebSocket = WebSocketsServer(82); -Preferences preferences; - -// Forward declaration -void IRAM_ATTR canISR(); -void serialWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); - -QueueHandle_t canQueue; -QueueHandle_t serialTxQueue; -QueueHandle_t serialLogQueue; -SemaphoreHandle_t sdMutex; -SemaphoreHandle_t rtcMutex; -SemaphoreHandle_t serialMutex; -TaskHandle_t canRxTaskHandle = NULL; -TaskHandle_t sdWriteTaskHandle = NULL; -TaskHandle_t webTaskHandle = NULL; -TaskHandle_t rtcTaskHandle = NULL; -TaskHandle_t serialRxTaskHandle = NULL; -TaskHandle_t serialTxTaskHandle = NULL; -TaskHandle_t serialLogTaskHandle = NULL; -TaskHandle_t serialWebTaskHandle = NULL; - -volatile bool loggingEnabled = false; -volatile bool sdCardReady = false; -File logFile; -char currentFilename[MAX_FILENAME_LEN]; -uint8_t fileBuffer[FILE_BUFFER_SIZE]; -uint16_t bufferIndex = 0; - -// 로깅 파일 크기 추적 (실시간 모니터링용) -volatile uint32_t currentFileSize = 0; - -// 현재 MCP2515 모드 -MCP2515Mode currentMcpMode = MCP_MODE_NORMAL; - -// RTC 관련 -SoftWire rtcWire(RTC_SDA, RTC_SCL); -char rtcSyncBuffer[20]; - -// CAN 속도 설정 -CAN_SPEED currentCanSpeed = CAN_1000KBPS; -const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; -CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; - -// 실시간 모니터링용 -RecentCANData recentData[RECENT_MSG_COUNT]; -uint32_t totalMsgCount = 0; -uint32_t msgPerSecond = 0; -uint32_t lastMsgCountTime = 0; -uint32_t lastMsgCount = 0; - -// 그래프 최대 개수 -#define MAX_GRAPH_SIGNALS 20 - -// CAN 송신용 -TxMessage txMessages[MAX_TX_MESSAGES]; -uint32_t totalTxCount = 0; - -// CAN 시퀀스 (최대 10개 저장 가능) -#define MAX_SEQUENCES 10 -CANSequence sequences[MAX_SEQUENCES]; -uint8_t sequenceCount = 0; -SequenceRuntime seqRuntime = {false, 0, 0, 0, -1}; - -// 파일 커멘트 저장 (최대 50개) -#define MAX_FILE_COMMENTS 50 -FileComment fileComments[MAX_FILE_COMMENTS]; -int commentCount = 0; - -// Serial 로그 파일 -File serialLogFile; -char currentSerialFilename[MAX_FILENAME_LEN]; -uint8_t serialLogBuffer[SERIAL_LOG_BUFFER_SIZE]; -uint16_t serialLogBufferIndex = 0; -volatile uint32_t currentSerialFileSize = 0; - - - - -// ======================================== -// Serial 설정 저장/로드 함수 -// ======================================== - -void saveSerialSettings() { - preferences.begin("serial-cfg", false); - preferences.putUInt("baudrate", serialConfig.baudRate); - preferences.putUChar("databits", serialConfig.dataBits); - preferences.putUChar("parity", serialConfig.parity); - preferences.putUChar("stopbits", serialConfig.stopBits); - preferences.putBool("flowctrl", serialConfig.flowControl); - preferences.end(); - Serial.println("✓ Serial 설정 저장 완료"); -} - -void loadSerialSettings() { - preferences.begin("serial-cfg", true); - serialConfig.baudRate = preferences.getUInt("baudrate", 115200); - serialConfig.dataBits = preferences.getUChar("databits", 8); - serialConfig.parity = preferences.getUChar("parity", 0); - serialConfig.stopBits = preferences.getUChar("stopbits", 1); - serialConfig.flowControl = preferences.getBool("flowctrl", false); - preferences.end(); - - Serial.printf("✓ Serial 설정 로드: %u bps, %dN%d\n", - serialConfig.baudRate, serialConfig.dataBits, serialConfig.stopBits); -} - -void applySerialConfig() { - Serial2.end(); - - uint32_t config = SERIAL_8N1; - - if (serialConfig.dataBits == 5) config = SERIAL_5N1; - else if (serialConfig.dataBits == 6) config = SERIAL_6N1; - else if (serialConfig.dataBits == 7) config = SERIAL_7N1; - else config = SERIAL_8N1; - - if (serialConfig.parity == 1) { - if (serialConfig.dataBits == 5) config = SERIAL_5E1; - else if (serialConfig.dataBits == 6) config = SERIAL_6E1; - else if (serialConfig.dataBits == 7) config = SERIAL_7E1; - else config = SERIAL_8E1; - } else if (serialConfig.parity == 2) { - if (serialConfig.dataBits == 5) config = SERIAL_5O1; - else if (serialConfig.dataBits == 6) config = SERIAL_6O1; - else if (serialConfig.dataBits == 7) config = SERIAL_7O1; - else config = SERIAL_8O1; - } - - if (serialConfig.stopBits == 2) { - config |= 0x3000; - } - - Serial2.begin(serialConfig.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN); - Serial2.setRxBufferSize(SERIAL_RX_BUFFER_SIZE); - - Serial.printf("✓ Serial2 재구성: %u bps\n", serialConfig.baudRate); -} - -// ======================================== -// Serial 로깅 함수 -// ======================================== - -void flushSerialLogBuffer() { - if (serialLogBufferIndex > 0 && serialLogFile) { - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - serialLogFile.write(serialLogBuffer, serialLogBufferIndex); - currentSerialFileSize += serialLogBufferIndex; - serialLogBufferIndex = 0; - xSemaphoreGive(sdMutex); - } - } -} - -void startSerialLogging() { - if (!sdCardReady) { - Serial.println("✗ SD 카드 없음 - Serial 로깅 불가"); - return; - } - - if (serialStatus.logging) { - Serial.println("⚠ Serial 로깅이 이미 실행 중입니다"); - return; - } - - struct tm timeinfo; - if (!getLocalTime(&timeinfo)) { - sprintf(currentSerialFilename, "/SERIAL_%lu.log", millis()); - } else { - sprintf(currentSerialFilename, "/SERIAL_%04d%02d%02d_%02d%02d%02d.log", - timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); - } - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - serialLogFile = SD.open(currentSerialFilename, FILE_WRITE); - if (serialLogFile) { - serialStatus.logging = true; - currentSerialFileSize = 0; - serialLogBufferIndex = 0; - - char header[256]; - snprintf(header, sizeof(header), - "Serial Log - BaudRate: %u, DataBits: %d, Parity: %d, StopBits: %d\n", - serialConfig.baudRate, serialConfig.dataBits, - serialConfig.parity, serialConfig.stopBits); - serialLogFile.print(header); - - Serial.printf("✓ Serial 로깅 시작: %s\n", currentSerialFilename); - } else { - Serial.println("✗ Serial 로그 파일 생성 실패"); - } - xSemaphoreGive(sdMutex); - } -} - -void stopSerialLogging() { - if (!serialStatus.logging) return; - - serialStatus.logging = false; - flushSerialLogBuffer(); - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (serialLogFile) { - serialLogFile.close(); - Serial.printf("✓ Serial 로깅 종료: %s (크기: %u bytes)\n", - currentSerialFilename, currentSerialFileSize); - } - xSemaphoreGive(sdMutex); - } -} - -// ======================================== -// Serial Task 함수들 -// ======================================== - -void serialRxTask(void* parameter) { - SerialLogData logData; - uint8_t rxBuffer[256]; - - Serial.println("✓ Serial RX Task 시작"); - - while (true) { - if (Serial2.available()) { - int len = Serial2.readBytes(rxBuffer, sizeof(rxBuffer)); - - if (len > 0) { - serialStatus.rxCount += len; - - String hexStr = ""; - String asciiStr = ""; - - for (int i = 0; i < len; i++) { - char hexBuf[4]; - sprintf(hexBuf, "%02X ", rxBuffer[i]); - hexStr += hexBuf; - - if (rxBuffer[i] >= 32 && rxBuffer[i] < 127) { - asciiStr += (char)rxBuffer[i]; - } else { - asciiStr += '.'; - } - } - - String wsData = "RX:" + hexStr + "|" + asciiStr; - serialWebSocket.broadcastTXT(wsData); - - if (serialStatus.logging) { - logData.timestamp_us = esp_timer_get_time(); - logData.isTx = false; - logData.length = len; - memcpy(logData.data, rxBuffer, len); - - xQueueSend(serialLogQueue, &logData, 0); - } - } - } - - vTaskDelay(pdMS_TO_TICKS(5)); - } -} - -void serialTxTask(void* parameter) { - uint8_t txData[256]; - int txLen; - - Serial.println("✓ Serial TX Task 시작"); - - while (true) { - if (xQueueReceive(serialTxQueue, txData, pdMS_TO_TICKS(100))) { - txLen = txData[0]; - - if (txLen > 0 && txLen < 256) { - Serial2.write(&txData[1], txLen); - serialStatus.txCount += txLen; - - if (serialStatus.logging) { - SerialLogData logData; - logData.timestamp_us = esp_timer_get_time(); - logData.isTx = true; - logData.length = txLen; - memcpy(logData.data, &txData[1], txLen); - - xQueueSend(serialLogQueue, &logData, 0); - } - - String hexStr = "TX:"; - for (int i = 0; i < txLen; i++) { - char hexBuf[4]; - sprintf(hexBuf, "%02X ", txData[i + 1]); - hexStr += hexBuf; - } - serialWebSocket.broadcastTXT(hexStr); - } - } - - vTaskDelay(pdMS_TO_TICKS(1)); - } -} - -void serialLogTask(void* parameter) { - SerialLogData logData; - char logLine[512]; - - Serial.println("✓ Serial Log Task 시작"); - - while (true) { - if (xQueueReceive(serialLogQueue, &logData, pdMS_TO_TICKS(100))) { - if (serialStatus.logging && serialLogFile) { - int offset = snprintf(logLine, sizeof(logLine), - "[%llu] %s: ", logData.timestamp_us, logData.isTx ? "TX" : "RX"); - - for (int i = 0; i < logData.length && offset < sizeof(logLine) - 10; i++) { - offset += snprintf(logLine + offset, sizeof(logLine) - offset, - "%02X ", logData.data[i]); - } - - offset += snprintf(logLine + offset, sizeof(logLine) - offset, " | "); - for (int i = 0; i < logData.length && offset < sizeof(logLine) - 5; i++) { - if (logData.data[i] >= 32 && logData.data[i] < 127) { - logLine[offset++] = logData.data[i]; - } else { - logLine[offset++] = '.'; - } - } - logLine[offset++] = '\n'; - logLine[offset] = '\0'; - - int lineLen = strlen(logLine); - if (serialLogBufferIndex + lineLen >= SERIAL_LOG_BUFFER_SIZE) { - flushSerialLogBuffer(); - } - - memcpy(&serialLogBuffer[serialLogBufferIndex], logLine, lineLen); - serialLogBufferIndex += lineLen; - } - } else { - if (serialLogBufferIndex > 0) { - flushSerialLogBuffer(); - } - } - - vTaskDelay(pdMS_TO_TICKS(10)); - } -} - -void serialWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { - if (type == WStype_TEXT) { - String msg = String((char*)payload); - - if (msg.startsWith("CONFIG:")) { - int idx2 = msg.indexOf(','); - int idx3 = msg.indexOf(',', idx2 + 1); - int idx4 = msg.indexOf(',', idx3 + 1); - - serialConfig.baudRate = msg.substring(7, idx2).toInt(); - serialConfig.dataBits = msg.substring(idx2 + 1, idx3).toInt(); - serialConfig.parity = msg.substring(idx3 + 1, idx4).toInt(); - serialConfig.stopBits = msg.substring(idx4 + 1).toInt(); - - applySerialConfig(); - saveSerialSettings(); - - serialWebSocket.sendTXT(num, "CONFIG_OK"); - - } else if (msg == "START_LOG") { - startSerialLogging(); - serialWebSocket.sendTXT(num, serialStatus.logging ? "LOG_STARTED" : "LOG_FAILED"); - - } else if (msg == "STOP_LOG") { - stopSerialLogging(); - serialWebSocket.sendTXT(num, "LOG_STOPPED"); - - } else if (msg == "GET_CONFIG") { - char cfgStr[128]; - snprintf(cfgStr, sizeof(cfgStr), "CFG:%u,%d,%d,%d,%d", - serialConfig.baudRate, serialConfig.dataBits, serialConfig.parity, - serialConfig.stopBits, serialConfig.flowControl ? 1 : 0); - serialWebSocket.sendTXT(num, cfgStr); - - } else if (msg == "GET_STATUS") { - char statStr[256]; - snprintf(statStr, sizeof(statStr), "STAT:%d,%u,%u,%u,%u,%s", - serialStatus.logging ? 1 : 0, - serialStatus.rxCount, serialStatus.txCount, - serialStatus.rxBytesPerSec, serialStatus.txBytesPerSec, - currentSerialFilename); - serialWebSocket.sendTXT(num, statStr); - - } else if (msg.startsWith("TX:")) { - String hexData = msg.substring(3); - hexData.trim(); - - uint8_t txBuffer[256]; - int txLen = 0; - - for (int i = 0; i < hexData.length() && txLen < 255; i += 2) { - if (i + 1 < hexData.length()) { - String hexByte = hexData.substring(i, i + 2); - txBuffer[txLen + 1] = (uint8_t)strtol(hexByte.c_str(), NULL, 16); - txLen++; - } - } - - if (txLen > 0) { - txBuffer[0] = txLen; - xQueueSend(serialTxQueue, txBuffer, 0); - } - } else if (msg.startsWith("TXT:")) { - String text = msg.substring(4); - uint8_t txBuffer[256]; - int txLen = text.length(); - - if (txLen > 0 && txLen < 255) { - txBuffer[0] = txLen; - memcpy(&txBuffer[1], text.c_str(), txLen); - xQueueSend(serialTxQueue, txBuffer, 0); - } - } - } -} - -void serialWebUpdateTask(void* parameter) { - Serial.println("✓ Serial Web Update Task 시작"); - - while (true) { - serialWebSocket.loop(); - - uint32_t now = millis(); - if (now - serialStatus.lastStatTime >= 1000) { - serialStatus.rxBytesPerSec = serialStatus.rxCount - serialStatus.lastRxBytes; - serialStatus.txBytesPerSec = serialStatus.txCount - serialStatus.lastTxBytes; - serialStatus.lastRxBytes = serialStatus.rxCount; - serialStatus.lastTxBytes = serialStatus.txCount; - serialStatus.lastStatTime = now; - } - - vTaskDelay(pdMS_TO_TICKS(10)); - } -} - -// ======================================== -// 설정 저장/로드 함수 -// ======================================== - -void loadSettings() { - preferences.begin("can-logger", false); - - // WiFi AP 설정 로드 - preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); - preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); - - // WiFi STA 모드 설정 로드 (추가) - enableSTAMode = preferences.getBool("sta_enable", false); - preferences.getString("sta_ssid", staSSID, sizeof(staSSID)); - preferences.getString("sta_pass", staPassword, sizeof(staPassword)); - - // 설정이 없으면 기본값 사용 - if (strlen(wifiSSID) == 0) { - strcpy(wifiSSID, "Byun_CAN_Logger"); - } - if (strlen(wifiPassword) == 0) { - strcpy(wifiPassword, "12345678"); - } - - // CAN 속도 로드 (기본값: 1Mbps = 3) - int speedIndex = preferences.getInt("can_speed", 3); - if (speedIndex >= 0 && speedIndex < 4) { - currentCanSpeed = canSpeedValues[speedIndex]; - Serial.printf("✓ 저장된 CAN 속도 로드: %s\n", canSpeedNames[speedIndex]); - } - - // MCP2515 모드 로드 (기본값: Normal = 0) - int mcpMode = preferences.getInt("mcp_mode", 0); - if (mcpMode >= 0 && mcpMode <= 3) { - currentMcpMode = (MCP2515Mode)mcpMode; - Serial.printf("✓ 저장된 MCP 모드 로드: %d\n", mcpMode); - } - - preferences.end(); - - // STA 모드 설정 출력 - if (enableSTAMode && strlen(staSSID) > 0) { - Serial.printf("✓ WiFi STA 모드: 활성화 (SSID: %s)\n", staSSID); - } -} - -void saveSettings() { - preferences.begin("can-logger", false); - - // WiFi AP 설정 저장 - preferences.putString("wifi_ssid", wifiSSID); - preferences.putString("wifi_pass", wifiPassword); - - // WiFi STA 모드 설정 저장 (추가) - preferences.putBool("sta_enable", enableSTAMode); - preferences.putString("sta_ssid", staSSID); - preferences.putString("sta_pass", staPassword); - - preferences.end(); - - Serial.println("\n✓ 설정 저장 완료:"); - Serial.println("----------------------------------------"); - Serial.printf(" WiFi AP SSID : %s\n", wifiSSID); - Serial.printf(" WiFi AP Password : %s\n", wifiPassword); - if (enableSTAMode && strlen(staSSID) > 0) { - Serial.printf(" STA Mode : 활성화\n"); - Serial.printf(" STA SSID : %s\n", staSSID); - } else { - Serial.printf(" STA Mode : 비활성화\n"); - } - Serial.println("----------------------------------------"); - Serial.println("⚠️ 재부팅 후 WiFi 설정이 적용됩니다."); -} - -void saveCANSettings() { - preferences.begin("can-logger", false); - - // CAN 속도 저장 (인덱스로 저장) - int speedIndex = 3; // 기본값: 1M - for (int i = 0; i < 4; i++) { - if (canSpeedValues[i] == currentCanSpeed) { - speedIndex = i; - break; - } - } - preferences.putInt("can_speed", speedIndex); - - // MCP2515 모드 저장 - preferences.putInt("mcp_mode", (int)currentMcpMode); - - preferences.end(); - - Serial.println("\n✓ CAN 설정 저장 완료:"); - Serial.println("----------------------------------------"); - Serial.printf(" CAN Speed : %s\n", canSpeedNames[speedIndex]); - Serial.printf(" MCP Mode : %d\n", (int)currentMcpMode); - Serial.println("----------------------------------------"); -} - -// ======================================== -// 시퀀스 관리 함수 -// ======================================== - -void loadSequences() { - if (!sdCardReady) return; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - File seqFile = SD.open("/sequences.bin", FILE_READ); - if (seqFile) { - sequenceCount = 0; - while (seqFile.available() && sequenceCount < MAX_SEQUENCES) { - seqFile.read((uint8_t*)&sequences[sequenceCount], sizeof(CANSequence)); - sequenceCount++; - } - seqFile.close(); - Serial.printf("✓ 시퀀스 로드: %d개\n", sequenceCount); - } - xSemaphoreGive(sdMutex); - } -} - -void saveSequences() { - if (!sdCardReady) return; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - // 기존 파일 삭제 - if (SD.exists("/sequences.bin")) { - SD.remove("/sequences.bin"); - } - - File seqFile = SD.open("/sequences.bin", FILE_WRITE); - if (seqFile) { - for (int i = 0; i < sequenceCount; i++) { - seqFile.write((uint8_t*)&sequences[i], sizeof(CANSequence)); - } - seqFile.close(); - Serial.println("✓ 시퀀스 저장 완료"); - } - xSemaphoreGive(sdMutex); - } -} - -int addSequence(const CANSequence& seq) { - if (sequenceCount >= MAX_SEQUENCES) { - return -1; // 가득 참 - } - - sequences[sequenceCount] = seq; - sequenceCount++; - saveSequences(); - return sequenceCount - 1; -} - -bool deleteSequence(uint8_t index) { - if (index >= sequenceCount) return false; - - // 배열 왼쪽으로 시프트 - for (int i = index; i < sequenceCount - 1; i++) { - sequences[i] = sequences[i + 1]; - } - sequenceCount--; - saveSequences(); - return true; -} - -void stopSequence() { - seqRuntime.running = false; - seqRuntime.currentStep = 0; - seqRuntime.currentRepeat = 0; - seqRuntime.activeSequenceIndex = -1; - Serial.println("✓ 시퀀스 실행 중지"); -} - -void startSequence(uint8_t index) { - if (index >= sequenceCount) return; - - seqRuntime.running = true; - seqRuntime.currentStep = 0; - seqRuntime.currentRepeat = 0; - seqRuntime.lastStepTime = millis(); - seqRuntime.activeSequenceIndex = index; - - Serial.printf("✓ 시퀀스 실행 시작: %s\n", sequences[index].name); -} - -// ======================================== -// 파일 커멘트 관리 함수 -// ======================================== - -void loadFileComments() { - if (!sdCardReady) return; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - File commentFile = SD.open("/comments.txt", FILE_READ); - if (commentFile) { - commentCount = 0; - while (commentFile.available() && commentCount < MAX_FILE_COMMENTS) { - String line = commentFile.readStringUntil('\n'); - line.trim(); - - int separatorPos = line.indexOf('|'); - if (separatorPos > 0) { - String filename = line.substring(0, separatorPos); - String comment = line.substring(separatorPos + 1); - - strncpy(fileComments[commentCount].filename, filename.c_str(), MAX_FILENAME_LEN - 1); - strncpy(fileComments[commentCount].comment, comment.c_str(), MAX_COMMENT_LEN - 1); - commentCount++; - } - } - commentFile.close(); - Serial.printf("✓ 파일 커멘트 로드: %d개\n", commentCount); - } - xSemaphoreGive(sdMutex); - } -} - -void saveFileComments() { - if (!sdCardReady) return; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - // 기존 파일 삭제 - if (SD.exists("/comments.txt")) { - SD.remove("/comments.txt"); - } - - File commentFile = SD.open("/comments.txt", FILE_WRITE); - if (commentFile) { - for (int i = 0; i < commentCount; i++) { - commentFile.print(fileComments[i].filename); - commentFile.print("|"); - commentFile.println(fileComments[i].comment); - } - commentFile.close(); - Serial.println("✓ 파일 커멘트 저장 완료"); - } - xSemaphoreGive(sdMutex); - } -} - -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); - saveFileComments(); - return; - } - } - - // 새로운 커멘트 추가 - if (commentCount < MAX_FILE_COMMENTS) { - strncpy(fileComments[commentCount].filename, filename, MAX_FILENAME_LEN - 1); - strncpy(fileComments[commentCount].comment, comment, MAX_COMMENT_LEN - 1); - commentCount++; - saveFileComments(); - } -} - -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 ""; -} - -// ======================================== -// 전력 모니터링 함수 -// ======================================== - -float readVoltage() { - // ESP32 내부 ADC로 전압 측정 - // GPIO34는 ADC1_CH6에 연결되어 있음 - // 실제 배터리 전압을 측정하려면 분압 회로가 필요할 수 있음 - - // 여러 번 샘플링하여 평균값 계산 - const int samples = 10; - uint32_t sum = 0; - - for (int i = 0; i < samples; i++) { - sum += analogRead(34); - delayMicroseconds(100); - } - - uint32_t avg = sum / samples; - - // ESP32 ADC: 12bit (0-4095), 참조전압 3.3V - // 실제 전압 = (ADC값 / 4095) * 3.3V - float voltage = (avg / 4095.0) * 3.3; - - return voltage; -} - -void updatePowerStatus() { - uint32_t now = millis(); - - // 5초마다 전압 체크 - if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { - powerStatus.voltage = readVoltage(); - - // 1초 단위 최소값 업데이트 - if (powerStatus.voltage < powerStatus.minVoltage) { - powerStatus.minVoltage = powerStatus.voltage; - } - - // 1초마다 최소값 리셋 - if (now - powerStatus.lastMinReset >= 1000) { - powerStatus.minVoltage = powerStatus.voltage; - powerStatus.lastMinReset = now; - } - - // 저전압 경고 - if (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD) { - if (!powerStatus.lowVoltage) { - Serial.printf("⚠️ 저전압 경고: %.2fV\n", powerStatus.voltage); - powerStatus.lowVoltage = true; - } - } else { - powerStatus.lowVoltage = false; - } - - powerStatus.lastCheck = now; - } -} - -// ======================================== -// RTC 함수 -// ======================================== - -uint8_t bcdToDec(uint8_t val) { - return ((val / 16 * 10) + (val % 16)); -} - -uint8_t decToBcd(uint8_t val) { - return ((val / 10 * 16) + (val % 10)); -} - -bool initRTC() { - rtcWire.begin(); - rtcWire.setTimeout(1000); - - rtcWire.beginTransmission(DS3231_ADDRESS); - uint8_t error = rtcWire.endTransmission(); - - if (error == 0) { - timeSyncStatus.rtcAvailable = true; - Serial.println("✓ RTC DS3231 초기화 완료"); - return true; - } else { - timeSyncStatus.rtcAvailable = false; - Serial.println("✗ RTC DS3231 없음 (수동 시간 설정 필요)"); - return false; - } -} - -bool setRTCTime(int year, int month, int day, int hour, int minute, int second) { - if (!timeSyncStatus.rtcAvailable) return false; - - if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - rtcWire.beginTransmission(DS3231_ADDRESS); - rtcWire.write(0x00); // 레지스터 주소 - - rtcWire.write(decToBcd(second)); - rtcWire.write(decToBcd(minute)); - rtcWire.write(decToBcd(hour)); - rtcWire.write(decToBcd(1)); // 요일 (사용안함) - rtcWire.write(decToBcd(day)); - rtcWire.write(decToBcd(month)); - rtcWire.write(decToBcd(year - 2000)); - - uint8_t error = rtcWire.endTransmission(); - xSemaphoreGive(rtcMutex); - - if (error == 0) { - Serial.printf("✓ RTC 시간 설정: %04d-%02d-%02d %02d:%02d:%02d\n", - year, month, day, hour, minute, second); - return true; - } - } - return false; -} - -bool getRTCTime(struct tm* timeinfo) { - if (!timeSyncStatus.rtcAvailable) return false; - - if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - rtcWire.beginTransmission(DS3231_ADDRESS); - rtcWire.write(0x00); // 레지스터 시작 주소 - - if (rtcWire.endTransmission() != 0) { - xSemaphoreGive(rtcMutex); - return false; - } - - rtcWire.requestFrom(DS3231_ADDRESS, 7); - - if (rtcWire.available() >= 7) { - uint8_t second = bcdToDec(rtcWire.read() & 0x7F); - uint8_t minute = bcdToDec(rtcWire.read()); - uint8_t hour = bcdToDec(rtcWire.read() & 0x3F); - rtcWire.read(); // 요일 스킵 - uint8_t day = bcdToDec(rtcWire.read()); - uint8_t month = bcdToDec(rtcWire.read()); - uint8_t year = bcdToDec(rtcWire.read()); - - timeinfo->tm_sec = second; - timeinfo->tm_min = minute; - timeinfo->tm_hour = hour; - timeinfo->tm_mday = day; - timeinfo->tm_mon = month - 1; - timeinfo->tm_year = year + 100; - - xSemaphoreGive(rtcMutex); - return true; - } - - xSemaphoreGive(rtcMutex); - } - - return false; -} - -void syncSystemTimeFromRTC() { - struct tm timeinfo; - - if (getRTCTime(&timeinfo)) { - struct timeval tv; - tv.tv_sec = mktime(&timeinfo); - tv.tv_usec = 0; - settimeofday(&tv, NULL); - - timeSyncStatus.synchronized = true; - timeSyncStatus.lastSyncTime = esp_timer_get_time(); - timeSyncStatus.rtcSyncCount++; - - Serial.printf("✓ RTC→시스템 동기화 (%d회): %04d-%02d-%02d %02d:%02d:%02d\n", - timeSyncStatus.rtcSyncCount, - timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); - } -} - -// ======================================== -// MCP2515 모드 제어 함수 -// ======================================== - -bool setMCP2515Mode(MCP2515Mode mode) { - MCP2515::ERROR result; - - switch (mode) { - case MCP_MODE_NORMAL: - result = mcp2515.setNormalMode(); - if (result == MCP2515::ERROR_OK) { - // 인터럽트 재활성화 - attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - currentMcpMode = MCP_MODE_NORMAL; - Serial.println("✓ MCP2515 모드: NORMAL"); - Serial.println(" 송수신 모두 가능, ACK 전송"); - return true; - } - break; - - case MCP_MODE_LISTEN_ONLY: - result = mcp2515.setListenOnlyMode(); - if (result == MCP2515::ERROR_OK) { - // 인터럽트 재활성화 - attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - currentMcpMode = MCP_MODE_LISTEN_ONLY; - Serial.println("✓ MCP2515 모드: LISTEN-ONLY"); - Serial.println(" 수신만 가능, ACK 전송 안 함"); - return true; - } - break; - - case MCP_MODE_LOOPBACK: - result = mcp2515.setLoopbackMode(); - if (result == MCP2515::ERROR_OK) { - // 인터럽트 재활성화 - attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - currentMcpMode = MCP_MODE_LOOPBACK; - Serial.println("✓ MCP2515 모드: LOOPBACK"); - Serial.println(" 자가 테스트 모드"); - return true; - } - break; - - case MCP_MODE_TRANSMIT: - // TRANSMIT 모드: Listen-Only 기반 - // 평상시에는 Listen-Only로 동작하여 ACK를 보내지 않음 - // 송신이 필요할 때만 일시적으로 Normal 모드로 전환 - result = mcp2515.setListenOnlyMode(); - if (result == MCP2515::ERROR_OK) { - // 인터럽트는 비활성화 (수신 처리하지 않음) - detachInterrupt(digitalPinToInterrupt(CAN_INT_PIN)); - - // 수신 버퍼를 비움 - struct can_frame frame; - while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { - // 버퍼만 비움 - } - - currentMcpMode = MCP_MODE_TRANSMIT; - Serial.println("✓ MCP2515 모드: TRANSMIT-ONLY"); - Serial.println(" 기본: Listen-Only (ACK 안 함)"); - Serial.println(" 송신 시: 일시적으로 Normal 전환"); - Serial.println(" 수신 인터럽트: 비활성화"); - return true; - } - break; - } - - Serial.println("✗ MCP2515 모드 변경 실패"); - return false; -} - -// ======================================== -// CAN 송신 헬퍼 함수 (TRANSMIT 모드 자동 처리) -// ======================================== - -MCP2515::ERROR sendCANMessage(struct can_frame* frame) { - MCP2515::ERROR result; - - // TRANSMIT 모드일 때만 특별 처리 - if (currentMcpMode == MCP_MODE_TRANSMIT) { - // 1. 일시적으로 Normal 모드로 전환 - result = mcp2515.setNormalMode(); - if (result != MCP2515::ERROR_OK) { - Serial.println("✗ TRANSMIT 모드: Normal 전환 실패"); - return result; - } - - // 2. 메시지 송신 - result = mcp2515.sendMessage(frame); - - // 3. 즉시 Listen-Only 모드로 복귀 - mcp2515.setListenOnlyMode(); - - // 4. 수신 버퍼 비우기 (전환 중 수신된 데이터) - struct can_frame dummy; - while (mcp2515.readMessage(&dummy) == MCP2515::ERROR_OK) { - // 버퍼만 비움 - } - - return result; - } else { - // 다른 모드에서는 바로 송신 - return mcp2515.sendMessage(frame); - } -} - -// ======================================== -// CAN 인터럽트 및 수신 함수 -// ======================================== - -void IRAM_ATTR canISR() { - BaseType_t xHigherPriorityTaskWoken = pdFALSE; - vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); - if (xHigherPriorityTaskWoken) { - portYIELD_FROM_ISR(); - } -} - -void canRxTask(void* parameter) { - struct can_frame frame; - CANMessage canMsg; - - for (;;) { - ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - - // 한 번에 여러 메시지를 읽어서 처리 속도 향상 - int readCount = 0; - while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK && readCount < 20) { - // 현재 시간 저장 (마이크로초) - struct timeval tv; - gettimeofday(&tv, NULL); - canMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + (uint64_t)tv.tv_usec; - - canMsg.id = frame.can_id; - canMsg.dlc = frame.can_dlc; - memcpy(canMsg.data, frame.data, 8); - - // 로깅 중일 때만 큐에 추가 - if (loggingEnabled) { - xQueueSend(canQueue, &canMsg, 0); - } - - // 실시간 데이터 업데이트 (웹 표시용) - bool found = false; - for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].count > 0 && 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; - } - } - } - - totalMsgCount++; - readCount++; - } - - // 메시지/초 계산 - uint32_t now = millis(); - if (now - lastMsgCountTime >= 1000) { - msgPerSecond = totalMsgCount - lastMsgCount; - lastMsgCount = totalMsgCount; - lastMsgCountTime = now; - } - } -} - -// ======================================== -// SD 카드 쓰기 태스크 -// ======================================== - -void sdWriteTask(void* parameter) { - CANMessage msg; - - for (;;) { - if (loggingEnabled && sdCardReady) { - if (xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(10)) == pdTRUE) { - // 버퍼에 데이터 추가 - memcpy(&fileBuffer[bufferIndex], &msg, sizeof(CANMessage)); - bufferIndex += sizeof(CANMessage); - currentFileSize += sizeof(CANMessage); - - // 버퍼가 가득 차면 SD에 쓰기 - if (bufferIndex >= FILE_BUFFER_SIZE - sizeof(CANMessage)) { - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - if (logFile) { - logFile.write(fileBuffer, bufferIndex); - logFile.flush(); - } - xSemaphoreGive(sdMutex); - } - bufferIndex = 0; - } - } - } else { - vTaskDelay(pdMS_TO_TICKS(100)); - } - } -} - -// ======================================== -// 로깅 제어 함수 -// ======================================== - -void startLogging() { - if (!sdCardReady) { - Serial.println("✗ SD 카드가 준비되지 않음"); - return; - } - - if (loggingEnabled) { - Serial.println("⚠️ 이미 로깅 중"); - return; - } - - // 파일명 생성 (현재 시간 사용) - time_t now; - struct tm timeinfo; - time(&now); - localtime_r(&now, &timeinfo); - - snprintf(currentFilename, MAX_FILENAME_LEN, - "/CAN_%04d%02d%02d_%02d%02d%02d.bin", - timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - logFile = SD.open(currentFilename, FILE_WRITE); - - if (logFile) { - loggingEnabled = true; - bufferIndex = 0; - currentFileSize = 0; - - Serial.print("✓ 로깅 시작: "); - Serial.println(currentFilename); - } else { - Serial.println("✗ 파일 생성 실패"); - } - - xSemaphoreGive(sdMutex); - } -} - -void stopLogging() { - if (!loggingEnabled) { - Serial.println("⚠️ 로깅 중이 아님"); - return; - } - - loggingEnabled = false; - - // 남은 버퍼 데이터 쓰기 - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (bufferIndex > 0 && logFile) { - logFile.write(fileBuffer, bufferIndex); - } - - if (logFile) { - logFile.close(); - } - - xSemaphoreGive(sdMutex); - } - - bufferIndex = 0; - - // 큐 비우기 (로깅 중이 아닐 때는 큐가 불필요) - CANMessage dummyMsg; - while (xQueueReceive(canQueue, &dummyMsg, 0) == pdTRUE) { - // 큐의 모든 메시지 제거 - } - - Serial.print("✓ 로깅 종료: "); - Serial.println(currentFilename); - Serial.printf(" 파일 크기: %u bytes\n", currentFileSize); - Serial.println("✓ 큐 비움 완료"); - - // 현재 파일명 초기화 - currentFilename[0] = '\0'; -} - -// ======================================== -// SD 모니터링 태스크 -// ======================================== - -void sdMonitorTask(void* parameter) { - for (;;) { - updatePowerStatus(); - vTaskDelay(pdMS_TO_TICKS(1000)); - } -} - -// ======================================== -// RTC 동기화 태스크 -// ======================================== - -void rtcSyncTask(void* parameter) { - for (;;) { - syncSystemTimeFromRTC(); - vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS)); - } -} - -// ======================================== -// CAN 송신 태스크 -// ======================================== - -void txTask(void* parameter) { - struct can_frame frame; - - for (;;) { - uint32_t now = millis(); - - // 주기적 송신 (기존) - for (int i = 0; i < MAX_TX_MESSAGES; i++) { - if (txMessages[i].active && txMessages[i].interval > 0) { - if (now - txMessages[i].lastSent >= txMessages[i].interval) { - 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 (sendCANMessage(&frame) == MCP2515::ERROR_OK) { - txMessages[i].lastSent = now; - totalTxCount++; - } - } - } - } - - vTaskDelay(pdMS_TO_TICKS(1)); - } -} - -// ======================================== -// 시퀀스 실행 태스크 -// ======================================== - -void sequenceTask(void* parameter) { - struct can_frame frame; - - for (;;) { - if (seqRuntime.running && seqRuntime.activeSequenceIndex >= 0) { - CANSequence* seq = &sequences[seqRuntime.activeSequenceIndex]; - uint32_t now = millis(); - - if (seqRuntime.currentStep < seq->stepCount) { - SequenceStep* step = &seq->steps[seqRuntime.currentStep]; - - // 첫 번째 스텝이거나 딜레이 시간이 지났으면 실행 - if (seqRuntime.currentStep == 0 || (now - seqRuntime.lastStepTime >= step->delayMs)) { - // CAN 메시지 전송 - 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); - - MCP2515::ERROR result = sendCANMessage(&frame); - if (result == MCP2515::ERROR_OK) { - totalTxCount++; - Serial.printf(" [Seq] Step %d/%d: ID=0x%X, DLC=%d, Delay=%dms - OK\n", - seqRuntime.currentStep + 1, seq->stepCount, - step->canId, step->dlc, step->delayMs); - } else { - Serial.printf(" [Seq] Step %d/%d: ID=0x%X - FAIL (Error %d)\n", - seqRuntime.currentStep + 1, seq->stepCount, step->canId, result); - } - - seqRuntime.currentStep++; - seqRuntime.lastStepTime = now; - } - } else { - // 모든 스텝 완료 - seqRuntime.currentRepeat++; - - // 반복 체크 - bool shouldContinue = false; - - if (seq->repeatMode == 0) { - // 한 번만 - shouldContinue = false; - } else if (seq->repeatMode == 1) { - // 특정 횟수 - if (seqRuntime.currentRepeat < seq->repeatCount) { - shouldContinue = true; - } - } else if (seq->repeatMode == 2) { - // 무한 반복 - shouldContinue = true; - } - - if (shouldContinue) { - seqRuntime.currentStep = 0; - seqRuntime.lastStepTime = now; - Serial.printf(" [Seq] 반복 %d회 시작\n", seqRuntime.currentRepeat + 1); - } else { - Serial.printf("✓ 시퀀스 실행 완료: %s (총 %d회 반복)\n", - seq->name, seqRuntime.currentRepeat); - stopSequence(); - } - } - } - - vTaskDelay(pdMS_TO_TICKS(10)); - } -} - -// ======================================== -// 파일 리스트 전송 함수 -// ======================================== - -void sendFileList() { - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - File root = SD.open("/"); - String fileList = "{\"type\":\"files\",\"files\":["; - bool first = true; - - File file = root.openNextFile(); - while (file) { - if (!file.isDirectory()) { - String fname = file.name(); - - // comments.txt 파일은 제외 - if (fname != "comments.txt" && fname != "/comments.txt") { - if (fname.startsWith("/")) fname = fname.substring(1); - - if (!first) fileList += ","; - fileList += "{\"name\":\"" + fname + "\","; - fileList += "\"size\":" + String(file.size()); - - // 커멘트 추가 - const char* comment = getFileComment(fname.c_str()); - if (strlen(comment) > 0) { - fileList += ",\"comment\":\"" + String(comment) + "\""; - } - - fileList += "}"; - first = false; - } - } - file.close(); - file = root.openNextFile(); - } - - root.close(); - fileList += "]}"; - - xSemaphoreGive(sdMutex); - webSocket.broadcastTXT(fileList); - } -} - -// ======================================== -// WebSocket 이벤트 처리 -// ======================================== - -void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { - if (type == WStype_TEXT) { - String message = String((char*)payload); - - // JSON 파싱 (간단한 방식) - if (message.indexOf("\"cmd\":\"startLogging\"") >= 0) { - startLogging(); - // 파일 리스트 자동 갱신 - delay(100); - sendFileList(); - - } else if (message.indexOf("\"cmd\":\"stopLogging\"") >= 0) { - stopLogging(); - // 파일 리스트 자동 갱신 - delay(100); - sendFileList(); - - } else if (message.indexOf("\"cmd\":\"getFiles\"") >= 0) { - // 파일 리스트 전송 - sendFileList(); - - } else if (message.indexOf("\"cmd\":\"deleteFile\"") >= 0) { - // 파일 삭제 - int filenameStart = message.indexOf("\"filename\":\"") + 12; - int filenameEnd = message.indexOf("\"", filenameStart); - String filename = message.substring(filenameStart, filenameEnd); - - // 로깅 중인 파일 체크 - bool canDelete = true; - if (loggingEnabled && currentFilename[0] != '\0') { - String currentFileStr = String(currentFilename); - if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); - - if (filename == currentFileStr) { - canDelete = false; - } - } - - if (!canDelete) { - webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":false,\"message\":\"Cannot delete file being logged\"}"); - } else { - String fullPath = "/" + filename; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - bool success = SD.remove(fullPath); - xSemaphoreGive(sdMutex); - - if (success) { - Serial.printf("✓ 파일 삭제: %s\n", filename.c_str()); - webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":true}"); - - // 파일 목록 자동 갱신 - delay(100); - sendFileList(); - } else { - webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":false,\"message\":\"Delete failed\"}"); - } - } - } - - } else if (message.indexOf("\"cmd\":\"setSpeed\"") >= 0) { - // CAN 속도 변경 - int speedStart = message.indexOf("\"speed\":") + 8; - int speedEnd = message.indexOf(",", speedStart); - if (speedEnd < 0) speedEnd = message.indexOf("}", speedStart); - - int speedIndex = message.substring(speedStart, speedEnd).toInt(); - - if (speedIndex >= 0 && speedIndex < 4) { - currentCanSpeed = canSpeedValues[speedIndex]; - mcp2515.reset(); - mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); - setMCP2515Mode(currentMcpMode); // 현재 모드 유지 - - // 비휘발성 메모리에 저장 - saveCANSettings(); - - Serial.printf("✓ CAN 속도 변경 및 저장: %s\n", canSpeedNames[speedIndex]); - } - - } else if (message.indexOf("\"cmd\":\"setMcpMode\"") >= 0) { - // MCP2515 모드 변경 - int modeStart = message.indexOf("\"mode\":") + 7; - int modeEnd = message.indexOf(",", modeStart); - if (modeEnd < 0) modeEnd = message.indexOf("}", modeStart); - - int mode = message.substring(modeStart, modeEnd).toInt(); - - if (mode >= 0 && mode <= 3) { // 0~3으로 확장 (TRANSMIT 모드 포함) - if (setMCP2515Mode((MCP2515Mode)mode)) { - // 모드 변경 성공 시 비휘발성 메모리에 저장 - saveCANSettings(); - } - } - - } else if (message.indexOf("\"cmd\":\"syncTimeFromPhone\"") >= 0) { - // 핸드폰 시간을 RTC와 시스템에 동기화 - int yearStart = message.indexOf("\"year\":") + 7; - int monthStart = message.indexOf("\"month\":") + 8; - int dayStart = message.indexOf("\"day\":") + 6; - int hourStart = message.indexOf("\"hour\":") + 7; - int minuteStart = message.indexOf("\"minute\":") + 9; - int secondStart = message.indexOf("\"second\":") + 9; - - int year = message.substring(yearStart, message.indexOf(",", yearStart)).toInt(); - int month = message.substring(monthStart, message.indexOf(",", monthStart)).toInt(); - int day = message.substring(dayStart, message.indexOf(",", dayStart)).toInt(); - int hour = message.substring(hourStart, message.indexOf(",", hourStart)).toInt(); - int minute = message.substring(minuteStart, message.indexOf(",", minuteStart)).toInt(); - int second = message.substring(secondStart, message.indexOf(",", secondStart)).toInt(); - - Serial.printf("📱 핸드폰 시간 수신: %04d-%02d-%02d %02d:%02d:%02d\n", - year, month, day, hour, minute, second); - - // 1. RTC에 시간 설정 (가능한 경우) - if (timeSyncStatus.rtcAvailable) { - setRTCTime(year, month, day, hour, minute, second); - } - - // 2. 시스템 시간 설정 - 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; - - struct timeval tv; - tv.tv_sec = mktime(&timeinfo); - tv.tv_usec = 0; - settimeofday(&tv, NULL); - - timeSyncStatus.synchronized = true; - timeSyncStatus.lastSyncTime = esp_timer_get_time(); - timeSyncStatus.syncCount++; - - Serial.println("✓ 시스템 시간 동기화 완료"); - - webSocket.sendTXT(num, "{\"type\":\"timeSyncResult\",\"success\":true}"); - - } else if (message.indexOf("\"cmd\":\"addComment\"") >= 0) { - // 파일 커멘트 추가 - int filenameStart = message.indexOf("\"filename\":\"") + 12; - int filenameEnd = message.indexOf("\"", filenameStart); - String filename = message.substring(filenameStart, filenameEnd); - - int commentStart = message.indexOf("\"comment\":\"") + 11; - int commentEnd = message.indexOf("\"", commentStart); - String comment = message.substring(commentStart, commentEnd); - - addFileComment(filename.c_str(), comment.c_str()); - Serial.printf("✓ 커멘트 추가: %s -> %s\n", filename.c_str(), comment.c_str()); - - webSocket.sendTXT(num, "{\"type\":\"commentResult\",\"success\":true}"); - - // 파일 목록 자동 갱신 - delay(100); - sendFileList(); - - } else if (message.indexOf("\"cmd\":\"getSettings\"") >= 0) { - // 설정 전송 - String settings = "{\"type\":\"settings\","; - settings += "\"ssid\":\"" + String(wifiSSID) + "\","; - settings += "\"password\":\"" + String(wifiPassword) + "\","; - settings += "\"staEnable\":" + String(enableSTAMode ? "true" : "false") + ","; - settings += "\"staSSID\":\"" + String(staSSID) + "\","; - settings += "\"staPassword\":\"" + String(staPassword) + "\","; - settings += "\"staConnected\":" + String(WiFi.status() == WL_CONNECTED ? "true" : "false") + ","; - settings += "\"staIP\":\"" + WiFi.localIP().toString() + "\"}"; - - webSocket.sendTXT(num, settings); - - } else if (message.indexOf("\"cmd\":\"saveSettings\"") >= 0) { - // 설정 저장 - int ssidStart = message.indexOf("\"ssid\":\"") + 8; - int ssidEnd = message.indexOf("\"", ssidStart); - String ssid = message.substring(ssidStart, ssidEnd); - - int passStart = message.indexOf("\"password\":\"") + 12; - int passEnd = message.indexOf("\"", passStart); - String password = message.substring(passStart, passEnd); - - // STA 모드 설정 파싱 - int staEnableIdx = message.indexOf("\"staEnable\":"); - if (staEnableIdx >= 0) { - String staEnableStr = message.substring(staEnableIdx + 12, staEnableIdx + 16); - enableSTAMode = (staEnableStr.indexOf("true") >= 0); - - if (enableSTAMode) { - int staSSIDStart = message.indexOf("\"staSSID\":\"") + 11; - int staSSIDEnd = message.indexOf("\"", staSSIDStart); - String staSsid = message.substring(staSSIDStart, staSSIDEnd); - - int staPassStart = message.indexOf("\"staPassword\":\"") + 15; - int staPassEnd = message.indexOf("\"", staPassStart); - String staPass = message.substring(staPassStart, staPassEnd); - - strncpy(staSSID, staSsid.c_str(), sizeof(staSSID) - 1); - strncpy(staPassword, staPass.c_str(), sizeof(staPassword) - 1); - } - } - - strncpy(wifiSSID, ssid.c_str(), sizeof(wifiSSID) - 1); - strncpy(wifiPassword, password.c_str(), sizeof(wifiPassword) - 1); - - saveSettings(); - - webSocket.sendTXT(num, "{\"type\":\"settingsSaved\",\"success\":true}"); - - } else if (message.indexOf("\"cmd\":\"getSequences\"") >= 0) { - // 시퀀스 리스트 전송 - String seqList = "{\"type\":\"sequences\",\"sequences\":["; - for (int i = 0; i < sequenceCount; i++) { - if (i > 0) seqList += ","; - seqList += "{\"index\":" + String(i); - seqList += ",\"name\":\"" + String(sequences[i].name) + "\""; - seqList += ",\"steps\":" + String(sequences[i].stepCount); - seqList += ",\"mode\":" + String(sequences[i].repeatMode); - seqList += ",\"count\":" + String(sequences[i].repeatCount) + "}"; - } - seqList += "]}"; - webSocket.sendTXT(num, seqList); - - } else if (message.indexOf("\"cmd\":\"getSequence\"") >= 0) { - // 특정 시퀀스 상세 정보 전송 - int indexStart = message.indexOf("\"index\":") + 8; - int indexEnd = message.indexOf(",", indexStart); - if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart); - int index = message.substring(indexStart, indexEnd).toInt(); - - if (index >= 0 && index < sequenceCount) { - String seqData = "{\"type\":\"sequenceDetail\",\"sequence\":{"; - seqData += "\"name\":\"" + String(sequences[index].name) + "\","; - seqData += "\"mode\":" + String(sequences[index].repeatMode) + ","; - seqData += "\"count\":" + String(sequences[index].repeatCount) + ","; - seqData += "\"steps\":["; - - for (int i = 0; i < sequences[index].stepCount; i++) { - if (i > 0) seqData += ","; - SequenceStep* step = &sequences[index].steps[i]; - seqData += "{\"id\":" + String(step->canId); - seqData += ",\"ext\":" + String(step->extended ? "true" : "false"); - seqData += ",\"dlc\":" + String(step->dlc); - seqData += ",\"data\":\""; - for (int j = 0; j < 8; j++) { - if (step->data[j] < 0x10) seqData += "0"; - seqData += String(step->data[j], HEX); - if (j < 7) seqData += " "; - } - seqData += "\",\"delay\":" + String(step->delayMs) + "}"; - } - seqData += "]}}"; - webSocket.sendTXT(num, seqData); - } - - } else if (message.indexOf("\"cmd\":\"saveSequence\"") >= 0) { - // 새 시퀀스 저장 (JSON 파싱) - CANSequence newSeq; - memset(&newSeq, 0, sizeof(CANSequence)); - - // 이름 추출 - int nameStart = message.indexOf("\"name\":\"") + 8; - int nameEnd = message.indexOf("\"", nameStart); - String name = message.substring(nameStart, nameEnd); - strncpy(newSeq.name, name.c_str(), sizeof(newSeq.name) - 1); - - // 모드 추출 - int modeStart = message.indexOf("\"mode\":") + 7; - int modeEnd = message.indexOf(",", modeStart); - newSeq.repeatMode = message.substring(modeStart, modeEnd).toInt(); - - // 반복 횟수 추출 - int countStart = message.indexOf("\"repeatCount\":") + 14; - int countEnd = message.indexOf(",", countStart); - if (countEnd < 0) countEnd = message.indexOf("}", countStart); - newSeq.repeatCount = message.substring(countStart, countEnd).toInt(); - - // 스텝 배열 파싱 - int stepsStart = message.indexOf("\"steps\":["); - if (stepsStart >= 0) { - stepsStart += 9; // "steps":[ 길이 - int stepsEnd = message.indexOf("]}", stepsStart); - String stepsJson = message.substring(stepsStart, stepsEnd); - - // 각 스텝 파싱 - newSeq.stepCount = 0; - int pos = 0; - - while (pos < stepsJson.length() && newSeq.stepCount < 20) { - int stepStart = stepsJson.indexOf("{", pos); - if (stepStart < 0) break; - - int stepEnd = stepsJson.indexOf("}", stepStart); - if (stepEnd < 0) break; - - String stepJson = stepsJson.substring(stepStart, stepEnd + 1); - - // canId 추출 - int idStart = stepJson.indexOf("\"canId\":") + 8; - int idEnd = stepJson.indexOf(",", idStart); - if (idEnd < 0) idEnd = stepJson.indexOf("}", idStart); - newSeq.steps[newSeq.stepCount].canId = stepJson.substring(idStart, idEnd).toInt(); - - // extended 추출 - int extStart = stepJson.indexOf("\"extended\":") + 11; - String extStr = stepJson.substring(extStart, extStart + 5); - newSeq.steps[newSeq.stepCount].extended = (extStr.indexOf("true") >= 0); - - // dlc 추출 - int dlcStart = stepJson.indexOf("\"dlc\":") + 6; - int dlcEnd = stepJson.indexOf(",", dlcStart); - newSeq.steps[newSeq.stepCount].dlc = stepJson.substring(dlcStart, dlcEnd).toInt(); - - // data 배열 추출 - int dataStart = stepJson.indexOf("\"data\":[") + 8; - int dataEnd = stepJson.indexOf("]", dataStart); - String dataStr = stepJson.substring(dataStart, dataEnd); - - // data 바이트 파싱 - int bytePos = 0; - int byteIdx = 0; - while (bytePos < dataStr.length() && byteIdx < 8) { - int commaPos = dataStr.indexOf(",", bytePos); - if (commaPos < 0) commaPos = dataStr.length(); - - String byteStr = dataStr.substring(bytePos, commaPos); - byteStr.trim(); - newSeq.steps[newSeq.stepCount].data[byteIdx] = byteStr.toInt(); - - byteIdx++; - bytePos = commaPos + 1; - } - - // delay 추출 - int delayStart = stepJson.indexOf("\"delayMs\":") + 10; - int delayEnd = stepJson.indexOf(",", delayStart); - if (delayEnd < 0) delayEnd = stepJson.indexOf("}", delayStart); - newSeq.steps[newSeq.stepCount].delayMs = stepJson.substring(delayStart, delayEnd).toInt(); - - newSeq.stepCount++; - pos = stepEnd + 1; - } - } - - Serial.printf("📝 시퀀스 저장: %s (%d 스텝)\n", newSeq.name, newSeq.stepCount); - for (int i = 0; i < newSeq.stepCount; i++) { - Serial.printf(" Step %d: ID=0x%X, DLC=%d, Delay=%dms\n", - i, newSeq.steps[i].canId, newSeq.steps[i].dlc, newSeq.steps[i].delayMs); - } - - int result = addSequence(newSeq); - if (result >= 0) { - webSocket.sendTXT(num, "{\"type\":\"sequenceSaved\",\"success\":true,\"index\":" + String(result) + "}"); - } else { - webSocket.sendTXT(num, "{\"type\":\"sequenceSaved\",\"success\":false}"); - } - - } else if (message.indexOf("\"cmd\":\"deleteSequence\"") >= 0) { - // 시퀀스 삭제 - int indexStart = message.indexOf("\"index\":") + 8; - int indexEnd = message.indexOf(",", indexStart); - if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart); - int index = message.substring(indexStart, indexEnd).toInt(); - - bool success = deleteSequence(index); - webSocket.sendTXT(num, "{\"type\":\"sequenceDeleted\",\"success\":" + String(success ? "true" : "false") + "}"); - - } else if (message.indexOf("\"cmd\":\"startSequence\"") >= 0) { - // 시퀀스 실행 - int indexStart = message.indexOf("\"index\":") + 8; - int indexEnd = message.indexOf(",", indexStart); - if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart); - int index = message.substring(indexStart, indexEnd).toInt(); - - startSequence(index); - webSocket.sendTXT(num, "{\"type\":\"sequenceStarted\",\"success\":true}"); - - } else if (message.indexOf("\"cmd\":\"stopSequence\"") >= 0) { - // 시퀀스 중지 - stopSequence(); - webSocket.sendTXT(num, "{\"type\":\"sequenceStopped\",\"success\":true}"); - } - } -} - -// ======================================== -// 웹 업데이트 태스크 -// ======================================== - -void webUpdateTask(void* parameter) { - uint32_t lastStatusUpdate = 0; - uint32_t lastCanUpdate = 0; - - for (;;) { - webSocket.loop(); - - uint32_t now = millis(); - - // 상태 업데이트 (500ms) - if (now - lastStatusUpdate >= 500) { - String status = "{\"type\":\"status\","; - status += "\"logging\":" + String(loggingEnabled ? "true" : "false") + ","; - status += "\"sdReady\":" + String(sdCardReady ? "true" : "false") + ","; - status += "\"queueSize\":" + String(uxQueueMessagesWaiting(canQueue)) + ","; - status += "\"queueMax\":" + String(CAN_QUEUE_SIZE) + ","; - status += "\"totalMsg\":" + String(totalMsgCount) + ","; - status += "\"msgPerSec\":" + String(msgPerSecond) + ","; - status += "\"fileSize\":" + String(currentFileSize) + ","; - status += "\"currentFile\":\""; - - if (currentFilename[0] != '\0') { - String fname = String(currentFilename); - if (fname.startsWith("/")) fname = fname.substring(1); - status += fname; - } - - status += "\",\"timeSynced\":" + String(timeSyncStatus.synchronized ? "true" : "false") + ","; - status += "\"rtcAvailable\":" + String(timeSyncStatus.rtcAvailable ? "true" : "false") + ","; - status += "\"rtcSyncCount\":" + String(timeSyncStatus.rtcSyncCount) + ","; - status += "\"voltage\":" + String(powerStatus.voltage, 2) + ","; - status += "\"minVoltage\":" + String(powerStatus.minVoltage, 2) + ","; - status += "\"lowVoltage\":" + String(powerStatus.lowVoltage ? "true" : "false") + ","; - status += "\"mcpMode\":" + String(currentMcpMode); - status += "}"; - - webSocket.broadcastTXT(status); - lastStatusUpdate = now; - } - - // CAN 데이터 배치 전송 (100ms) - if (now - lastCanUpdate >= 100) { - String canBatch = "{\"type\":\"canBatch\",\"messages\":["; - int messageCount = 0; - - for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].count > 0) { - CANMessage* msg = &recentData[i].msg; - - if (messageCount > 0) canBatch += ","; - - canBatch += "{\"id\":\"0x"; - canBatch += String(msg->id, HEX); - canBatch += "\",\"dlc\":" + String(msg->dlc); - canBatch += ",\"data\":\""; - - for (int j = 0; j < msg->dlc; j++) { - if (msg->data[j] < 0x10) canBatch += "0"; - canBatch += String(msg->data[j], HEX); - if (j < msg->dlc - 1) canBatch += " "; - } - - uint64_t timestamp_ms = msg->timestamp_us / 1000; - canBatch += "\",\"timestamp\":" + String((uint32_t)timestamp_ms); - canBatch += ",\"count\":" + String(recentData[i].count) + "}"; - - messageCount++; - } - } - - canBatch += "]}"; - - if (messageCount > 0) { - webSocket.broadcastTXT(canBatch); - } - - lastCanUpdate = now; - } - - vTaskDelay(pdMS_TO_TICKS(50)); - } -} - -void setup() { - Serial.begin(115200); - delay(1000); - Serial.println("\n========================================"); - Serial.println(" ESP32 CAN Logger v2.0 "); - Serial.println(" + Phone Time Sync & File Comments "); - Serial.println(" + MCP2515 Mode Control "); - Serial.println("========================================"); - - // 설정 로드 - loadSettings(); - loadSerialSettings(); - - // 설정값 표시 - Serial.println("\n📋 현재 설정값:"); - Serial.println("----------------------------------------"); - Serial.printf(" WiFi SSID : %s\n", wifiSSID); - Serial.printf(" WiFi Password : %s\n", wifiPassword); - Serial.println("----------------------------------------"); - Serial.println("💡 설정 변경: http://[IP]/settings\n"); - - memset(recentData, 0, sizeof(recentData)); - memset(txMessages, 0, sizeof(txMessages)); - memset(fileComments, 0, sizeof(fileComments)); - - pinMode(CAN_INT_PIN, INPUT_PULLUP); - - // Serial2 초기화 - applySerialConfig(); - Serial.println("✓ Serial2 초기화 완료"); - - // ADC 설정 (전압 모니터링용) - analogSetAttenuation(ADC_11db); // 0-3.3V 범위 - - // SPI 초기화 - hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); - vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); - - // VSPI 클럭 속도를 40MHz로 설정 (SD 카드용) - vspi.setFrequency(40000000); // 40MHz - - // MCP2515 초기화 - mcp2515.reset(); - // 저장된 CAN 속도 적용 (loadSettings에서 로드됨) - mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); - // 저장된 MCP 모드 적용 (loadSettings에서 로드됨) - setMCP2515Mode(currentMcpMode); - Serial.println("✓ MCP2515 초기화 완료 (저장된 설정 적용)"); - - // Mutex 생성 (다른 초기화보다 먼저!) - sdMutex = xSemaphoreCreateMutex(); - rtcMutex = xSemaphoreCreateMutex(); - serialMutex = xSemaphoreCreateMutex(); - - if (sdMutex == NULL || rtcMutex == NULL || serialMutex == NULL) { - Serial.println("✗ Mutex 생성 실패!"); - while (1) delay(1000); - } - - // RTC 초기화 (SoftWire 사용) - Mutex 생성 후 - initRTC(); - - // SD 카드 초기화 - if (SD.begin(VSPI_CS, vspi)) { - sdCardReady = true; - Serial.println("✓ SD 카드 초기화 완료"); - - // 파일 커멘트 로드 - loadFileComments(); - } else { - Serial.println("✗ SD 카드 초기화 실패"); - } - - // WiFi 설정 - APSTA 모드 지원 - if (enableSTAMode && strlen(staSSID) > 0) { - // APSTA 모드 (AP + Station 동시 동작) - Serial.println("\n📶 WiFi APSTA 모드 시작..."); - - WiFi.mode(WIFI_AP_STA); - - // AP 모드 시작 - WiFi.softAP(wifiSSID, wifiPassword); - Serial.print("✓ AP SSID: "); - Serial.println(wifiSSID); - Serial.print("✓ AP IP: "); - Serial.println(WiFi.softAPIP()); - - // Station 모드로 WiFi 연결 시도 - Serial.printf("📡 WiFi 연결 시도: %s\n", staSSID); - WiFi.begin(staSSID, staPassword); - - // 연결 대기 (최대 10초) - int attempts = 0; - while (WiFi.status() != WL_CONNECTED && attempts < 20) { - delay(500); - Serial.print("."); - attempts++; - } - Serial.println(); - - if (WiFi.status() == WL_CONNECTED) { - Serial.println("✓ WiFi 연결 성공!"); - Serial.print("✓ STA IP: "); - Serial.println(WiFi.localIP()); - Serial.print("✓ Gateway: "); - Serial.println(WiFi.gatewayIP()); - Serial.print("✓ DNS: "); - Serial.println(WiFi.dnsIP()); - } else { - Serial.println("✗ WiFi 연결 실패 (AP 모드는 정상 동작)"); - } - } else { - // AP 모드만 사용 - Serial.println("\n📶 WiFi AP 모드 시작..."); - WiFi.mode(WIFI_AP); - WiFi.softAP(wifiSSID, wifiPassword); - - Serial.print("✓ AP SSID: "); - Serial.println(wifiSSID); - Serial.print("✓ AP IP: "); - Serial.println(WiFi.softAPIP()); - } - - // WiFi 성능 최적화 - WiFi.setSleep(false); // WiFi 절전 모드 비활성화 (신호 강도 개선) - esp_wifi_set_max_tx_power(84); // TX 출력 최대화 (20.5dBm = 84/4) - - // WebSocket 시작 - webSocket.begin(); - webSocket.onEvent(webSocketEvent); - - // Serial WebSocket 시작 - serialWebSocket.begin(); - serialWebSocket.onEvent(serialWebSocketEvent); - Serial.println("✓ Serial WebSocket 시작 (포트 82)"); - - // 웹 서버 라우팅 - server.on("/", HTTP_GET, []() { - server.send_P(200, "text/html", index_html); - }); - - server.on("/transmit", HTTP_GET, []() { - server.send_P(200, "text/html", transmit_html); - }); - - server.on("/graph", HTTP_GET, []() { - server.send_P(200, "text/html", graph_html); - }); - - server.on("/graph-view", HTTP_GET, []() { - server.send_P(200, "text/html", graph_viewer_html); - }); - - server.on("/settings", HTTP_GET, []() { - server.send_P(200, "text/html", settings_html); - }); - - server.on("/serial", HTTP_GET, []() { - server.send_P(200, "text/html", serial_terminal_html); - }); - - server.on("/download", HTTP_GET, []() { - if (server.hasArg("file")) { - String filename = "/" + server.arg("file"); - - if (SD.exists(filename)) { - File file = SD.open(filename, FILE_READ); - if (file) { - String displayName = server.arg("file"); - - server.sendHeader("Content-Disposition", - "attachment; filename=\"" + displayName + "\""); - server.sendHeader("Content-Type", "application/octet-stream"); - - server.streamFile(file, "application/octet-stream"); - file.close(); - } else { - server.send(500, "text/plain", "Failed to open file"); - } - } else { - server.send(404, "text/plain", "File not found"); - } - } else { - server.send(400, "text/plain", "Bad request"); - } - }); - - // 파일 삭제 HTTP 엔드포인트 추가 (백업용 - 주로 WebSocket 사용) - server.on("/delete", HTTP_GET, []() { - if (server.hasArg("file")) { - String filename = server.arg("file"); - - // 로깅 중인 파일은 삭제 불가 - if (loggingEnabled && currentFilename[0] != '\0') { - String currentFileStr = String(currentFilename); - if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); - - if (filename == currentFileStr) { - server.send(403, "text/plain", "Cannot delete file currently being logged"); - return; - } - } - - String fullPath = "/" + filename; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (SD.exists(fullPath)) { - if (SD.remove(fullPath)) { - xSemaphoreGive(sdMutex); - server.send(200, "text/plain", "File deleted successfully"); - Serial.printf("✓ HTTP 파일 삭제: %s\n", filename.c_str()); - } else { - xSemaphoreGive(sdMutex); - server.send(500, "text/plain", "Failed to delete file"); - } - } else { - xSemaphoreGive(sdMutex); - server.send(404, "text/plain", "File not found"); - } - } else { - server.send(503, "text/plain", "SD card busy"); - } - } else { - server.send(400, "text/plain", "Bad request"); - } - }); - - server.begin(); - - // Queue 생성 - canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); - serialTxQueue = xQueueCreate(SERIAL_TX_QUEUE_SIZE, 256); - serialLogQueue = xQueueCreate(200, sizeof(SerialLogData)); - - if (canQueue == NULL || serialTxQueue == NULL || serialLogQueue == NULL) { - Serial.println("✗ Queue 생성 실패!"); - while (1) delay(1000); - } - Serial.println("✓ Serial Queue 생성 완료"); - - // CAN 인터럽트 활성화 - attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - - // Task 생성 - xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 5, &canRxTaskHandle, 1); // 우선순위 4→5 - xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 12288, NULL, 3, &sdWriteTaskHandle, 1); - xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0); - xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0); - xTaskCreatePinnedToCore(txTask, "TX_TASK", 4096, NULL, 2, NULL, 1); - xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4096, NULL, 2, NULL, 1); // 시퀀스 Task 추가 - - // Serial Task 생성 - xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 4096, NULL, 4, &serialRxTaskHandle, 1); - xTaskCreatePinnedToCore(serialTxTask, "SERIAL_TX", 4096, NULL, 3, &serialTxTaskHandle, 1); - xTaskCreatePinnedToCore(serialLogTask, "SERIAL_LOG", 8192, NULL, 2, &serialLogTaskHandle, 1); - xTaskCreatePinnedToCore(serialWebUpdateTask, "SERIAL_WEB", 4096, NULL, 2, &serialWebTaskHandle, 0); - Serial.println("✓ Serial Task 시작 완료"); - - // RTC 동기화 Task - if (timeSyncStatus.rtcAvailable) { - xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 4096, NULL, 0, &rtcTaskHandle, 0); - Serial.println("✓ RTC 자동 동기화 Task 시작"); - } - - // 시퀀스 로드 - loadSequences(); - - Serial.println("✓ 모든 태스크 시작 완료"); - Serial.println("\n========================================"); - Serial.println(" 웹 인터페이스 접속 방법"); - Serial.println("========================================"); - Serial.printf(" 1. WiFi 연결\n"); - Serial.printf(" - SSID : %s\n", wifiSSID); - Serial.printf(" - Password : %s\n", wifiPassword); - Serial.printf(" 2. 웹 브라우저에서 접속\n"); - Serial.print(" - URL : http://"); - Serial.println(WiFi.softAPIP()); - Serial.println(" 3. 페이지 메뉴:"); - Serial.println(" - Monitor : /"); - Serial.println(" - Transmit : /transmit"); - Serial.println(" - Graph : /graph"); - Serial.println(" - Settings : /settings"); - Serial.println(" - Serial : /serial"); - Serial.println("========================================\n"); -} - -void loop() { - server.handleClient(); - vTaskDelay(pdMS_TO_TICKS(10)); - - static uint32_t lastPrint = 0; - if (millis() - lastPrint > 10000) { - Serial.printf("[상태] CAN큐: %d/%d | CAN: %s | Serial: RX=%u TX=%u LOG=%s | SD: %s | 시간: %s | 전압: %.2fV%s\n", - uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, - loggingEnabled ? "ON " : "OFF", - serialStatus.rxCount, serialStatus.txCount, - serialStatus.logging ? "ON" : "OFF", - sdCardReady ? "OK" : "NO", - timeSyncStatus.synchronized ? "OK" : "NO", - powerStatus.voltage, - powerStatus.lowVoltage ? " ⚠️" : ""); - lastPrint = millis(); - } -} +/* + * Byun CAN Logger with Web Interface + RTC Time Synchronization + * Version: 2.0 + * Added: Phone time sync to RTC, File comments, MCP2515 mode control + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "index.h" +#include "transmit.h" +#include "graph.h" +#include "graph_viewer.h" +#include "settings.h" + +// GPIO 핀 정의 +#define CAN_INT_PIN 27 + +// HSPI 핀 (CAN) +#define HSPI_MISO 12 +#define HSPI_MOSI 13 +#define HSPI_SCLK 14 +#define HSPI_CS 15 + +// VSPI 핀 (SD Card) +#define VSPI_MISO 19 +#define VSPI_MOSI 23 +#define VSPI_SCLK 18 +#define VSPI_CS 5 + +// I2C2 핀 (RTC DS3231) - SoftWire 사용 +#define RTC_SDA 25 +#define RTC_SCL 26 +#define DS3231_ADDRESS 0x68 + +// 버퍼 설정 +#define CAN_QUEUE_SIZE 2000 // 1000 → 2000으로 증가 +#define FILE_BUFFER_SIZE 16384 // 8192 → 16384 (16KB)로 증가 +#define MAX_FILENAME_LEN 64 +#define RECENT_MSG_COUNT 100 +#define MAX_TX_MESSAGES 20 +#define MAX_COMMENT_LEN 128 + +// RTC 동기화 설정 +#define RTC_SYNC_INTERVAL_MS 60000 // 1분마다 RTC와 동기화 + +// 전력 모니터링 설정 +#define VOLTAGE_CHECK_INTERVAL_MS 5000 // 5초마다 전압 체크 +#define LOW_VOLTAGE_THRESHOLD 3.0 // 3.0V 이하이면 경고 + +// CAN 메시지 구조체 - 마이크로초 단위 타임스탬프 +struct CANMessage { + uint64_t timestamp_us; // 마이크로초 단위 Unix timestamp + uint32_t id; + uint8_t dlc; + uint8_t data[8]; +} __attribute__((packed)); + +// 실시간 모니터링용 구조체 +struct RecentCANData { + CANMessage msg; + uint32_t count; +}; + +// CAN 송신용 구조체 +struct TxMessage { + uint32_t id; + bool extended; + uint8_t dlc; + uint8_t data[8]; + uint32_t interval; + uint32_t lastSent; + bool active; +}; + +// CAN 시퀀스 스텝 구조체 +struct SequenceStep { + uint32_t canId; + bool extended; + uint8_t dlc; + uint8_t data[8]; + uint32_t delayMs; // 이 스텝 실행 후 대기 시간 (ms) +}; + +// CAN 시퀀스 구조체 +struct CANSequence { + char name[32]; + SequenceStep steps[20]; // 최대 20개 스텝 + uint8_t stepCount; + uint8_t repeatMode; // 0=한번, 1=특정횟수, 2=무한 + uint32_t repeatCount; // repeatMode=1일 때 반복 횟수 +}; + +// 시퀀스 실행 상태 +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; // 1초 단위 최소 전압 + bool lowVoltage; + uint32_t lastCheck; + uint32_t lastMinReset; // 최소값 리셋 시간 +} powerStatus = {0.0, 999.9, false, 0, 0}; + +// MCP2515 레지스터 주소 정의 (라이브러리에 없는 경우) +#ifndef MCP_CANCTRL +#define MCP_CANCTRL 0x0F +#endif + +#ifndef MCP_CANSTAT +#define MCP_CANSTAT 0x0E +#endif + +// MCP2515 모드 정의 +enum MCP2515Mode { + MCP_MODE_NORMAL = 0, + MCP_MODE_LISTEN_ONLY = 1, + MCP_MODE_LOOPBACK = 2, + MCP_MODE_TRANSMIT = 3 // 송신만 (ACK 없음) +}; + +// WiFi AP 기본 설정 +char wifiSSID[32] = "Byun_CAN_Logger"; +char wifiPassword[64] = "12345678"; + +// WiFi Station 모드 설정 (추가) +bool enableSTAMode = false; // APSTA 모드 활성화 여부 +char staSSID[32] = ""; // 연결할 WiFi SSID +char staPassword[64] = ""; // 연결할 WiFi 비밀번호 + +// 전역 변수 +SPIClass hspi(HSPI); +SPIClass vspi(VSPI); +MCP2515 mcp2515(HSPI_CS, 20000000, &hspi); // 20MHz로 증가 (10MHz → 20MHz) + +WebServer server(80); +WebSocketsServer webSocket = WebSocketsServer(81); +Preferences preferences; + +// Forward declaration +void IRAM_ATTR canISR(); + +QueueHandle_t canQueue; +SemaphoreHandle_t sdMutex; +SemaphoreHandle_t rtcMutex; +TaskHandle_t canRxTaskHandle = NULL; +TaskHandle_t sdWriteTaskHandle = NULL; +TaskHandle_t webTaskHandle = NULL; +TaskHandle_t rtcTaskHandle = NULL; + +volatile bool loggingEnabled = false; +volatile bool sdCardReady = false; +File logFile; +char currentFilename[MAX_FILENAME_LEN]; +uint8_t fileBuffer[FILE_BUFFER_SIZE]; +uint16_t bufferIndex = 0; + +// 로깅 파일 크기 추적 (실시간 모니터링용) +volatile uint32_t currentFileSize = 0; + +// 현재 MCP2515 모드 +MCP2515Mode currentMcpMode = MCP_MODE_NORMAL; + +// RTC 관련 +SoftWire rtcWire(RTC_SDA, RTC_SCL); +char rtcSyncBuffer[20]; + +// CAN 속도 설정 +CAN_SPEED currentCanSpeed = CAN_1000KBPS; +const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; +CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; + +// 실시간 모니터링용 +RecentCANData recentData[RECENT_MSG_COUNT]; +uint32_t totalMsgCount = 0; +uint32_t msgPerSecond = 0; +uint32_t lastMsgCountTime = 0; +uint32_t lastMsgCount = 0; + +// 그래프 최대 개수 +#define MAX_GRAPH_SIGNALS 20 + +// CAN 송신용 +TxMessage txMessages[MAX_TX_MESSAGES]; +uint32_t totalTxCount = 0; + +// CAN 시퀀스 (최대 10개 저장 가능) +#define MAX_SEQUENCES 10 +CANSequence sequences[MAX_SEQUENCES]; +uint8_t sequenceCount = 0; +SequenceRuntime seqRuntime = {false, 0, 0, 0, -1}; + +// 파일 커멘트 저장 (최대 50개) +#define MAX_FILE_COMMENTS 50 +FileComment fileComments[MAX_FILE_COMMENTS]; +int commentCount = 0; + +// ======================================== +// 설정 저장/로드 함수 +// ======================================== + +void loadSettings() { + preferences.begin("can-logger", false); + + // WiFi AP 설정 로드 + preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); + preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); + + // WiFi STA 모드 설정 로드 (추가) + enableSTAMode = preferences.getBool("sta_enable", false); + preferences.getString("sta_ssid", staSSID, sizeof(staSSID)); + preferences.getString("sta_pass", staPassword, sizeof(staPassword)); + + // 설정이 없으면 기본값 사용 + if (strlen(wifiSSID) == 0) { + strcpy(wifiSSID, "Byun_CAN_Logger"); + } + if (strlen(wifiPassword) == 0) { + strcpy(wifiPassword, "12345678"); + } + + // CAN 속도 로드 (기본값: 1Mbps = 3) + int speedIndex = preferences.getInt("can_speed", 3); + if (speedIndex >= 0 && speedIndex < 4) { + currentCanSpeed = canSpeedValues[speedIndex]; + Serial.printf("✓ 저장된 CAN 속도 로드: %s\n", canSpeedNames[speedIndex]); + } + + // MCP2515 모드 로드 (기본값: Normal = 0) + int mcpMode = preferences.getInt("mcp_mode", 0); + if (mcpMode >= 0 && mcpMode <= 3) { + currentMcpMode = (MCP2515Mode)mcpMode; + Serial.printf("✓ 저장된 MCP 모드 로드: %d\n", mcpMode); + } + + preferences.end(); + + // STA 모드 설정 출력 + if (enableSTAMode && strlen(staSSID) > 0) { + Serial.printf("✓ WiFi STA 모드: 활성화 (SSID: %s)\n", staSSID); + } +} + +void saveSettings() { + preferences.begin("can-logger", false); + + // WiFi AP 설정 저장 + preferences.putString("wifi_ssid", wifiSSID); + preferences.putString("wifi_pass", wifiPassword); + + // WiFi STA 모드 설정 저장 (추가) + preferences.putBool("sta_enable", enableSTAMode); + preferences.putString("sta_ssid", staSSID); + preferences.putString("sta_pass", staPassword); + + preferences.end(); + + Serial.println("\n✓ 설정 저장 완료:"); + Serial.println("----------------------------------------"); + Serial.printf(" WiFi AP SSID : %s\n", wifiSSID); + Serial.printf(" WiFi AP Password : %s\n", wifiPassword); + if (enableSTAMode && strlen(staSSID) > 0) { + Serial.printf(" STA Mode : 활성화\n"); + Serial.printf(" STA SSID : %s\n", staSSID); + } else { + Serial.printf(" STA Mode : 비활성화\n"); + } + Serial.println("----------------------------------------"); + Serial.println("⚠️ 재부팅 후 WiFi 설정이 적용됩니다."); +} + +void saveCANSettings() { + preferences.begin("can-logger", false); + + // CAN 속도 저장 (인덱스로 저장) + int speedIndex = 3; // 기본값: 1M + for (int i = 0; i < 4; i++) { + if (canSpeedValues[i] == currentCanSpeed) { + speedIndex = i; + break; + } + } + preferences.putInt("can_speed", speedIndex); + + // MCP2515 모드 저장 + preferences.putInt("mcp_mode", (int)currentMcpMode); + + preferences.end(); + + Serial.println("\n✓ CAN 설정 저장 완료:"); + Serial.println("----------------------------------------"); + Serial.printf(" CAN Speed : %s\n", canSpeedNames[speedIndex]); + Serial.printf(" MCP Mode : %d\n", (int)currentMcpMode); + Serial.println("----------------------------------------"); +} + +// ======================================== +// 시퀀스 관리 함수 +// ======================================== + +void loadSequences() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File seqFile = SD.open("/sequences.bin", FILE_READ); + if (seqFile) { + sequenceCount = 0; + while (seqFile.available() && sequenceCount < MAX_SEQUENCES) { + seqFile.read((uint8_t*)&sequences[sequenceCount], sizeof(CANSequence)); + sequenceCount++; + } + seqFile.close(); + Serial.printf("✓ 시퀀스 로드: %d개\n", sequenceCount); + } + xSemaphoreGive(sdMutex); + } +} + +void saveSequences() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + // 기존 파일 삭제 + if (SD.exists("/sequences.bin")) { + SD.remove("/sequences.bin"); + } + + File seqFile = SD.open("/sequences.bin", FILE_WRITE); + if (seqFile) { + for (int i = 0; i < sequenceCount; i++) { + seqFile.write((uint8_t*)&sequences[i], sizeof(CANSequence)); + } + seqFile.close(); + Serial.println("✓ 시퀀스 저장 완료"); + } + xSemaphoreGive(sdMutex); + } +} + +int addSequence(const CANSequence& seq) { + if (sequenceCount >= MAX_SEQUENCES) { + return -1; // 가득 참 + } + + sequences[sequenceCount] = seq; + sequenceCount++; + saveSequences(); + return sequenceCount - 1; +} + +bool deleteSequence(uint8_t index) { + if (index >= sequenceCount) return false; + + // 배열 왼쪽으로 시프트 + for (int i = index; i < sequenceCount - 1; i++) { + sequences[i] = sequences[i + 1]; + } + sequenceCount--; + saveSequences(); + return true; +} + +void stopSequence() { + seqRuntime.running = false; + seqRuntime.currentStep = 0; + seqRuntime.currentRepeat = 0; + seqRuntime.activeSequenceIndex = -1; + Serial.println("✓ 시퀀스 실행 중지"); +} + +void startSequence(uint8_t index) { + if (index >= sequenceCount) return; + + seqRuntime.running = true; + seqRuntime.currentStep = 0; + seqRuntime.currentRepeat = 0; + seqRuntime.lastStepTime = millis(); + seqRuntime.activeSequenceIndex = index; + + Serial.printf("✓ 시퀀스 실행 시작: %s\n", sequences[index].name); +} + +// ======================================== +// 파일 커멘트 관리 함수 +// ======================================== + +void loadFileComments() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File commentFile = SD.open("/comments.txt", FILE_READ); + if (commentFile) { + commentCount = 0; + while (commentFile.available() && commentCount < MAX_FILE_COMMENTS) { + String line = commentFile.readStringUntil('\n'); + line.trim(); + + int separatorPos = line.indexOf('|'); + if (separatorPos > 0) { + String filename = line.substring(0, separatorPos); + String comment = line.substring(separatorPos + 1); + + strncpy(fileComments[commentCount].filename, filename.c_str(), MAX_FILENAME_LEN - 1); + strncpy(fileComments[commentCount].comment, comment.c_str(), MAX_COMMENT_LEN - 1); + commentCount++; + } + } + commentFile.close(); + Serial.printf("✓ 파일 커멘트 로드: %d개\n", commentCount); + } + xSemaphoreGive(sdMutex); + } +} + +void saveFileComments() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + // 기존 파일 삭제 + if (SD.exists("/comments.txt")) { + SD.remove("/comments.txt"); + } + + File commentFile = SD.open("/comments.txt", FILE_WRITE); + if (commentFile) { + for (int i = 0; i < commentCount; i++) { + commentFile.print(fileComments[i].filename); + commentFile.print("|"); + commentFile.println(fileComments[i].comment); + } + commentFile.close(); + Serial.println("✓ 파일 커멘트 저장 완료"); + } + xSemaphoreGive(sdMutex); + } +} + +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); + saveFileComments(); + return; + } + } + + // 새로운 커멘트 추가 + if (commentCount < MAX_FILE_COMMENTS) { + strncpy(fileComments[commentCount].filename, filename, MAX_FILENAME_LEN - 1); + strncpy(fileComments[commentCount].comment, comment, MAX_COMMENT_LEN - 1); + commentCount++; + saveFileComments(); + } +} + +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 ""; +} + +// ======================================== +// 전력 모니터링 함수 +// ======================================== + +float readVoltage() { + // ESP32 내부 ADC로 전압 측정 + // GPIO34는 ADC1_CH6에 연결되어 있음 + // 실제 배터리 전압을 측정하려면 분압 회로가 필요할 수 있음 + + // 여러 번 샘플링하여 평균값 계산 + const int samples = 10; + uint32_t sum = 0; + + for (int i = 0; i < samples; i++) { + sum += analogRead(34); + delayMicroseconds(100); + } + + uint32_t avg = sum / samples; + + // ESP32 ADC: 12bit (0-4095), 참조전압 3.3V + // 실제 전압 = (ADC값 / 4095) * 3.3V + float voltage = (avg / 4095.0) * 3.3; + + return voltage; +} + +void updatePowerStatus() { + uint32_t now = millis(); + + // 5초마다 전압 체크 + if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { + powerStatus.voltage = readVoltage(); + + // 1초 단위 최소값 업데이트 + if (powerStatus.voltage < powerStatus.minVoltage) { + powerStatus.minVoltage = powerStatus.voltage; + } + + // 1초마다 최소값 리셋 + if (now - powerStatus.lastMinReset >= 1000) { + powerStatus.minVoltage = powerStatus.voltage; + powerStatus.lastMinReset = now; + } + + // 저전압 경고 + if (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD) { + if (!powerStatus.lowVoltage) { + Serial.printf("⚠️ 저전압 경고: %.2fV\n", powerStatus.voltage); + powerStatus.lowVoltage = true; + } + } else { + powerStatus.lowVoltage = false; + } + + powerStatus.lastCheck = now; + } +} + +// ======================================== +// RTC 함수 +// ======================================== + +uint8_t bcdToDec(uint8_t val) { + return ((val / 16 * 10) + (val % 16)); +} + +uint8_t decToBcd(uint8_t val) { + return ((val / 10 * 16) + (val % 10)); +} + +bool initRTC() { + rtcWire.begin(); + rtcWire.setTimeout(1000); + + rtcWire.beginTransmission(DS3231_ADDRESS); + uint8_t error = rtcWire.endTransmission(); + + if (error == 0) { + timeSyncStatus.rtcAvailable = true; + Serial.println("✓ RTC DS3231 초기화 완료"); + return true; + } else { + timeSyncStatus.rtcAvailable = false; + Serial.println("✗ RTC DS3231 없음 (수동 시간 설정 필요)"); + return false; + } +} + +bool setRTCTime(int year, int month, int day, int hour, int minute, int second) { + if (!timeSyncStatus.rtcAvailable) return false; + + if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x00); // 레지스터 주소 + + rtcWire.write(decToBcd(second)); + rtcWire.write(decToBcd(minute)); + rtcWire.write(decToBcd(hour)); + rtcWire.write(decToBcd(1)); // 요일 (사용안함) + rtcWire.write(decToBcd(day)); + rtcWire.write(decToBcd(month)); + rtcWire.write(decToBcd(year - 2000)); + + uint8_t error = rtcWire.endTransmission(); + xSemaphoreGive(rtcMutex); + + if (error == 0) { + Serial.printf("✓ RTC 시간 설정: %04d-%02d-%02d %02d:%02d:%02d\n", + year, month, day, hour, minute, second); + return true; + } + } + return false; +} + +bool getRTCTime(struct tm* timeinfo) { + if (!timeSyncStatus.rtcAvailable) return false; + + if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x00); // 레지스터 시작 주소 + + if (rtcWire.endTransmission() != 0) { + xSemaphoreGive(rtcMutex); + return false; + } + + rtcWire.requestFrom(DS3231_ADDRESS, 7); + + if (rtcWire.available() >= 7) { + uint8_t second = bcdToDec(rtcWire.read() & 0x7F); + uint8_t minute = bcdToDec(rtcWire.read()); + uint8_t hour = bcdToDec(rtcWire.read() & 0x3F); + rtcWire.read(); // 요일 스킵 + uint8_t day = bcdToDec(rtcWire.read()); + uint8_t month = bcdToDec(rtcWire.read()); + uint8_t year = bcdToDec(rtcWire.read()); + + timeinfo->tm_sec = second; + timeinfo->tm_min = minute; + timeinfo->tm_hour = hour; + timeinfo->tm_mday = day; + timeinfo->tm_mon = month - 1; + timeinfo->tm_year = year + 100; + + xSemaphoreGive(rtcMutex); + return true; + } + + xSemaphoreGive(rtcMutex); + } + + return false; +} + +void syncSystemTimeFromRTC() { + struct tm timeinfo; + + if (getRTCTime(&timeinfo)) { + struct timeval tv; + tv.tv_sec = mktime(&timeinfo); + tv.tv_usec = 0; + settimeofday(&tv, NULL); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = esp_timer_get_time(); + timeSyncStatus.rtcSyncCount++; + + Serial.printf("✓ RTC→시스템 동기화 (%d회): %04d-%02d-%02d %02d:%02d:%02d\n", + timeSyncStatus.rtcSyncCount, + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); + } +} + +// ======================================== +// MCP2515 모드 제어 함수 +// ======================================== + +bool setMCP2515Mode(MCP2515Mode mode) { + MCP2515::ERROR result; + + switch (mode) { + case MCP_MODE_NORMAL: + result = mcp2515.setNormalMode(); + if (result == MCP2515::ERROR_OK) { + // 인터럽트 재활성화 + attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); + currentMcpMode = MCP_MODE_NORMAL; + Serial.println("✓ MCP2515 모드: NORMAL"); + Serial.println(" 송수신 모두 가능, ACK 전송"); + return true; + } + break; + + case MCP_MODE_LISTEN_ONLY: + result = mcp2515.setListenOnlyMode(); + if (result == MCP2515::ERROR_OK) { + // 인터럽트 재활성화 + attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); + currentMcpMode = MCP_MODE_LISTEN_ONLY; + Serial.println("✓ MCP2515 모드: LISTEN-ONLY"); + Serial.println(" 수신만 가능, ACK 전송 안 함"); + return true; + } + break; + + case MCP_MODE_LOOPBACK: + result = mcp2515.setLoopbackMode(); + if (result == MCP2515::ERROR_OK) { + // 인터럽트 재활성화 + attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); + currentMcpMode = MCP_MODE_LOOPBACK; + Serial.println("✓ MCP2515 모드: LOOPBACK"); + Serial.println(" 자가 테스트 모드"); + return true; + } + break; + + case MCP_MODE_TRANSMIT: + // TRANSMIT 모드: Listen-Only 기반 + // 평상시에는 Listen-Only로 동작하여 ACK를 보내지 않음 + // 송신이 필요할 때만 일시적으로 Normal 모드로 전환 + result = mcp2515.setListenOnlyMode(); + if (result == MCP2515::ERROR_OK) { + // 인터럽트는 비활성화 (수신 처리하지 않음) + detachInterrupt(digitalPinToInterrupt(CAN_INT_PIN)); + + // 수신 버퍼를 비움 + struct can_frame frame; + while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { + // 버퍼만 비움 + } + + currentMcpMode = MCP_MODE_TRANSMIT; + Serial.println("✓ MCP2515 모드: TRANSMIT-ONLY"); + Serial.println(" 기본: Listen-Only (ACK 안 함)"); + Serial.println(" 송신 시: 일시적으로 Normal 전환"); + Serial.println(" 수신 인터럽트: 비활성화"); + return true; + } + break; + } + + Serial.println("✗ MCP2515 모드 변경 실패"); + return false; +} + +// ======================================== +// CAN 송신 헬퍼 함수 (TRANSMIT 모드 자동 처리) +// ======================================== + +MCP2515::ERROR sendCANMessage(struct can_frame* frame) { + MCP2515::ERROR result; + + // TRANSMIT 모드일 때만 특별 처리 + if (currentMcpMode == MCP_MODE_TRANSMIT) { + // 1. 일시적으로 Normal 모드로 전환 + result = mcp2515.setNormalMode(); + if (result != MCP2515::ERROR_OK) { + Serial.println("✗ TRANSMIT 모드: Normal 전환 실패"); + return result; + } + + // 2. 메시지 송신 + result = mcp2515.sendMessage(frame); + + // 3. 즉시 Listen-Only 모드로 복귀 + mcp2515.setListenOnlyMode(); + + // 4. 수신 버퍼 비우기 (전환 중 수신된 데이터) + struct can_frame dummy; + while (mcp2515.readMessage(&dummy) == MCP2515::ERROR_OK) { + // 버퍼만 비움 + } + + return result; + } else { + // 다른 모드에서는 바로 송신 + return mcp2515.sendMessage(frame); + } +} + +// ======================================== +// CAN 인터럽트 및 수신 함수 +// ======================================== + +void IRAM_ATTR canISR() { + BaseType_t xHigherPriorityTaskWoken = pdFALSE; + vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); + if (xHigherPriorityTaskWoken) { + portYIELD_FROM_ISR(); + } +} + +void canRxTask(void* parameter) { + struct can_frame frame; + CANMessage canMsg; + + for (;;) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + // 한 번에 여러 메시지를 읽어서 처리 속도 향상 + int readCount = 0; + while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK && readCount < 20) { + // 현재 시간 저장 (마이크로초) + struct timeval tv; + gettimeofday(&tv, NULL); + canMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + (uint64_t)tv.tv_usec; + + canMsg.id = frame.can_id; + canMsg.dlc = frame.can_dlc; + memcpy(canMsg.data, frame.data, 8); + + // 로깅 중일 때만 큐에 추가 + if (loggingEnabled) { + xQueueSend(canQueue, &canMsg, 0); + } + + // 실시간 데이터 업데이트 (웹 표시용) + bool found = false; + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].count > 0 && 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; + } + } + } + + totalMsgCount++; + readCount++; + } + + // 메시지/초 계산 + uint32_t now = millis(); + if (now - lastMsgCountTime >= 1000) { + msgPerSecond = totalMsgCount - lastMsgCount; + lastMsgCount = totalMsgCount; + lastMsgCountTime = now; + } + } +} + +// ======================================== +// SD 카드 쓰기 태스크 +// ======================================== + +void sdWriteTask(void* parameter) { + CANMessage msg; + + for (;;) { + if (loggingEnabled && sdCardReady) { + if (xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(10)) == pdTRUE) { + // 버퍼에 데이터 추가 + memcpy(&fileBuffer[bufferIndex], &msg, sizeof(CANMessage)); + bufferIndex += sizeof(CANMessage); + currentFileSize += sizeof(CANMessage); + + // 버퍼가 가득 차면 SD에 쓰기 + if (bufferIndex >= FILE_BUFFER_SIZE - sizeof(CANMessage)) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + if (logFile) { + logFile.write(fileBuffer, bufferIndex); + logFile.flush(); + } + xSemaphoreGive(sdMutex); + } + bufferIndex = 0; + } + } + } else { + vTaskDelay(pdMS_TO_TICKS(100)); + } + } +} + +// ======================================== +// 로깅 제어 함수 +// ======================================== + +void startLogging() { + if (!sdCardReady) { + Serial.println("✗ SD 카드가 준비되지 않음"); + return; + } + + if (loggingEnabled) { + Serial.println("⚠️ 이미 로깅 중"); + return; + } + + // 파일명 생성 (현재 시간 사용) + time_t now; + struct tm timeinfo; + time(&now); + localtime_r(&now, &timeinfo); + + snprintf(currentFilename, MAX_FILENAME_LEN, + "/CAN_%04d%02d%02d_%02d%02d%02d.bin", + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + logFile = SD.open(currentFilename, FILE_WRITE); + + if (logFile) { + loggingEnabled = true; + bufferIndex = 0; + currentFileSize = 0; + + Serial.print("✓ 로깅 시작: "); + Serial.println(currentFilename); + } else { + Serial.println("✗ 파일 생성 실패"); + } + + xSemaphoreGive(sdMutex); + } +} + +void stopLogging() { + if (!loggingEnabled) { + Serial.println("⚠️ 로깅 중이 아님"); + return; + } + + loggingEnabled = false; + + // 남은 버퍼 데이터 쓰기 + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (bufferIndex > 0 && logFile) { + logFile.write(fileBuffer, bufferIndex); + } + + if (logFile) { + logFile.close(); + } + + xSemaphoreGive(sdMutex); + } + + bufferIndex = 0; + + // 큐 비우기 (로깅 중이 아닐 때는 큐가 불필요) + CANMessage dummyMsg; + while (xQueueReceive(canQueue, &dummyMsg, 0) == pdTRUE) { + // 큐의 모든 메시지 제거 + } + + Serial.print("✓ 로깅 종료: "); + Serial.println(currentFilename); + Serial.printf(" 파일 크기: %u bytes\n", currentFileSize); + Serial.println("✓ 큐 비움 완료"); + + // 현재 파일명 초기화 + currentFilename[0] = '\0'; +} + +// ======================================== +// SD 모니터링 태스크 +// ======================================== + +void sdMonitorTask(void* parameter) { + for (;;) { + updatePowerStatus(); + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} + +// ======================================== +// RTC 동기화 태스크 +// ======================================== + +void rtcSyncTask(void* parameter) { + for (;;) { + syncSystemTimeFromRTC(); + vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS)); + } +} + +// ======================================== +// CAN 송신 태스크 +// ======================================== + +void txTask(void* parameter) { + struct can_frame frame; + + for (;;) { + uint32_t now = millis(); + + // 주기적 송신 (기존) + for (int i = 0; i < MAX_TX_MESSAGES; i++) { + if (txMessages[i].active && txMessages[i].interval > 0) { + if (now - txMessages[i].lastSent >= txMessages[i].interval) { + 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 (sendCANMessage(&frame) == MCP2515::ERROR_OK) { + txMessages[i].lastSent = now; + totalTxCount++; + } + } + } + } + + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + +// ======================================== +// 시퀀스 실행 태스크 +// ======================================== + +void sequenceTask(void* parameter) { + struct can_frame frame; + + for (;;) { + if (seqRuntime.running && seqRuntime.activeSequenceIndex >= 0) { + CANSequence* seq = &sequences[seqRuntime.activeSequenceIndex]; + uint32_t now = millis(); + + if (seqRuntime.currentStep < seq->stepCount) { + SequenceStep* step = &seq->steps[seqRuntime.currentStep]; + + // 첫 번째 스텝이거나 딜레이 시간이 지났으면 실행 + if (seqRuntime.currentStep == 0 || (now - seqRuntime.lastStepTime >= step->delayMs)) { + // CAN 메시지 전송 + 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); + + MCP2515::ERROR result = sendCANMessage(&frame); + if (result == MCP2515::ERROR_OK) { + totalTxCount++; + Serial.printf(" [Seq] Step %d/%d: ID=0x%X, DLC=%d, Delay=%dms - OK\n", + seqRuntime.currentStep + 1, seq->stepCount, + step->canId, step->dlc, step->delayMs); + } else { + Serial.printf(" [Seq] Step %d/%d: ID=0x%X - FAIL (Error %d)\n", + seqRuntime.currentStep + 1, seq->stepCount, step->canId, result); + } + + seqRuntime.currentStep++; + seqRuntime.lastStepTime = now; + } + } else { + // 모든 스텝 완료 + seqRuntime.currentRepeat++; + + // 반복 체크 + bool shouldContinue = false; + + if (seq->repeatMode == 0) { + // 한 번만 + shouldContinue = false; + } else if (seq->repeatMode == 1) { + // 특정 횟수 + if (seqRuntime.currentRepeat < seq->repeatCount) { + shouldContinue = true; + } + } else if (seq->repeatMode == 2) { + // 무한 반복 + shouldContinue = true; + } + + if (shouldContinue) { + seqRuntime.currentStep = 0; + seqRuntime.lastStepTime = now; + Serial.printf(" [Seq] 반복 %d회 시작\n", seqRuntime.currentRepeat + 1); + } else { + Serial.printf("✓ 시퀀스 실행 완료: %s (총 %d회 반복)\n", + seq->name, seqRuntime.currentRepeat); + stopSequence(); + } + } + } + + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +// ======================================== +// 파일 리스트 전송 함수 +// ======================================== + +void sendFileList() { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File root = SD.open("/"); + String fileList = "{\"type\":\"files\",\"files\":["; + bool first = true; + + File file = root.openNextFile(); + while (file) { + if (!file.isDirectory()) { + String fname = file.name(); + + // comments.txt 파일은 제외 + if (fname != "comments.txt" && fname != "/comments.txt") { + if (fname.startsWith("/")) fname = fname.substring(1); + + if (!first) fileList += ","; + fileList += "{\"name\":\"" + fname + "\","; + fileList += "\"size\":" + String(file.size()); + + // 커멘트 추가 + const char* comment = getFileComment(fname.c_str()); + if (strlen(comment) > 0) { + fileList += ",\"comment\":\"" + String(comment) + "\""; + } + + fileList += "}"; + first = false; + } + } + file.close(); + file = root.openNextFile(); + } + + root.close(); + fileList += "]}"; + + xSemaphoreGive(sdMutex); + webSocket.broadcastTXT(fileList); + } +} + +// ======================================== +// WebSocket 이벤트 처리 +// ======================================== + +void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + if (type == WStype_TEXT) { + String message = String((char*)payload); + + // JSON 파싱 (간단한 방식) + if (message.indexOf("\"cmd\":\"startLogging\"") >= 0) { + startLogging(); + // 파일 리스트 자동 갱신 + delay(100); + sendFileList(); + + } else if (message.indexOf("\"cmd\":\"stopLogging\"") >= 0) { + stopLogging(); + // 파일 리스트 자동 갱신 + delay(100); + sendFileList(); + + } else if (message.indexOf("\"cmd\":\"getFiles\"") >= 0) { + // 파일 리스트 전송 + sendFileList(); + + } else if (message.indexOf("\"cmd\":\"deleteFile\"") >= 0) { + // 파일 삭제 + int filenameStart = message.indexOf("\"filename\":\"") + 12; + int filenameEnd = message.indexOf("\"", filenameStart); + String filename = message.substring(filenameStart, filenameEnd); + + // 로깅 중인 파일 체크 + bool canDelete = true; + if (loggingEnabled && currentFilename[0] != '\0') { + String currentFileStr = String(currentFilename); + if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); + + if (filename == currentFileStr) { + canDelete = false; + } + } + + if (!canDelete) { + webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":false,\"message\":\"Cannot delete file being logged\"}"); + } else { + String fullPath = "/" + filename; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + bool success = SD.remove(fullPath); + xSemaphoreGive(sdMutex); + + if (success) { + Serial.printf("✓ 파일 삭제: %s\n", filename.c_str()); + webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":true}"); + + // 파일 목록 자동 갱신 + delay(100); + sendFileList(); + } else { + webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":false,\"message\":\"Delete failed\"}"); + } + } + } + + } else if (message.indexOf("\"cmd\":\"setSpeed\"") >= 0) { + // CAN 속도 변경 + int speedStart = message.indexOf("\"speed\":") + 8; + int speedEnd = message.indexOf(",", speedStart); + if (speedEnd < 0) speedEnd = message.indexOf("}", speedStart); + + int speedIndex = message.substring(speedStart, speedEnd).toInt(); + + if (speedIndex >= 0 && speedIndex < 4) { + currentCanSpeed = canSpeedValues[speedIndex]; + mcp2515.reset(); + mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + setMCP2515Mode(currentMcpMode); // 현재 모드 유지 + + // 비휘발성 메모리에 저장 + saveCANSettings(); + + Serial.printf("✓ CAN 속도 변경 및 저장: %s\n", canSpeedNames[speedIndex]); + } + + } else if (message.indexOf("\"cmd\":\"setMcpMode\"") >= 0) { + // MCP2515 모드 변경 + int modeStart = message.indexOf("\"mode\":") + 7; + int modeEnd = message.indexOf(",", modeStart); + if (modeEnd < 0) modeEnd = message.indexOf("}", modeStart); + + int mode = message.substring(modeStart, modeEnd).toInt(); + + if (mode >= 0 && mode <= 3) { // 0~3으로 확장 (TRANSMIT 모드 포함) + if (setMCP2515Mode((MCP2515Mode)mode)) { + // 모드 변경 성공 시 비휘발성 메모리에 저장 + saveCANSettings(); + } + } + + } else if (message.indexOf("\"cmd\":\"syncTimeFromPhone\"") >= 0) { + // 핸드폰 시간을 RTC와 시스템에 동기화 + int yearStart = message.indexOf("\"year\":") + 7; + int monthStart = message.indexOf("\"month\":") + 8; + int dayStart = message.indexOf("\"day\":") + 6; + int hourStart = message.indexOf("\"hour\":") + 7; + int minuteStart = message.indexOf("\"minute\":") + 9; + int secondStart = message.indexOf("\"second\":") + 9; + + int year = message.substring(yearStart, message.indexOf(",", yearStart)).toInt(); + int month = message.substring(monthStart, message.indexOf(",", monthStart)).toInt(); + int day = message.substring(dayStart, message.indexOf(",", dayStart)).toInt(); + int hour = message.substring(hourStart, message.indexOf(",", hourStart)).toInt(); + int minute = message.substring(minuteStart, message.indexOf(",", minuteStart)).toInt(); + int second = message.substring(secondStart, message.indexOf(",", secondStart)).toInt(); + + Serial.printf("📱 핸드폰 시간 수신: %04d-%02d-%02d %02d:%02d:%02d\n", + year, month, day, hour, minute, second); + + // 1. RTC에 시간 설정 (가능한 경우) + if (timeSyncStatus.rtcAvailable) { + setRTCTime(year, month, day, hour, minute, second); + } + + // 2. 시스템 시간 설정 + 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; + + struct timeval tv; + tv.tv_sec = mktime(&timeinfo); + tv.tv_usec = 0; + settimeofday(&tv, NULL); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = esp_timer_get_time(); + timeSyncStatus.syncCount++; + + Serial.println("✓ 시스템 시간 동기화 완료"); + + webSocket.sendTXT(num, "{\"type\":\"timeSyncResult\",\"success\":true}"); + + } else if (message.indexOf("\"cmd\":\"addComment\"") >= 0) { + // 파일 커멘트 추가 + int filenameStart = message.indexOf("\"filename\":\"") + 12; + int filenameEnd = message.indexOf("\"", filenameStart); + String filename = message.substring(filenameStart, filenameEnd); + + int commentStart = message.indexOf("\"comment\":\"") + 11; + int commentEnd = message.indexOf("\"", commentStart); + String comment = message.substring(commentStart, commentEnd); + + addFileComment(filename.c_str(), comment.c_str()); + Serial.printf("✓ 커멘트 추가: %s -> %s\n", filename.c_str(), comment.c_str()); + + webSocket.sendTXT(num, "{\"type\":\"commentResult\",\"success\":true}"); + + // 파일 목록 자동 갱신 + delay(100); + sendFileList(); + + } else if (message.indexOf("\"cmd\":\"getSettings\"") >= 0) { + // 설정 전송 + String settings = "{\"type\":\"settings\","; + settings += "\"ssid\":\"" + String(wifiSSID) + "\","; + settings += "\"password\":\"" + String(wifiPassword) + "\","; + settings += "\"staEnable\":" + String(enableSTAMode ? "true" : "false") + ","; + settings += "\"staSSID\":\"" + String(staSSID) + "\","; + settings += "\"staPassword\":\"" + String(staPassword) + "\","; + settings += "\"staConnected\":" + String(WiFi.status() == WL_CONNECTED ? "true" : "false") + ","; + settings += "\"staIP\":\"" + WiFi.localIP().toString() + "\"}"; + + webSocket.sendTXT(num, settings); + + } else if (message.indexOf("\"cmd\":\"saveSettings\"") >= 0) { + // 설정 저장 + int ssidStart = message.indexOf("\"ssid\":\"") + 8; + int ssidEnd = message.indexOf("\"", ssidStart); + String ssid = message.substring(ssidStart, ssidEnd); + + int passStart = message.indexOf("\"password\":\"") + 12; + int passEnd = message.indexOf("\"", passStart); + String password = message.substring(passStart, passEnd); + + // STA 모드 설정 파싱 + int staEnableIdx = message.indexOf("\"staEnable\":"); + if (staEnableIdx >= 0) { + String staEnableStr = message.substring(staEnableIdx + 12, staEnableIdx + 16); + enableSTAMode = (staEnableStr.indexOf("true") >= 0); + + if (enableSTAMode) { + int staSSIDStart = message.indexOf("\"staSSID\":\"") + 11; + int staSSIDEnd = message.indexOf("\"", staSSIDStart); + String staSsid = message.substring(staSSIDStart, staSSIDEnd); + + int staPassStart = message.indexOf("\"staPassword\":\"") + 15; + int staPassEnd = message.indexOf("\"", staPassStart); + String staPass = message.substring(staPassStart, staPassEnd); + + strncpy(staSSID, staSsid.c_str(), sizeof(staSSID) - 1); + strncpy(staPassword, staPass.c_str(), sizeof(staPassword) - 1); + } + } + + strncpy(wifiSSID, ssid.c_str(), sizeof(wifiSSID) - 1); + strncpy(wifiPassword, password.c_str(), sizeof(wifiPassword) - 1); + + saveSettings(); + + webSocket.sendTXT(num, "{\"type\":\"settingsSaved\",\"success\":true}"); + + } else if (message.indexOf("\"cmd\":\"getSequences\"") >= 0) { + // 시퀀스 리스트 전송 + String seqList = "{\"type\":\"sequences\",\"sequences\":["; + for (int i = 0; i < sequenceCount; i++) { + if (i > 0) seqList += ","; + seqList += "{\"index\":" + String(i); + seqList += ",\"name\":\"" + String(sequences[i].name) + "\""; + seqList += ",\"steps\":" + String(sequences[i].stepCount); + seqList += ",\"mode\":" + String(sequences[i].repeatMode); + seqList += ",\"count\":" + String(sequences[i].repeatCount) + "}"; + } + seqList += "]}"; + webSocket.sendTXT(num, seqList); + + } else if (message.indexOf("\"cmd\":\"getSequence\"") >= 0) { + // 특정 시퀀스 상세 정보 전송 + int indexStart = message.indexOf("\"index\":") + 8; + int indexEnd = message.indexOf(",", indexStart); + if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart); + int index = message.substring(indexStart, indexEnd).toInt(); + + if (index >= 0 && index < sequenceCount) { + String seqData = "{\"type\":\"sequenceDetail\",\"sequence\":{"; + seqData += "\"name\":\"" + String(sequences[index].name) + "\","; + seqData += "\"mode\":" + String(sequences[index].repeatMode) + ","; + seqData += "\"count\":" + String(sequences[index].repeatCount) + ","; + seqData += "\"steps\":["; + + for (int i = 0; i < sequences[index].stepCount; i++) { + if (i > 0) seqData += ","; + SequenceStep* step = &sequences[index].steps[i]; + seqData += "{\"id\":" + String(step->canId); + seqData += ",\"ext\":" + String(step->extended ? "true" : "false"); + seqData += ",\"dlc\":" + String(step->dlc); + seqData += ",\"data\":\""; + for (int j = 0; j < 8; j++) { + if (step->data[j] < 0x10) seqData += "0"; + seqData += String(step->data[j], HEX); + if (j < 7) seqData += " "; + } + seqData += "\",\"delay\":" + String(step->delayMs) + "}"; + } + seqData += "]}}"; + webSocket.sendTXT(num, seqData); + } + + } else if (message.indexOf("\"cmd\":\"saveSequence\"") >= 0) { + // 새 시퀀스 저장 (JSON 파싱) + CANSequence newSeq; + memset(&newSeq, 0, sizeof(CANSequence)); + + // 이름 추출 + int nameStart = message.indexOf("\"name\":\"") + 8; + int nameEnd = message.indexOf("\"", nameStart); + String name = message.substring(nameStart, nameEnd); + strncpy(newSeq.name, name.c_str(), sizeof(newSeq.name) - 1); + + // 모드 추출 + int modeStart = message.indexOf("\"mode\":") + 7; + int modeEnd = message.indexOf(",", modeStart); + newSeq.repeatMode = message.substring(modeStart, modeEnd).toInt(); + + // 반복 횟수 추출 + int countStart = message.indexOf("\"repeatCount\":") + 14; + int countEnd = message.indexOf(",", countStart); + if (countEnd < 0) countEnd = message.indexOf("}", countStart); + newSeq.repeatCount = message.substring(countStart, countEnd).toInt(); + + // 스텝 배열 파싱 + int stepsStart = message.indexOf("\"steps\":["); + if (stepsStart >= 0) { + stepsStart += 9; // "steps":[ 길이 + int stepsEnd = message.indexOf("]}", stepsStart); + String stepsJson = message.substring(stepsStart, stepsEnd); + + // 각 스텝 파싱 + newSeq.stepCount = 0; + int pos = 0; + + while (pos < stepsJson.length() && newSeq.stepCount < 20) { + int stepStart = stepsJson.indexOf("{", pos); + if (stepStart < 0) break; + + int stepEnd = stepsJson.indexOf("}", stepStart); + if (stepEnd < 0) break; + + String stepJson = stepsJson.substring(stepStart, stepEnd + 1); + + // canId 추출 + int idStart = stepJson.indexOf("\"canId\":") + 8; + int idEnd = stepJson.indexOf(",", idStart); + if (idEnd < 0) idEnd = stepJson.indexOf("}", idStart); + newSeq.steps[newSeq.stepCount].canId = stepJson.substring(idStart, idEnd).toInt(); + + // extended 추출 + int extStart = stepJson.indexOf("\"extended\":") + 11; + String extStr = stepJson.substring(extStart, extStart + 5); + newSeq.steps[newSeq.stepCount].extended = (extStr.indexOf("true") >= 0); + + // dlc 추출 + int dlcStart = stepJson.indexOf("\"dlc\":") + 6; + int dlcEnd = stepJson.indexOf(",", dlcStart); + newSeq.steps[newSeq.stepCount].dlc = stepJson.substring(dlcStart, dlcEnd).toInt(); + + // data 배열 추출 + int dataStart = stepJson.indexOf("\"data\":[") + 8; + int dataEnd = stepJson.indexOf("]", dataStart); + String dataStr = stepJson.substring(dataStart, dataEnd); + + // data 바이트 파싱 + int bytePos = 0; + int byteIdx = 0; + while (bytePos < dataStr.length() && byteIdx < 8) { + int commaPos = dataStr.indexOf(",", bytePos); + if (commaPos < 0) commaPos = dataStr.length(); + + String byteStr = dataStr.substring(bytePos, commaPos); + byteStr.trim(); + newSeq.steps[newSeq.stepCount].data[byteIdx] = byteStr.toInt(); + + byteIdx++; + bytePos = commaPos + 1; + } + + // delay 추출 + int delayStart = stepJson.indexOf("\"delayMs\":") + 10; + int delayEnd = stepJson.indexOf(",", delayStart); + if (delayEnd < 0) delayEnd = stepJson.indexOf("}", delayStart); + newSeq.steps[newSeq.stepCount].delayMs = stepJson.substring(delayStart, delayEnd).toInt(); + + newSeq.stepCount++; + pos = stepEnd + 1; + } + } + + Serial.printf("📝 시퀀스 저장: %s (%d 스텝)\n", newSeq.name, newSeq.stepCount); + for (int i = 0; i < newSeq.stepCount; i++) { + Serial.printf(" Step %d: ID=0x%X, DLC=%d, Delay=%dms\n", + i, newSeq.steps[i].canId, newSeq.steps[i].dlc, newSeq.steps[i].delayMs); + } + + int result = addSequence(newSeq); + if (result >= 0) { + webSocket.sendTXT(num, "{\"type\":\"sequenceSaved\",\"success\":true,\"index\":" + String(result) + "}"); + } else { + webSocket.sendTXT(num, "{\"type\":\"sequenceSaved\",\"success\":false}"); + } + + } else if (message.indexOf("\"cmd\":\"deleteSequence\"") >= 0) { + // 시퀀스 삭제 + int indexStart = message.indexOf("\"index\":") + 8; + int indexEnd = message.indexOf(",", indexStart); + if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart); + int index = message.substring(indexStart, indexEnd).toInt(); + + bool success = deleteSequence(index); + webSocket.sendTXT(num, "{\"type\":\"sequenceDeleted\",\"success\":" + String(success ? "true" : "false") + "}"); + + } else if (message.indexOf("\"cmd\":\"startSequence\"") >= 0) { + // 시퀀스 실행 + int indexStart = message.indexOf("\"index\":") + 8; + int indexEnd = message.indexOf(",", indexStart); + if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart); + int index = message.substring(indexStart, indexEnd).toInt(); + + startSequence(index); + webSocket.sendTXT(num, "{\"type\":\"sequenceStarted\",\"success\":true}"); + + } else if (message.indexOf("\"cmd\":\"stopSequence\"") >= 0) { + // 시퀀스 중지 + stopSequence(); + webSocket.sendTXT(num, "{\"type\":\"sequenceStopped\",\"success\":true}"); + } + } +} + +// ======================================== +// 웹 업데이트 태스크 +// ======================================== + +void webUpdateTask(void* parameter) { + uint32_t lastStatusUpdate = 0; + uint32_t lastCanUpdate = 0; + + for (;;) { + webSocket.loop(); + + uint32_t now = millis(); + + // 상태 업데이트 (500ms) + if (now - lastStatusUpdate >= 500) { + String status = "{\"type\":\"status\","; + status += "\"logging\":" + String(loggingEnabled ? "true" : "false") + ","; + status += "\"sdReady\":" + String(sdCardReady ? "true" : "false") + ","; + status += "\"queueSize\":" + String(uxQueueMessagesWaiting(canQueue)) + ","; + status += "\"queueMax\":" + String(CAN_QUEUE_SIZE) + ","; + status += "\"totalMsg\":" + String(totalMsgCount) + ","; + status += "\"msgPerSec\":" + String(msgPerSecond) + ","; + status += "\"fileSize\":" + String(currentFileSize) + ","; + status += "\"currentFile\":\""; + + if (currentFilename[0] != '\0') { + String fname = String(currentFilename); + if (fname.startsWith("/")) fname = fname.substring(1); + status += fname; + } + + status += "\",\"timeSynced\":" + String(timeSyncStatus.synchronized ? "true" : "false") + ","; + status += "\"rtcAvailable\":" + String(timeSyncStatus.rtcAvailable ? "true" : "false") + ","; + status += "\"rtcSyncCount\":" + String(timeSyncStatus.rtcSyncCount) + ","; + status += "\"voltage\":" + String(powerStatus.voltage, 2) + ","; + status += "\"minVoltage\":" + String(powerStatus.minVoltage, 2) + ","; + status += "\"lowVoltage\":" + String(powerStatus.lowVoltage ? "true" : "false") + ","; + status += "\"mcpMode\":" + String(currentMcpMode); + status += "}"; + + webSocket.broadcastTXT(status); + lastStatusUpdate = now; + } + + // CAN 데이터 배치 전송 (100ms) + if (now - lastCanUpdate >= 100) { + String canBatch = "{\"type\":\"canBatch\",\"messages\":["; + int messageCount = 0; + + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].count > 0) { + CANMessage* msg = &recentData[i].msg; + + if (messageCount > 0) canBatch += ","; + + canBatch += "{\"id\":\"0x"; + canBatch += String(msg->id, HEX); + canBatch += "\",\"dlc\":" + String(msg->dlc); + canBatch += ",\"data\":\""; + + for (int j = 0; j < msg->dlc; j++) { + if (msg->data[j] < 0x10) canBatch += "0"; + canBatch += String(msg->data[j], HEX); + if (j < msg->dlc - 1) canBatch += " "; + } + + uint64_t timestamp_ms = msg->timestamp_us / 1000; + canBatch += "\",\"timestamp\":" + String((uint32_t)timestamp_ms); + canBatch += ",\"count\":" + String(recentData[i].count) + "}"; + + messageCount++; + } + } + + canBatch += "]}"; + + if (messageCount > 0) { + webSocket.broadcastTXT(canBatch); + } + + lastCanUpdate = now; + } + + vTaskDelay(pdMS_TO_TICKS(50)); + } +} + +void setup() { + Serial.begin(115200); + delay(1000); + Serial.println("\n========================================"); + Serial.println(" ESP32 CAN Logger v2.0 "); + Serial.println(" + Phone Time Sync & File Comments "); + Serial.println(" + MCP2515 Mode Control "); + Serial.println("========================================"); + + // 설정 로드 + loadSettings(); + + // 설정값 표시 + Serial.println("\n📋 현재 설정값:"); + Serial.println("----------------------------------------"); + Serial.printf(" WiFi SSID : %s\n", wifiSSID); + Serial.printf(" WiFi Password : %s\n", wifiPassword); + Serial.println("----------------------------------------"); + Serial.println("💡 설정 변경: http://[IP]/settings\n"); + + memset(recentData, 0, sizeof(recentData)); + memset(txMessages, 0, sizeof(txMessages)); + memset(fileComments, 0, sizeof(fileComments)); + + pinMode(CAN_INT_PIN, INPUT_PULLUP); + + // ADC 설정 (전압 모니터링용) + analogSetAttenuation(ADC_11db); // 0-3.3V 범위 + + // SPI 초기화 + hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); + vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); + + // VSPI 클럭 속도를 40MHz로 설정 (SD 카드용) + vspi.setFrequency(40000000); // 40MHz + + // MCP2515 초기화 + mcp2515.reset(); + // 저장된 CAN 속도 적용 (loadSettings에서 로드됨) + mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + // 저장된 MCP 모드 적용 (loadSettings에서 로드됨) + setMCP2515Mode(currentMcpMode); + Serial.println("✓ MCP2515 초기화 완료 (저장된 설정 적용)"); + + // Mutex 생성 (다른 초기화보다 먼저!) + sdMutex = xSemaphoreCreateMutex(); + rtcMutex = xSemaphoreCreateMutex(); + + if (sdMutex == NULL || rtcMutex == NULL) { + Serial.println("✗ Mutex 생성 실패!"); + while (1) delay(1000); + } + + // RTC 초기화 (SoftWire 사용) - Mutex 생성 후 + initRTC(); + + // SD 카드 초기화 + if (SD.begin(VSPI_CS, vspi)) { + sdCardReady = true; + Serial.println("✓ SD 카드 초기화 완료"); + + // 파일 커멘트 로드 + loadFileComments(); + } else { + Serial.println("✗ SD 카드 초기화 실패"); + } + + // WiFi 설정 - APSTA 모드 지원 + if (enableSTAMode && strlen(staSSID) > 0) { + // APSTA 모드 (AP + Station 동시 동작) + Serial.println("\n📶 WiFi APSTA 모드 시작..."); + + WiFi.mode(WIFI_AP_STA); + + // AP 모드 시작 + WiFi.softAP(wifiSSID, wifiPassword); + Serial.print("✓ AP SSID: "); + Serial.println(wifiSSID); + Serial.print("✓ AP IP: "); + Serial.println(WiFi.softAPIP()); + + // Station 모드로 WiFi 연결 시도 + Serial.printf("📡 WiFi 연결 시도: %s\n", staSSID); + WiFi.begin(staSSID, staPassword); + + // 연결 대기 (최대 10초) + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 20) { + delay(500); + Serial.print("."); + attempts++; + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) { + Serial.println("✓ WiFi 연결 성공!"); + Serial.print("✓ STA IP: "); + Serial.println(WiFi.localIP()); + Serial.print("✓ Gateway: "); + Serial.println(WiFi.gatewayIP()); + Serial.print("✓ DNS: "); + Serial.println(WiFi.dnsIP()); + } else { + Serial.println("✗ WiFi 연결 실패 (AP 모드는 정상 동작)"); + } + } else { + // AP 모드만 사용 + Serial.println("\n📶 WiFi AP 모드 시작..."); + WiFi.mode(WIFI_AP); + WiFi.softAP(wifiSSID, wifiPassword); + + Serial.print("✓ AP SSID: "); + Serial.println(wifiSSID); + Serial.print("✓ AP IP: "); + Serial.println(WiFi.softAPIP()); + } + + // WiFi 성능 최적화 + WiFi.setSleep(false); // WiFi 절전 모드 비활성화 (신호 강도 개선) + esp_wifi_set_max_tx_power(84); // TX 출력 최대화 (20.5dBm = 84/4) + + // 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("/download", HTTP_GET, []() { + if (server.hasArg("file")) { + String filename = "/" + server.arg("file"); + + if (SD.exists(filename)) { + File file = SD.open(filename, FILE_READ); + if (file) { + String displayName = server.arg("file"); + + server.sendHeader("Content-Disposition", + "attachment; filename=\"" + displayName + "\""); + server.sendHeader("Content-Type", "application/octet-stream"); + + server.streamFile(file, "application/octet-stream"); + file.close(); + } else { + server.send(500, "text/plain", "Failed to open file"); + } + } else { + server.send(404, "text/plain", "File not found"); + } + } else { + server.send(400, "text/plain", "Bad request"); + } + }); + + // 파일 삭제 HTTP 엔드포인트 추가 (백업용 - 주로 WebSocket 사용) + server.on("/delete", HTTP_GET, []() { + if (server.hasArg("file")) { + String filename = server.arg("file"); + + // 로깅 중인 파일은 삭제 불가 + if (loggingEnabled && currentFilename[0] != '\0') { + String currentFileStr = String(currentFilename); + if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); + + if (filename == currentFileStr) { + server.send(403, "text/plain", "Cannot delete file currently being logged"); + return; + } + } + + String fullPath = "/" + filename; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (SD.exists(fullPath)) { + if (SD.remove(fullPath)) { + xSemaphoreGive(sdMutex); + server.send(200, "text/plain", "File deleted successfully"); + Serial.printf("✓ HTTP 파일 삭제: %s\n", filename.c_str()); + } else { + xSemaphoreGive(sdMutex); + server.send(500, "text/plain", "Failed to delete file"); + } + } else { + xSemaphoreGive(sdMutex); + server.send(404, "text/plain", "File not found"); + } + } else { + server.send(503, "text/plain", "SD card busy"); + } + } else { + server.send(400, "text/plain", "Bad request"); + } + }); + + server.begin(); + + // Queue 생성 + canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); + + if (canQueue == NULL) { + Serial.println("✗ Queue 생성 실패!"); + while (1) delay(1000); + } + + // CAN 인터럽트 활성화 + attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); + + // Task 생성 + xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 5, &canRxTaskHandle, 1); // 우선순위 4→5 + xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 12288, NULL, 3, &sdWriteTaskHandle, 1); + xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0); + xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0); + xTaskCreatePinnedToCore(txTask, "TX_TASK", 4096, NULL, 2, NULL, 1); + xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4096, NULL, 2, NULL, 1); // 시퀀스 Task 추가 + + // RTC 동기화 Task + if (timeSyncStatus.rtcAvailable) { + xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 4096, NULL, 0, &rtcTaskHandle, 0); + Serial.println("✓ RTC 자동 동기화 Task 시작"); + } + + // 시퀀스 로드 + loadSequences(); + + Serial.println("✓ 모든 태스크 시작 완료"); + Serial.println("\n========================================"); + Serial.println(" 웹 인터페이스 접속 방법"); + Serial.println("========================================"); + Serial.printf(" 1. WiFi 연결\n"); + Serial.printf(" - SSID : %s\n", wifiSSID); + Serial.printf(" - Password : %s\n", wifiPassword); + Serial.printf(" 2. 웹 브라우저에서 접속\n"); + Serial.print(" - URL : http://"); + Serial.println(WiFi.softAPIP()); + Serial.println(" 3. 페이지 메뉴:"); + Serial.println(" - Monitor : /"); + Serial.println(" - Transmit : /transmit"); + Serial.println(" - Graph : /graph"); + Serial.println(" - Settings : /settings"); + Serial.println("========================================\n"); +} + +void loop() { + server.handleClient(); + vTaskDelay(pdMS_TO_TICKS(10)); + + static uint32_t lastPrint = 0; + if (millis() - lastPrint > 10000) { + Serial.printf("[상태] 큐: %d/%d | 로깅: %s | SD: %s | RX: %lu | TX: %lu | 파일크기: %u | 시간: %s | RTC: %s(%u) | 전압: %.2fV%s | 모드: %d\n", + uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, + loggingEnabled ? "ON " : "OFF", + sdCardReady ? "OK" : "NO", + totalMsgCount, totalTxCount, + currentFileSize, + timeSyncStatus.synchronized ? "OK" : "NO", + timeSyncStatus.rtcAvailable ? "OK" : "NO", + timeSyncStatus.rtcSyncCount, + powerStatus.voltage, + powerStatus.lowVoltage ? " ⚠️" : "", + currentMcpMode); + lastPrint = millis(); + } +} diff --git a/test_i2c_reset/index.h b/test_i2c_reset/index.h index c0fca79..0909ef1 100644 --- a/test_i2c_reset/index.h +++ b/test_i2c_reset/index.h @@ -53,12 +53,144 @@ const char index_html[] PROGMEM = R"rawliteral( .nav a.active { background: #3498db; } .content { padding: 15px; } + /* 전력 경고 배너 */ + .power-warning { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + color: white; + padding: 12px 20px; + border-radius: 8px; + margin-bottom: 15px; + display: none; + align-items: center; + gap: 10px; + box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4); + animation: pulse 2s ease-in-out infinite; + } + .power-warning.show { display: flex; } + .power-warning-icon { font-size: 1.5em; } + .power-warning-text { flex: 1; font-weight: 600; } + .power-voltage { + font-family: 'Courier New', monospace; + font-size: 1.2em; + font-weight: 700; + background: rgba(255,255,255,0.2); + padding: 5px 12px; + border-radius: 5px; + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.85; } + } + + /* 전력 상태 표시 */ + .power-status { + background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%); + color: white; + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 15px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 3px 10px rgba(86, 171, 47, 0.3); + flex-wrap: wrap; + gap: 10px; + } + .power-status.low { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + box-shadow: 0 3px 10px rgba(255, 107, 107, 0.3); + } + .power-status-label { + font-size: 0.85em; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + .power-status-values { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + } + .power-status-item { + display: flex; + flex-direction: column; + align-items: flex-end; + } + .power-status-item-label { + font-size: 0.7em; + opacity: 0.9; + } + .power-status-value { + font-family: 'Courier New', monospace; + font-size: 1.2em; + font-weight: 700; + } + + /* 큐 상태 표시 */ + .queue-status { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 15px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 3px 10px rgba(102, 126, 234, 0.3); + } + .queue-status.warning { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + box-shadow: 0 3px 10px rgba(240, 147, 251, 0.3); + } + .queue-status.critical { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + box-shadow: 0 3px 10px rgba(255, 107, 107, 0.3); + animation: pulse 2s ease-in-out infinite; + } + .queue-info { + display: flex; + align-items: center; + gap: 10px; + } + .queue-bar-container { + flex: 1; + min-width: 150px; + height: 20px; + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; + overflow: hidden; + position: relative; + } + .queue-bar { + height: 100%; + background: rgba(255, 255, 255, 0.8); + border-radius: 10px; + transition: width 0.3s ease; + } + .queue-text { + position: absolute; + width: 100%; + text-align: center; + line-height: 20px; + font-size: 0.75em; + font-weight: 700; + color: white; + text-shadow: 0 1px 2px rgba(0,0,0,0.3); + } + .time-sync-banner { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; padding: 15px 20px; border-radius: 10px; margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; box-shadow: 0 4px 15px rgba(240, 147, 251, 0.4); } .time-sync-info { @@ -66,7 +198,6 @@ const char index_html[] PROGMEM = R"rawliteral( gap: 20px; align-items: center; flex-wrap: wrap; - margin-bottom: 15px; } .time-info-item { display: flex; @@ -83,65 +214,21 @@ const char index_html[] PROGMEM = R"rawliteral( font-size: 1.1em; font-weight: 700; } - - .rtc-panel { - background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); - padding: 15px; - border-radius: 10px; - margin-top: 15px; - border-left: 4px solid #667eea; - } - .rtc-panel h3 { - color: #333; - margin-bottom: 10px; - font-size: 1em; - } - .rtc-controls { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; - } - .rtc-time { - flex: 1; - min-width: 200px; - padding: 10px 15px; - background: white; - border-radius: 8px; - font-family: 'Courier New', monospace; - font-size: 1.1em; - font-weight: 600; - color: #667eea; - text-align: center; - } - .rtc-status { - padding: 6px 12px; - background: white; - border-radius: 5px; - font-size: 0.85em; - font-weight: 600; - } - .rtc-status.available { color: #11998e; } - .rtc-status.unavailable { color: #e74c3c; } - - .btn-time-sync, .btn-rtc { + .btn-time-sync { background: white; color: #f5576c; - padding: 10px 20px; + padding: 12px 24px; border: none; border-radius: 8px; - font-size: 0.9em; + font-size: 1em; font-weight: 700; cursor: pointer; transition: all 0.3s; - box-shadow: 0 3px 10px rgba(0,0,0,0.2); + white-space: nowrap; } - .btn-rtc { - color: #667eea; - } - .btn-time-sync:hover, .btn-rtc:hover { - transform: translateY(-3px); - box-shadow: 0 6px 20px rgba(0,0,0,0.3); + .btn-time-sync:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .status-grid { @@ -156,114 +243,315 @@ const char index_html[] PROGMEM = R"rawliteral( padding: 15px; border-radius: 10px; text-align: center; + box-shadow: 0 4px 15px rgba(0,0,0,0.2); + transition: all 0.3s; } - .status-card h3 { font-size: 0.75em; opacity: 0.9; margin-bottom: 8px; } - .status-card .value { font-size: 1.5em; font-weight: bold; word-break: break-all; } - .status-on { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%) !important; } - .status-off { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%) !important; } + .status-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0,0,0,0.3); + } + .status-card h3 { + font-size: 0.75em; + opacity: 0.9; + margin-bottom: 8px; + letter-spacing: 1px; + } + .status-card .value { + font-size: 1.5em; + font-weight: 700; + font-family: 'Courier New', monospace; + } + .status-card.status-on { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + } + .status-card.status-off { + background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); + } + + h2 { + color: #333; + margin: 20px 0 10px 0; + font-size: 1.3em; + border-bottom: 3px solid #667eea; + padding-bottom: 8px; + } + .control-panel { background: #f8f9fa; padding: 15px; border-radius: 10px; margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .control-row { display: flex; - gap: 10px; align-items: center; - flex-wrap: wrap; + gap: 10px; margin-bottom: 10px; + flex-wrap: wrap; } - .control-row:last-child { margin-bottom: 0; } - label { font-weight: 600; color: #333; font-size: 0.9em; } - select, button { - padding: 8px 15px; + .control-row:last-child { + margin-bottom: 0; + } + .control-row label { + font-weight: 600; + color: #333; + white-space: nowrap; + } + .control-row select { + padding: 8px 12px; + border: 2px solid #ddd; + border-radius: 5px; + font-size: 0.95em; + transition: all 0.3s; + background: white; + } + .control-row select:focus { + outline: none; + border-color: #667eea; + } + .control-row button { + padding: 8px 16px; border: none; border-radius: 5px; - font-size: 0.9em; + font-size: 0.95em; + font-weight: 600; cursor: pointer; transition: all 0.3s; - } - select { - background: white; - border: 2px solid #667eea; - color: #333; - } - button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - font-weight: 600; } - button:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.3); } - button:active { transform: translateY(0); } + .control-row button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + .can-table-container { - background: #f8f9fa; - border-radius: 10px; - padding: 10px; overflow-x: auto; - -webkit-overflow-scrolling: touch; + background: white; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin-bottom: 20px; } table { width: 100%; - min-width: 500px; border-collapse: collapse; - background: white; - border-radius: 8px; - overflow: hidden; + min-width: 600px; } - th { + thead { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - padding: 10px 8px; + } + th { + padding: 12px 8px; text-align: left; font-weight: 600; font-size: 0.85em; + letter-spacing: 0.5px; } td { - padding: 8px; - border-bottom: 1px solid #e9ecef; + padding: 10px 8px; + border-bottom: 1px solid #eee; + font-size: 0.9em; + } + tr:hover { + background: #f8f9fa; + } + .mono { font-family: 'Courier New', monospace; - font-size: 0.8em; + font-weight: 500; } - tr:hover { background: #f8f9fa; } - .flash-row { - animation: flashAnimation 0.3s ease-in-out; - } - @keyframes flashAnimation { + @keyframes flash { 0%, 100% { background-color: transparent; } 50% { background-color: #fff3cd; } } - .mono { font-family: 'Courier New', monospace; } - h2 { - color: #333; - margin: 20px 0 15px 0; - padding-bottom: 8px; - border-bottom: 3px solid #667eea; - font-size: 1.3em; + .flash-row { + animation: flash 0.3s ease-in-out; } + .file-list { background: #f8f9fa; border-radius: 10px; padding: 15px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .file-item { background: white; + border: 1px solid #e0e0e0; padding: 12px; - margin-bottom: 8px; + margin-bottom: 10px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); transition: all 0.3s; flex-wrap: wrap; gap: 10px; } - .file-item:hover { transform: translateX(5px); box-shadow: 0 3px 10px rgba(0,0,0,0.1); } - .file-name { font-weight: 600; color: #333; font-size: 0.9em; } - .file-size { color: #666; margin-left: 10px; font-size: 0.85em; } - .download-btn { - padding: 6px 12px; + .file-item:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + transform: translateY(-2px); + } + .file-item:last-child { + margin-bottom: 0; + } + .file-item.logging { + border: 2px solid #11998e; + background: linear-gradient(135deg, rgba(17, 153, 142, 0.05) 0%, rgba(56, 239, 125, 0.05) 100%); + } + .file-item.selected { + border: 2px solid #667eea; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); + } + .file-checkbox { + width: 20px; + height: 20px; + cursor: pointer; + } + .file-info { + flex: 1; + min-width: 200px; + } + .file-name { + font-weight: 600; + color: #333; + margin-bottom: 4px; + font-family: 'Courier New', monospace; + font-size: 0.95em; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .file-comment { font-size: 0.85em; + color: #666; + font-style: italic; + margin-top: 4px; + } + .file-size { + color: #666; + font-size: 0.85em; + } + .file-actions { + display: flex; + gap: 8px; + } + .logging-badge { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + color: white; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.75em; + font-weight: 700; + letter-spacing: 0.5px; + } + .download-btn, .delete-btn, .comment-btn { + padding: 6px 12px; + border: none; + border-radius: 5px; + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + white-space: nowrap; + } + .download-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + .download-btn:hover { + background: linear-gradient(135deg, #5568d3 0%, #66409e 100%); + } + .comment-btn { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + } + .comment-btn:hover { + background: linear-gradient(135deg, #e77fe8 0%, #e44459 100%); + } + .delete-btn { + background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); + color: white; + } + .delete-btn:hover { + background: linear-gradient(135deg, #d32f3f 0%, #e53935 100%); + } + .delete-btn:disabled, .comment-btn:disabled { + background: #cccccc; + cursor: not-allowed; + opacity: 0.6; + } + + /* 모달 스타일 */ + .modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + } + .modal-content { + background-color: white; + margin: 15% auto; + padding: 25px; + border-radius: 10px; + width: 90%; + max-width: 500px; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); + } + .modal-header { + font-size: 1.3em; + font-weight: 700; + color: #333; + margin-bottom: 15px; + } + .modal-body { + margin-bottom: 20px; + } + .modal-body label { + display: block; + font-weight: 600; + color: #333; + margin-bottom: 8px; + } + .modal-body input, .modal-body textarea { + width: 100%; + padding: 10px; + border: 2px solid #ddd; + border-radius: 5px; + font-size: 1em; + font-family: inherit; + } + .modal-body input:focus, .modal-body textarea:focus { + outline: none; + border-color: #667eea; + } + .modal-buttons { + display: flex; + gap: 10px; + justify-content: flex-end; + } + .modal-buttons button { + padding: 10px 20px; + border: none; + border-radius: 5px; + font-size: 1em; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + } + .btn-modal-save { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + color: white; + } + .btn-modal-cancel { + background: #ddd; + color: #333; } @media (max-width: 768px) { @@ -280,6 +568,8 @@ const char index_html[] PROGMEM = R"rawliteral( table { min-width: 400px; } th, td { padding: 6px 4px; font-size: 0.75em; } .time-sync-banner { + flex-direction: column; + align-items: stretch; padding: 12px 15px; } .time-sync-info { @@ -288,12 +578,16 @@ const char index_html[] PROGMEM = R"rawliteral( .time-value { font-size: 1em; } - .rtc-controls { - flex-direction: column; - align-items: stretch; + .btn-time-sync { + width: 100%; + padding: 10px 20px; } - .rtc-time { - min-width: 100%; + .file-actions { + width: 100%; + justify-content: stretch; + } + .download-btn, .delete-btn, .comment-btn { + flex: 1; } } @@ -301,48 +595,67 @@ const char index_html[] PROGMEM = R"rawliteral(
-

Byun CAN Logger

-

Real-time CAN Bus Monitor & Data Logger with RTC

+

🚗 Byun CAN Logger v2.0

+

Real-time CAN Bus Monitor & Logger + Phone Time Sync + MCP2515 Mode Control

- ⏰ 시간 동기화 상태 - 대기 중... +
CURRENT TIME
+
--:--:--
- 🕐 현재 시간 - --:--:-- +
CONNECTION
+
연결 중...
- - -
-

🕰️ RTC DS3231 모듈

-
-
--:--:--
- 확인 중... - - - + +
+ +
+
+ + POWER STATUS +
+
+
+
CURRENT
+
-.--V
+
+
+
MIN (1s)
+
-.--V
+
+
+ 📦 + QUEUE STATUS +
+
+
+
0 / 1000
+
+
+
-
+

LOGGING

OFF
-
+

SD CARD

NOT READY
@@ -358,10 +671,18 @@ const char index_html[] PROGMEM = R"rawliteral(

TIME SYNC

0
+
+

MCP MODE

+
NORMAL
+

CURRENT FILE

-
+
+

FILE SIZE

+
0 B
+
@@ -377,6 +698,17 @@ const char index_html[] PROGMEM = R"rawliteral(
+
+ + + + +
@@ -402,236 +734,236 @@ const char index_html[] PROGMEM = R"rawliteral(

Log Files

+
+ + + + +

Loading...

+ + +