diff --git a/ESP32_CAN_Logger.ino b/ESP32_CAN_Logger.ino index db88042..100e23f 100644 --- a/ESP32_CAN_Logger.ino +++ b/ESP32_CAN_Logger.ino @@ -1,7 +1,7 @@ /* * Byun CAN Logger with Web Interface + RTC Time Synchronization + Timezone Settings - * Version: 1.4 - * Added: Timezone configuration, WiFi settings, Power monitoring + * Version: 1.5 + * Added: File delete function, Real-time file size monitoring */ #include @@ -132,6 +132,9 @@ char currentFilename[MAX_FILENAME_LEN]; uint8_t fileBuffer[FILE_BUFFER_SIZE]; uint16_t bufferIndex = 0; +// 로깅 파일 크기 추적 (실시간 모니터링용) +volatile uint32_t currentFileSize = 0; + // RTC 관련 SoftWire rtcWire(RTC_SDA, RTC_SCL); char rtcSyncBuffer[20]; @@ -247,12 +250,8 @@ float readVoltage() { } float avgReading = sum / (float)samples; - - // ADC 값을 전압으로 변환 (ESP32는 12-bit ADC, 0-4095) - // 기본 참조 전압은 3.3V - // 실제 전압 = (ADC값 / 4095) * 3.3V * 보정계수 - // 보정계수는 실제 하드웨어에 따라 조정 필요 - float voltage = (avgReading / 4095.0) * 3.3 * 1.1; // 1.1은 보정계수 + // ADC 값을 전압으로 변환 (0-4095 -> 0-3.3V) + float voltage = (avgReading / 4095.0) * 3.3; return voltage; } @@ -263,150 +262,140 @@ void updatePowerStatus() { if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { powerStatus.voltage = readVoltage(); - // 1초마다 최소 전압 리셋 + // 1초 단위 최소값 추적 if (now - powerStatus.lastMinReset >= 1000) { powerStatus.minVoltage = powerStatus.voltage; powerStatus.lastMinReset = now; } else { - // 1초 내에서 최소값 추적 if (powerStatus.voltage < powerStatus.minVoltage) { powerStatus.minVoltage = powerStatus.voltage; } } - powerStatus.lowVoltage = (powerStatus.minVoltage < LOW_VOLTAGE_THRESHOLD); - powerStatus.lastCheck = now; + // 저전압 경고 + powerStatus.lowVoltage = (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD); - if (powerStatus.lowVoltage) { - Serial.printf("⚠️ 전압 경고: 현재=%.2fV, 최소(1s)=%.2fV (임계값: %.2fV)\n", - powerStatus.voltage, powerStatus.minVoltage, LOW_VOLTAGE_THRESHOLD); - } + powerStatus.lastCheck = now; } } // ======================================== -// RTC 관련 함수 +// RTC (DS3231) 함수 // ======================================== -// BCD to Decimal 변환 -uint8_t bcd2dec(uint8_t val) { +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)); } -// Decimal to BCD 변환 -uint8_t dec2bcd(uint8_t val) { +uint8_t decToBcd(uint8_t val) { return ((val / 10 * 16) + (val % 10)); } -// DS3231에서 시간 읽기 (Non-blocking, SoftWire 사용) bool readRTC(struct tm* timeinfo) { - if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(10)) != pdTRUE) { - return false; // Mutex를 얻지 못하면 실패 반환 - } - - rtcWire.beginTransmission(DS3231_ADDRESS); - rtcWire.write(0x00); // 초 레지스터부터 시작 - - if (rtcWire.endTransmission() != 0) { - xSemaphoreGive(rtcMutex); - return false; - } - - rtcWire.requestFrom(DS3231_ADDRESS, 7); - - if (rtcWire.available() < 7) { - xSemaphoreGive(rtcMutex); - return false; - } - - uint8_t seconds = bcd2dec(rtcWire.read() & 0x7F); - uint8_t minutes = bcd2dec(rtcWire.read()); - uint8_t hours = bcd2dec(rtcWire.read() & 0x3F); - rtcWire.read(); // day of week (skip) - uint8_t day = bcd2dec(rtcWire.read()); - uint8_t month = bcd2dec(rtcWire.read()); - uint8_t year = bcd2dec(rtcWire.read()); - - xSemaphoreGive(rtcMutex); - - timeinfo->tm_sec = seconds; - timeinfo->tm_min = minutes; - timeinfo->tm_hour = hours; - timeinfo->tm_mday = day; - timeinfo->tm_mon = month - 1; // 0-11 - timeinfo->tm_year = year + 100; // years since 1900 - - return true; -} - -// DS3231에 시간 설정 (Non-blocking, SoftWire 사용) -bool writeRTC(const struct tm* timeinfo) { - if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(10)) != pdTRUE) { - return false; - } - - rtcWire.beginTransmission(DS3231_ADDRESS); - rtcWire.write(0x00); // 초 레지스터부터 시작 - rtcWire.write(dec2bcd(timeinfo->tm_sec)); - rtcWire.write(dec2bcd(timeinfo->tm_min)); - rtcWire.write(dec2bcd(timeinfo->tm_hour)); - rtcWire.write(dec2bcd(1)); // day of week (1-7) - rtcWire.write(dec2bcd(timeinfo->tm_mday)); - rtcWire.write(dec2bcd(timeinfo->tm_mon + 1)); - rtcWire.write(dec2bcd(timeinfo->tm_year - 100)); - - bool success = (rtcWire.endTransmission() == 0); - - xSemaphoreGive(rtcMutex); - - return success; -} - -// RTC 초기화 확인 -bool initRTC() { - rtcWire.begin(); - rtcWire.setTimeout(100); // 100ms timeout + if (!timeSyncStatus.rtcAvailable) return false; if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) { return false; } rtcWire.beginTransmission(DS3231_ADDRESS); - bool available = (rtcWire.endTransmission() == 0); + 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); - if (available) { - Serial.println("✓ DS3231 RTC 감지됨"); - - // RTC에서 시간 읽어서 시스템 시간 설정 - // RTC는 로컬 시간(예: 서울 시간)을 저장한다고 가정 - struct tm rtcTime; - if (readRTC(&rtcTime)) { - // RTC 로컬 시간을 UTC로 변환 - time_t localTime = mktime(&rtcTime); - time_t utcTime = localTime - (timezoneOffset * 3600); // UTC로 변환 - - struct timeval tv = { .tv_sec = utcTime, .tv_usec = 0 }; - settimeofday(&tv, NULL); - - timeSyncStatus.synchronized = true; - timeSyncStatus.rtcAvailable = true; - timeSyncStatus.lastSyncTime = getMicrosecondTimestamp(); - timeSyncStatus.rtcSyncCount++; - - char timeStr[64]; - strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &rtcTime); - Serial.printf("✓ RTC 시간 동기화 완료: RTC=%s (로컬), UTC%+d 기준\n", timeStr, timezoneOffset); - } - } else { - Serial.println("✗ DS3231 RTC를 찾을 수 없음"); - } + 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 available; + return true; } -// 마이크로초 단위 Unix 타임스탬프 획득 +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); @@ -414,45 +403,40 @@ uint64_t getMicrosecondTimestamp() { } // ======================================== -// CAN 인터럽트 핸들러 +// CAN 관련 함수 // ======================================== void IRAM_ATTR canISR() { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if (canRxTaskHandle != NULL) { vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); - portYIELD_FROM_ISR(xHigherPriorityTaskWoken); + if (xHigherPriorityTaskWoken) { + portYIELD_FROM_ISR(); + } } } -// ======================================== -// CAN 수신 Task -// ======================================== - -void canRxTask(void* parameter) { - Serial.println("✓ CAN RX Task 시작"); - +void canRxTask(void *parameter) { + CANMessage msg; struct can_frame frame; - CANMessage canMsg; while (1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { - canMsg.timestamp_us = getMicrosecondTimestamp(); - canMsg.id = frame.can_id; - canMsg.dlc = frame.can_dlc; - memcpy(canMsg.data, frame.data, 8); - - xQueueSend(canQueue, &canMsg, 0); + msg.timestamp_us = getMicrosecondTimestamp(); + msg.id = frame.can_id; + msg.dlc = frame.can_dlc; + memcpy(msg.data, frame.data, 8); + xQueueSend(canQueue, &msg, 0); totalMsgCount++; - // 실시간 모니터링용 데이터 업데이트 + // 실시간 모니터링 업데이트 bool found = false; for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.id == canMsg.id || recentData[i].msg.timestamp_us == 0) { - recentData[i].msg = canMsg; + if (recentData[i].msg.id == msg.id) { + recentData[i].msg = msg; recentData[i].count++; found = true; break; @@ -460,123 +444,105 @@ void canRxTask(void* parameter) { } if (!found) { - uint32_t oldestIdx = 0; - uint64_t oldestTime = recentData[0].msg.timestamp_us; - for (int i = 1; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.timestamp_us < oldestTime) { - oldestTime = recentData[i].msg.timestamp_us; - oldestIdx = i; + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].msg.timestamp_us == 0) { + recentData[i].msg = msg; + recentData[i].count = 1; + break; } } - recentData[oldestIdx].msg = canMsg; - recentData[oldestIdx].count = 1; } } } } // ======================================== -// SD 카드 쓰기 Task +// SD 카드 관련 함수 // ======================================== void flushBuffer() { - if (bufferIndex > 0 && logFile) { + if (bufferIndex > 0 && loggingEnabled && logFile) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - logFile.write(fileBuffer, bufferIndex); - bufferIndex = 0; + size_t written = logFile.write(fileBuffer, bufferIndex); + if (written == bufferIndex) { + currentFileSize += bufferIndex; // 파일 크기 업데이트 + bufferIndex = 0; + } xSemaphoreGive(sdMutex); } } } -void sdWriteTask(void* parameter) { - Serial.println("✓ SD Write Task 시작"); - +void sdWriteTask(void *parameter) { CANMessage msg; - uint32_t lastFlushTime = millis(); - const uint32_t FLUSH_INTERVAL = 1000; while (1) { - if (loggingEnabled && xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(100)) == pdTRUE) { - if (logFile) { - size_t writeSize = sizeof(CANMessage); - - if (bufferIndex + writeSize > FILE_BUFFER_SIZE) { + if (xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(10)) == pdTRUE) { + if (loggingEnabled) { + if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) { flushBuffer(); } - memcpy(&fileBuffer[bufferIndex], &msg, writeSize); - bufferIndex += writeSize; + memcpy(&fileBuffer[bufferIndex], &msg, sizeof(CANMessage)); + bufferIndex += sizeof(CANMessage); } } else { + if (loggingEnabled && bufferIndex > 0) { + flushBuffer(); + } vTaskDelay(pdMS_TO_TICKS(10)); } - - if (millis() - lastFlushTime >= FLUSH_INTERVAL) { - flushBuffer(); - lastFlushTime = millis(); - } } } -// ======================================== -// SD 카드 모니터링 Task -// ======================================== - -void sdMonitorTask(void* parameter) { - Serial.println("✓ SD Monitor Task 시작"); +void sdMonitorTask(void *parameter) { + const uint32_t FLUSH_INTERVAL = 1000; + uint32_t lastFlush = 0; while (1) { - vTaskDelay(pdMS_TO_TICKS(5000)); + uint32_t now = millis(); - if (!SD.begin(VSPI_CS, vspi)) { - if (sdCardReady) { - Serial.println("✗ SD 카드 연결 끊김!"); - sdCardReady = false; - - if (loggingEnabled) { - loggingEnabled = false; - if (logFile) { - flushBuffer(); - logFile.close(); - } - } + if (loggingEnabled && (now - lastFlush >= FLUSH_INTERVAL)) { + if (bufferIndex > 0) { + flushBuffer(); } - } else { - if (!sdCardReady) { - Serial.println("✓ SD 카드 재연결됨"); - sdCardReady = true; + + // 주기적으로 sync 호출하여 SD 카드에 완전히 기록 + if (logFile && xSemaphoreTake(sdMutex, pdMS_TO_TICKS(50)) == pdTRUE) { + logFile.flush(); + xSemaphoreGive(sdMutex); } + + lastFlush = now; } + + vTaskDelay(pdMS_TO_TICKS(100)); } } // ======================================== -// RTC 동기화 Task +// RTC 동기화 태스크 // ======================================== -void rtcSyncTask(void* parameter) { - Serial.println("✓ RTC Sync Task 시작"); - +void rtcSyncTask(void *parameter) { while (1) { vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS)); if (timeSyncStatus.rtcAvailable) { - struct tm rtcTime; - if (readRTC(&rtcTime)) { - // RTC 로컬 시간을 UTC로 변환 - time_t localTime = mktime(&rtcTime); + 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.lastSyncTime = getMicrosecondTimestamp(); timeSyncStatus.rtcSyncCount++; - Serial.printf("✓ RTC 자동 동기화 완료 (UTC%+d) [#%u]\n", - timezoneOffset, timeSyncStatus.rtcSyncCount); + char timeStr[64]; + strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); + Serial.printf("🕐 RTC 자동 동기화: %s (로컬 시간)\n", timeStr); } } } @@ -586,8 +552,15 @@ void rtcSyncTask(void* parameter) { // WebSocket 이벤트 핸들러 // ======================================== -void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { - if (type == WStype_TEXT) { +void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { + if (type == WStype_DISCONNECTED) { + Serial.printf("[%u] WebSocket 연결 끊김\n", num); + } + else if (type == WStype_CONNECTED) { + IPAddress ip = webSocket.remoteIP(num); + Serial.printf("[%u] WebSocket 연결됨: %s\n", num, ip.toString().c_str()); + } + else if (type == WStype_TEXT) { String message = String((char*)payload); // JSON 파싱 @@ -613,6 +586,7 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) if (logFile) { loggingEnabled = true; bufferIndex = 0; + currentFileSize = 0; // 파일 크기 초기화 Serial.printf("✓ 로깅 시작: %s (UTC%+d)\n", currentFilename, timezoneOffset); } xSemaphoreGive(sdMutex); @@ -626,7 +600,8 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (logFile) { logFile.close(); - Serial.println("✓ 로깅 중지"); + Serial.printf("✓ 로깅 중지 (최종 크기: %u bytes)\n", currentFileSize); + currentFileSize = 0; } xSemaphoreGive(sdMutex); } @@ -698,6 +673,51 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) 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); + + // 로깅 중인 파일은 삭제 불가 + 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; + } + } + + // 파일 삭제 수행 + String fullPath = "/" + filename; + bool deleteSuccess = false; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (SD.exists(fullPath)) { + deleteSuccess = SD.remove(fullPath); + if (deleteSuccess) { + 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 == "setSpeed") { int speedStart = message.indexOf("\"speed\":") + 8; int speedValue = message.substring(speedStart, message.indexOf("}", speedStart)).toInt(); @@ -722,65 +742,145 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) else if (cmd == "saveSettings") { int ssidStart = message.indexOf("\"ssid\":\"") + 8; int ssidEnd = message.indexOf("\"", ssidStart); - String newSSID = message.substring(ssidStart, ssidEnd); - int passStart = message.indexOf("\"password\":\"") + 12; int passEnd = message.indexOf("\"", passStart); - String newPassword = message.substring(passStart, passEnd); - int tzStart = message.indexOf("\"timezone\":") + 11; + + 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; saveSettings(); - // 설정 저장 성공 응답 - String response = "{\"type\":\"settingsSaved\",\"success\":true}"; + String response = "{\"type\":\"settingsResult\",\"success\":true}"; webSocket.sendTXT(num, response); - - Serial.println("✓ 설정이 저장되었습니다. 재부팅 후 적용됩니다."); } - // CAN 송신 관련 명령어 else if (cmd == "sendCAN") { - // 송신 처리 (기존 코드 유지) + 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 if (cmd == "addTxMessage") { + 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); } } } // ======================================== -// 웹 업데이트 Task +// 주기 송신 태스크 // ======================================== -void webUpdateTask(void* parameter) { - Serial.println("✓ Web Update Task 시작"); - - const uint32_t STATUS_UPDATE_INTERVAL = 1000; - const uint32_t CAN_UPDATE_INTERVAL = 200; - - uint32_t lastStatusUpdate = 0; - uint32_t lastCanUpdate = 0; - uint32_t lastTxStatusUpdate = 0; - +void txTask(void *parameter) { while (1) { - webSocket.loop(); uint32_t now = millis(); - // 전력 상태 업데이트 - updatePowerStatus(); - - // CAN 송신 처리 (기존 코드 유지) for (int i = 0; i < MAX_TX_MESSAGES; i++) { - if (txMessages[i].active && txMessages[i].interval > 0) { + if (txMessages[i].active) { if (now - txMessages[i].lastSent >= txMessages[i].interval) { struct can_frame frame; 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); @@ -792,21 +892,37 @@ void webUpdateTask(void* parameter) { } } - // TX 상태 업데이트 - if (now - lastTxStatusUpdate >= 1000) { - String txStatus = "{\"type\":\"txStatus\",\"count\":" + String(totalTxCount) + "}"; - webSocket.broadcastTXT(txStatus); - lastTxStatusUpdate = now; + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + +// ======================================== +// 웹 업데이트 태스크 +// ======================================== + +void webUpdateTask(void *parameter) { + const uint32_t STATUS_UPDATE_INTERVAL = 500; + const uint32_t CAN_UPDATE_INTERVAL = 200; + + uint32_t lastStatusUpdate = 0; + uint32_t lastCanUpdate = 0; + uint32_t lastMsgSpeedCalc = 0; + + while (1) { + webSocket.loop(); + updatePowerStatus(); + + uint32_t now = millis(); + + // 메시지 속도 계산 + if (now - lastMsgSpeedCalc >= 1000) { + msgPerSecond = totalMsgCount - lastMsgCount; + lastMsgCount = totalMsgCount; + lastMsgSpeedCalc = now; } - // 상태 업데이트 (전력 상태 포함) + // 상태 업데이트 if (now - lastStatusUpdate >= STATUS_UPDATE_INTERVAL) { - if (now - lastMsgCountTime >= 1000) { - msgPerSecond = totalMsgCount - lastMsgCount; - lastMsgCount = totalMsgCount; - lastMsgCountTime = now; - } - String status = "{\"type\":\"status\","; status += "\"logging\":" + String(loggingEnabled ? "true" : "false") + ","; status += "\"sdReady\":" + String(sdCardReady ? "true" : "false") + ","; @@ -823,9 +939,11 @@ void webUpdateTask(void* parameter) { status += "\"queueSize\":" + String(CAN_QUEUE_SIZE) + ","; if (loggingEnabled && logFile) { - status += "\"currentFile\":\"" + String(currentFilename) + "\""; + status += "\"currentFile\":\"" + String(currentFilename) + "\","; + status += "\"currentFileSize\":" + String(currentFileSize); } else { - status += "\"currentFile\":\"\""; + status += "\"currentFile\":\"\","; + status += "\"currentFileSize\":0"; } status += "}"; @@ -885,7 +1003,8 @@ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); - Serial.println(" ESP32 CAN Logger v1.4 with Timezone "); + Serial.println(" ESP32 CAN Logger v1.5 "); + Serial.println(" + File Delete & Size Monitor "); Serial.println("========================================"); // 설정 로드 @@ -991,6 +1110,46 @@ void setup() { } }); + // 파일 삭제 HTTP 엔드포인트 추가 (백업용 - 주로 WebSocket 사용) + server.on("/delete", HTTP_GET, []() { + if (server.hasArg("file")) { + String filename = server.arg("file"); + + // 로깅 중인 파일은 삭제 불가 + if (loggingEnabled && currentFilename[0] != '\0') { + String currentFileStr = String(currentFilename); + if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); + + if (filename == currentFileStr) { + server.send(403, "text/plain", "Cannot delete file currently being logged"); + return; + } + } + + String fullPath = "/" + filename; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (SD.exists(fullPath)) { + if (SD.remove(fullPath)) { + xSemaphoreGive(sdMutex); + server.send(200, "text/plain", "File deleted successfully"); + Serial.printf("✓ HTTP 파일 삭제: %s\n", filename.c_str()); + } else { + xSemaphoreGive(sdMutex); + server.send(500, "text/plain", "Failed to delete file"); + } + } else { + xSemaphoreGive(sdMutex); + server.send(404, "text/plain", "File not found"); + } + } else { + server.send(503, "text/plain", "SD card busy"); + } + } else { + server.send(400, "text/plain", "Bad request"); + } + }); + server.begin(); // Queue 생성 @@ -1009,6 +1168,7 @@ void setup() { xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 12288, NULL, 3, &sdWriteTaskHandle, 1); xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0); xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0); + xTaskCreatePinnedToCore(txTask, "TX_TASK", 4096, NULL, 2, NULL, 1); // RTC 동기화 Task if (timeSyncStatus.rtcAvailable) { @@ -1042,11 +1202,12 @@ void loop() { static uint32_t lastPrint = 0; if (millis() - lastPrint > 10000) { - Serial.printf("[상태] 큐: %d/%d | 로깅: %s | SD: %s | RX: %lu | TX: %lu | 시간: %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\n", uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, loggingEnabled ? "ON " : "OFF", sdCardReady ? "OK" : "NO", totalMsgCount, totalTxCount, + currentFileSize, timeSyncStatus.synchronized ? "OK" : "NO", timeSyncStatus.rtcAvailable ? "OK" : "NO", timeSyncStatus.rtcSyncCount, diff --git a/index.h b/index.h index 9d3532c..3080e10 100644 --- a/index.h +++ b/index.h @@ -349,6 +349,10 @@ const char index_html[] PROGMEM = R"rawliteral( flex-wrap: wrap; } .file-item:hover { transform: translateX(5px); box-shadow: 0 3px 10px rgba(0,0,0,0.1); } + .file-item.logging { + border: 2px solid #11998e; + background: linear-gradient(to right, rgba(17, 153, 142, 0.05), white); + } .file-info { flex: 1; min-width: 0; @@ -361,18 +365,46 @@ const char index_html[] PROGMEM = R"rawliteral( color: #333; font-size: 0.9em; word-break: break-all; + display: flex; + align-items: center; + gap: 8px; + } + .logging-badge { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75em; + font-weight: 700; + animation: pulse 2s ease-in-out infinite; } .file-size { color: #666; font-size: 0.85em; font-weight: 600; } - .download-btn { + .file-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + } + .download-btn, .delete-btn { padding: 8px 16px; font-size: 0.85em; white-space: nowrap; flex-shrink: 0; - min-width: 100px; + min-width: 80px; + } + .delete-btn { + background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); + } + .delete-btn:hover { + background: linear-gradient(135deg, #d32f3f 0%, #e53935 100%); + } + .delete-btn:disabled { + background: #cccccc; + cursor: not-allowed; + opacity: 0.6; } @media (max-width: 768px) { @@ -399,29 +431,16 @@ const char index_html[] PROGMEM = R"rawliteral( .time-value { font-size: 1em; } - - /* 파일 목록 모바일 최적화 */ - .file-item { - padding: 10px; - gap: 10px; - align-items: flex-start; - } - .file-info { + .btn-time-sync { width: 100%; - margin-bottom: 5px; + padding: 10px 20px; } - .file-name { - font-size: 0.85em; - } - .file-size { - font-size: 0.8em; - display: block; - margin-top: 3px; - } - .download-btn { + .file-actions { width: 100%; - padding: 10px; - min-width: auto; + justify-content: stretch; + } + .download-btn, .delete-btn { + flex: 1; } } @@ -429,70 +448,67 @@ const char index_html[] PROGMEM = R"rawliteral(
-

