버젼 1.5 로깅데이터 모니터링, 파일삭제기능 추가

This commit is contained in:
2025-11-05 18:30:47 +00:00
parent 41e8d18072
commit 2ee1ad905e
2 changed files with 568 additions and 347 deletions

View File

@@ -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 <Arduino.h>
@@ -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,

254
index.h
View File

@@ -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;
}
}
</style>
@@ -429,70 +448,67 @@ const char index_html[] PROGMEM = R"rawliteral(
<body>
<div class="container">
<div class="header">
<h1>Byun CAN Logger</h1>
<p>Real-time CAN Bus Monitor & Data Logger with Time Sync</p>
<h1>🚗 Byun CAN Logger v1.5</h1>
<p>Real-time CAN Bus Monitor & Logger + File Management</p>
</div>
<div class="nav">
<a href="/" class="active">Monitor</a>
<a href="/transmit">Transmit</a>
<a href="/graph">Graph</a>
<a href="/" class="active">📊 Monitor</a>
<a href="/transmit">📤 Transmit</a>
<a href="/graph">📈 Graph</a>
<a href="/graph-view">📊 Graph View</a>
<a href="/settings"> Settings</a>
</div>
<div class="content">
<!-- -->
<div class="queue-status" id="queue-status">
<div class="queue-info">
<span style="font-size: 1.3em;">📊</span>
<span style="font-weight: 600; font-size: 0.85em;">CAN Queue</span>
</div>
<div style="flex: 1; min-width: 200px;">
<div class="queue-bar-container">
<div class="queue-bar" id="queue-bar" style="width: 0%"></div>
<div class="queue-text" id="queue-text">0/1000</div>
</div>
</div>
</div>
<!-- -->
<div class="power-status" id="power-status">
<div class="power-status-label">
<span id="power-icon"></span>
<span id="power-text"> </span>
</div>
<div class="power-status-values">
<div class="power-status-item">
<span class="power-status-item-label"></span>
<span class="power-status-value" id="power-value">--V</span>
</div>
<div class="power-status-item">
<span class="power-status-item-label">(1s)</span>
<span class="power-status-value" id="power-min-value">--V</span>
</div>
</div>
</div>
<div class="time-sync-banner">
<div class="time-sync-info">
<div class="time-info-item">
<span class="time-label"> </span>
<span class="time-value" id="sync-status"> ...</span>
<div class="time-label">CURRENT TIME</div>
<div class="time-value" id="current-time">--:--:--</div>
</div>
<div class="time-info-item">
<span class="time-label">🕐 </span>
<span class="time-value" id="current-time">--:--:--</span>
<div class="time-label">CONNECTION</div>
<div class="time-value" id="sync-status"> ...</div>
</div>
</div>
<button class="btn-time-sync" onclick="syncTime()"> </button>
<button class="btn-time-sync" onclick="syncTime()">🕐 Sync Time</button>
</div>
<div class="power-status" id="power-status">
<div class="power-status-label">
<span></span>
<span>POWER STATUS</span>
</div>
<div class="power-status-values">
<div class="power-status-item">
<div class="power-status-item-label">CURRENT</div>
<div class="power-status-value" id="voltage-current">-.--V</div>
</div>
<div class="power-status-item">
<div class="power-status-item-label">MIN (1s)</div>
<div class="power-status-value" id="voltage-min">-.--V</div>
</div>
</div>
</div>
<div class="queue-status" id="queue-status">
<div class="queue-info">
<span style="font-size: 1.2em;">📦</span>
<span style="font-weight: 700; font-size: 0.9em;">QUEUE STATUS</span>
</div>
<div class="queue-bar-container">
<div class="queue-bar" id="queue-bar" style="width: 0%;"></div>
<div class="queue-text" id="queue-text">0 / 1000</div>
</div>
</div>
<div class="status-grid">
<div class="status-card" id="logging-status">
<div class="status-card status-off" id="logging-status">
<h3>LOGGING</h3>
<div class="value">OFF</div>
</div>
<div class="status-card" id="sd-status">
<div class="status-card status-off" id="sd-status">
<h3>SD CARD</h3>
<div class="value">NOT READY</div>
</div>
@@ -512,6 +528,10 @@ const char index_html[] PROGMEM = R"rawliteral(
<h3>CURRENT FILE</h3>
<div class="value" id="current-file" style="font-size: 0.85em;">-</div>
</div>
<div class="status-card" id="filesize-status">
<h3>FILE SIZE</h3>
<div class="value" id="current-file-size">0 B</div>
</div>
</div>
<div class="control-panel">
@@ -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 = '<div class="file-name">' + file.name;
if (isLogging) {
nameHtml += '<span class="logging-badge">LOGGING</span>';
}
nameHtml += '</div>';
fileItem.innerHTML =
'<div class="file-info">' +
'<div class="file-name">' + file.name + '</div>' +
nameHtml +
'<div class="file-size">' + formatBytes(file.size) + '</div>' +
'</div>' +
'<button class="download-btn" onclick="downloadFile(\'' + file.name + '\')">Download</button>';
'<div class="file-actions">' +
'<button class="download-btn" onclick="downloadFile(\'' + file.name + '\')">Download</button>' +
'<button class="delete-btn" onclick="deleteFile(\'' + file.name + '\')" ' +
(isLogging ? 'disabled title="Cannot delete file being logged"' : '') + '>Delete</button>' +
'</div>';
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();
});