파일 커멘트 추가, listen-only모드, tranmit에만 normal모드

This commit is contained in:
2025-11-06 16:59:44 +00:00
parent 2ee1ad905e
commit d970f53186
2 changed files with 668 additions and 28 deletions

View File

@@ -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 <Arduino.h>
@@ -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("✓ 모든 태스크 시작 완료");

364
index.h
View File

@@ -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(
<body>
<div class="container">
<div class="header">
<h1>🚗 Byun CAN Logger v1.5</h1>
<p>Real-time CAN Bus Monitor & Logger + File Management</p>
<h1>🚗 Byun CAN Logger v1.6</h1>
<p>Listen-Only Mode - No CAN Bus Impact (RX Only)</p>
</div>
<div class="nav">
@@ -503,6 +607,27 @@ const char index_html[] PROGMEM = R"rawliteral(
</div>
</div>
<div class="sd-capacity" id="sd-capacity">
<div class="sd-capacity-label">
<span>💾</span>
<span>SD CARD CAPACITY</span>
</div>
<div class="sd-capacity-values">
<div class="sd-capacity-item">
<div class="sd-capacity-item-label">TOTAL</div>
<div class="sd-capacity-value" id="sd-total">0 MB</div>
</div>
<div class="sd-capacity-item">
<div class="sd-capacity-item-label">USED</div>
<div class="sd-capacity-value" id="sd-used">0 MB</div>
</div>
<div class="sd-capacity-item">
<div class="sd-capacity-item-label">FREE</div>
<div class="sd-capacity-value" id="sd-free">0 MB</div>
</div>
</div>
</div>
<div class="status-grid">
<div class="status-card status-off" id="logging-status">
<h3>LOGGING</h3>
@@ -572,6 +697,15 @@ const char index_html[] PROGMEM = R"rawliteral(
</div>
<h2>Log Files</h2>
<div class="file-selection-controls">
<button onclick="selectAllFiles()">Select All</button>
<button onclick="deselectAllFiles()">Deselect All</button>
<button onclick="downloadSelectedFiles()" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">Download Selected</button>
<button onclick="deleteSelectedFiles()" style="background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);">Delete Selected</button>
<div class="selection-info">
<span id="selection-count">0 files selected</span>
</div>
</div>
<div class="file-list" id="file-list">
<p style="text-align: center; color: #666; font-size: 0.9em;">Loading...</p>
</div>
@@ -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 += '</div>';
// 코멘트 표시
const comment = file.comment || '';
let commentHtml = '';
if (comment.trim() === '') {
commentHtml = '<div class="file-comment empty" onclick="editComment(\'' + file.name + '\', \'\')">💬 Click to add comment</div>';
} else {
const escapedComment = comment.replace(/'/g, "\\'");
commentHtml = '<div class="file-comment" onclick="editComment(\'' + file.name + '\', \'' + escapedComment + '\')">💬 ' + comment + '</div>';
}
const isChecked = selectedFiles.has(file.name);
fileItem.innerHTML =
'<input type="checkbox" class="file-checkbox" data-filename="' + file.name + '" ' +
'onchange="toggleFileSelection(\'' + file.name + '\', this.checked)" ' +
(isLogging ? 'disabled' : '') + (isChecked ? ' checked' : '') + '>' +
'<div class="file-info">' +
nameHtml +
'<div class="file-size">' + formatBytes(file.size) + '</div>' +
commentHtml +
'</div>' +
'<div class="file-actions">' +
'<button class="download-btn" onclick="downloadFile(\'' + file.name + '\')">Download</button>' +
@@ -888,6 +1233,8 @@ const char index_html[] PROGMEM = R"rawliteral(
'</div>';
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);