From 37da387904133be17245fd631c57dc2bd66981f9 Mon Sep 17 00:00:00 2001 From: byun Date: Fri, 7 Nov 2025 09:54:14 +0000 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=BB=A4=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20can=20mode=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 고쳐햐 할 점으로 can 중지 시 que가 적산됨 2025년 11월 6일 오전 3:30 GMT+9 작성 내용 기반으로 esp32 로 mcp2515, sdcard 를 spi 로 연결하여 can 최대 속도를 빠짐 없이 실시간 로깅하는 코드야 RTC DS3231로 i2c 연결(softwire 라이브러리)하여 esp32의 시간데이터를 보정하는 기능으로 보다 신뢰성 있는 로깅을 해주지, wifi ap로 연결하면 monitoring페이지가 있고 모니터링 페이지에 초기 접속 시(로깅하지 않은상태) 핸드폰의 시간을 rtc에 저장 하고 시스템 시간을 핸드폰시간으로 맞춰줘 그리고 log files 항목에 파일 리스트를 보여주는데 사용자가 커멘트입력하여 차후 어떤 파일인지 알 수 있게 해줘 또한 컨트롤 패널 항목에 추가로 MCP2515의 CAN 모드를 넣어 MCP2515컨트롤 loop-back, normal, listen-only모드 등을 넣어 사용자가 상황에 맞게 mcp2515를 동작시키게 해줘, settings 페이지에서는 timezone은 불필요한것 같아 삭제 해줘, 이 요구사항을 우선 첨부한 코드를 분석하고 해당 요구사항을 적용하여 수정해줘, 참고로 transmit 페이지는 파일용량이 커서 첨부에 뺐으니 빼고 수정해줘(수정과 상관 없는 graph.h, graph_viewer.h,transmit.h 는 첨부에 뺐음) --- ESP32_CAN_Logger.ino | 1521 ++++++++++++++++++------------------------ index.h | 872 +++++++++++------------- settings.h | 124 +--- 3 files changed, 1064 insertions(+), 1453 deletions(-) diff --git a/ESP32_CAN_Logger.ino b/ESP32_CAN_Logger.ino index 9fb3501..1a78bec 100644 --- a/ESP32_CAN_Logger.ino +++ b/ESP32_CAN_Logger.ino @@ -1,8 +1,7 @@ /* - * Byun CAN Logger with Web Interface + RTC Time Synchronization + Timezone Settings - * Version: 1.7 - * Mode: Listen-Only (Default, Safe) / Normal (Transmit enabled) - * Features: File comment, Auto time sync, Multiple file operations, CAN mode switching + * Byun CAN Logger with Web Interface + RTC Time Synchronization + * Version: 2.0 + * Added: Phone time sync to RTC, File comments, MCP2515 mode control */ #include @@ -47,11 +46,12 @@ #define DS3231_ADDRESS 0x68 // 버퍼 설정 -#define CAN_QUEUE_SIZE 1000 -#define FILE_BUFFER_SIZE 8192 +#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와 동기화 @@ -85,6 +85,12 @@ struct TxMessage { bool active; }; +// 파일 커멘트 구조체 +struct FileComment { + char filename[MAX_FILENAME_LEN]; + char comment[MAX_COMMENT_LEN]; +}; + // 시간 동기화 상태 struct TimeSyncStatus { bool synchronized; @@ -104,15 +110,21 @@ struct PowerStatus { uint32_t lastMinReset; // 최소값 리셋 시간 } powerStatus = {0.0, 999.9, false, 0, 0}; +// MCP2515 모드 정의 +enum MCP2515Mode { + MCP_MODE_NORMAL = 0, + MCP_MODE_LISTEN_ONLY = 1, + MCP_MODE_LOOPBACK = 2 +}; + // WiFi AP 기본 설정 char wifiSSID[32] = "Byun_CAN_Logger"; char wifiPassword[64] = "12345678"; -int timezoneOffset = 9; // 기본값: 서울 (UTC+9) // 전역 변수 SPIClass hspi(HSPI); SPIClass vspi(VSPI); -MCP2515 mcp2515(HSPI_CS, 10000000, &hspi); +MCP2515 mcp2515(HSPI_CS, 20000000, &hspi); // 20MHz로 증가 (10MHz → 20MHz) WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(81); @@ -136,9 +148,8 @@ uint16_t bufferIndex = 0; // 로깅 파일 크기 추적 (실시간 모니터링용) volatile uint32_t currentFileSize = 0; -// 자동 시간 동기화 상태 -volatile bool autoTimeSyncRequested = false; -volatile bool autoTimeSyncCompleted = false; +// 현재 MCP2515 모드 +MCP2515Mode currentMcpMode = MCP_MODE_NORMAL; // RTC 관련 SoftWire rtcWire(RTC_SDA, RTC_SCL); @@ -149,13 +160,6 @@ CAN_SPEED currentCanSpeed = CAN_1000KBPS; const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; -// CAN 모드 설정 -enum CANMode { - CAN_MODE_LISTEN_ONLY = 0, // 수신 전용 (기본값, 안전) - CAN_MODE_NORMAL = 1 // 송수신 가능 (Transmit 기능용) -}; -volatile CANMode currentCanMode = CAN_MODE_LISTEN_ONLY; - // 실시간 모니터링용 RecentCANData recentData[RECENT_MSG_COUNT]; uint32_t totalMsgCount = 0; @@ -170,49 +174,20 @@ uint32_t lastMsgCount = 0; TxMessage txMessages[MAX_TX_MESSAGES]; uint32_t totalTxCount = 0; +// 파일 커멘트 저장 (최대 50개) +#define MAX_FILE_COMMENTS 50 +FileComment fileComments[MAX_FILE_COMMENTS]; +int commentCount = 0; + // ======================================== // 설정 저장/로드 함수 // ======================================== -const char* getTimezoneName(int offset) { - switch(offset) { - case -12: return "Baker Island"; - case -11: return "American Samoa"; - case -10: return "Hawaii"; - case -9: return "Alaska"; - case -8: return "Pacific (LA)"; - case -7: return "Mountain (Denver)"; - case -6: return "Central (Chicago)"; - case -5: return "Eastern (NY)"; - case -4: return "Atlantic"; - case -3: return "Buenos Aires"; - case -2: return "Mid-Atlantic"; - case -1: return "Azores"; - case 0: return "London/UTC"; - case 1: return "Paris/Berlin"; - case 2: return "Athens/Cairo"; - case 3: return "Moscow"; - case 4: return "Dubai"; - case 5: return "Karachi"; - case 6: return "Dhaka"; - case 7: return "Bangkok"; - case 8: return "Beijing/Singapore"; - case 9: return "Seoul/Tokyo"; - case 10: return "Sydney"; - case 11: return "Solomon Islands"; - case 12: return "Auckland/Fiji"; - case 13: return "Samoa"; - case 14: return "Line Islands"; - default: return "Custom"; - } -} - void loadSettings() { preferences.begin("can-logger", false); preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); - timezoneOffset = preferences.getInt("timezone", 9); // 설정이 없으면 기본값 사용 if (strlen(wifiSSID) == 0) { @@ -230,7 +205,6 @@ void saveSettings() { preferences.putString("wifi_ssid", wifiSSID); preferences.putString("wifi_pass", wifiPassword); - preferences.putInt("timezone", timezoneOffset); preferences.end(); @@ -238,64 +212,92 @@ void saveSettings() { Serial.println("----------------------------------------"); Serial.printf(" WiFi SSID : %s\n", wifiSSID); Serial.printf(" WiFi Password : %s\n", wifiPassword); - Serial.printf(" Timezone : UTC%+d (%s)\n", timezoneOffset, getTimezoneName(timezoneOffset)); Serial.println("----------------------------------------"); Serial.println("⚠️ 재부팅 후 WiFi 설정이 적용됩니다."); } -// 파일 코멘트 저장/로드 함수 -void saveFileComment(const String& filename, const String& comment) { - preferences.begin("comments", false); +// ======================================== +// 파일 커멘트 관리 함수 +// ======================================== + +void loadFileComments() { + if (!sdCardReady) return; - // 파일명을 키로 사용 (최대 15자 제한이 있으므로 해시 사용) - String key = "c_" + filename; - if (key.length() > 15) { - // 파일명이 길면 CRC32로 해시 - uint32_t hash = 0; - for (int i = 0; i < filename.length(); i++) { - hash = ((hash << 5) - hash) + filename[i]; + 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); } - key = "c_" + String(hash, HEX); + xSemaphoreGive(sdMutex); } - - preferences.putString(key.c_str(), comment); - preferences.end(); - - Serial.printf("✓ 코멘트 저장: %s -> %s\n", filename.c_str(), comment.c_str()); } -String loadFileComment(const String& filename) { - preferences.begin("comments", true); // read-only +void saveFileComments() { + if (!sdCardReady) return; - String key = "c_" + filename; - if (key.length() > 15) { - uint32_t hash = 0; - for (int i = 0; i < filename.length(); i++) { - hash = ((hash << 5) - hash) + filename[i]; + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + // 기존 파일 삭제 + if (SD.exists("/comments.txt")) { + SD.remove("/comments.txt"); } - key = "c_" + String(hash, HEX); + + 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); } - - String comment = preferences.getString(key.c_str(), ""); - preferences.end(); - - return comment; } -void deleteFileComment(const String& filename) { - preferences.begin("comments", false); - - String key = "c_" + filename; - if (key.length() > 15) { - uint32_t hash = 0; - for (int i = 0; i < filename.length(); i++) { - hash = ((hash << 5) - hash) + filename[i]; +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; } - key = "c_" + String(hash, HEX); } - preferences.remove(key.c_str()); - preferences.end(); + // 새로운 커멘트 추가 + 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 ""; } // ======================================== @@ -316,9 +318,11 @@ float readVoltage() { delayMicroseconds(100); } - float avgReading = sum / (float)samples; - // ADC 값을 전압으로 변환 (0-4095 -> 0-3.3V) - float voltage = (avgReading / 4095.0) * 3.3; + 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; } @@ -326,64 +330,39 @@ float readVoltage() { void updatePowerStatus() { uint32_t now = millis(); + // 5초마다 전압 체크 if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { powerStatus.voltage = readVoltage(); - // 1초 단위 최소값 추적 + // 1초 단위 최소값 업데이트 + if (powerStatus.voltage < powerStatus.minVoltage) { + powerStatus.minVoltage = powerStatus.voltage; + } + + // 1초마다 최소값 리셋 if (now - powerStatus.lastMinReset >= 1000) { powerStatus.minVoltage = powerStatus.voltage; powerStatus.lastMinReset = now; - } else { - if (powerStatus.voltage < powerStatus.minVoltage) { - powerStatus.minVoltage = powerStatus.voltage; - } } // 저전압 경고 - powerStatus.lowVoltage = (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD); + 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 (DS3231) 함수 +// RTC 함수 // ======================================== -void initRTC() { - rtcWire.begin(); - rtcWire.setClock(100000); - - rtcWire.beginTransmission(DS3231_ADDRESS); - uint8_t error = rtcWire.endTransmission(); - - if (error == 0) { - timeSyncStatus.rtcAvailable = true; - Serial.println("✓ RTC DS3231 감지됨"); - - // RTC에서 시간 읽어서 시스템 시간 초기화 - struct tm timeinfo; - if (readRTC(&timeinfo)) { - // RTC의 로컬 시간을 UTC로 변환 - time_t localTime = mktime(&timeinfo); - time_t utcTime = localTime - (timezoneOffset * 3600); - - struct timeval tv = { .tv_sec = utcTime, .tv_usec = 0 }; - settimeofday(&tv, NULL); - - timeSyncStatus.synchronized = true; - timeSyncStatus.rtcSyncCount++; - - char timeStr[64]; - strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); - Serial.printf("✓ RTC에서 시간 로드: %s (로컬 시간, UTC%+d)\n", timeStr, timezoneOffset); - } - } else { - timeSyncStatus.rtcAvailable = false; - Serial.println("⚠️ RTC DS3231 없음 - 웹에서 시간 동기화 필요"); - } -} - uint8_t bcdToDec(uint8_t val) { return ((val / 16 * 10) + (val % 16)); } @@ -392,137 +371,190 @@ uint8_t decToBcd(uint8_t val) { return ((val / 10 * 16) + (val % 10)); } -bool readRTC(struct tm* timeinfo) { - if (!timeSyncStatus.rtcAvailable) return false; - - if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) { - return false; - } +bool initRTC() { + rtcWire.begin(); + rtcWire.setTimeout(1000); rtcWire.beginTransmission(DS3231_ADDRESS); - rtcWire.write(0x00); uint8_t error = rtcWire.endTransmission(); - if (error != 0) { - xSemaphoreGive(rtcMutex); - return false; - } - - if (rtcWire.requestFrom(DS3231_ADDRESS, 7) != 7) { - xSemaphoreGive(rtcMutex); - return false; - } - - 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()); - - xSemaphoreGive(rtcMutex); - - 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; - timeinfo->tm_wday = 0; - timeinfo->tm_yday = 0; - timeinfo->tm_isdst = -1; - - return true; -} - -bool writeRTC(struct tm* timeinfo) { - if (!timeSyncStatus.rtcAvailable) return false; - - if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) { - return false; - } - - rtcWire.beginTransmission(DS3231_ADDRESS); - rtcWire.write(0x00); - rtcWire.write(decToBcd(timeinfo->tm_sec)); - rtcWire.write(decToBcd(timeinfo->tm_min)); - rtcWire.write(decToBcd(timeinfo->tm_hour)); - rtcWire.write(decToBcd(0)); - rtcWire.write(decToBcd(timeinfo->tm_mday)); - rtcWire.write(decToBcd(timeinfo->tm_mon + 1)); - rtcWire.write(decToBcd(timeinfo->tm_year - 100)); - - uint8_t error = rtcWire.endTransmission(); - xSemaphoreGive(rtcMutex); - - return (error == 0); -} - -// ======================================== -// 시간 관련 함수 -// ======================================== - -uint64_t getMicrosecondTimestamp() { - struct timeval tv; - gettimeofday(&tv, NULL); - return (uint64_t)tv.tv_sec * 1000000ULL + (uint64_t)tv.tv_usec; -} - -// ======================================== -// CAN 모드 관리 함수 -// ======================================== - -void setCANMode(CANMode mode) { - currentCanMode = mode; - - mcp2515.reset(); - mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); - - if (mode == CAN_MODE_LISTEN_ONLY) { - mcp2515.setListenOnlyMode(); - Serial.println("✓ CAN 모드: Listen-Only (수신 전용, 버스 영향 없음)"); + if (error == 0) { + timeSyncStatus.rtcAvailable = true; + Serial.println("✓ RTC DS3231 초기화 완료"); + return true; } else { - mcp2515.setNormalMode(); - Serial.println("⚠️ CAN 모드: Normal (송수신 가능, 버스 영향 있음)"); + 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); } } // ======================================== -// CAN 관련 함수 +// MCP2515 모드 제어 함수 +// ======================================== + +bool setMCP2515Mode(MCP2515Mode mode) { + MCP2515::ERROR result; + + switch (mode) { + case MCP_MODE_NORMAL: + result = mcp2515.setNormalMode(); + if (result == MCP2515::ERROR_OK) { + currentMcpMode = MCP_MODE_NORMAL; + Serial.println("✓ MCP2515 모드: NORMAL"); + return true; + } + break; + + case MCP_MODE_LISTEN_ONLY: + result = mcp2515.setListenOnlyMode(); + if (result == MCP2515::ERROR_OK) { + currentMcpMode = MCP_MODE_LISTEN_ONLY; + Serial.println("✓ MCP2515 모드: LISTEN-ONLY"); + return true; + } + break; + + case MCP_MODE_LOOPBACK: + result = mcp2515.setLoopbackMode(); + if (result == MCP2515::ERROR_OK) { + currentMcpMode = MCP_MODE_LOOPBACK; + Serial.println("✓ MCP2515 모드: LOOPBACK"); + return true; + } + break; + } + + Serial.println("✗ MCP2515 모드 변경 실패"); + return false; +} + +// ======================================== +// CAN 인터럽트 및 수신 함수 // ======================================== void IRAM_ATTR canISR() { BaseType_t xHigherPriorityTaskWoken = pdFALSE; - if (canRxTaskHandle != NULL) { - vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); - if (xHigherPriorityTaskWoken) { - portYIELD_FROM_ISR(); - } + vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); + if (xHigherPriorityTaskWoken) { + portYIELD_FROM_ISR(); } } -void canRxTask(void *parameter) { - CANMessage msg; +void canRxTask(void* parameter) { struct can_frame frame; + CANMessage canMsg; - while (1) { + for (;;) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { - msg.timestamp_us = getMicrosecondTimestamp(); - msg.id = frame.can_id; - msg.dlc = frame.can_dlc; - memcpy(msg.data, frame.data, 8); + // 한 번에 여러 메시지를 읽어서 처리 속도 향상 + 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; - xQueueSend(canQueue, &msg, 0); - totalMsgCount++; + canMsg.id = frame.can_id; + canMsg.dlc = frame.can_dlc; + memcpy(canMsg.data, frame.data, 8); - // 실시간 모니터링 업데이트 + // 큐에 추가 (블로킹 없이) + xQueueSend(canQueue, &canMsg, 0); + + // 실시간 데이터 업데이트 bool found = false; for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.id == msg.id) { - recentData[i].msg = msg; + if (recentData[i].count > 0 && recentData[i].msg.id == canMsg.id) { + recentData[i].msg = canMsg; recentData[i].count++; found = true; break; @@ -531,78 +563,144 @@ void canRxTask(void *parameter) { if (!found) { for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.timestamp_us == 0) { - recentData[i].msg = msg; + 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 카드 관련 함수 +// SD 카드 쓰기 태스크 // ======================================== -void flushBuffer() { - if (bufferIndex > 0 && loggingEnabled && logFile) { - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - size_t written = logFile.write(fileBuffer, bufferIndex); - if (written == bufferIndex) { - currentFileSize += bufferIndex; // 파일 크기 업데이트 - bufferIndex = 0; - } - xSemaphoreGive(sdMutex); - } - } -} - -void sdWriteTask(void *parameter) { +void sdWriteTask(void* parameter) { CANMessage msg; - while (1) { - if (xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(10)) == pdTRUE) { - if (loggingEnabled) { - if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) { - flushBuffer(); - } - + 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 { - if (loggingEnabled && bufferIndex > 0) { - flushBuffer(); - } - vTaskDelay(pdMS_TO_TICKS(10)); + vTaskDelay(pdMS_TO_TICKS(100)); } } } -void sdMonitorTask(void *parameter) { - const uint32_t FLUSH_INTERVAL = 1000; - uint32_t lastFlush = 0; +// ======================================== +// 로깅 제어 함수 +// ======================================== + +void startLogging() { + if (!sdCardReady) { + Serial.println("✗ SD 카드가 준비되지 않음"); + return; + } - while (1) { - uint32_t now = millis(); + 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 (loggingEnabled && (now - lastFlush >= FLUSH_INTERVAL)) { - if (bufferIndex > 0) { - flushBuffer(); - } + if (logFile) { + loggingEnabled = true; + bufferIndex = 0; + currentFileSize = 0; - // 주기적으로 sync 호출하여 SD 카드에 완전히 기록 - if (logFile && xSemaphoreTake(sdMutex, pdMS_TO_TICKS(50)) == pdTRUE) { - logFile.flush(); - xSemaphoreGive(sdMutex); - } - - lastFlush = now; + Serial.print("✓ 로깅 시작: "); + Serial.println(currentFilename); + } else { + Serial.println("✗ 파일 생성 실패"); } - vTaskDelay(pdMS_TO_TICKS(100)); + 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; + + Serial.print("✓ 로깅 종료: "); + Serial.println(currentFilename); + Serial.printf(" 파일 크기: %u bytes\n", currentFileSize); + + // 현재 파일명 초기화 + currentFilename[0] = '\0'; +} + +// ======================================== +// SD 모니터링 태스크 +// ======================================== + +void sdMonitorTask(void* parameter) { + for (;;) { + updatePowerStatus(); + vTaskDelay(pdMS_TO_TICKS(1000)); } } @@ -610,539 +708,272 @@ void sdMonitorTask(void *parameter) { // RTC 동기화 태스크 // ======================================== -void rtcSyncTask(void *parameter) { - while (1) { +void rtcSyncTask(void* parameter) { + for (;;) { + syncSystemTimeFromRTC(); vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS)); - - if (timeSyncStatus.rtcAvailable) { - struct tm timeinfo; - if (readRTC(&timeinfo)) { - // RTC의 로컬 시간을 UTC로 변환 - time_t localTime = mktime(&timeinfo); - time_t utcTime = localTime - (timezoneOffset * 3600); - - struct timeval tv = { .tv_sec = utcTime, .tv_usec = 0 }; - settimeofday(&tv, NULL); - - timeSyncStatus.rtcSyncCount++; - - char timeStr[64]; - strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); - Serial.printf("🕐 RTC 자동 동기화: %s (로컬 시간)\n", timeStr); - } - } } } // ======================================== -// WebSocket 이벤트 핸들러 +// CAN 송신 태스크 // ======================================== -void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { - if (type == WStype_DISCONNECTED) { - Serial.printf("[%u] WebSocket 연결 끊김\n", num); +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 (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { + txMessages[i].lastSent = now; + totalTxCount++; + } + } + } + } + + vTaskDelay(pdMS_TO_TICKS(1)); } - else if (type == WStype_CONNECTED) { - IPAddress ip = webSocket.remoteIP(num); - Serial.printf("[%u] WebSocket 연결됨: %s\n", num, ip.toString().c_str()); +} + +// ======================================== +// 파일 리스트 전송 함수 +// ======================================== + +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); } - else if (type == WStype_TEXT) { +} + +// ======================================== +// 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 파싱 - int cmdStart = message.indexOf("\"cmd\":\"") + 7; - int cmdEnd = message.indexOf("\"", cmdStart); - String cmd = message.substring(cmdStart, cmdEnd); - - if (cmd == "startLogging") { - if (sdCardReady && !loggingEnabled) { - // 시스템 UTC 시간에 타임존 적용하여 로컬 시간으로 변환 - time_t now; - time(&now); - now += (timezoneOffset * 3600); // UTC에 타임존 오프셋 적용 - struct tm* timeinfo = gmtime(&now); // UTC 기준으로 분해 - - 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.printf("✓ 로깅 시작: %s (UTC%+d)\n", currentFilename, timezoneOffset); - } - xSemaphoreGive(sdMutex); - } - - // 파일 목록 자동 갱신 (로깅 상태 즉시 반영) - vTaskDelay(pdMS_TO_TICKS(50)); - webSocketEvent(num, WStype_TEXT, (uint8_t*)"{\"cmd\":\"getFiles\"}", 17); - } - } - else if (cmd == "stopLogging") { - if (loggingEnabled) { - loggingEnabled = false; - flushBuffer(); - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (logFile) { - logFile.close(); - Serial.printf("✓ 로깅 중지 (최종 크기: %u bytes)\n", currentFileSize); - currentFileSize = 0; - } - xSemaphoreGive(sdMutex); - } - - // 파일 목록 자동 갱신 (로깅 상태 즉시 반영) - vTaskDelay(pdMS_TO_TICKS(50)); - webSocketEvent(num, WStype_TEXT, (uint8_t*)"{\"cmd\":\"getFiles\"}", 17); - } - } - else if (cmd == "syncTime") { - 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 minStart = message.indexOf("\"minute\":") + 9; - int secStart = message.indexOf("\"second\":") + 9; + // JSON 파싱 (간단한 방식) + if (message.indexOf("\"cmd\":\"startLogging\"") >= 0) { + startLogging(); + // 파일 리스트 자동 갱신 + delay(100); + sendFileList(); - struct tm timeinfo; - timeinfo.tm_year = message.substring(yearStart, message.indexOf(",", yearStart)).toInt() - 1900; - timeinfo.tm_mon = message.substring(monthStart, message.indexOf(",", monthStart)).toInt() - 1; - timeinfo.tm_mday = message.substring(dayStart, message.indexOf(",", dayStart)).toInt(); - timeinfo.tm_hour = message.substring(hourStart, message.indexOf(",", hourStart)).toInt(); - timeinfo.tm_min = message.substring(minStart, message.indexOf(",", minStart)).toInt(); - timeinfo.tm_sec = message.substring(secStart, message.indexOf("}", secStart)).toInt(); + } else if (message.indexOf("\"cmd\":\"stopLogging\"") >= 0) { + stopLogging(); + // 파일 리스트 자동 갱신 + delay(100); + sendFileList(); - // 브라우저에서 받은 시간은 로컬 시간이므로 UTC로 변환 - time_t localTime = mktime(&timeinfo); - time_t utcTime = localTime - (timezoneOffset * 3600); + } else if (message.indexOf("\"cmd\":\"getFiles\"") >= 0) { + // 파일 리스트 전송 + sendFileList(); - struct timeval tv = { .tv_sec = utcTime, .tv_usec = 0 }; - settimeofday(&tv, NULL); + } else if (message.indexOf("\"cmd\":\"deleteFile\"") >= 0) { + // 파일 삭제 + int filenameStart = message.indexOf("\"filename\":\"") + 12; + int filenameEnd = message.indexOf("\"", filenameStart); + String filename = message.substring(filenameStart, filenameEnd); - if (timeSyncStatus.rtcAvailable) { - // RTC에는 로컬 시간을 저장 - if (writeRTC(&timeinfo)) { - Serial.println("✓ RTC 시간 설정 완료 (로컬 시간)"); - } - } - - timeSyncStatus.synchronized = true; - timeSyncStatus.lastSyncTime = getMicrosecondTimestamp(); - timeSyncStatus.syncCount++; - - char timeStr[64]; - strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); - Serial.printf("✓ 시간 동기화 완료: 로컬=%s (UTC%+d)\n", timeStr, timezoneOffset); - } - else if (cmd == "getFiles") { - 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()) { - if (!first) fileList += ","; - first = false; - - String fileName = String(file.name()); - if (fileName.startsWith("/")) fileName = fileName.substring(1); - - // 파일 코멘트 로드 - String comment = loadFileComment(fileName); - comment.replace("\"", "\\\""); // JSON 이스케이프 - comment.replace("\n", "\\n"); - - fileList += "{\"name\":\"" + fileName + "\",\"size\":" + String(file.size()) + - ",\"comment\":\"" + comment + "\"}"; - } - file.close(); - file = root.openNextFile(); - } - - fileList += "]}"; - root.close(); - xSemaphoreGive(sdMutex); - - webSocket.sendTXT(num, fileList); - } - } - else if (cmd == "deleteFile") { - // 파일 삭제 명령 처리 - int fileStart = message.indexOf("\"filename\":\"") + 12; - int fileEnd = message.indexOf("\"", fileStart); - String filename = message.substring(fileStart, fileEnd); - - // 로깅 중인 파일은 삭제 불가 + // 로깅 중인 파일 체크 + bool canDelete = true; if (loggingEnabled && currentFilename[0] != '\0') { String currentFileStr = String(currentFilename); if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); if (filename == currentFileStr) { - String response = "{\"type\":\"deleteResult\",\"success\":false,\"message\":\"Cannot delete file currently being logged\"}"; - webSocket.sendTXT(num, response); - Serial.printf("⚠️ 파일 삭제 실패: 로깅 중인 파일입니다 (%s)\n", filename.c_str()); - return; + canDelete = false; } } - // 파일 삭제 수행 - String fullPath = "/" + filename; - bool deleteSuccess = false; - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (SD.exists(fullPath)) { - deleteSuccess = SD.remove(fullPath); - if (deleteSuccess) { - deleteFileComment(filename); // 코멘트도 삭제 - Serial.printf("✓ 파일 삭제 완료: %s\n", filename.c_str()); - } else { - Serial.printf("✗ 파일 삭제 실패: %s\n", filename.c_str()); - } - } else { - Serial.printf("✗ 파일이 존재하지 않음: %s\n", filename.c_str()); - } - xSemaphoreGive(sdMutex); - } - - String response = "{\"type\":\"deleteResult\",\"success\":" + String(deleteSuccess ? "true" : "false") + - ",\"message\":\"" + (deleteSuccess ? "File deleted successfully" : "Failed to delete file") + "\"}"; - webSocket.sendTXT(num, response); - - // 파일 목록 자동 갱신 - vTaskDelay(pdMS_TO_TICKS(100)); - webSocketEvent(num, WStype_TEXT, (uint8_t*)"{\"cmd\":\"getFiles\"}", 17); - } - else if (cmd == "deleteFiles") { - // 복수 파일 삭제 명령 처리 - int filesStart = message.indexOf("\"filenames\":[") + 13; - int filesEnd = message.indexOf("]", filesStart); - String filesStr = message.substring(filesStart, filesEnd); - - int deletedCount = 0; - int failedCount = 0; - String failedFiles = ""; - - // JSON 배열 파싱 (간단한 방식) - int pos = 0; - while (pos < filesStr.length()) { - int quoteStart = filesStr.indexOf("\"", pos); - if (quoteStart < 0) break; - int quoteEnd = filesStr.indexOf("\"", quoteStart + 1); - if (quoteEnd < 0) break; + if (!canDelete) { + webSocket.sendTXT(num, "{\"type\":\"deleteResult\",\"success\":false,\"message\":\"Cannot delete file being logged\"}"); + } else { + String fullPath = "/" + filename; - String filename = filesStr.substring(quoteStart + 1, quoteEnd); - - // 로깅 중인 파일은 건너뛰기 - bool isLogging = false; - if (loggingEnabled && currentFilename[0] != '\0') { - String currentFileStr = String(currentFilename); - if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); - if (filename == currentFileStr) { - isLogging = true; - failedCount++; - if (failedFiles.length() > 0) failedFiles += ", "; - failedFiles += filename + " (logging)"; - } - } - - if (!isLogging) { - String fullPath = "/" + filename; + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + bool success = SD.remove(fullPath); + xSemaphoreGive(sdMutex); - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (SD.exists(fullPath)) { - if (SD.remove(fullPath)) { - deleteFileComment(filename); // 코멘트도 삭제 - deletedCount++; - Serial.printf("✓ 파일 삭제: %s\n", filename.c_str()); - } else { - failedCount++; - if (failedFiles.length() > 0) failedFiles += ", "; - failedFiles += filename; - Serial.printf("✗ 파일 삭제 실패: %s\n", filename.c_str()); - } - } else { - failedCount++; - if (failedFiles.length() > 0) failedFiles += ", "; - failedFiles += filename + " (not found)"; - } - 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\"}"); } } - - pos = quoteEnd + 1; } - String response = "{\"type\":\"deleteResult\",\"success\":true,"; - response += "\"deletedCount\":" + String(deletedCount) + ","; - response += "\"failedCount\":" + String(failedCount) + ","; - response += "\"message\":\"Deleted " + String(deletedCount) + " files"; - if (failedCount > 0) { - response += ", Failed: " + String(failedCount); - } - response += "\"}"; - - webSocket.sendTXT(num, response); - Serial.printf("✓ 복수 삭제 완료: 성공=%d, 실패=%d\n", deletedCount, failedCount); - - // 파일 목록 자동 갱신 - vTaskDelay(pdMS_TO_TICKS(100)); - webSocketEvent(num, WStype_TEXT, (uint8_t*)"{\"cmd\":\"getFiles\"}", 17); - } - else if (cmd == "setSpeed") { + } else if (message.indexOf("\"cmd\":\"setSpeed\"") >= 0) { + // CAN 속도 변경 int speedStart = message.indexOf("\"speed\":") + 8; - int speedValue = message.substring(speedStart, message.indexOf("}", speedStart)).toInt(); + int speedEnd = message.indexOf(",", speedStart); + if (speedEnd < 0) speedEnd = message.indexOf("}", speedStart); - if (speedValue >= 0 && speedValue < 4) { - currentCanSpeed = canSpeedValues[speedValue]; - - // 현재 모드 유지하면서 속도만 변경 + int speedIndex = message.substring(speedStart, speedEnd).toInt(); + + if (speedIndex >= 0 && speedIndex < 4) { + currentCanSpeed = canSpeedValues[speedIndex]; mcp2515.reset(); mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + setMCP2515Mode(currentMcpMode); // 현재 모드 유지 - if (currentCanMode == CAN_MODE_LISTEN_ONLY) { - mcp2515.setListenOnlyMode(); - } else { - mcp2515.setNormalMode(); - } - - Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedValue]); + Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedIndex]); } - } - else if (cmd == "setCanMode") { - // CAN 모드 변경 (Listen-Only ↔ Normal) + + } else if (message.indexOf("\"cmd\":\"setMcpMode\"") >= 0) { + // MCP2515 모드 변경 int modeStart = message.indexOf("\"mode\":") + 7; - int modeValue = message.substring(modeStart, message.indexOf("}", modeStart)).toInt(); + int modeEnd = message.indexOf(",", modeStart); + if (modeEnd < 0) modeEnd = message.indexOf("}", modeStart); - if (modeValue == 0) { - setCANMode(CAN_MODE_LISTEN_ONLY); - } else if (modeValue == 1) { - setCANMode(CAN_MODE_NORMAL); + int mode = message.substring(modeStart, modeEnd).toInt(); + + if (mode >= 0 && mode <= 2) { + setMCP2515Mode((MCP2515Mode)mode); } - String response = "{\"type\":\"canModeResult\",\"mode\":" + String(currentCanMode) + "}"; - webSocket.sendTXT(num, response); - } - else if (cmd == "getCanMode") { - // 현재 CAN 모드 조회 - String response = "{\"type\":\"canModeStatus\",\"mode\":" + String(currentCanMode) + "}"; - webSocket.sendTXT(num, response); - } - else if (cmd == "getSettings") { + } 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 += "\"timezone\":" + String(timezoneOffset) + "}"; + settings += "\"password\":\"" + String(wifiPassword) + "\"}"; webSocket.sendTXT(num, settings); - } - else if (cmd == "saveSettings") { + + } 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); - int tzStart = message.indexOf("\"timezone\":") + 11; + String password = message.substring(passStart, passEnd); - String newSSID = message.substring(ssidStart, ssidEnd); - String newPassword = message.substring(passStart, passEnd); - int newTimezone = message.substring(tzStart, message.indexOf("}", tzStart)).toInt(); - - newSSID.toCharArray(wifiSSID, sizeof(wifiSSID)); - newPassword.toCharArray(wifiPassword, sizeof(wifiPassword)); - timezoneOffset = newTimezone; + strncpy(wifiSSID, ssid.c_str(), sizeof(wifiSSID) - 1); + strncpy(wifiPassword, password.c_str(), sizeof(wifiPassword) - 1); saveSettings(); - String response = "{\"type\":\"settingsResult\",\"success\":true}"; - webSocket.sendTXT(num, response); - } - else if (cmd == "sendCAN") { - // Normal Mode에서만 송신 가능 - if (currentCanMode != CAN_MODE_NORMAL) { - String response = "{\"type\":\"error\",\"message\":\"Switch to Normal Mode to send CAN messages\"}"; - webSocket.sendTXT(num, response); - Serial.println("⚠️ CAN 송신 차단: Listen-Only Mode"); - return; - } - - int idStart = message.indexOf("\"id\":\"") + 6; - int idEnd = message.indexOf("\"", idStart); - String idStr = message.substring(idStart, idEnd); - - int dataStart = message.indexOf("\"data\":\"") + 8; - int dataEnd = message.indexOf("\"", dataStart); - String dataStr = message.substring(dataStart, dataEnd); - - uint32_t canId = strtoul(idStr.c_str(), NULL, 16); - - struct can_frame frame; - frame.can_id = canId; - frame.can_dlc = 0; - - dataStr.replace(" ", ""); - for (int i = 0; i < dataStr.length() && i < 16; i += 2) { - String byteStr = dataStr.substring(i, i + 2); - frame.data[frame.can_dlc++] = strtoul(byteStr.c_str(), NULL, 16); - } - - MCP2515::ERROR result = mcp2515.sendMessage(&frame); - if (result == MCP2515::ERROR_OK) { - totalTxCount++; - Serial.printf("✓ CAN 송신: ID=0x%X, DLC=%d\n", canId, frame.can_dlc); - } else { - Serial.printf("✗ CAN 송신 실패: ID=0x%X, Error=%d\n", canId, result); - } - } - else if (cmd == "addTxMessage") { - // Normal Mode에서만 주기 송신 가능 - if (currentCanMode != CAN_MODE_NORMAL) { - String response = "{\"type\":\"error\",\"message\":\"Switch to Normal Mode for periodic transmission\"}"; - webSocket.sendTXT(num, response); - Serial.println("⚠️ 주기 송신 차단: Listen-Only Mode"); - return; - } - - int slot = -1; - for (int i = 0; i < MAX_TX_MESSAGES; i++) { - if (!txMessages[i].active) { - slot = i; - break; - } - } - - if (slot >= 0) { - int idStart = message.indexOf("\"id\":\"") + 6; - int idEnd = message.indexOf("\"", idStart); - String idStr = message.substring(idStart, idEnd); - - int dataStart = message.indexOf("\"data\":\"") + 8; - int dataEnd = message.indexOf("\"", dataStart); - String dataStr = message.substring(dataStart, dataEnd); - - int intervalStart = message.indexOf("\"interval\":") + 11; - int intervalEnd = message.indexOf(",", intervalStart); - if (intervalEnd < 0) intervalEnd = message.indexOf("}", intervalStart); - int interval = message.substring(intervalStart, intervalEnd).toInt(); - - txMessages[slot].id = strtoul(idStr.c_str(), NULL, 16); - txMessages[slot].extended = false; - txMessages[slot].interval = interval; - txMessages[slot].lastSent = 0; - txMessages[slot].active = true; - - dataStr.replace(" ", ""); - txMessages[slot].dlc = 0; - for (int i = 0; i < dataStr.length() && i < 16; i += 2) { - String byteStr = dataStr.substring(i, i + 2); - txMessages[slot].data[txMessages[slot].dlc++] = strtoul(byteStr.c_str(), NULL, 16); - } - - Serial.printf("✓ 주기 송신 추가: Slot=%d, ID=0x%X, Interval=%dms\n", - slot, txMessages[slot].id, interval); - } - } - else if (cmd == "removeTxMessage") { - int slotStart = message.indexOf("\"slot\":") + 7; - int slot = message.substring(slotStart, message.indexOf("}", slotStart)).toInt(); - - if (slot >= 0 && slot < MAX_TX_MESSAGES) { - txMessages[slot].active = false; - Serial.printf("✓ 주기 송신 제거: Slot=%d\n", slot); - } - } - else if (cmd == "getTxMessages") { - String txList = "{\"type\":\"txMessages\",\"messages\":["; - bool first = true; - - for (int i = 0; i < MAX_TX_MESSAGES; i++) { - if (txMessages[i].active) { - if (!first) txList += ","; - first = false; - - char idStr[16], dataStr[32]; - sprintf(idStr, "%08X", txMessages[i].id); - - dataStr[0] = '\0'; - for (int j = 0; j < txMessages[i].dlc; j++) { - char byteStr[4]; - sprintf(byteStr, "%02X", txMessages[i].data[j]); - strcat(dataStr, byteStr); - if (j < txMessages[i].dlc - 1) strcat(dataStr, " "); - } - - txList += "{\"slot\":" + String(i) + - ",\"id\":\"" + String(idStr) + - "\",\"data\":\"" + String(dataStr) + - "\",\"interval\":" + String(txMessages[i].interval) + "}"; - } - } - - txList += "]}"; - webSocket.sendTXT(num, txList); - } - else if (cmd == "saveComment") { - // 파일 코멘트 저장 - int fileStart = message.indexOf("\"filename\":\"") + 12; - int fileEnd = message.indexOf("\"", fileStart); - String filename = message.substring(fileStart, fileEnd); - - int commentStart = message.indexOf("\"comment\":\"") + 11; - int commentEnd = message.lastIndexOf("\""); - String comment = message.substring(commentStart, commentEnd); - - // JSON 이스케이프 복원 - comment.replace("\\n", "\n"); - comment.replace("\\\"", "\""); - - saveFileComment(filename, comment); - - String response = "{\"type\":\"commentResult\",\"success\":true,\"message\":\"Comment saved\"}"; - webSocket.sendTXT(num, response); - } - else if (cmd == "requestAutoTimeSync") { - // 클라이언트가 자동 시간 동기화 완료를 알림 - autoTimeSyncCompleted = true; - Serial.println("✓ 자동 시간 동기화 완료 (클라이언트)"); - } - } -} - -// ======================================== -// 주기 송신 태스크 (Normal Mode에서만 동작) -// ======================================== - -void txTask(void *parameter) { - while (1) { - // Normal Mode에서만 주기 송신 동작 - if (currentCanMode == CAN_MODE_NORMAL) { - uint32_t now = millis(); - - for (int i = 0; i < MAX_TX_MESSAGES; i++) { - if (txMessages[i].active) { - if (now - txMessages[i].lastSent >= txMessages[i].interval) { - struct can_frame frame; - frame.can_id = txMessages[i].id; - frame.can_dlc = txMessages[i].dlc; - memcpy(frame.data, txMessages[i].data, 8); - - if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { - totalTxCount++; - txMessages[i].lastSent = now; - } - } - } - } - - vTaskDelay(pdMS_TO_TICKS(1)); - } else { - // Listen-Only Mode에서는 대기 - vTaskDelay(pdMS_TO_TICKS(100)); + webSocket.sendTXT(num, "{\"type\":\"settingsSaved\",\"success\":true}"); } } } @@ -1151,101 +982,58 @@ void txTask(void *parameter) { // 웹 업데이트 태스크 // ======================================== -void webUpdateTask(void *parameter) { - const uint32_t STATUS_UPDATE_INTERVAL = 500; - const uint32_t CAN_UPDATE_INTERVAL = 200; - +void webUpdateTask(void* parameter) { uint32_t lastStatusUpdate = 0; uint32_t lastCanUpdate = 0; - uint32_t lastMsgSpeedCalc = 0; - while (1) { + for (;;) { webSocket.loop(); - updatePowerStatus(); uint32_t now = millis(); - // 자동 시간 동기화 요청 (최초 1회, 클라이언트 연결 후) - if (autoTimeSyncRequested && !autoTimeSyncCompleted) { - if (webSocket.connectedClients() > 0) { - String syncRequest = "{\"type\":\"autoTimeSyncRequest\"}"; - webSocket.broadcastTXT(syncRequest); - Serial.println("⏰ 자동 시간 동기화 요청 전송"); - vTaskDelay(pdMS_TO_TICKS(1000)); // 1초 대기 - } - } - - // 메시지 속도 계산 - if (now - lastMsgSpeedCalc >= 1000) { - msgPerSecond = totalMsgCount - lastMsgCount; - lastMsgCount = totalMsgCount; - lastMsgSpeedCalc = now; - } - - // 상태 업데이트 - if (now - lastStatusUpdate >= STATUS_UPDATE_INTERVAL) { + // 상태 업데이트 (500ms) + if (now - lastStatusUpdate >= 500) { String status = "{\"type\":\"status\","; status += "\"logging\":" + String(loggingEnabled ? "true" : "false") + ","; status += "\"sdReady\":" + String(sdCardReady ? "true" : "false") + ","; - status += "\"msgCount\":" + String(totalMsgCount) + ","; - status += "\"msgSpeed\":" + String(msgPerSecond) + ","; - status += "\"timeSync\":" + String(timeSyncStatus.synchronized ? "true" : "false") + ","; - status += "\"syncCount\":" + String(timeSyncStatus.syncCount) + ","; + 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 += "\"queueUsed\":" + String(uxQueueMessagesWaiting(canQueue)) + ","; - status += "\"queueSize\":" + String(CAN_QUEUE_SIZE) + ","; - status += "\"canMode\":" + String(currentCanMode) + ","; - - // SD 카드 용량 정보 추가 - if (sdCardReady) { - uint64_t cardSize = SD.cardSize() / (1024 * 1024); // MB - uint64_t totalBytes = SD.totalBytes() / (1024 * 1024); // MB - uint64_t usedBytes = SD.usedBytes() / (1024 * 1024); // MB - uint64_t freeBytes = totalBytes - usedBytes; // MB - - status += "\"sdTotalMB\":" + String((uint32_t)totalBytes) + ","; - status += "\"sdUsedMB\":" + String((uint32_t)usedBytes) + ","; - status += "\"sdFreeMB\":" + String((uint32_t)freeBytes) + ","; - } else { - status += "\"sdTotalMB\":0,"; - status += "\"sdUsedMB\":0,"; - status += "\"sdFreeMB\":0,"; - } - - if (loggingEnabled && logFile) { - status += "\"currentFile\":\"" + String(currentFilename) + "\","; - status += "\"currentFileSize\":" + String(currentFileSize); - } else { - status += "\"currentFile\":\"\","; - status += "\"currentFileSize\":0"; - } + status += "\"mcpMode\":" + String(currentMcpMode); status += "}"; webSocket.broadcastTXT(status); lastStatusUpdate = now; } - // CAN 메시지 일괄 업데이트 - if (now - lastCanUpdate >= CAN_UPDATE_INTERVAL) { + // CAN 데이터 배치 전송 (100ms) + if (now - lastCanUpdate >= 100) { String canBatch = "{\"type\":\"canBatch\",\"messages\":["; - bool first = true; int messageCount = 0; for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.timestamp_us > 0) { + if (recentData[i].count > 0) { CANMessage* msg = &recentData[i].msg; - if (!first) canBatch += ","; - first = false; + if (messageCount > 0) canBatch += ","; - canBatch += "{\"id\":\""; - if (msg->id < 0x10) canBatch += "0"; - if (msg->id < 0x100) canBatch += "0"; - if (msg->id < 0x1000) canBatch += "0"; + canBatch += "{\"id\":\"0x"; canBatch += String(msg->id, HEX); canBatch += "\",\"dlc\":" + String(msg->dlc); canBatch += ",\"data\":\""; @@ -1281,8 +1069,9 @@ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); - Serial.println(" ESP32 CAN Logger v1.7 "); - Serial.println(" Listen-Only Mode (Default, Safe) "); + Serial.println(" ESP32 CAN Logger v2.0 "); + Serial.println(" + Phone Time Sync & File Comments "); + Serial.println(" + MCP2515 Mode Control "); Serial.println("========================================"); // 설정 로드 @@ -1293,12 +1082,12 @@ void setup() { Serial.println("----------------------------------------"); Serial.printf(" WiFi SSID : %s\n", wifiSSID); Serial.printf(" WiFi Password : %s\n", wifiPassword); - Serial.printf(" Timezone : UTC%+d (%s)\n", timezoneOffset, getTimezoneName(timezoneOffset)); 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); @@ -1309,27 +1098,38 @@ void setup() { hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); - // MCP2515 초기화 - Listen-Only Mode (버스에 영향 없음) + // VSPI 클럭 속도를 40MHz로 설정 (SD 카드용) + vspi.setFrequency(40000000); // 40MHz + + // MCP2515 초기화 mcp2515.reset(); mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); - mcp2515.setListenOnlyMode(); // ⭐ Listen-Only Mode: ACK 전송 안 함, 수신만 가능 - Serial.println("✓ MCP2515 초기화 완료 (Listen-Only Mode)"); + setMCP2515Mode(MCP_MODE_NORMAL); + 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 카드 초기화 실패"); } - // Mutex 생성 - sdMutex = xSemaphoreCreateMutex(); - rtcMutex = xSemaphoreCreateMutex(); - - // RTC 초기화 (SoftWire 사용) - initRTC(); - // WiFi AP 시작 WiFi.softAP(wifiSSID, wifiPassword); Serial.print("✓ AP SSID: "); @@ -1433,8 +1233,8 @@ void setup() { // Queue 생성 canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); - if (canQueue == NULL || sdMutex == NULL || rtcMutex == NULL) { - Serial.println("✗ RTOS 객체 생성 실패!"); + if (canQueue == NULL) { + Serial.println("✗ Queue 생성 실패!"); while (1) delay(1000); } @@ -1442,7 +1242,7 @@ void setup() { attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); // Task 생성 - xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 4, &canRxTaskHandle, 1); + 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); @@ -1452,10 +1252,6 @@ void setup() { if (timeSyncStatus.rtcAvailable) { xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 4096, NULL, 0, &rtcTaskHandle, 0); Serial.println("✓ RTC 자동 동기화 Task 시작"); - } else { - // RTC가 없으면 웹에서 자동 시간 동기화 요청 - autoTimeSyncRequested = true; - Serial.println("⏰ 웹 브라우저 연결 시 자동 시간 동기화 예정"); } Serial.println("✓ 모든 태스크 시작 완료"); @@ -1473,8 +1269,6 @@ void setup() { Serial.println(" - Transmit : /transmit"); Serial.println(" - Graph : /graph"); Serial.println(" - Settings : /settings"); - Serial.println("========================================"); - Serial.printf(" Timezone: UTC%+d (%s)\n", timezoneOffset, getTimezoneName(timezoneOffset)); Serial.println("========================================\n"); } @@ -1484,7 +1278,7 @@ void loop() { 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\n", + 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", @@ -1494,7 +1288,8 @@ void loop() { timeSyncStatus.rtcAvailable ? "OK" : "NO", timeSyncStatus.rtcSyncCount, powerStatus.voltage, - powerStatus.lowVoltage ? " ⚠️" : ""); + powerStatus.lowVoltage ? " ⚠️" : "", + currentMcpMode); lastPrint = millis(); } } diff --git a/index.h b/index.h index 4ae786a..7fb43df 100644 --- a/index.h +++ b/index.h @@ -224,14 +224,11 @@ const char index_html[] PROGMEM = R"rawliteral( font-weight: 700; cursor: pointer; transition: all 0.3s; - box-shadow: 0 3px 10px rgba(0,0,0,0.2); + white-space: nowrap; } .btn-time-sync:hover { - transform: translateY(-3px); - box-shadow: 0 6px 20px rgba(0,0,0,0.3); - } - .btn-time-sync:active { - transform: translateY(-1px); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .status-grid { @@ -246,81 +243,37 @@ 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; } - - /* SD 카드 용량 표시 */ - .sd-capacity { - background: linear-gradient(135deg, #4facfe 0%, #00f2fe 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(79, 172, 254, 0.3); - flex-wrap: wrap; - gap: 10px; + .status-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0,0,0,0.3); } - .sd-capacity-label { - font-size: 0.85em; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; - } - .sd-capacity-values { - display: flex; - gap: 15px; - align-items: center; - flex-wrap: wrap; - } - .sd-capacity-item { - display: flex; - flex-direction: column; - align-items: flex-end; - } - .sd-capacity-item-label { - font-size: 0.7em; + .status-card h3 { + font-size: 0.75em; opacity: 0.9; + margin-bottom: 8px; + letter-spacing: 1px; } - .sd-capacity-value { - font-family: 'Courier New', monospace; - font-size: 1.2em; + .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%); } - /* 파일 선택 체크박스 */ - .file-checkbox { - width: 20px; - height: 20px; - cursor: pointer; - flex-shrink: 0; - } - .file-selection-controls { - background: #f8f9fa; - padding: 12px; - border-radius: 8px; - margin-bottom: 10px; - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; - } - .file-selection-controls button { - padding: 8px 16px; - font-size: 0.85em; - } - .selection-info { - flex: 1; - min-width: 150px; - color: #666; - font-weight: 600; - font-size: 0.9em; + h2 { + color: #333; + margin: 20px 0 10px 0; + font-size: 1.3em; + border-bottom: 3px solid #667eea; + padding-bottom: 8px; } .control-panel { @@ -328,189 +281,269 @@ const char index_html[] PROGMEM = R"rawliteral( 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; 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; - gap: 12px; flex-wrap: wrap; + gap: 10px; } - .file-item:hover { transform: translateX(5px); box-shadow: 0 3px 10px rgba(0,0,0,0.1); } - .file-item.logging { + .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(to right, rgba(17, 153, 142, 0.05), white); + background: linear-gradient(135deg, rgba(17, 153, 142, 0.05) 0%, rgba(56, 239, 125, 0.05) 100%); } .file-info { flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; + min-width: 200px; } - .file-name { - font-weight: 600; - color: #333; - font-size: 0.9em; - word-break: break-all; + .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: 2px 8px; + padding: 3px 8px; border-radius: 4px; font-size: 0.75em; font-weight: 700; - animation: pulse 2s ease-in-out infinite; + letter-spacing: 0.5px; } - .file-size { - color: #666; + .download-btn, .delete-btn, .comment-btn { + padding: 6px 12px; + border: none; + border-radius: 5px; font-size: 0.85em; font-weight: 600; - } - .file-comment { - color: #888; - font-size: 0.8em; - font-style: italic; - margin-top: 2px; cursor: pointer; - transition: color 0.3s; - } - .file-comment:hover { - color: #667eea; - } - .file-comment.empty { - color: #ccc; - } - .comment-input { - width: 100%; - padding: 6px; - border: 2px solid #667eea; - border-radius: 4px; - font-size: 0.85em; - margin-top: 4px; - font-family: inherit; - } - .comment-actions { - display: flex; - gap: 6px; - margin-top: 6px; - } - .comment-actions button { - padding: 4px 12px; - font-size: 0.8em; - } - .file-actions { - display: flex; - gap: 8px; - flex-shrink: 0; - } - .download-btn, .delete-btn { - padding: 8px 16px; - font-size: 0.85em; + transition: all 0.3s; white-space: nowrap; - flex-shrink: 0; - min-width: 80px; + } + .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 { + .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) { body { padding: 5px; } .header h1 { font-size: 1.5em; } @@ -543,7 +576,7 @@ const char index_html[] PROGMEM = R"rawliteral( width: 100%; justify-content: stretch; } - .download-btn, .delete-btn { + .download-btn, .delete-btn, .comment-btn { flex: 1; } } @@ -552,8 +585,8 @@ const char index_html[] PROGMEM = R"rawliteral(
-

🚗 Byun CAN Logger v1.6

-

Listen-Only Mode - No CAN Bus Impact (RX Only)

+

🚗 Byun CAN Logger v2.0

+

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

- +
@@ -607,27 +640,6 @@ const char index_html[] PROGMEM = R"rawliteral(
-
-
- 💾 - SD CARD CAPACITY -
-
-
-
TOTAL
-
0 MB
-
-
-
USED
-
0 MB
-
-
-
FREE
-
0 MB
-
-
-
-

LOGGING

@@ -649,6 +661,10 @@ const char index_html[] PROGMEM = R"rawliteral(

TIME SYNC

0
+
+

MCP MODE

+
NORMAL
+

CURRENT FILE

-
@@ -672,6 +688,16 @@ const char index_html[] PROGMEM = R"rawliteral(
+
+ + + + +
@@ -697,188 +723,38 @@ const char index_html[] PROGMEM = R"rawliteral(

Log Files

-
- - - - -
- 0 files selected -
-

Loading...

+ + +