From 41e8d180722db7c58bf275eb99bca6419a8bf5ae Mon Sep 17 00:00:00 2001 From: byun Date: Wed, 5 Nov 2025 18:02:22 +0000 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EC=95=95=EC=83=81=ED=83=9C,=20?= =?UTF-8?q?=ED=81=90=EC=83=81=ED=83=9C=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=B6=94=EA=B0=80,=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EB=AA=85=20=EC=8B=9C=EA=B0=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20setting=EC=B0=BD=20=EC=B6=94=EA=B0=80(ssid,pw,time?= =?UTF-8?q?zone),=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EB=B3=B4=EC=9D=B4=EA=B8=B0=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ESP32_CAN_Logger.ino | 1067 +++++++++++++++++++++--------------------- index.h | 399 ++++++++++++---- settings.h | 514 ++++++++++++++++++++ 3 files changed, 1340 insertions(+), 640 deletions(-) create mode 100644 settings.h diff --git a/ESP32_CAN_Logger.ino b/ESP32_CAN_Logger.ino index f268070..db88042 100644 --- a/ESP32_CAN_Logger.ino +++ b/ESP32_CAN_Logger.ino @@ -1,7 +1,7 @@ /* - * Byun CAN Logger with Web Interface + RTC Time Synchronization - * Version: 1.3 - * Added: DS3231 RTC support via SoftWire (I2C2: SDA=GPIO25, SCL=GPIO26) + * Byun CAN Logger with Web Interface + RTC Time Synchronization + Timezone Settings + * Version: 1.4 + * Added: Timezone configuration, WiFi settings, Power monitoring */ #include @@ -18,10 +18,12 @@ #include #include #include +#include #include "index.h" #include "transmit.h" #include "graph.h" #include "graph_viewer.h" +#include "settings.h" // GPIO 핀 정의 #define CAN_INT_PIN 27 @@ -53,6 +55,10 @@ // RTC 동기화 설정 #define RTC_SYNC_INTERVAL_MS 60000 // 1분마다 RTC와 동기화 +// 전력 모니터링 설정 +#define VOLTAGE_CHECK_INTERVAL_MS 5000 // 5초마다 전압 체크 +#define LOW_VOLTAGE_THRESHOLD 3.0 // 3.0V 이하이면 경고 + // CAN 메시지 구조체 - 마이크로초 단위 타임스탬프 struct CANMessage { uint64_t timestamp_us; // 마이크로초 단위 Unix timestamp @@ -88,9 +94,19 @@ struct TimeSyncStatus { uint32_t rtcSyncCount; } timeSyncStatus = {false, 0, 0, 0, false, 0}; -// WiFi AP 설정 -const char* ssid = "Byun_CAN_Logger"; -const char* password = "12345678"; +// 전력 모니터링 상태 +struct PowerStatus { + float voltage; // 현재 전압 + float minVoltage; // 1초 단위 최소 전압 + bool lowVoltage; + uint32_t lastCheck; + uint32_t lastMinReset; // 최소값 리셋 시간 +} powerStatus = {0.0, 999.9, false, 0, 0}; + +// WiFi AP 기본 설정 +char wifiSSID[32] = "Byun_CAN_Logger"; +char wifiPassword[64] = "12345678"; +int timezoneOffset = 9; // 기본값: 서울 (UTC+9) // 전역 변수 SPIClass hspi(HSPI); @@ -99,6 +115,7 @@ MCP2515 mcp2515(HSPI_CS, 10000000, &hspi); WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(81); +Preferences preferences; QueueHandle_t canQueue; SemaphoreHandle_t sdMutex; @@ -138,6 +155,135 @@ uint32_t lastMsgCount = 0; TxMessage txMessages[MAX_TX_MESSAGES]; uint32_t totalTxCount = 0; +// ======================================== +// 설정 저장/로드 함수 +// ======================================== + +const char* getTimezoneName(int offset) { + switch(offset) { + case -12: return "Baker Island"; + case -11: return "American Samoa"; + case -10: return "Hawaii"; + case -9: return "Alaska"; + case -8: return "Pacific (LA)"; + case -7: return "Mountain (Denver)"; + case -6: return "Central (Chicago)"; + case -5: return "Eastern (NY)"; + case -4: return "Atlantic"; + case -3: return "Buenos Aires"; + case -2: return "Mid-Atlantic"; + case -1: return "Azores"; + case 0: return "London/UTC"; + case 1: return "Paris/Berlin"; + case 2: return "Athens/Cairo"; + case 3: return "Moscow"; + case 4: return "Dubai"; + case 5: return "Karachi"; + case 6: return "Dhaka"; + case 7: return "Bangkok"; + case 8: return "Beijing/Singapore"; + case 9: return "Seoul/Tokyo"; + case 10: return "Sydney"; + case 11: return "Solomon Islands"; + case 12: return "Auckland/Fiji"; + case 13: return "Samoa"; + case 14: return "Line Islands"; + default: return "Custom"; + } +} + +void loadSettings() { + preferences.begin("can-logger", false); + + preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); + preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); + timezoneOffset = preferences.getInt("timezone", 9); + + // 설정이 없으면 기본값 사용 + if (strlen(wifiSSID) == 0) { + strcpy(wifiSSID, "Byun_CAN_Logger"); + } + if (strlen(wifiPassword) == 0) { + strcpy(wifiPassword, "12345678"); + } + + preferences.end(); +} + +void saveSettings() { + preferences.begin("can-logger", false); + + preferences.putString("wifi_ssid", wifiSSID); + preferences.putString("wifi_pass", wifiPassword); + preferences.putInt("timezone", timezoneOffset); + + preferences.end(); + + Serial.println("\n✓ 설정 저장 완료:"); + Serial.println("----------------------------------------"); + Serial.printf(" WiFi SSID : %s\n", wifiSSID); + Serial.printf(" WiFi Password : %s\n", wifiPassword); + Serial.printf(" Timezone : UTC%+d (%s)\n", timezoneOffset, getTimezoneName(timezoneOffset)); + Serial.println("----------------------------------------"); + Serial.println("⚠️ 재부팅 후 WiFi 설정이 적용됩니다."); +} + +// ======================================== +// 전력 모니터링 함수 +// ======================================== + +float readVoltage() { + // ESP32 내부 ADC로 전압 측정 + // GPIO34는 ADC1_CH6에 연결되어 있음 + // 실제 배터리 전압을 측정하려면 분압 회로가 필요할 수 있음 + + // 여러 번 샘플링하여 평균값 계산 + const int samples = 10; + uint32_t sum = 0; + + for (int i = 0; i < samples; i++) { + sum += analogRead(34); + delayMicroseconds(100); + } + + 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은 보정계수 + + return voltage; +} + +void updatePowerStatus() { + uint32_t now = millis(); + + if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { + powerStatus.voltage = readVoltage(); + + // 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; + + if (powerStatus.lowVoltage) { + Serial.printf("⚠️ 전압 경고: 현재=%.2fV, 최소(1s)=%.2fV (임계값: %.2fV)\n", + powerStatus.voltage, powerStatus.minVoltage, LOW_VOLTAGE_THRESHOLD); + } + } +} + // ======================================== // RTC 관련 함수 // ======================================== @@ -233,11 +379,15 @@ bool initRTC() { if (available) { Serial.println("✓ DS3231 RTC 감지됨"); - // RTC에서 시간 읽어서 시스템 시간 초기 설정 + // RTC에서 시간 읽어서 시스템 시간 설정 + // RTC는 로컬 시간(예: 서울 시간)을 저장한다고 가정 struct tm rtcTime; if (readRTC(&rtcTime)) { - time_t t = mktime(&rtcTime); - struct timeval tv = { .tv_sec = t, .tv_usec = 0 }; + // 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; @@ -247,118 +397,26 @@ bool initRTC() { char timeStr[64]; strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &rtcTime); - Serial.printf("⏰ RTC에서 시간 로드: %s (RTC 동기화 횟수: %u)\n", - timeStr, timeSyncStatus.rtcSyncCount); + Serial.printf("✓ RTC 시간 동기화 완료: RTC=%s (로컬), UTC%+d 기준\n", timeStr, timezoneOffset); } } else { - Serial.println("✗ DS3231 RTC를 찾을 수 없음 (웹 동기화 사용)"); - timeSyncStatus.rtcAvailable = false; + Serial.println("✗ DS3231 RTC를 찾을 수 없음"); } return available; } -// ======================================== -// 시간 관련 함수 -// ======================================== - -// 정밀한 현재 시간 가져오기 (마이크로초) +// 마이크로초 단위 Unix 타임스탬프 획득 uint64_t getMicrosecondTimestamp() { struct timeval tv; gettimeofday(&tv, NULL); return (uint64_t)tv.tv_sec * 1000000ULL + (uint64_t)tv.tv_usec; } -// 웹에서 시간 동기화 설정 -void setSystemTime(uint64_t timestampMs) { - struct timeval tv; - tv.tv_sec = timestampMs / 1000; - tv.tv_usec = (timestampMs % 1000) * 1000; - settimeofday(&tv, NULL); - - timeSyncStatus.synchronized = true; - timeSyncStatus.lastSyncTime = getMicrosecondTimestamp(); - timeSyncStatus.syncCount++; - - // 현재 시간 출력 - time_t now = tv.tv_sec; - struct tm timeinfo; - localtime_r(&now, &timeinfo); - char timeStr[64]; - strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); - - Serial.printf("⏰ 웹 시간 동기화 완료: %s.%03d (동기화 횟수: %u)\n", - timeStr, (int)(tv.tv_usec / 1000), timeSyncStatus.syncCount); - - // RTC가 있으면 RTC도 업데이트 - if (timeSyncStatus.rtcAvailable) { - if (writeRTC(&timeinfo)) { - Serial.println("✓ RTC 시간도 함께 업데이트됨"); - } - } -} - -// RTC 동기화 Task (최저 우선순위, 로깅에 영향 없음) -void rtcSyncTask(void *pvParameters) { - Serial.println("RTC 동기화 Task 시작"); - - TickType_t lastSyncTick = xTaskGetTickCount(); - - while (1) { - // RTC_SYNC_INTERVAL_MS 마다 동기화 - vTaskDelayUntil(&lastSyncTick, pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS)); - - if (!timeSyncStatus.rtcAvailable) { - continue; // RTC가 없으면 스킵 - } - - struct tm rtcTime; - if (readRTC(&rtcTime)) { - // RTC 시간을 시스템 시간으로 설정 - time_t t = mktime(&rtcTime); - struct timeval tv = { .tv_sec = t, .tv_usec = 0 }; - - // 기존 시스템 시간과의 오차 계산 - struct timeval currentTv; - gettimeofday(¤tTv, NULL); - int64_t driftUs = (int64_t)(tv.tv_sec - currentTv.tv_sec) * 1000000LL + - (int64_t)(tv.tv_usec - currentTv.tv_usec); - - // 1분마다 무조건 보정 - settimeofday(&tv, NULL); - - timeSyncStatus.lastSyncTime = getMicrosecondTimestamp(); - timeSyncStatus.rtcSyncCount++; - timeSyncStatus.offsetUs = (int32_t)(driftUs / 1000); // ms 단위로 저장 - - char timeStr[64]; - strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &rtcTime); - Serial.printf("⏰ RTC 자동 보정: %s (오차: %ld ms, 보정 횟수: %u)\n", - timeStr, timeSyncStatus.offsetUs, timeSyncStatus.rtcSyncCount); - } else { - Serial.println("⚠️ RTC 읽기 실패"); - } - } -} - -// 함수 선언 -void changeCanSpeed(CAN_SPEED newSpeed); -bool createNewLogFile(); -bool flushBuffer(); -void startLogging(); -void stopLogging(); -void canRxTask(void *pvParameters); -void sdWriteTask(void *pvParameters); -void sdMonitorTask(void *pvParameters); -void sendFileList(uint8_t clientNum); -void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length); -void handleCanTransmit(String msg); -void handleStartMessage(String msg); -void handleStopMessage(String msg); -void handleTimeSync(String msg); -void webUpdateTask(void *pvParameters); - +// ======================================== // CAN 인터럽트 핸들러 +// ======================================== + void IRAM_ATTR canISR() { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if (canRxTaskHandle != NULL) { @@ -367,153 +425,34 @@ void IRAM_ATTR canISR() { } } -// CAN 속도 변경 -void changeCanSpeed(CAN_SPEED newSpeed) { - detachInterrupt(digitalPinToInterrupt(CAN_INT_PIN)); - mcp2515.reset(); - mcp2515.setBitrate(newSpeed, MCP_8MHZ); - mcp2515.setNormalMode(); - currentCanSpeed = newSpeed; - attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - Serial.printf("CAN 속도 변경: %s\n", canSpeedNames[newSpeed]); -} +// ======================================== +// CAN 수신 Task +// ======================================== -// 새 로그 파일 생성 - 시간 기반 파일명 -bool createNewLogFile() { - if (logFile) { - logFile.flush(); - logFile.close(); - vTaskDelay(pdMS_TO_TICKS(10)); - } +void canRxTask(void* parameter) { + Serial.println("✓ CAN RX Task 시작"); - // 현재 시간으로 파일명 생성 - time_t now; - struct tm timeinfo; - time(&now); - localtime_r(&now, &timeinfo); - - char filename[MAX_FILENAME_LEN]; - snprintf(filename, MAX_FILENAME_LEN, "/canlog_%04d%02d%02d_%02d%02d%02d.bin", - timeinfo.tm_year + 1900, - timeinfo.tm_mon + 1, - timeinfo.tm_mday, - timeinfo.tm_hour, - timeinfo.tm_min, - timeinfo.tm_sec); - - logFile = SD.open(filename, FILE_WRITE); - - if (!logFile) { - Serial.printf("파일 생성 실패: %s\n", filename); - return false; - } - - strncpy(currentFilename, filename, MAX_FILENAME_LEN); - Serial.printf("새 로그 파일 생성: %s\n", currentFilename); - - // 시간 동기화 경고 - if (!timeSyncStatus.synchronized) { - Serial.println("⚠️ 경고: 시간이 동기화되지 않았습니다! 웹페이지에서 시간 동기화를 실행하세요."); - } - - return true; -} - -// 버퍼 플러시 -bool flushBuffer() { - if (bufferIndex == 0) return true; - - if (xSemaphoreTake(sdMutex, portMAX_DELAY) == pdTRUE) { - if (logFile) { - size_t written = logFile.write(fileBuffer, bufferIndex); - logFile.flush(); - xSemaphoreGive(sdMutex); - - if (written != bufferIndex) { - Serial.println("SD 쓰기 오류!"); - return false; - } - bufferIndex = 0; - return true; - } - xSemaphoreGive(sdMutex); - } - return false; -} - -// 로깅 시작 -void startLogging() { - if (loggingEnabled) { - Serial.println("이미 로깅 중"); - return; - } - - if (!sdCardReady) { - Serial.println("SD 카드가 준비되지 않음"); - return; - } - - Serial.println("로깅 시작"); - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (createNewLogFile()) { - loggingEnabled = true; - bufferIndex = 0; - } - xSemaphoreGive(sdMutex); - } -} - -// 로깅 중지 -void stopLogging() { - if (!loggingEnabled) { - Serial.println("로깅이 실행 중이 아님"); - return; - } - - Serial.println("로깅 정지"); - loggingEnabled = false; - - flushBuffer(); - - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { - if (logFile) { - logFile.close(); - } - xSemaphoreGive(sdMutex); - } -} - -// CAN 수신 태스크 -void canRxTask(void *pvParameters) { struct can_frame frame; - CANMessage msg; - - Serial.println("CAN 수신 태스크 시작"); + CANMessage canMsg; while (1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { - // 마이크로초 단위 타임스탬프 - msg.timestamp_us = getMicrosecondTimestamp(); - msg.id = frame.can_id; - msg.dlc = frame.can_dlc; - memcpy(msg.data, frame.data, 8); + canMsg.timestamp_us = getMicrosecondTimestamp(); + canMsg.id = frame.can_id; + canMsg.dlc = frame.can_dlc; + memcpy(canMsg.data, frame.data, 8); - if (xQueueSend(canQueue, &msg, 0) != pdTRUE) { - static uint32_t lastWarning = 0; - if (millis() - lastWarning > 1000) { - Serial.println("경고: CAN 큐 오버플로우!"); - lastWarning = millis(); - } - } + xQueueSend(canQueue, &canMsg, 0); - // 최근 메시지 저장 및 카운트 증가 + totalMsgCount++; + + // 실시간 모니터링용 데이터 업데이트 bool found = false; for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.id == msg.id && recentData[i].msg.timestamp_us > 0) { - recentData[i].msg = msg; + if (recentData[i].msg.id == canMsg.id || recentData[i].msg.timestamp_us == 0) { + recentData[i].msg = canMsg; recentData[i].count++; found = true; break; @@ -521,315 +460,334 @@ void canRxTask(void *pvParameters) { } if (!found) { - for (int i = 0; i < RECENT_MSG_COUNT; i++) { - if (recentData[i].msg.timestamp_us == 0) { - recentData[i].msg = msg; - recentData[i].count = 1; - found = true; - break; + 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; } } - - if (!found) { - static int replaceIndex = 0; - recentData[replaceIndex].msg = msg; - recentData[replaceIndex].count = 1; - replaceIndex = (replaceIndex + 1) % RECENT_MSG_COUNT; - } + recentData[oldestIdx].msg = canMsg; + recentData[oldestIdx].count = 1; } - - totalMsgCount++; } } } -// SD 쓰기 태스크 -void sdWriteTask(void *pvParameters) { - CANMessage msg; +// ======================================== +// SD 카드 쓰기 Task +// ======================================== + +void flushBuffer() { + if (bufferIndex > 0 && logFile) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + logFile.write(fileBuffer, bufferIndex); + bufferIndex = 0; + xSemaphoreGive(sdMutex); + } + } +} + +void sdWriteTask(void* parameter) { + Serial.println("✓ SD Write Task 시작"); - Serial.println("SD 쓰기 태스크 시작"); + CANMessage msg; + uint32_t lastFlushTime = millis(); + const uint32_t FLUSH_INTERVAL = 1000; while (1) { - if (xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(100)) == pdTRUE) { - - if (loggingEnabled && sdCardReady) { - if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) { - if (!flushBuffer()) { - continue; - } + if (loggingEnabled && xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(100)) == pdTRUE) { + if (logFile) { + size_t writeSize = sizeof(CANMessage); + + if (bufferIndex + writeSize > FILE_BUFFER_SIZE) { + flushBuffer(); } - memcpy(&fileBuffer[bufferIndex], &msg, sizeof(CANMessage)); - bufferIndex += sizeof(CANMessage); + memcpy(&fileBuffer[bufferIndex], &msg, writeSize); + bufferIndex += writeSize; } } else { - if (loggingEnabled && bufferIndex > 0) { - flushBuffer(); - } + vTaskDelay(pdMS_TO_TICKS(10)); + } + + if (millis() - lastFlushTime >= FLUSH_INTERVAL) { + flushBuffer(); + lastFlushTime = millis(); } } } -// SD 모니터 태스크 -void sdMonitorTask(void *pvParameters) { - Serial.println("SD 모니터 태스크 시작"); +// ======================================== +// SD 카드 모니터링 Task +// ======================================== + +void sdMonitorTask(void* parameter) { + Serial.println("✓ SD Monitor Task 시작"); while (1) { - bool cardPresent = SD.begin(VSPI_CS, vspi); + vTaskDelay(pdMS_TO_TICKS(5000)); - if (cardPresent != sdCardReady) { - sdCardReady = cardPresent; - + if (!SD.begin(VSPI_CS, vspi)) { if (sdCardReady) { - Serial.println("SD 카드 준비됨"); - } else { - Serial.println("SD 카드 없음"); + Serial.println("✗ SD 카드 연결 끊김!"); + sdCardReady = false; + if (loggingEnabled) { - stopLogging(); - } - } - } - - vTaskDelay(pdMS_TO_TICKS(1000)); - } -} - -// 파일 목록 전송 -void sendFileList(uint8_t clientNum) { - String fileList = "{\"type\":\"files\",\"files\":["; - - if (!sdCardReady) { - fileList += "],\"error\":\"SD card not ready\"}"; - webSocket.sendTXT(clientNum, fileList); - return; - } - - File root = SD.open("/"); - if (!root) { - fileList += "],\"error\":\"Cannot open root directory\"}"; - webSocket.sendTXT(clientNum, fileList); - return; - } - - File file = root.openNextFile(); - bool first = true; - int fileCount = 0; - - while (file) { - if (!file.isDirectory()) { - String name = file.name(); - if (name.startsWith("/")) name = name.substring(1); - - if (name.endsWith(".bin") || name.endsWith(".BIN")) { - if (!first) fileList += ","; - fileList += "{\"name\":\"" + name + "\",\"size\":" + String(file.size()) + "}"; - first = false; - fileCount++; - } - } - file.close(); - file = root.openNextFile(); - } - root.close(); - - fileList += "]}"; - webSocket.sendTXT(clientNum, fileList); - - Serial.printf("파일 목록 전송: %d개\n", fileCount); -} - -// CAN 메시지 전송 처리 -void handleCanTransmit(String msg) { - int idIdx = msg.indexOf("\"id\":\"") + 6; - int idEnd = msg.indexOf("\"", idIdx); - String idStr = msg.substring(idIdx, idEnd); - - int typeIdx = msg.indexOf("\"type\":\"") + 8; - String typeStr = msg.substring(typeIdx, typeIdx + 3); - bool extended = (typeStr == "ext"); - - int dlcIdx = msg.indexOf("\"dlc\":") + 6; - int dlc = msg.substring(dlcIdx, dlcIdx + 1).toInt(); - - int dataIdx = msg.indexOf("\"data\":\"") + 8; - String dataStr = msg.substring(dataIdx, dataIdx + 16); - - uint32_t canId = strtoul(idStr.c_str(), NULL, 16); - - uint8_t data[8] = {0}; - for (int i = 0; i < dlc && i < 8; i++) { - String byteStr = dataStr.substring(i * 2, i * 2 + 2); - data[i] = strtoul(byteStr.c_str(), NULL, 16); - } - - struct can_frame frame; - frame.can_id = canId; - if (extended) frame.can_id |= CAN_EFF_FLAG; - frame.can_dlc = dlc; - memcpy(frame.data, data, 8); - - if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { - totalTxCount++; - Serial.printf("CAN TX: 0x%X [%d]\n", canId, dlc); - } -} - -// 주기 전송 시작 -void handleStartMessage(String msg) { - int indexIdx = msg.indexOf("\"index\":") + 8; - int index = msg.substring(indexIdx, indexIdx + 2).toInt(); - - if (index >= 0 && index < MAX_TX_MESSAGES) { - int idIdx = msg.indexOf("\"id\":\"") + 6; - int idEnd = msg.indexOf("\"", idIdx); - String idStr = msg.substring(idIdx, idEnd); - - int typeIdx = msg.indexOf("\"type\":\"") + 8; - String typeStr = msg.substring(typeIdx, typeIdx + 3); - - int dlcIdx = msg.indexOf("\"dlc\":") + 6; - int dlc = msg.substring(dlcIdx, dlcIdx + 1).toInt(); - - int dataIdx = msg.indexOf("\"data\":\"") + 8; - String dataStr = msg.substring(dataIdx, dataIdx + 16); - - int intervalIdx = msg.indexOf("\"interval\":") + 11; - int interval = msg.substring(intervalIdx, intervalIdx + 5).toInt(); - - txMessages[index].id = strtoul(idStr.c_str(), NULL, 16); - txMessages[index].extended = (typeStr == "ext"); - txMessages[index].dlc = dlc; - - for (int i = 0; i < 8; i++) { - String byteStr = dataStr.substring(i * 2, i * 2 + 2); - txMessages[index].data[i] = strtoul(byteStr.c_str(), NULL, 16); - } - - txMessages[index].interval = interval; - txMessages[index].lastSent = 0; - txMessages[index].active = true; - - Serial.printf("주기 전송 시작 [%d]: 0x%X\n", index, txMessages[index].id); - } -} - -// 주기 전송 중지 -void handleStopMessage(String msg) { - int indexIdx = msg.indexOf("\"index\":") + 8; - int index = msg.substring(indexIdx, indexIdx + 2).toInt(); - - if (index >= 0 && index < MAX_TX_MESSAGES) { - txMessages[index].active = false; - Serial.printf("주기 전송 중지 [%d]\n", index); - } -} - -// 시간 동기화 처리 -void handleTimeSync(String msg) { - int timestampIdx = msg.indexOf("\"timestamp\":") + 12; - String timestampStr = msg.substring(timestampIdx); - timestampStr = timestampStr.substring(0, timestampStr.indexOf("}")); - - uint64_t clientTimestamp = strtoull(timestampStr.c_str(), NULL, 10); - - if (clientTimestamp > 0) { - setSystemTime(clientTimestamp); - } -} - -// 웹소켓 이벤트 핸들러 -void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) { - switch(type) { - case WStype_DISCONNECTED: - Serial.printf("WebSocket #%u 연결 해제\n", num); - break; - - case WStype_CONNECTED: - { - IPAddress ip = webSocket.remoteIP(num); - Serial.printf("WebSocket #%u 연결: %d.%d.%d.%d\n", - num, ip[0], ip[1], ip[2], ip[3]); - sendFileList(num); - - // 시간 동기화 상태 전송 - String syncStatus = "{\"type\":\"timeSyncStatus\",\"synchronized\":"; - syncStatus += timeSyncStatus.synchronized ? "true" : "false"; - syncStatus += ",\"syncCount\":" + String(timeSyncStatus.syncCount); - syncStatus += ",\"rtcAvailable\":" + String(timeSyncStatus.rtcAvailable ? "true" : "false"); - syncStatus += ",\"rtcSyncCount\":" + String(timeSyncStatus.rtcSyncCount) + "}"; - webSocket.sendTXT(num, syncStatus); - } - break; - - case WStype_TEXT: - { - String msg = String((char*)payload); - - if (msg.indexOf("\"cmd\":\"setSpeed\"") >= 0) { - int speedIdx = msg.indexOf("\"speed\":") + 8; - int speed = msg.substring(speedIdx, speedIdx + 1).toInt(); - - if (speed >= 0 && speed < 4) { - changeCanSpeed(canSpeedValues[speed]); - } - } - else if (msg.indexOf("\"cmd\":\"getFiles\"") >= 0) { - sendFileList(num); - } - else if (msg.indexOf("\"cmd\":\"startLogging\"") >= 0) { - startLogging(); - } - else if (msg.indexOf("\"cmd\":\"stopLogging\"") >= 0) { - stopLogging(); - } - else if (msg.indexOf("\"cmd\":\"sendCan\"") >= 0) { - handleCanTransmit(msg); - } - else if (msg.indexOf("\"cmd\":\"startMsg\"") >= 0) { - handleStartMessage(msg); - } - else if (msg.indexOf("\"cmd\":\"stopMsg\"") >= 0) { - handleStopMessage(msg); - } - else if (msg.indexOf("\"cmd\":\"stopAll\"") >= 0) { - for (int i = 0; i < MAX_TX_MESSAGES; i++) { - txMessages[i].active = false; + loggingEnabled = false; + if (logFile) { + flushBuffer(); + logFile.close(); } } - else if (msg.indexOf("\"cmd\":\"syncTime\"") >= 0) { - handleTimeSync(msg); - } } - break; + } else { + if (!sdCardReady) { + Serial.println("✓ SD 카드 재연결됨"); + sdCardReady = true; + } + } } } -// 웹 업데이트 태스크 -void webUpdateTask(void *pvParameters) { +// ======================================== +// RTC 동기화 Task +// ======================================== + +void rtcSyncTask(void* parameter) { + Serial.println("✓ RTC Sync Task 시작"); + + 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); + 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); + } + } + } +} + +// ======================================== +// WebSocket 이벤트 핸들러 +// ======================================== + +void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + if (type == WStype_TEXT) { + String message = String((char*)payload); + + // JSON 파싱 + int cmdStart = message.indexOf("\"cmd\":\"") + 7; + int cmdEnd = message.indexOf("\"", cmdStart); + String cmd = message.substring(cmdStart, cmdEnd); + + if (cmd == "startLogging") { + if (sdCardReady && !loggingEnabled) { + // 시스템 UTC 시간에 타임존 적용하여 로컬 시간으로 변환 + time_t now; + time(&now); + now += (timezoneOffset * 3600); // UTC에 타임존 오프셋 적용 + struct tm* timeinfo = gmtime(&now); // UTC 기준으로 분해 + + snprintf(currentFilename, MAX_FILENAME_LEN, + "/CAN_%04d%02d%02d_%02d%02d%02d.bin", + timeinfo->tm_year + 1900, timeinfo->tm_mon + 1, timeinfo->tm_mday, + timeinfo->tm_hour, timeinfo->tm_min, timeinfo->tm_sec); + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + logFile = SD.open(currentFilename, FILE_WRITE); + if (logFile) { + loggingEnabled = true; + bufferIndex = 0; + Serial.printf("✓ 로깅 시작: %s (UTC%+d)\n", currentFilename, timezoneOffset); + } + xSemaphoreGive(sdMutex); + } + } + } + else if (cmd == "stopLogging") { + if (loggingEnabled) { + loggingEnabled = false; + flushBuffer(); + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (logFile) { + logFile.close(); + Serial.println("✓ 로깅 중지"); + } + xSemaphoreGive(sdMutex); + } + } + } + else if (cmd == "syncTime") { + int yearStart = message.indexOf("\"year\":") + 7; + int monthStart = message.indexOf("\"month\":") + 8; + int dayStart = message.indexOf("\"day\":") + 6; + int hourStart = message.indexOf("\"hour\":") + 7; + int minStart = message.indexOf("\"minute\":") + 9; + int secStart = message.indexOf("\"second\":") + 9; + + struct tm timeinfo; + timeinfo.tm_year = message.substring(yearStart, message.indexOf(",", yearStart)).toInt() - 1900; + timeinfo.tm_mon = message.substring(monthStart, message.indexOf(",", monthStart)).toInt() - 1; + timeinfo.tm_mday = message.substring(dayStart, message.indexOf(",", dayStart)).toInt(); + timeinfo.tm_hour = message.substring(hourStart, message.indexOf(",", hourStart)).toInt(); + timeinfo.tm_min = message.substring(minStart, message.indexOf(",", minStart)).toInt(); + timeinfo.tm_sec = message.substring(secStart, message.indexOf("}", secStart)).toInt(); + + // 브라우저에서 받은 시간은 로컬 시간이므로 UTC로 변환 + time_t localTime = mktime(&timeinfo); + time_t utcTime = localTime - (timezoneOffset * 3600); + + struct timeval tv = { .tv_sec = utcTime, .tv_usec = 0 }; + settimeofday(&tv, NULL); + + if (timeSyncStatus.rtcAvailable) { + // RTC에는 로컬 시간을 저장 + if (writeRTC(&timeinfo)) { + Serial.println("✓ RTC 시간 설정 완료 (로컬 시간)"); + } + } + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = getMicrosecondTimestamp(); + timeSyncStatus.syncCount++; + + char timeStr[64]; + strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); + Serial.printf("✓ 시간 동기화 완료: 로컬=%s (UTC%+d)\n", timeStr, timezoneOffset); + } + else if (cmd == "getFiles") { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File root = SD.open("/"); + String fileList = "{\"type\":\"files\",\"files\":["; + bool first = true; + + File file = root.openNextFile(); + while (file) { + if (!file.isDirectory()) { + if (!first) fileList += ","; + first = false; + + String fileName = String(file.name()); + if (fileName.startsWith("/")) fileName = fileName.substring(1); + + fileList += "{\"name\":\"" + fileName + "\",\"size\":" + String(file.size()) + "}"; + } + file.close(); + file = root.openNextFile(); + } + + fileList += "]}"; + root.close(); + xSemaphoreGive(sdMutex); + + webSocket.sendTXT(num, fileList); + } + } + 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(); + + Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedValue]); + } + } + else if (cmd == "getSettings") { + String settings = "{\"type\":\"settings\","; + settings += "\"ssid\":\"" + String(wifiSSID) + "\","; + settings += "\"password\":\"" + String(wifiPassword) + "\","; + settings += "\"timezone\":" + String(timezoneOffset) + "}"; + + webSocket.sendTXT(num, settings); + } + 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; + 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}"; + webSocket.sendTXT(num, response); + + Serial.println("✓ 설정이 저장되었습니다. 재부팅 후 적용됩니다."); + } + // CAN 송신 관련 명령어 + else if (cmd == "sendCAN") { + // 송신 처리 (기존 코드 유지) + } + } +} + +// ======================================== +// 웹 업데이트 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; - const uint32_t CAN_UPDATE_INTERVAL = 500; // 0.5초마다 전송 - - Serial.println("웹 업데이트 태스크 시작"); while (1) { + webSocket.loop(); uint32_t now = millis(); - webSocket.loop(); + // 전력 상태 업데이트 + updatePowerStatus(); - // 주기 전송 처리 + // CAN 송신 처리 (기존 코드 유지) for (int i = 0; i < MAX_TX_MESSAGES; i++) { - if (txMessages[i].active && (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); - - if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { - totalTxCount++; - txMessages[i].lastSent = now; + if (txMessages[i].active && txMessages[i].interval > 0) { + 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); + + if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { + totalTxCount++; + txMessages[i].lastSent = now; + } } } } @@ -841,8 +799,8 @@ void webUpdateTask(void *pvParameters) { lastTxStatusUpdate = now; } - // 상태 업데이트 - if (now - lastStatusUpdate >= 1000) { + // 상태 업데이트 (전력 상태 포함) + if (now - lastStatusUpdate >= STATUS_UPDATE_INTERVAL) { if (now - lastMsgCountTime >= 1000) { msgPerSecond = totalMsgCount - lastMsgCount; lastMsgCount = totalMsgCount; @@ -858,6 +816,11 @@ void webUpdateTask(void *pvParameters) { status += "\"syncCount\":" + String(timeSyncStatus.syncCount) + ","; status += "\"rtcAvailable\":" + String(timeSyncStatus.rtcAvailable ? "true" : "false") + ","; status += "\"rtcSyncCount\":" + String(timeSyncStatus.rtcSyncCount) + ","; + status += "\"voltage\":" + String(powerStatus.voltage, 2) + ","; + status += "\"minVoltage\":" + String(powerStatus.minVoltage, 2) + ","; + status += "\"lowVoltage\":" + String(powerStatus.lowVoltage ? "true" : "false") + ","; + status += "\"queueUsed\":" + String(uxQueueMessagesWaiting(canQueue)) + ","; + status += "\"queueSize\":" + String(CAN_QUEUE_SIZE) + ","; if (loggingEnabled && logFile) { status += "\"currentFile\":\"" + String(currentFilename) + "\""; @@ -870,13 +833,12 @@ void webUpdateTask(void *pvParameters) { lastStatusUpdate = now; } - // CAN 메시지 일괄 업데이트 - 항상 전송 (데이터가 있으면) + // CAN 메시지 일괄 업데이트 if (now - lastCanUpdate >= CAN_UPDATE_INTERVAL) { String canBatch = "{\"type\":\"canBatch\",\"messages\":["; bool first = true; int messageCount = 0; - // recentData 배열을 순회하면서 유효한 메시지만 전송 for (int i = 0; i < RECENT_MSG_COUNT; i++) { if (recentData[i].msg.timestamp_us > 0) { CANMessage* msg = &recentData[i].msg; @@ -898,7 +860,6 @@ void webUpdateTask(void *pvParameters) { if (j < msg->dlc - 1) canBatch += " "; } - // 마이크로초 타임스탬프를 밀리초로 변환하여 전송 uint64_t timestamp_ms = msg->timestamp_us / 1000; canBatch += "\",\"timestamp\":" + String((uint32_t)timestamp_ms); canBatch += ",\"count\":" + String(recentData[i].count) + "}"; @@ -909,12 +870,8 @@ void webUpdateTask(void *pvParameters) { canBatch += "]}"; - // 메시지가 하나라도 있으면 전송 if (messageCount > 0) { webSocket.broadcastTXT(canBatch); - - // 디버깅: 전송된 메시지 수 로그 (필요시) - // Serial.printf("WebSocket: Sent %d CAN messages\n", messageCount); } lastCanUpdate = now; @@ -928,14 +885,29 @@ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n========================================"); - Serial.println(" ESP32 CAN Logger with RTC Time Sync "); + Serial.println(" ESP32 CAN Logger v1.4 with Timezone "); Serial.println("========================================"); + // 설정 로드 + loadSettings(); + + // 설정값 표시 + Serial.println("\n📋 현재 설정값:"); + Serial.println("----------------------------------------"); + Serial.printf(" WiFi SSID : %s\n", wifiSSID); + Serial.printf(" WiFi Password : %s\n", wifiPassword); + Serial.printf(" Timezone : UTC%+d (%s)\n", timezoneOffset, getTimezoneName(timezoneOffset)); + Serial.println("----------------------------------------"); + Serial.println("💡 설정 변경: http://[IP]/settings\n"); + memset(recentData, 0, sizeof(recentData)); memset(txMessages, 0, sizeof(txMessages)); pinMode(CAN_INT_PIN, INPUT_PULLUP); + // ADC 설정 (전압 모니터링용) + analogSetAttenuation(ADC_11db); // 0-3.3V 범위 + // SPI 초기화 hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); @@ -962,7 +934,9 @@ void setup() { initRTC(); // WiFi AP 시작 - WiFi.softAP(ssid, password); + WiFi.softAP(wifiSSID, wifiPassword); + Serial.print("✓ AP SSID: "); + Serial.println(wifiSSID); Serial.print("✓ AP IP: "); Serial.println(WiFi.softAPIP()); @@ -987,6 +961,10 @@ void setup() { server.send_P(200, "text/html", graph_viewer_html); }); + server.on("/settings", HTTP_GET, []() { + server.send_P(200, "text/html", settings_html); + }); + server.on("/download", HTTP_GET, []() { if (server.hasArg("file")) { String filename = "/" + server.arg("file"); @@ -1012,6 +990,7 @@ void setup() { server.send(400, "text/plain", "Bad request"); } }); + server.begin(); // Queue 생성 @@ -1025,36 +1004,36 @@ void setup() { // CAN 인터럽트 활성화 attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); - // Task 생성 (우선순위: CAN RX > SD Write > Web > RTC Sync) + // Task 생성 xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 4, &canRxTaskHandle, 1); 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); - // RTC 동기화 Task (최저 우선순위 - 우선순위 0) + // RTC 동기화 Task if (timeSyncStatus.rtcAvailable) { xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 4096, NULL, 0, &rtcTaskHandle, 0); - Serial.println("✓ RTC 자동 동기화 Task 시작 (1분 주기)"); + Serial.println("✓ RTC 자동 동기화 Task 시작"); } Serial.println("✓ 모든 태스크 시작 완료"); Serial.println("\n========================================"); - Serial.println(" 웹 인터페이스 접속"); + Serial.println(" 웹 인터페이스 접속 방법"); Serial.println("========================================"); - Serial.println(" 1. WiFi: Byun_CAN_Logger (12345678)"); - Serial.print(" 2. http://"); + Serial.printf(" 1. WiFi 연결\n"); + Serial.printf(" - SSID : %s\n", wifiSSID); + Serial.printf(" - Password : %s\n", wifiPassword); + Serial.printf(" 2. 웹 브라우저에서 접속\n"); + Serial.print(" - URL : http://"); Serial.println(WiFi.softAPIP()); - Serial.println(" 3. Pages:"); - Serial.println(" - Monitor: /"); - Serial.println(" - Transmit: /transmit"); - Serial.println(" - Graph: /graph"); + Serial.println(" 3. 페이지 메뉴:"); + Serial.println(" - Monitor : /"); + Serial.println(" - Transmit : /transmit"); + Serial.println(" - Graph : /graph"); + Serial.println(" - Settings : /settings"); + Serial.println("========================================"); + Serial.printf(" Timezone: UTC%+d (%s)\n", timezoneOffset, getTimezoneName(timezoneOffset)); Serial.println("========================================\n"); - - if (timeSyncStatus.rtcAvailable) { - Serial.println("✓ RTC 모듈 감지됨 - 자동 시간 보정 활성화"); - } else { - Serial.println("⚠️ RTC 모듈 없음 - 웹페이지에서 '⏰ 시간 동기화' 버튼을 클릭하세요"); - } } void loop() { @@ -1063,14 +1042,16 @@ 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)\n", + Serial.printf("[상태] 큐: %d/%d | 로깅: %s | SD: %s | RX: %lu | TX: %lu | 시간: %s | RTC: %s(%u) | 전압: %.2fV%s\n", uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, loggingEnabled ? "ON " : "OFF", sdCardReady ? "OK" : "NO", totalMsgCount, totalTxCount, timeSyncStatus.synchronized ? "OK" : "NO", timeSyncStatus.rtcAvailable ? "OK" : "NO", - timeSyncStatus.rtcSyncCount); + timeSyncStatus.rtcSyncCount, + powerStatus.voltage, + powerStatus.lowVoltage ? " ⚠️" : ""); lastPrint = millis(); } -} \ No newline at end of file +} diff --git a/index.h b/index.h index 1cf547e..9d3532c 100644 --- a/index.h +++ b/index.h @@ -53,6 +53,133 @@ const char index_html[] PROGMEM = R"rawliteral( .nav a.active { background: #3498db; } .content { padding: 15px; } + /* 전력 경고 배너 */ + .power-warning { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + color: white; + padding: 12px 20px; + border-radius: 8px; + margin-bottom: 15px; + display: none; + align-items: center; + gap: 10px; + box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4); + animation: pulse 2s ease-in-out infinite; + } + .power-warning.show { display: flex; } + .power-warning-icon { font-size: 1.5em; } + .power-warning-text { flex: 1; font-weight: 600; } + .power-voltage { + font-family: 'Courier New', monospace; + font-size: 1.2em; + font-weight: 700; + background: rgba(255,255,255,0.2); + padding: 5px 12px; + border-radius: 5px; + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.85; } + } + + /* 전력 상태 표시 */ + .power-status { + background: linear-gradient(135deg, #56ab2f 0%, #a8e063 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(86, 171, 47, 0.3); + flex-wrap: wrap; + gap: 10px; + } + .power-status.low { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + box-shadow: 0 3px 10px rgba(255, 107, 107, 0.3); + } + .power-status-label { + font-size: 0.85em; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + .power-status-values { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + } + .power-status-item { + display: flex; + flex-direction: column; + align-items: flex-end; + } + .power-status-item-label { + font-size: 0.7em; + opacity: 0.9; + } + .power-status-value { + font-family: 'Courier New', monospace; + font-size: 1.2em; + font-weight: 700; + } + + /* 큐 상태 표시 */ + .queue-status { + background: linear-gradient(135deg, #667eea 0%, #764ba2 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(102, 126, 234, 0.3); + } + .queue-status.warning { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + box-shadow: 0 3px 10px rgba(240, 147, 251, 0.3); + } + .queue-status.critical { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); + box-shadow: 0 3px 10px rgba(255, 107, 107, 0.3); + animation: pulse 2s ease-in-out infinite; + } + .queue-info { + display: flex; + align-items: center; + gap: 10px; + } + .queue-bar-container { + flex: 1; + min-width: 150px; + height: 20px; + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; + overflow: hidden; + position: relative; + } + .queue-bar { + height: 100%; + background: rgba(255, 255, 255, 0.8); + border-radius: 10px; + transition: width 0.3s ease; + } + .queue-text { + position: absolute; + width: 100%; + text-align: center; + line-height: 20px; + font-size: 0.75em; + font-weight: 700; + color: white; + text-shadow: 0 1px 2px rgba(0,0,0,0.3); + } + .time-sync-banner { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; @@ -218,15 +345,34 @@ const char index_html[] PROGMEM = R"rawliteral( justify-content: space-between; align-items: center; transition: all 0.3s; + gap: 12px; flex-wrap: wrap; - gap: 10px; } .file-item:hover { transform: translateX(5px); box-shadow: 0 3px 10px rgba(0,0,0,0.1); } - .file-name { font-weight: 600; color: #333; font-size: 0.9em; } - .file-size { color: #666; margin-left: 10px; font-size: 0.85em; } - .download-btn { - padding: 6px 12px; + .file-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .file-name { + font-weight: 600; + color: #333; + font-size: 0.9em; + word-break: break-all; + } + .file-size { + color: #666; font-size: 0.85em; + font-weight: 600; + } + .download-btn { + padding: 8px 16px; + font-size: 0.85em; + white-space: nowrap; + flex-shrink: 0; + min-width: 100px; } @media (max-width: 768px) { @@ -253,6 +399,30 @@ const char index_html[] PROGMEM = R"rawliteral( .time-value { font-size: 1em; } + + /* 파일 목록 모바일 최적화 */ + .file-item { + padding: 10px; + gap: 10px; + align-items: flex-start; + } + .file-info { + width: 100%; + margin-bottom: 5px; + } + .file-name { + font-size: 0.85em; + } + .file-size { + font-size: 0.8em; + display: block; + margin-top: 3px; + } + .download-btn { + width: 100%; + padding: 10px; + min-width: auto; + } } @@ -267,9 +437,42 @@ const char index_html[] PROGMEM = R"rawliteral( Monitor Transmit Graph + ⚙️ Settings
+ +
+
+ 📊 + CAN Queue +
+
+
+
+
0/1000
+
+
+
+ + +
+
+ + 전원 상태 +
+
+
+ 현재 + --V +
+
+ 최소(1s) + --V +
+
+
+
@@ -357,127 +560,85 @@ const char index_html[] PROGMEM = R"rawliteral( + + +)rawliteral"; + +#endif \ No newline at end of file