Byun CAN Logger

-

Real-time CAN Bus Monitor & Data Logger with Time Sync

+

🚗 Byun CAN Logger v1.5

+

Real-time CAN Bus Monitor & Logger + File Management

- -
-
- 📊 - CAN Queue -
-
-
-
-
0/1000
-
-
-
- - -
-
- - 전원 상태 -
-
-
- 현재 - --V -
-
- 최소(1s) - --V -
-
-
-
- ⏰ 시간 동기화 상태 - 대기 중... +
CURRENT TIME
+
--:--:--
- 🕐 현재 시간 - --:--:-- +
CONNECTION
+
연결 중...
- + +
+ +
+
+ + POWER STATUS +
+
+
+
CURRENT
+
-.--V
+
+
+
MIN (1s)
+
-.--V
+
+
+
+ +
+
+ 📦 + QUEUE STATUS +
+
+
+
0 / 1000
+
-
+

LOGGING

OFF
-
+

SD CARD

NOT READY
@@ -512,6 +528,10 @@ const char index_html[] PROGMEM = R"rawliteral(

CURRENT FILE

-
+
+

FILE SIZE

+
0 B
+
@@ -564,6 +584,7 @@ const char index_html[] PROGMEM = R"rawliteral( let messageOrder = []; let lastMessageData = {}; const speedNames = {0: '125K', 1: '250K', 2: '500K', 3: '1M'}; + let currentLoggingFile = ''; function updateCurrentTime() { const now = new Date(); @@ -636,6 +657,8 @@ const char index_html[] PROGMEM = R"rawliteral( updateCanBatch(data.messages); } else if (data.type === 'files') { updateFileList(data.files); + } else if (data.type === 'deleteResult') { + handleDeleteResult(data); } } catch (e) { console.error('Parse error:', e); @@ -648,6 +671,7 @@ const char index_html[] PROGMEM = R"rawliteral( const sdCard = document.getElementById('sd-status'); const timeSyncCard = document.getElementById('time-sync-card'); const fileCard = document.getElementById('file-status'); + const filesizeCard = document.getElementById('filesize-status'); if (data.logging) { loggingCard.classList.add('status-on'); @@ -684,10 +708,26 @@ const char index_html[] PROGMEM = R"rawliteral( if (data.currentFile && data.currentFile !== '') { fileCard.classList.add('status-on'); fileCard.classList.remove('status-off'); - document.getElementById('current-file').textContent = data.currentFile; + const filename = data.currentFile.startsWith('/') ? data.currentFile.substring(1) : data.currentFile; + document.getElementById('current-file').textContent = filename; + currentLoggingFile = filename; } else { fileCard.classList.remove('status-on', 'status-off'); document.getElementById('current-file').textContent = '-'; + currentLoggingFile = ''; + } + + // 실시간 파일 크기 표시 + if (data.currentFileSize !== undefined) { + const sizeStr = formatBytes(data.currentFileSize); + document.getElementById('current-file-size').textContent = sizeStr; + + if (data.currentFileSize > 0) { + filesizeCard.classList.add('status-on'); + filesizeCard.classList.remove('status-off'); + } else { + filesizeCard.classList.remove('status-on', 'status-off'); + } } document.getElementById('msg-count').textContent = data.msgCount.toLocaleString(); @@ -699,42 +739,29 @@ const char index_html[] PROGMEM = R"rawliteral( const queueBar = document.getElementById('queue-bar'); const queueText = document.getElementById('queue-text'); - const queuePercent = (data.queueUsed / data.queueSize) * 100; - queueBar.style.width = queuePercent + '%'; - queueText.textContent = data.queueUsed + '/' + data.queueSize; + const percentage = (data.queueUsed / data.queueSize) * 100; + queueBar.style.width = percentage + '%'; + queueText.textContent = data.queueUsed + ' / ' + data.queueSize; - // 큐 상태에 따른 색상 변경 queueStatus.classList.remove('warning', 'critical'); - if (queuePercent >= 90) { + if (percentage >= 90) { queueStatus.classList.add('critical'); - } else if (queuePercent >= 70) { + } else if (percentage >= 70) { queueStatus.classList.add('warning'); } } // 전력 상태 업데이트 if (data.voltage !== undefined) { - const powerStatus = document.getElementById('power-status'); - const powerValue = document.getElementById('power-value'); - const powerMinValue = document.getElementById('power-min-value'); - const powerIcon = document.getElementById('power-icon'); - const powerText = document.getElementById('power-text'); - - powerValue.textContent = data.voltage.toFixed(2) + 'V'; - - if (data.minVoltage !== undefined) { - powerMinValue.textContent = data.minVoltage.toFixed(2) + 'V'; - } - - if (data.lowVoltage) { - powerStatus.classList.add('low'); - powerIcon.textContent = '⚠️'; - powerText.textContent = '전력 부족 경고!'; - } else { - powerStatus.classList.remove('low'); - powerIcon.textContent = '⚡'; - powerText.textContent = '전원 정상'; - } + document.getElementById('voltage-current').textContent = data.voltage.toFixed(2) + 'V'; + } + if (data.minVoltage !== undefined) { + document.getElementById('voltage-min').textContent = data.minVoltage.toFixed(2) + 'V'; + } + if (data.lowVoltage !== undefined && data.lowVoltage) { + document.getElementById('power-status').classList.add('low'); + } else { + document.getElementById('power-status').classList.remove('low'); } } @@ -839,14 +866,26 @@ const char index_html[] PROGMEM = R"rawliteral( fileList.innerHTML = ''; files.forEach(file => { + const isLogging = (currentLoggingFile && file.name === currentLoggingFile); const fileItem = document.createElement('div'); - fileItem.className = 'file-item'; + fileItem.className = 'file-item' + (isLogging ? ' logging' : ''); + + let nameHtml = '
' + file.name; + if (isLogging) { + nameHtml += 'LOGGING'; + } + nameHtml += '
'; + fileItem.innerHTML = '
' + - '
' + file.name + '
' + + nameHtml + '
' + formatBytes(file.size) + '
' + '
' + - ''; + '
' + + '' + + '' + + '
'; fileList.appendChild(fileItem); }); } @@ -909,6 +948,27 @@ const char index_html[] PROGMEM = R"rawliteral( window.location.href = '/download?file=' + encodeURIComponent(filename); } + function deleteFile(filename) { + if (!confirm('Are you sure you want to delete "' + filename + '"?\n\nThis action cannot be undone.')) { + return; + } + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({cmd: 'deleteFile', filename: filename})); + console.log('Delete file command sent:', filename); + } + } + + function handleDeleteResult(data) { + if (data.success) { + console.log('File deleted successfully'); + // 파일 목록은 서버에서 자동으로 갱신됨 + } else { + alert('Failed to delete file: ' + data.message); + console.error('Delete failed:', data.message); + } + } + window.addEventListener('load', function() { loadCanSpeed(); });