파일 커멘트 추가, listen-only모드, tranmit에만 normal모드
This commit is contained in:
@@ -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
364
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(
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user