From d970f531865e6385a73ddc462a679cd5e33bbfc5 Mon Sep 17 00:00:00 2001 From: byun Date: Thu, 6 Nov 2025 16:59:44 +0000 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=BB=A4=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20listen-only=EB=AA=A8=EB=93=9C?= =?UTF-8?q?,=20tranmit=EC=97=90=EB=A7=8C=20normal=EB=AA=A8=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ESP32_CAN_Logger.ino | 332 ++++++++++++++++++++++++++++++++++++--- index.h | 364 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 668 insertions(+), 28 deletions(-) diff --git a/ESP32_CAN_Logger.ino b/ESP32_CAN_Logger.ino index 100e23f..9fb3501 100644 --- a/ESP32_CAN_Logger.ino +++ b/ESP32_CAN_Logger.ino @@ -1,7 +1,8 @@ /* * Byun CAN Logger with Web Interface + RTC Time Synchronization + Timezone Settings - * Version: 1.5 - * Added: File delete function, Real-time file size monitoring + * Version: 1.7 + * Mode: Listen-Only (Default, Safe) / Normal (Transmit enabled) + * Features: File comment, Auto time sync, Multiple file operations, CAN mode switching */ #include @@ -135,6 +136,10 @@ uint16_t bufferIndex = 0; // 로깅 파일 크기 추적 (실시간 모니터링용) volatile uint32_t currentFileSize = 0; +// 자동 시간 동기화 상태 +volatile bool autoTimeSyncRequested = false; +volatile bool autoTimeSyncCompleted = false; + // RTC 관련 SoftWire rtcWire(RTC_SDA, RTC_SCL); char rtcSyncBuffer[20]; @@ -144,6 +149,13 @@ 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; @@ -231,6 +243,61 @@ void saveSettings() { Serial.println("⚠️ 재부팅 후 WiFi 설정이 적용됩니다."); } +// 파일 코멘트 저장/로드 함수 +void saveFileComment(const String& filename, const String& comment) { + preferences.begin("comments", false); + + // 파일명을 키로 사용 (최대 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]; + } + key = "c_" + String(hash, HEX); + } + + 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 + + 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]; + } + key = "c_" + String(hash, HEX); + } + + 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]; + } + key = "c_" + String(hash, HEX); + } + + preferences.remove(key.c_str()); + preferences.end(); +} + // ======================================== // 전력 모니터링 함수 // ======================================== @@ -402,6 +469,25 @@ uint64_t getMicrosecondTimestamp() { 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 (수신 전용, 버스 영향 없음)"); + } else { + mcp2515.setNormalMode(); + Serial.println("⚠️ CAN 모드: Normal (송수신 가능, 버스 영향 있음)"); + } +} + // ======================================== // CAN 관련 함수 // ======================================== @@ -591,6 +677,10 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } xSemaphoreGive(sdMutex); } + + // 파일 목록 자동 갱신 (로깅 상태 즉시 반영) + vTaskDelay(pdMS_TO_TICKS(50)); + webSocketEvent(num, WStype_TEXT, (uint8_t*)"{\"cmd\":\"getFiles\"}", 17); } } else if (cmd == "stopLogging") { @@ -605,6 +695,10 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) } xSemaphoreGive(sdMutex); } + + // 파일 목록 자동 갱신 (로깅 상태 즉시 반영) + vTaskDelay(pdMS_TO_TICKS(50)); + webSocketEvent(num, WStype_TEXT, (uint8_t*)"{\"cmd\":\"getFiles\"}", 17); } } else if (cmd == "syncTime") { @@ -660,7 +754,13 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) String fileName = String(file.name()); if (fileName.startsWith("/")) fileName = fileName.substring(1); - fileList += "{\"name\":\"" + fileName + "\",\"size\":" + String(file.size()) + "}"; + // 파일 코멘트 로드 + 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(); @@ -700,6 +800,7 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) 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()); @@ -718,19 +819,121 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) 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; + + 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) { + 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); + } + } + + 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") { int speedStart = message.indexOf("\"speed\":") + 8; int speedValue = message.substring(speedStart, message.indexOf("}", speedStart)).toInt(); if (speedValue >= 0 && speedValue < 4) { currentCanSpeed = canSpeedValues[speedValue]; + + // 현재 모드 유지하면서 속도만 변경 mcp2515.reset(); mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); - mcp2515.setNormalMode(); + + if (currentCanMode == CAN_MODE_LISTEN_ONLY) { + mcp2515.setListenOnlyMode(); + } else { + mcp2515.setNormalMode(); + } Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedValue]); } } + else if (cmd == "setCanMode") { + // CAN 모드 변경 (Listen-Only ↔ Normal) + int modeStart = message.indexOf("\"mode\":") + 7; + int modeValue = message.substring(modeStart, message.indexOf("}", modeStart)).toInt(); + + if (modeValue == 0) { + setCANMode(CAN_MODE_LISTEN_ONLY); + } else if (modeValue == 1) { + setCANMode(CAN_MODE_NORMAL); + } + + 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") { String settings = "{\"type\":\"settings\","; settings += "\"ssid\":\"" + String(wifiSSID) + "\","; @@ -760,6 +963,14 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) 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); @@ -784,9 +995,19 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) 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) { @@ -865,34 +1086,64 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) 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) { - 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; + // 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)); } - - vTaskDelay(pdMS_TO_TICKS(1)); } } @@ -914,6 +1165,16 @@ void webUpdateTask(void *parameter) { 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; @@ -937,6 +1198,23 @@ void webUpdateTask(void *parameter) { 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) + "\","; @@ -1003,8 +1281,8 @@ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); - Serial.println(" ESP32 CAN Logger v1.5 "); - Serial.println(" + File Delete & Size Monitor "); + Serial.println(" ESP32 CAN Logger v1.7 "); + Serial.println(" Listen-Only Mode (Default, Safe) "); Serial.println("========================================"); // 설정 로드 @@ -1031,11 +1309,11 @@ void setup() { hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); - // MCP2515 초기화 + // MCP2515 초기화 - Listen-Only Mode (버스에 영향 없음) mcp2515.reset(); mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); - mcp2515.setNormalMode(); - Serial.println("✓ MCP2515 초기화 완료"); + mcp2515.setListenOnlyMode(); // ⭐ Listen-Only Mode: ACK 전송 안 함, 수신만 가능 + Serial.println("✓ MCP2515 초기화 완료 (Listen-Only Mode)"); // SD 카드 초기화 if (SD.begin(VSPI_CS, vspi)) { @@ -1174,6 +1452,10 @@ 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("✓ 모든 태스크 시작 완료"); diff --git a/index.h b/index.h index 3080e10..4ae786a 100644 --- a/index.h +++ b/index.h @@ -251,6 +251,78 @@ const char index_html[] PROGMEM = R"rawliteral( .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; + } + .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; + opacity: 0.9; + } + .sd-capacity-value { + font-family: 'Courier New', monospace; + font-size: 1.2em; + font-weight: 700; + } + + /* 파일 선택 체크박스 */ + .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; + } + .control-panel { background: #f8f9fa; padding: 15px; @@ -383,6 +455,38 @@ const char index_html[] PROGMEM = R"rawliteral( 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; @@ -448,8 +552,8 @@ const char index_html[] PROGMEM = R"rawliteral(
-

