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...

+ + +