🚗 Byun CAN Logger v1.5

-

Real-time CAN Bus Monitor & Logger + File Management

+

🚗 Byun CAN Logger v1.6

+

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

+
+
+ 💾 + SD CARD CAPACITY +
+
+
+
TOTAL
+
0 MB
+
+
+
USED
+
0 MB
+
+
+
FREE
+
0 MB
+
+
+
+

LOGGING

@@ -572,6 +697,15 @@ const char index_html[] PROGMEM = R"rawliteral(

Log Files

+
+ + + + +
+ 0 files selected +
+

Loading...

@@ -585,6 +719,166 @@ const char index_html[] PROGMEM = R"rawliteral( let lastMessageData = {}; const speedNames = {0: '125K', 1: '250K', 2: '500K', 3: '1M'}; let currentLoggingFile = ''; + let selectedFiles = new Set(); + + function updateSelectionCount() { + document.getElementById('selection-count').textContent = selectedFiles.size + ' files selected'; + } + + function selectAllFiles() { + document.querySelectorAll('.file-checkbox').forEach(cb => { + if (!cb.disabled) { + cb.checked = true; + selectedFiles.add(cb.dataset.filename); + } + }); + updateSelectionCount(); + } + + function deselectAllFiles() { + document.querySelectorAll('.file-checkbox').forEach(cb => { + cb.checked = false; + }); + selectedFiles.clear(); + updateSelectionCount(); + } + + function toggleFileSelection(filename, checked) { + if (checked) { + selectedFiles.add(filename); + } else { + selectedFiles.delete(filename); + } + updateSelectionCount(); + } + + function downloadSelectedFiles() { + if (selectedFiles.size === 0) { + alert('Please select files to download'); + return; + } + + // 각 파일을 순차적으로 다운로드 + let filesArray = Array.from(selectedFiles); + let index = 0; + + function downloadNext() { + if (index < filesArray.length) { + downloadFile(filesArray[index]); + index++; + setTimeout(downloadNext, 500); // 500ms 간격으로 다운로드 + } + } + + downloadNext(); + } + + function deleteSelectedFiles() { + if (selectedFiles.size === 0) { + alert('Please select files to delete'); + return; + } + + let filesArray = Array.from(selectedFiles); + let fileList = filesArray.join('\\n'); + + if (!confirm('Are you sure you want to delete ' + selectedFiles.size + ' files?\\n\\n' + fileList + '\\n\\nThis action cannot be undone.')) { + return; + } + + if (ws && ws.readyState === WebSocket.OPEN) { + let filenames = JSON.stringify(filesArray); + ws.send(JSON.stringify({cmd: 'deleteFiles', filenames: filesArray})); + console.log('Delete multiple files command sent:', filesArray); + + // 선택 해제 + selectedFiles.clear(); + updateSelectionCount(); + } + } + + function editComment(filename, currentComment) { + const fileItem = event.target.closest('.file-item'); + const commentDiv = fileItem.querySelector('.file-comment'); + + // 이미 편집 중이면 무시 + if (fileItem.querySelector('.comment-input')) { + return; + } + + // 코멘트 편집 UI 생성 + const input = document.createElement('textarea'); + input.className = 'comment-input'; + input.value = currentComment; + input.rows = 2; + input.placeholder = 'Enter comment for this log file...'; + + const actions = document.createElement('div'); + actions.className = 'comment-actions'; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save'; + saveBtn.style.background = 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'; + saveBtn.onclick = function() { + saveComment(filename, input.value, fileItem); + }; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + cancelBtn.onclick = function() { + cancelEditComment(fileItem, currentComment); + }; + + actions.appendChild(saveBtn); + actions.appendChild(cancelBtn); + + // 기존 코멘트 숨기고 편집 UI 표시 + commentDiv.style.display = 'none'; + fileItem.querySelector('.file-info').appendChild(input); + fileItem.querySelector('.file-info').appendChild(actions); + + input.focus(); + } + + function saveComment(filename, comment, fileItem) { + if (ws && ws.readyState === WebSocket.OPEN) { + // JSON 이스케이프 + const escapedComment = comment.replace(/\n/g, '\\n').replace(/"/g, '\\"'); + ws.send(JSON.stringify({cmd: 'saveComment', filename: filename, comment: escapedComment})); + console.log('Save comment:', filename, comment); + + // UI 업데이트 + const commentDiv = fileItem.querySelector('.file-comment'); + const input = fileItem.querySelector('.comment-input'); + const actions = fileItem.querySelector('.comment-actions'); + + if (comment.trim() === '') { + commentDiv.textContent = '💬 Click to add comment'; + commentDiv.className = 'file-comment empty'; + } else { + commentDiv.textContent = '💬 ' + comment; + commentDiv.className = 'file-comment'; + } + + commentDiv.style.display = 'block'; + commentDiv.onclick = function() { editComment(filename, comment); }; + + if (input) input.remove(); + if (actions) actions.remove(); + } + } + + function cancelEditComment(fileItem, originalComment) { + const commentDiv = fileItem.querySelector('.file-comment'); + const input = fileItem.querySelector('.comment-input'); + const actions = fileItem.querySelector('.comment-actions'); + + commentDiv.style.display = 'block'; + + if (input) input.remove(); + if (actions) actions.remove(); + } function updateCurrentTime() { const now = new Date(); @@ -659,6 +953,20 @@ const char index_html[] PROGMEM = R"rawliteral( updateFileList(data.files); } else if (data.type === 'deleteResult') { handleDeleteResult(data); + } else if (data.type === 'autoTimeSyncRequest') { + // 서버에서 자동 시간 동기화 요청 + console.log('Auto time sync requested by server'); + syncTime(); + // 동기화 완료 알림 + setTimeout(() => { + ws.send(JSON.stringify({cmd: 'requestAutoTimeSync'})); + }, 500); + } else if (data.type === 'commentResult') { + if (data.success) { + console.log('Comment saved successfully'); + } else { + alert('Failed to save comment: ' + data.message); + } } } catch (e) { console.error('Parse error:', e); @@ -763,6 +1071,27 @@ const char index_html[] PROGMEM = R"rawliteral( } else { document.getElementById('power-status').classList.remove('low'); } + + // SD 카드 용량 업데이트 + if (data.sdTotalMB !== undefined) { + const totalGB = (data.sdTotalMB / 1024).toFixed(2); + const usedMB = data.sdUsedMB || 0; + const freeMB = data.sdFreeMB || 0; + + document.getElementById('sd-total').textContent = totalGB + ' GB'; + + if (usedMB >= 1024) { + document.getElementById('sd-used').textContent = (usedMB / 1024).toFixed(2) + ' GB'; + } else { + document.getElementById('sd-used').textContent = usedMB + ' MB'; + } + + if (freeMB >= 1024) { + document.getElementById('sd-free').textContent = (freeMB / 1024).toFixed(2) + ' GB'; + } else { + document.getElementById('sd-free').textContent = freeMB + ' MB'; + } + } } function addCanMessage(data) { @@ -876,10 +1205,26 @@ const char index_html[] PROGMEM = R"rawliteral( } nameHtml += '
'; + // 코멘트 표시 + const comment = file.comment || ''; + let commentHtml = ''; + if (comment.trim() === '') { + commentHtml = '
💬 Click to add comment
'; + } else { + const escapedComment = comment.replace(/'/g, "\\'"); + commentHtml = '
💬 ' + comment + '
'; + } + + const isChecked = selectedFiles.has(file.name); + fileItem.innerHTML = + '' + '
' + nameHtml + '
' + formatBytes(file.size) + '
' + + commentHtml + '
' + '
' + '' + @@ -888,6 +1233,8 @@ const char index_html[] PROGMEM = R"rawliteral( '
'; fileList.appendChild(fileItem); }); + + updateSelectionCount(); } function formatBytes(bytes) { @@ -961,7 +1308,18 @@ const char index_html[] PROGMEM = R"rawliteral( function handleDeleteResult(data) { if (data.success) { - console.log('File deleted successfully'); + if (data.deletedCount !== undefined) { + // 복수 파일 삭제 결과 + let message = 'Deleted ' + data.deletedCount + ' file(s) successfully'; + if (data.failedCount > 0) { + message += '\nFailed: ' + data.failedCount + ' file(s)'; + } + alert(message); + console.log('Multiple files deleted:', data); + } else { + // 단일 파일 삭제 결과 + console.log('File deleted successfully'); + } // 파일 목록은 서버에서 자동으로 갱신됨 } else { alert('Failed to delete file: ' + data.message);