From 49a0be0972a8c1a9b1441ca8c9de18a5cca197ec Mon Sep 17 00:00:00 2001 From: byun Date: Sat, 29 Nov 2025 08:57:11 +0000 Subject: [PATCH] Upload files to "/" --- ESP32_CAN_Logger-a.ino | 1882 ++++++++++++++++++++++++++++++++++++++++ graph.h | 793 +++++++++++++++++ graph_viewer.h | 877 +++++++++++++++++++ index.h | 1395 +++++++++++++++++++++++++++++ serial.h | 691 +++++++++++++++ 5 files changed, 5638 insertions(+) create mode 100644 ESP32_CAN_Logger-a.ino create mode 100644 graph.h create mode 100644 graph_viewer.h create mode 100644 index.h create mode 100644 serial.h diff --git a/ESP32_CAN_Logger-a.ino b/ESP32_CAN_Logger-a.ino new file mode 100644 index 0000000..c965cb4 --- /dev/null +++ b/ESP32_CAN_Logger-a.ino @@ -0,0 +1,1882 @@ +/* + * Byun CAN Logger with Web Interface + Serial Terminal + * Version: 2.1 + * Added: Serial communication (RS232) with web terminal interface + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "index.h" +#include "transmit.h" +#include "graph.h" +#include "graph_viewer.h" +#include "settings.h" +#include "serial_terminal.h" + +// GPIO 핀 정의 +#define CAN_INT_PIN 4 + +// Serial 통신 핀 (추가) +#define SERIAL_TX_PIN 17 +#define SERIAL_RX_PIN 18 + +// HSPI 핀 (CAN) +#define HSPI_MISO 13 +#define HSPI_MOSI 11 +#define HSPI_SCLK 12 +#define HSPI_CS 10 + +// VSPI 핀 (SD Card) +#define VSPI_MISO 41 +#define VSPI_MOSI 40 +#define VSPI_SCLK 39 +#define VSPI_CS 42 + +// I2C2 핀 (RTC DS3231) - SoftWire 사용 +#define RTC_SDA 8 +#define RTC_SCL 9 +#define DS3231_ADDRESS 0x68 + +// 버퍼 설정 +#define CAN_QUEUE_SIZE 1000 // 1500 → 1000으로 축소 +#define FILE_BUFFER_SIZE 8192 // 16384 → 8192로 축소 +#define MAX_FILENAME_LEN 64 +#define RECENT_MSG_COUNT 50 // 100 → 50으로 축소 +#define MAX_TX_MESSAGES 20 +#define MAX_COMMENT_LEN 128 + +// Serial 버퍼 설정 (추가) +#define SERIAL_QUEUE_SIZE 100 // 200 → 100으로 축소 +#define SERIAL_BUFFER_SIZE 1024 // 2048 → 1024로 축소 +#define MAX_SERIAL_LINE_LEN 128 + +// RTC 동기화 설정 +#define RTC_SYNC_INTERVAL_MS 60000 + +// 전력 모니터링 설정 +#define VOLTAGE_CHECK_INTERVAL_MS 5000 +#define LOW_VOLTAGE_THRESHOLD 3.0 +#define MONITORING_VOLT 5 +// CAN 메시지 구조체 +struct CANMessage { + uint64_t timestamp_us; + uint32_t id; + uint8_t dlc; + uint8_t data[8]; +} __attribute__((packed)); + +// Serial 메시지 구조체 (추가) +struct SerialMessage { + uint64_t timestamp_us; + uint16_t length; + uint8_t data[MAX_SERIAL_LINE_LEN]; + bool isTx; // true=송신, false=수신 +} __attribute__((packed)); + +// Serial 설정 구조체 (추가) +struct SerialSettings { + uint32_t baudRate; + uint8_t dataBits; // 5, 6, 7, 8 + uint8_t parity; // 0=None, 1=Even, 2=Odd + uint8_t stopBits; // 1, 2 +} serialSettings = {115200, 8, 0, 1}; + +// 실시간 모니터링용 구조체 +struct RecentCANData { + CANMessage msg; + uint32_t count; +}; + +// CAN 송신용 구조체 +struct TxMessage { + uint32_t id; + bool extended; + uint8_t dlc; + uint8_t data[8]; + uint32_t interval; + uint32_t lastSent; + bool active; +}; + +// CAN 시퀀스 스텝 구조체 +struct SequenceStep { + uint32_t canId; + bool extended; + uint8_t dlc; + uint8_t data[8]; + uint32_t delayMs; +}; + +// CAN 시퀀스 구조체 +struct CANSequence { + char name[32]; + SequenceStep steps[20]; + uint8_t stepCount; + uint8_t repeatMode; + uint32_t repeatCount; +}; + +// 시퀀스 실행 상태 +struct SequenceRuntime { + bool running; + uint8_t currentStep; + uint32_t currentRepeat; + uint32_t lastStepTime; + int8_t activeSequenceIndex; +}; + +// 파일 커멘트 구조체 +struct FileComment { + char filename[MAX_FILENAME_LEN]; + char comment[MAX_COMMENT_LEN]; +}; + +// 시간 동기화 상태 +struct TimeSyncStatus { + bool synchronized; + uint64_t lastSyncTime; + int32_t offsetUs; + uint32_t syncCount; + bool rtcAvailable; + uint32_t rtcSyncCount; +} timeSyncStatus = {false, 0, 0, 0, false, 0}; + +// 전력 모니터링 상태 +struct PowerStatus { + float voltage; + float minVoltage; + bool lowVoltage; + uint32_t lastCheck; + uint32_t lastMinReset; +} powerStatus = {0.0, 999.9, false, 0, 0}; + +// MCP2515 레지스터 주소 정의 +#ifndef MCP_CANCTRL +#define MCP_CANCTRL 0x0F +#endif + +#ifndef MCP_CANSTAT +#define MCP_CANSTAT 0x0E +#endif + +// MCP2515 모드 정의 +enum MCP2515Mode { + MCP_MODE_NORMAL = 0, + MCP_MODE_LISTEN_ONLY = 1, + MCP_MODE_LOOPBACK = 2, + MCP_MODE_TRANSMIT = 3 +}; + +// WiFi AP 기본 설정 +char wifiSSID[32] = "Byun_CAN_Logger"; +char wifiPassword[64] = "12345678"; + +// WiFi Station 모드 설정 +bool enableSTAMode = false; +char staSSID[32] = ""; +char staPassword[64] = ""; + +// 전역 변수 +SPIClass hspi(HSPI); +SPIClass vspi(FSPI); +MCP2515 mcp2515(HSPI_CS, 20000000, &hspi); + +// Serial 통신용 (추가) +HardwareSerial SerialComm(1); // UART1 사용 + +WebServer server(80); +WebSocketsServer webSocket = WebSocketsServer(81); +Preferences preferences; + +// Forward declaration +void IRAM_ATTR canISR(); + +QueueHandle_t canQueue; +QueueHandle_t serialQueue; // Serial Queue 추가 +SemaphoreHandle_t sdMutex; +SemaphoreHandle_t rtcMutex; +SemaphoreHandle_t serialMutex; // Serial Mutex 추가 +TaskHandle_t canRxTaskHandle = NULL; +TaskHandle_t sdWriteTaskHandle = NULL; +TaskHandle_t webTaskHandle = NULL; +TaskHandle_t rtcTaskHandle = NULL; +TaskHandle_t serialRxTaskHandle = NULL; // Serial Task 추가 + +volatile bool loggingEnabled = false; +volatile bool serialLoggingEnabled = false; // Serial 로깅 상태 추가 +volatile bool sdCardReady = false; +File logFile; +File serialLogFile; // Serial 로그 파일 추가 +char currentFilename[MAX_FILENAME_LEN]; +char currentSerialFilename[MAX_FILENAME_LEN]; // Serial 로그 파일명 추가 +uint8_t fileBuffer[FILE_BUFFER_SIZE]; +uint8_t serialFileBuffer[SERIAL_BUFFER_SIZE]; // Serial 파일 버퍼 추가 +uint16_t bufferIndex = 0; +uint16_t serialBufferIndex = 0; // Serial 버퍼 인덱스 추가 + +// 로깅 파일 크기 추적 +volatile uint32_t currentFileSize = 0; +volatile uint32_t currentSerialFileSize = 0; // Serial 파일 크기 추가 + +// 현재 MCP2515 모드 +MCP2515Mode currentMcpMode = MCP_MODE_NORMAL; + +// RTC 관련 +SoftWire rtcWire(RTC_SDA, RTC_SCL); +char rtcSyncBuffer[20]; + +// CAN 속도 설정 +CAN_SPEED currentCanSpeed = CAN_1000KBPS; +const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; +CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; + +// 실시간 모니터링용 +RecentCANData recentData[RECENT_MSG_COUNT]; +uint32_t totalMsgCount = 0; +uint32_t msgPerSecond = 0; +uint32_t lastMsgCountTime = 0; +uint32_t lastMsgCount = 0; + +// Serial 통신 카운터 (추가) +volatile uint32_t totalSerialRxCount = 0; +volatile uint32_t totalSerialTxCount = 0; + +// 그래프 최대 개수 +#define MAX_GRAPH_SIGNALS 20 + +// CAN 송신용 +TxMessage txMessages[MAX_TX_MESSAGES]; +uint32_t totalTxCount = 0; + +// CAN 시퀀스 +#define MAX_SEQUENCES 10 +CANSequence sequences[MAX_SEQUENCES]; +uint8_t sequenceCount = 0; +SequenceRuntime seqRuntime = {false, 0, 0, 0, -1}; + +// 파일 커멘트 저장 +#define MAX_FILE_COMMENTS 50 +FileComment fileComments[MAX_FILE_COMMENTS]; +int commentCount = 0; + +// ======================================== +// Serial 설정 저장/로드 함수 (추가) +// ======================================== + +void loadSerialSettings() { + serialSettings.baudRate = preferences.getUInt("ser_baud", 115200); + serialSettings.dataBits = preferences.getUChar("ser_data", 8); + serialSettings.parity = preferences.getUChar("ser_parity", 0); + serialSettings.stopBits = preferences.getUChar("ser_stop", 1); + + Serial.printf("✓ Serial 설정 로드: %u-%u-%u-%u\n", + serialSettings.baudRate, serialSettings.dataBits, + serialSettings.parity, serialSettings.stopBits); +} + +void saveSerialSettings() { + preferences.putUInt("ser_baud", serialSettings.baudRate); + preferences.putUChar("ser_data", serialSettings.dataBits); + preferences.putUChar("ser_parity", serialSettings.parity); + preferences.putUChar("ser_stop", serialSettings.stopBits); + + Serial.printf("✓ Serial 설정 저장: %u-%u-%u-%u\n", + serialSettings.baudRate, serialSettings.dataBits, + serialSettings.parity, serialSettings.stopBits); +} + +void applySerialSettings() { + uint32_t config = SERIAL_8N1; + + // Data bits + Parity 설정 + if (serialSettings.dataBits == 5) { + if (serialSettings.parity == 0) config = SERIAL_5N1; + else if (serialSettings.parity == 1) config = SERIAL_5E1; + else if (serialSettings.parity == 2) config = SERIAL_5O1; + } else if (serialSettings.dataBits == 6) { + if (serialSettings.parity == 0) config = SERIAL_6N1; + else if (serialSettings.parity == 1) config = SERIAL_6E1; + else if (serialSettings.parity == 2) config = SERIAL_6O1; + } else if (serialSettings.dataBits == 7) { + if (serialSettings.parity == 0) config = SERIAL_7N1; + else if (serialSettings.parity == 1) config = SERIAL_7E1; + else if (serialSettings.parity == 2) config = SERIAL_7O1; + } else { // 8 bits + if (serialSettings.parity == 0) config = SERIAL_8N1; + else if (serialSettings.parity == 1) config = SERIAL_8E1; + else if (serialSettings.parity == 2) config = SERIAL_8O1; + } + + // Stop bits 설정 + if (serialSettings.stopBits == 2) { + config |= 0x3000; // 2 stop bits + } + + SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN); + SerialComm.setRxBufferSize(2048); + + Serial.printf("✓ Serial 설정 적용: %u baud, config=0x%X\n", + serialSettings.baudRate, config); +} + +// ======================================== +// 설정 저장/로드 함수 +// ======================================== + +void loadSettings() { + preferences.begin("can-logger", false); + + // WiFi AP 설정 로드 + preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); + preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); + + // WiFi STA 모드 설정 로드 + enableSTAMode = preferences.getBool("sta_enable", false); + preferences.getString("sta_ssid", staSSID, sizeof(staSSID)); + preferences.getString("sta_pass", staPassword, sizeof(staPassword)); + + // 설정이 없으면 기본값 사용 + if (strlen(wifiSSID) == 0) { + strcpy(wifiSSID, "Byun_CAN_Logger"); + } + if (strlen(wifiPassword) == 0) { + strcpy(wifiPassword, "12345678"); + } + + // CAN 속도 로드 + int speedIndex = preferences.getInt("can_speed", 3); + if (speedIndex >= 0 && speedIndex < 4) { + currentCanSpeed = canSpeedValues[speedIndex]; + Serial.printf("✓ 저장된 CAN 속도 로드: %s\n", canSpeedNames[speedIndex]); + } + + // MCP2515 모드 로드 + int savedMode = preferences.getInt("mcp_mode", 0); + if (savedMode >= 0 && savedMode <= 3) { + currentMcpMode = (MCP2515Mode)savedMode; + Serial.printf("✓ 저장된 MCP 모드 로드: %d\n", savedMode); + } + + // Serial 설정 로드 (추가) + loadSerialSettings(); + + preferences.end(); +} + +void saveSettings() { + preferences.begin("can-logger", false); + + preferences.putString("wifi_ssid", wifiSSID); + preferences.putString("wifi_pass", wifiPassword); + preferences.putBool("sta_enable", enableSTAMode); + preferences.putString("sta_ssid", staSSID); + preferences.putString("sta_pass", staPassword); + + // CAN 속도 저장 + for (int i = 0; i < 4; i++) { + if (canSpeedValues[i] == currentCanSpeed) { + preferences.putInt("can_speed", i); + Serial.printf("✓ CAN 속도 저장: %s (인덱스 %d)\n", canSpeedNames[i], i); + break; + } + } + + // MCP2515 모드 저장 + preferences.putInt("mcp_mode", (int)currentMcpMode); + Serial.printf("✓ MCP 모드 저장: %d\n", (int)currentMcpMode); + + preferences.end(); + + Serial.println("✓ 설정 저장 완료"); +} + +// ======================================== +// RTC 함수 +// ======================================== + +void initRTC() { + rtcWire.begin(); + rtcWire.setClock(100000); + + rtcWire.beginTransmission(DS3231_ADDRESS); + if (rtcWire.endTransmission() == 0) { + timeSyncStatus.rtcAvailable = true; + Serial.println("✓ RTC(DS3231) 감지됨"); + } else { + timeSyncStatus.rtcAvailable = false; + Serial.println("! RTC(DS3231) 없음 - 시간 동기화 필요"); + } +} + +uint8_t bcdToDec(uint8_t val) { + return (val >> 4) * 10 + (val & 0x0F); +} + +uint8_t decToBcd(uint8_t val) { + return ((val / 10) << 4) | (val % 10); +} + +bool readRTC(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); + if (rtcWire.endTransmission() != 0) { + xSemaphoreGive(rtcMutex); + return false; + } + + if (rtcWire.requestFrom(DS3231_ADDRESS, 7) != 7) { + xSemaphoreGive(rtcMutex); + return false; + } + + uint8_t buffer[7]; + for (int i = 0; i < 7; i++) { + buffer[i] = rtcWire.read(); + } + + xSemaphoreGive(rtcMutex); + + timeinfo->tm_sec = bcdToDec(buffer[0] & 0x7F); + timeinfo->tm_min = bcdToDec(buffer[1] & 0x7F); + timeinfo->tm_hour = bcdToDec(buffer[2] & 0x3F); + timeinfo->tm_wday = bcdToDec(buffer[3] & 0x07) - 1; + timeinfo->tm_mday = bcdToDec(buffer[4] & 0x3F); + timeinfo->tm_mon = bcdToDec(buffer[5] & 0x1F) - 1; + timeinfo->tm_year = bcdToDec(buffer[6]) + 100; + + return true; +} + +bool writeRTC(const 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(timeinfo->tm_wday + 1)); + rtcWire.write(decToBcd(timeinfo->tm_mday)); + rtcWire.write(decToBcd(timeinfo->tm_mon + 1)); + rtcWire.write(decToBcd(timeinfo->tm_year - 100)); + + bool success = (rtcWire.endTransmission() == 0); + + xSemaphoreGive(rtcMutex); + + return success; +} + +void rtcSyncTask(void *parameter) { + const TickType_t xDelay = pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS); + + while (1) { + if (timeSyncStatus.rtcAvailable) { + struct tm timeinfo; + if (readRTC(&timeinfo)) { + time_t now = mktime(&timeinfo); + struct timeval tv = { .tv_sec = now, .tv_usec = 0 }; + settimeofday(&tv, NULL); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = (uint64_t)now * 1000000ULL; + timeSyncStatus.rtcSyncCount++; + } + } + + vTaskDelay(xDelay); + } +} + +// ======================================== +// MCP2515 모드 설정 +// ======================================== + +bool setMCP2515Mode(MCP2515Mode mode) { + const char* modeName; + MCP2515::ERROR result; + + switch (mode) { + case MCP_MODE_NORMAL: + result = mcp2515.setNormalMode(); + modeName = "Normal"; + break; + case MCP_MODE_LISTEN_ONLY: + result = mcp2515.setListenOnlyMode(); + modeName = "Listen-Only"; + break; + case MCP_MODE_LOOPBACK: + result = mcp2515.setLoopbackMode(); + modeName = "Loopback"; + break; + case MCP_MODE_TRANSMIT: + result = mcp2515.setNormalMode(); // Transmit는 Normal 모드 사용 + modeName = "Transmit"; + break; + default: + return false; + } + + if (result == MCP2515::ERROR_OK) { + currentMcpMode = mode; + Serial.printf("✓ MCP2515 모드 변경: %s\n", modeName); + return true; + } else { + Serial.printf("✗ MCP2515 모드 변경 실패: %s (error=%d)\n", modeName, result); + return false; + } +} + +// ======================================== +// CAN 인터럽트 핸들러 +// ======================================== + +void IRAM_ATTR canISR() { + BaseType_t xHigherPriorityTaskWoken = pdFALSE; + if (canRxTaskHandle != NULL) { + vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); + portYIELD_FROM_ISR(xHigherPriorityTaskWoken); + } +} + +// ======================================== +// Serial 수신 Task (추가) +// ======================================== + +void serialRxTask(void *parameter) { + SerialMessage serialMsg; + uint8_t lineBuffer[MAX_SERIAL_LINE_LEN]; + uint16_t lineIndex = 0; + + Serial.println("✓ Serial RX Task 시작"); + + while (1) { + // Serial 데이터 수신 + while (SerialComm.available()) { + uint8_t c = SerialComm.read(); + + // 바이너리 모드로 처리 (라인 단위) + lineBuffer[lineIndex++] = c; + + // 개행 문자 또는 버퍼 가득 참 + if (c == '\n' || c == '\r' || lineIndex >= MAX_SERIAL_LINE_LEN - 1) { + if (lineIndex > 0) { + // 타임스탬프 생성 + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + + serialMsg.length = lineIndex; + memcpy(serialMsg.data, lineBuffer, lineIndex); + serialMsg.isTx = false; // 수신 데이터 + + // Queue에 전송 + if (xQueueSend(serialQueue, &serialMsg, 0) == pdTRUE) { + totalSerialRxCount++; + } + + lineIndex = 0; + } + } + } + + vTaskDelay(pdMS_TO_TICKS(1)); // 1ms 간격 + } +} + +// ======================================== +// CAN 수신 Task +// ======================================== + +void canRxTask(void *parameter) { + struct can_frame frame; + CANMessage msg; + + Serial.println("✓ CAN RX Task 시작"); + + while (1) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { + struct timeval tv; + gettimeofday(&tv, NULL); + msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + + msg.id = frame.can_id & 0x1FFFFFFF; + msg.dlc = frame.can_dlc; + memcpy(msg.data, frame.data, 8); + + if (xQueueSend(canQueue, &msg, 0) == pdTRUE) { + totalMsgCount++; + } + } + } +} + +// ======================================== +// SD 쓰기 Task (CAN + Serial 동시 지원) +// ======================================== + +void sdWriteTask(void *parameter) { + CANMessage canMsg; + SerialMessage serialMsg; + + Serial.println("✓ SD Write Task 시작"); + + while (1) { + bool hasWork = false; + + // CAN 메시지 처리 + if (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) { + hasWork = true; + + // 실시간 모니터링 업데이트 + bool found = false; + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].msg.id == canMsg.id) { + recentData[i].msg = canMsg; + recentData[i].count++; + found = true; + break; + } + } + + if (!found) { + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].count == 0) { + recentData[i].msg = canMsg; + recentData[i].count = 1; + break; + } + } + } + + // CAN 로깅 + if (loggingEnabled && sdCardReady) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + if (bufferIndex + sizeof(CANMessage) <= FILE_BUFFER_SIZE) { + memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage)); + bufferIndex += sizeof(CANMessage); + currentFileSize += sizeof(CANMessage); + } + + if (bufferIndex >= FILE_BUFFER_SIZE - sizeof(CANMessage)) { + if (logFile) { + logFile.write(fileBuffer, bufferIndex); + logFile.flush(); + bufferIndex = 0; + } + } + + xSemaphoreGive(sdMutex); + } + } + } + + // Serial 메시지 처리 (추가) + if (xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { + hasWork = true; + + // Serial 로깅 + if (serialLoggingEnabled && sdCardReady) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + if (serialBufferIndex + sizeof(SerialMessage) <= SERIAL_BUFFER_SIZE) { + memcpy(&serialFileBuffer[serialBufferIndex], &serialMsg, sizeof(SerialMessage)); + serialBufferIndex += sizeof(SerialMessage); + currentSerialFileSize += sizeof(SerialMessage); + } + + if (serialBufferIndex >= SERIAL_BUFFER_SIZE - sizeof(SerialMessage)) { + if (serialLogFile) { + serialLogFile.write(serialFileBuffer, serialBufferIndex); + serialLogFile.flush(); + serialBufferIndex = 0; + } + } + + xSemaphoreGive(sdMutex); + } + } + } + + if (!hasWork) { + vTaskDelay(pdMS_TO_TICKS(5)); + } + } +} + +// ======================================== +// SD 모니터링 Task +// ======================================== + +void sdMonitorTask(void *parameter) { + const TickType_t xDelay = pdMS_TO_TICKS(1000); + + while (1) { + if (!sdCardReady) { + if (SD.begin(VSPI_CS, vspi)) { + sdCardReady = true; + Serial.println("✓ SD 카드 재연결 감지"); + } + } + + uint32_t currentTime = millis(); + + // 메시지/초 계산 + if (currentTime - lastMsgCountTime >= 1000) { + msgPerSecond = totalMsgCount - lastMsgCount; + lastMsgCount = totalMsgCount; + lastMsgCountTime = currentTime; + } + + // 전압 체크 + if (currentTime - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { + float rawVoltage = analogRead(MONITORING_VOLT) * (3.3 / 4095.0); + powerStatus.voltage = rawVoltage * 1.0; + + // 1초 단위 최소값 업데이트 + if (currentTime - powerStatus.lastMinReset >= 1000) { + powerStatus.minVoltage = powerStatus.voltage; + powerStatus.lastMinReset = currentTime; + } else { + if (powerStatus.voltage < powerStatus.minVoltage) { + powerStatus.minVoltage = powerStatus.voltage; + } + } + + powerStatus.lowVoltage = (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD); + powerStatus.lastCheck = currentTime; + } + + vTaskDelay(xDelay); + } +} + +// ======================================== +// 파일 커멘트 관리 +// ======================================== + +void saveFileComments() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File commentFile = SD.open("/comments.dat", FILE_WRITE); + if (commentFile) { + commentFile.write((uint8_t*)&commentCount, sizeof(commentCount)); + commentFile.write((uint8_t*)fileComments, sizeof(FileComment) * commentCount); + commentFile.close(); + Serial.println("✓ 파일 커멘트 저장 완료"); + } + xSemaphoreGive(sdMutex); + } +} + +void loadFileComments() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (SD.exists("/comments.dat")) { + File commentFile = SD.open("/comments.dat", FILE_READ); + if (commentFile) { + commentFile.read((uint8_t*)&commentCount, sizeof(commentCount)); + if (commentCount > MAX_FILE_COMMENTS) commentCount = MAX_FILE_COMMENTS; + commentFile.read((uint8_t*)fileComments, sizeof(FileComment) * commentCount); + commentFile.close(); + Serial.printf("✓ 파일 커멘트 %d개 로드 완료\n", commentCount); + } + } + xSemaphoreGive(sdMutex); + } +} + +const char* getFileComment(const char* filename) { + for (int i = 0; i < commentCount; i++) { + if (strcmp(fileComments[i].filename, filename) == 0) { + return fileComments[i].comment; + } + } + return ""; +} + +void addFileComment(const char* filename, const char* comment) { + for (int i = 0; i < commentCount; i++) { + if (strcmp(fileComments[i].filename, filename) == 0) { + strncpy(fileComments[i].comment, comment, MAX_COMMENT_LEN - 1); + fileComments[i].comment[MAX_COMMENT_LEN - 1] = '\0'; + saveFileComments(); + return; + } + } + + if (commentCount < MAX_FILE_COMMENTS) { + strncpy(fileComments[commentCount].filename, filename, MAX_FILENAME_LEN - 1); + fileComments[commentCount].filename[MAX_FILENAME_LEN - 1] = '\0'; + strncpy(fileComments[commentCount].comment, comment, MAX_COMMENT_LEN - 1); + fileComments[commentCount].comment[MAX_COMMENT_LEN - 1] = '\0'; + commentCount++; + saveFileComments(); + } +} + +// ======================================== +// CAN 시퀀스 관리 +// ======================================== + +void saveSequences() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File seqFile = SD.open("/sequences.dat", FILE_WRITE); + if (seqFile) { + seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount)); + seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount); + seqFile.close(); + Serial.println("✓ 시퀀스 저장 완료"); + } + xSemaphoreGive(sdMutex); + } +} + +void loadSequences() { + if (!sdCardReady) return; + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (SD.exists("/sequences.dat")) { + File seqFile = SD.open("/sequences.dat", FILE_READ); + if (seqFile) { + seqFile.read((uint8_t*)&sequenceCount, sizeof(sequenceCount)); + if (sequenceCount > MAX_SEQUENCES) sequenceCount = MAX_SEQUENCES; + seqFile.read((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount); + seqFile.close(); + Serial.printf("✓ 시퀀스 %d개 로드 완료\n", sequenceCount); + } + } + xSemaphoreGive(sdMutex); + } +} + +// ======================================== +// CAN TX Task +// ======================================== + +void txTask(void *parameter) { + struct can_frame frame; + + Serial.println("✓ TX Task 시작"); + + while (1) { + uint32_t now = millis(); + bool anyActive = false; + + for (int i = 0; i < MAX_TX_MESSAGES; i++) { + if (txMessages[i].active && txMessages[i].interval > 0) { + anyActive = true; + + if (now - txMessages[i].lastSent >= txMessages[i].interval) { + 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 (anyActive) { + vTaskDelay(pdMS_TO_TICKS(1)); + } else { + vTaskDelay(pdMS_TO_TICKS(10)); + } + } +} + +// ======================================== +// 시퀀스 Task +// ======================================== + +void sequenceTask(void *parameter) { + Serial.println("✓ Sequence Task 시작"); + + while (1) { + if (seqRuntime.running && seqRuntime.activeSequenceIndex >= 0 && + seqRuntime.activeSequenceIndex < sequenceCount) { + + CANSequence* seq = &sequences[seqRuntime.activeSequenceIndex]; + uint32_t now = millis(); + + if (seqRuntime.currentStep < seq->stepCount) { + SequenceStep* step = &seq->steps[seqRuntime.currentStep]; + + if (now - seqRuntime.lastStepTime >= step->delayMs) { + struct can_frame frame; + frame.can_id = step->canId; + if (step->extended) { + frame.can_id |= CAN_EFF_FLAG; + } + frame.can_dlc = step->dlc; + memcpy(frame.data, step->data, 8); + + if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { + totalTxCount++; + } + + seqRuntime.currentStep++; + seqRuntime.lastStepTime = now; + } + } else { + if (seq->repeatMode == 0) { + seqRuntime.running = false; + } else if (seq->repeatMode == 1) { + seqRuntime.currentRepeat++; + if (seqRuntime.currentRepeat >= seq->repeatCount) { + seqRuntime.running = false; + } else { + seqRuntime.currentStep = 0; + seqRuntime.lastStepTime = now; + } + } else if (seq->repeatMode == 2) { + seqRuntime.currentStep = 0; + seqRuntime.lastStepTime = now; + } + } + + vTaskDelay(pdMS_TO_TICKS(1)); + } else { + vTaskDelay(pdMS_TO_TICKS(10)); + } + } +} + +// ======================================== +// WebSocket 이벤트 처리 (Serial 명령 추가) +// ======================================== + +void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { + if (type == WStype_TEXT) { + DynamicJsonDocument doc(2048); + DeserializationError error = deserializeJson(doc, payload); + + if (error) { + Serial.print("✗ JSON 파싱 실패: "); + Serial.println(error.c_str()); + return; + } + + const char* cmd = doc["cmd"]; + + if (strcmp(cmd, "startLogging") == 0) { + if (!loggingEnabled && sdCardReady) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + struct tm timeinfo; + time_t now; + time(&now); + localtime_r(&now, &timeinfo); + + snprintf(currentFilename, sizeof(currentFilename), + "/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); + + logFile = SD.open(currentFilename, FILE_WRITE); + + if (logFile) { + loggingEnabled = true; + bufferIndex = 0; + currentFileSize = 0; + Serial.printf("✓ CAN 로깅 시작: %s\n", currentFilename); + } else { + Serial.println("✗ 파일 생성 실패"); + } + + xSemaphoreGive(sdMutex); + } + } + } + else if (strcmp(cmd, "stopLogging") == 0) { + if (loggingEnabled) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (bufferIndex > 0 && logFile) { + logFile.write(fileBuffer, bufferIndex); + bufferIndex = 0; + } + + if (logFile) { + logFile.close(); + } + + loggingEnabled = false; + Serial.printf("✓ CAN 로깅 종료: %s (%u bytes)\n", currentFilename, currentFileSize); + + xSemaphoreGive(sdMutex); + } + } + } + else if (strcmp(cmd, "startSerialLogging") == 0) { // Serial 로깅 시작 + if (!serialLoggingEnabled && sdCardReady) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + struct tm timeinfo; + time_t now; + time(&now); + localtime_r(&now, &timeinfo); + + snprintf(currentSerialFilename, sizeof(currentSerialFilename), + "/SER_%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); + + serialLogFile = SD.open(currentSerialFilename, FILE_WRITE); + + if (serialLogFile) { + serialLoggingEnabled = true; + serialBufferIndex = 0; + currentSerialFileSize = 0; + Serial.printf("✓ Serial 로깅 시작: %s\n", currentSerialFilename); + } else { + Serial.println("✗ Serial 파일 생성 실패"); + } + + xSemaphoreGive(sdMutex); + } + } + } + else if (strcmp(cmd, "stopSerialLogging") == 0) { // Serial 로깅 종료 + if (serialLoggingEnabled) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (serialBufferIndex > 0 && serialLogFile) { + serialLogFile.write(serialFileBuffer, serialBufferIndex); + serialBufferIndex = 0; + } + + if (serialLogFile) { + serialLogFile.close(); + } + + serialLoggingEnabled = false; + Serial.printf("✓ Serial 로깅 종료: %s (%u bytes)\n", + currentSerialFilename, currentSerialFileSize); + + xSemaphoreGive(sdMutex); + } + } + } + else if (strcmp(cmd, "sendSerial") == 0) { // Serial 데이터 전송 + const char* data = doc["data"]; + if (data && strlen(data) > 0) { + SerialComm.println(data); + + // 송신 데이터를 Queue에 추가 (모니터링용) + SerialMessage serialMsg; + struct timeval tv; + gettimeofday(&tv, NULL); + serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + serialMsg.length = strlen(data); + if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) { + serialMsg.length = MAX_SERIAL_LINE_LEN - 1; + } + memcpy(serialMsg.data, data, serialMsg.length); + serialMsg.isTx = true; // 송신 데이터 + + xQueueSend(serialQueue, &serialMsg, 0); + totalSerialTxCount++; + + Serial.printf("→ Serial TX: %s\n", data); + } + } + else if (strcmp(cmd, "setSerialConfig") == 0) { // Serial 설정 변경 + uint32_t baud = doc["baudRate"] | 115200; + uint8_t data = doc["dataBits"] | 8; + uint8_t parity = doc["parity"] | 0; + uint8_t stop = doc["stopBits"] | 1; + + serialSettings.baudRate = baud; + serialSettings.dataBits = data; + serialSettings.parity = parity; + serialSettings.stopBits = stop; + + saveSerialSettings(); + applySerialSettings(); + + Serial.printf("✓ Serial 설정 변경: %u-%u-%u-%u\n", baud, data, parity, stop); + } + else if (strcmp(cmd, "getSerialConfig") == 0) { // Serial 설정 조회 + DynamicJsonDocument response(512); + response["type"] = "serialConfig"; + response["baudRate"] = serialSettings.baudRate; + response["dataBits"] = serialSettings.dataBits; + response["parity"] = serialSettings.parity; + response["stopBits"] = serialSettings.stopBits; + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + } + else if (strcmp(cmd, "setSpeed") == 0) { + int speedIndex = doc["speed"]; + if (speedIndex >= 0 && speedIndex < 4) { + currentCanSpeed = canSpeedValues[speedIndex]; + mcp2515.reset(); + mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + setMCP2515Mode(currentMcpMode); + + saveSettings(); + + Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedIndex]); + } + } + else if (strcmp(cmd, "setMcpMode") == 0) { + int mode = doc["mode"]; + if (mode >= 0 && mode <= 3) { + setMCP2515Mode((MCP2515Mode)mode); + saveSettings(); + } + } + else if (strcmp(cmd, "syncTime") == 0) { + uint64_t phoneTime = doc["time"]; + if (phoneTime > 0) { + time_t seconds = phoneTime / 1000; + suseconds_t microseconds = (phoneTime % 1000) * 1000; + + struct timeval tv = {seconds, microseconds}; + settimeofday(&tv, NULL); + + timeSyncStatus.synchronized = true; + timeSyncStatus.lastSyncTime = phoneTime * 1000; + timeSyncStatus.syncCount++; + + if (timeSyncStatus.rtcAvailable) { + struct tm timeinfo; + localtime_r(&seconds, &timeinfo); + writeRTC(&timeinfo); + Serial.println("✓ 시간 동기화 완료 (Phone → ESP32 → RTC)"); + } else { + Serial.println("✓ 시간 동기화 완료 (Phone → ESP32)"); + } + } + } + else if (strcmp(cmd, "getFiles") == 0) { + if (sdCardReady) { + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + DynamicJsonDocument response(6144); + response["type"] = "files"; + JsonArray files = response.createNestedArray("list"); + + File root = SD.open("/"); + File file = root.openNextFile(); + + while (file) { + if (!file.isDirectory()) { + const char* filename = file.name(); + + if (filename[0] != '.' && + strcmp(filename, "System Volume Information") != 0) { + + JsonObject fileObj = files.createNestedObject(); + fileObj["name"] = filename; + fileObj["size"] = file.size(); + + const char* comment = getFileComment(filename); + if (strlen(comment) > 0) { + fileObj["comment"] = comment; + } + } + } + file = root.openNextFile(); + } + + xSemaphoreGive(sdMutex); + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + } + } + } + else if (strcmp(cmd, "deleteFile") == 0) { + const char* filename = doc["filename"]; + + if (filename && strlen(filename) > 0) { + if (loggingEnabled && currentFilename[0] != '\0') { + String currentFileStr = String(currentFilename); + if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1); + + if (strcmp(filename, currentFileStr.c_str()) == 0) { + DynamicJsonDocument response(256); + response["type"] = "deleteResult"; + response["success"] = false; + response["message"] = "Cannot delete file currently being logged"; + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + return; + } + } + + String fullPath = "/" + String(filename); + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + bool success = false; + String message = ""; + + if (SD.exists(fullPath)) { + if (SD.remove(fullPath)) { + success = true; + message = "File deleted successfully"; + Serial.printf("✓ 파일 삭제: %s\n", filename); + } else { + message = "Failed to delete file"; + } + } else { + message = "File not found"; + } + + xSemaphoreGive(sdMutex); + + DynamicJsonDocument response(256); + response["type"] = "deleteResult"; + response["success"] = success; + response["message"] = message; + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + + if (success) { + vTaskDelay(pdMS_TO_TICKS(100)); + + DynamicJsonDocument filesDoc(6144); + filesDoc["type"] = "files"; + JsonArray files = filesDoc.createNestedArray("list"); + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + File root = SD.open("/"); + File file = root.openNextFile(); + + while (file) { + if (!file.isDirectory()) { + const char* fname = file.name(); + + if (fname[0] != '.' && + strcmp(fname, "System Volume Information") != 0) { + + JsonObject fileObj = files.createNestedObject(); + fileObj["name"] = fname; + fileObj["size"] = file.size(); + + const char* comment = getFileComment(fname); + if (strlen(comment) > 0) { + fileObj["comment"] = comment; + } + } + } + file = root.openNextFile(); + } + + xSemaphoreGive(sdMutex); + } + + String filesJson; + serializeJson(filesDoc, filesJson); + webSocket.sendTXT(num, filesJson); + } + } + } + } + else if (strcmp(cmd, "addComment") == 0) { + const char* filename = doc["filename"]; + const char* comment = doc["comment"]; + + if (filename && comment) { + addFileComment(filename, comment); + Serial.printf("✓ 커멘트 추가: %s\n", filename); + + vTaskDelay(pdMS_TO_TICKS(100)); + + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + DynamicJsonDocument response(6144); + response["type"] = "files"; + JsonArray files = response.createNestedArray("list"); + + File root = SD.open("/"); + File file = root.openNextFile(); + + while (file) { + if (!file.isDirectory()) { + const char* fname = file.name(); + + if (fname[0] != '.' && + strcmp(fname, "System Volume Information") != 0) { + + JsonObject fileObj = files.createNestedObject(); + fileObj["name"] = fname; + fileObj["size"] = file.size(); + + const char* fcomment = getFileComment(fname); + if (strlen(fcomment) > 0) { + fileObj["comment"] = fcomment; + } + } + } + file = root.openNextFile(); + } + + xSemaphoreGive(sdMutex); + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + } + } + } + else if (strcmp(cmd, "addTx") == 0) { + for (int i = 0; i < MAX_TX_MESSAGES; i++) { + if (!txMessages[i].active) { + txMessages[i].id = strtoul(doc["id"], NULL, 16); + txMessages[i].extended = doc["ext"] | false; + txMessages[i].dlc = doc["dlc"] | 8; + + JsonArray dataArray = doc["data"]; + for (int j = 0; j < 8; j++) { + txMessages[i].data[j] = dataArray[j] | 0; + } + + txMessages[i].interval = doc["interval"] | 1000; + txMessages[i].active = true; + txMessages[i].lastSent = 0; + + Serial.printf("✓ TX 메시지 추가: ID=0x%X\n", txMessages[i].id); + break; + } + } + } + else if (strcmp(cmd, "removeTx") == 0) { + uint32_t id = strtoul(doc["id"], NULL, 16); + + for (int i = 0; i < MAX_TX_MESSAGES; i++) { + if (txMessages[i].active && txMessages[i].id == id) { + txMessages[i].active = false; + Serial.printf("✓ TX 메시지 제거: ID=0x%X\n", id); + break; + } + } + } + else if (strcmp(cmd, "updateTx") == 0) { + uint32_t id = strtoul(doc["id"], NULL, 16); + + for (int i = 0; i < MAX_TX_MESSAGES; i++) { + if (txMessages[i].active && txMessages[i].id == id) { + txMessages[i].interval = doc["interval"]; + Serial.printf("✓ TX 주기 변경: ID=0x%X, Interval=%u\n", + id, txMessages[i].interval); + break; + } + } + } + else if (strcmp(cmd, "sendOnce") == 0) { + struct can_frame frame; + frame.can_id = strtoul(doc["id"], NULL, 16); + + if (doc["ext"] | false) { + frame.can_id |= CAN_EFF_FLAG; + } + + frame.can_dlc = doc["dlc"] | 8; + + JsonArray dataArray = doc["data"]; + for (int i = 0; i < 8; i++) { + frame.data[i] = dataArray[i] | 0; + } + + if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) { + totalTxCount++; + Serial.printf("✓ CAN 메시지 전송: ID=0x%X\n", frame.can_id & 0x1FFFFFFF); + } + } + else if (strcmp(cmd, "addSequence") == 0) { + if (sequenceCount < MAX_SEQUENCES) { + CANSequence* seq = &sequences[sequenceCount]; + + strncpy(seq->name, doc["name"] | "Unnamed", sizeof(seq->name) - 1); + seq->name[sizeof(seq->name) - 1] = '\0'; + + JsonArray stepsArray = doc["steps"]; + seq->stepCount = min((int)stepsArray.size(), 20); + + for (int i = 0; i < seq->stepCount; i++) { + JsonObject stepObj = stepsArray[i]; + seq->steps[i].canId = strtoul(stepObj["id"], NULL, 16); + seq->steps[i].extended = stepObj["ext"] | false; + seq->steps[i].dlc = stepObj["dlc"] | 8; + + JsonArray dataArray = stepObj["data"]; + for (int j = 0; j < 8; j++) { + seq->steps[i].data[j] = dataArray[j] | 0; + } + + seq->steps[i].delayMs = stepObj["delay"] | 0; + } + + seq->repeatMode = doc["repeatMode"] | 0; + seq->repeatCount = doc["repeatCount"] | 1; + + sequenceCount++; + saveSequences(); + + Serial.printf("✓ 시퀀스 추가: %s (%d steps)\n", seq->name, seq->stepCount); + } + } + else if (strcmp(cmd, "removeSequence") == 0) { + int index = doc["index"]; + + if (index >= 0 && index < sequenceCount) { + for (int i = index; i < sequenceCount - 1; i++) { + sequences[i] = sequences[i + 1]; + } + sequenceCount--; + saveSequences(); + + Serial.printf("✓ 시퀀스 삭제: index=%d\n", index); + } + } + else if (strcmp(cmd, "startSequence") == 0) { + int index = doc["index"]; + + if (index >= 0 && index < sequenceCount && !seqRuntime.running) { + seqRuntime.running = true; + seqRuntime.currentStep = 0; + seqRuntime.currentRepeat = 0; + seqRuntime.lastStepTime = millis(); + seqRuntime.activeSequenceIndex = index; + + Serial.printf("✓ 시퀀스 시작: %s\n", sequences[index].name); + } + } + else if (strcmp(cmd, "stopSequence") == 0) { + if (seqRuntime.running) { + seqRuntime.running = false; + Serial.println("✓ 시퀀스 중지"); + } + } + else if (strcmp(cmd, "getSequences") == 0) { + DynamicJsonDocument response(3072); + response["type"] = "sequences"; + JsonArray seqArray = response.createNestedArray("list"); + + for (int i = 0; i < sequenceCount; i++) { + JsonObject seqObj = seqArray.createNestedObject(); + seqObj["name"] = sequences[i].name; + seqObj["steps"] = sequences[i].stepCount; + seqObj["repeatMode"] = sequences[i].repeatMode; + seqObj["repeatCount"] = sequences[i].repeatCount; + } + + String json; + serializeJson(response, json); + webSocket.sendTXT(num, json); + } + } +} + +// ======================================== +// 웹 업데이트 Task (Serial 데이터 전송 추가) +// ======================================== + +void webUpdateTask(void *parameter) { + const TickType_t xDelay = pdMS_TO_TICKS(100); + + Serial.println("✓ Web Update Task 시작"); + + while (1) { + webSocket.loop(); + + // CAN 데이터 전송 + if (webSocket.connectedClients() > 0) { + DynamicJsonDocument doc(3072); // 4096 → 3072로 축소 + doc["type"] = "update"; + doc["logging"] = loggingEnabled; + doc["serialLogging"] = serialLoggingEnabled; // Serial 로깅 상태 추가 + doc["sdReady"] = sdCardReady; + doc["totalMsg"] = totalMsgCount; + doc["msgPerSec"] = msgPerSecond; + doc["totalTx"] = totalTxCount; + doc["totalSerialRx"] = totalSerialRxCount; // Serial RX 카운터 추가 + doc["totalSerialTx"] = totalSerialTxCount; // Serial TX 카운터 추가 + doc["fileSize"] = currentFileSize; + doc["serialFileSize"] = currentSerialFileSize; // Serial 파일 크기 추가 + doc["queueUsed"] = uxQueueMessagesWaiting(canQueue); + doc["queueSize"] = CAN_QUEUE_SIZE; + doc["serialQueueUsed"] = uxQueueMessagesWaiting(serialQueue); // Serial Queue 사용량 추가 + doc["serialQueueSize"] = SERIAL_QUEUE_SIZE; + doc["timeSync"] = timeSyncStatus.synchronized; + doc["rtcAvail"] = timeSyncStatus.rtcAvailable; + doc["rtcSyncCnt"] = timeSyncStatus.rtcSyncCount; + doc["voltage"] = powerStatus.voltage; + doc["minVoltage"] = powerStatus.minVoltage; + doc["lowVoltage"] = powerStatus.lowVoltage; + doc["mcpMode"] = (int)currentMcpMode; + + time_t now; + time(&now); + doc["timestamp"] = (uint64_t)now; + + JsonArray messages = doc.createNestedArray("messages"); + for (int i = 0; i < RECENT_MSG_COUNT; i++) { + if (recentData[i].count > 0) { + JsonObject msgObj = messages.createNestedObject(); + msgObj["id"] = recentData[i].msg.id; + msgObj["dlc"] = recentData[i].msg.dlc; + msgObj["count"] = recentData[i].count; + + JsonArray dataArray = msgObj.createNestedArray("data"); + for (int j = 0; j < recentData[i].msg.dlc; j++) { + dataArray.add(recentData[i].msg.data[j]); + } + } + } + + // Serial 메시지 전송 (추가) + SerialMessage serialMsg; + JsonArray serialMessages = doc.createNestedArray("serialMessages"); + int serialCount = 0; + + while (serialCount < 5 && xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) { // 10 → 5개로 축소 + JsonObject serMsgObj = serialMessages.createNestedObject(); + serMsgObj["timestamp"] = serialMsg.timestamp_us; + serMsgObj["isTx"] = serialMsg.isTx; + + // 데이터를 문자열로 변환 (printable characters) + char dataStr[MAX_SERIAL_LINE_LEN + 1]; + memcpy(dataStr, serialMsg.data, serialMsg.length); + dataStr[serialMsg.length] = '\0'; + serMsgObj["data"] = dataStr; + + serialCount++; + } + + String json; + serializeJson(doc, json); + webSocket.broadcastTXT(json); + } + + vTaskDelay(xDelay); + } +} + +// ======================================== +// Setup +// ======================================== + +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println("\n========================================"); + Serial.println(" Byun CAN Logger + Serial Terminal"); + Serial.println(" Version 2.1 - ESP32-S3 Edition"); + Serial.println("========================================\n"); + + loadSettings(); + analogSetPinAttenuation(MONITORING_VOLT, ADC_11db); + Serial.println("💡 설정 변경: http://[IP]/settings\n"); + + memset(recentData, 0, sizeof(recentData)); + memset(txMessages, 0, sizeof(txMessages)); + memset(fileComments, 0, sizeof(fileComments)); + + pinMode(CAN_INT_PIN, INPUT_PULLUP); + + // ADC 설정 + analogSetAttenuation(ADC_11db); + + // SPI 초기화 (먼저 완료) + Serial.println("SPI 초기화 중..."); + hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); + hspi.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0)); + hspi.endTransaction(); + pinMode(VSPI_CS, OUTPUT); + digitalWrite(VSPI_CS, HIGH); + delay(100); + + vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS); + vspi.setFrequency(40000000); + Serial.println("✓ SPI 초기화 완료"); + + // Watchdog 완전 비활성화 (SPI 초기화 후) + Serial.println("Watchdog 비활성화..."); + esp_task_wdt_deinit(); + Serial.println("✓ Watchdog 비활성화 완료"); + + // MCP2515 초기화 (간소화) + Serial.println("MCP2515 초기화 중..."); + mcp2515.reset(); + delay(50); + + // Bitrate만 설정 (모드는 나중에) + MCP2515::ERROR result = mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ); + if (result != MCP2515::ERROR_OK) { + Serial.printf("! MCP2515 Bitrate 설정 실패: %d (계속 진행)\n", result); + } + + // Normal 모드로 직접 설정 (함수 호출 안함) + mcp2515.setNormalMode(); + Serial.println("✓ MCP2515 초기화 완료"); + + // Serial 통신 초기화 (추가) + applySerialSettings(); + Serial.println("✓ Serial 통신 초기화 완료 (UART1)"); + + // Mutex 생성 + sdMutex = xSemaphoreCreateMutex(); + rtcMutex = xSemaphoreCreateMutex(); + serialMutex = xSemaphoreCreateMutex(); + + if (sdMutex == NULL || rtcMutex == NULL || serialMutex == NULL) { + Serial.println("✗ Mutex 생성 실패!"); + while (1) delay(1000); + } + + // RTC 초기화 + initRTC(); + + // SD 카드 초기화 + if (SD.begin(VSPI_CS, vspi)) { + sdCardReady = true; + Serial.println("✓ SD 카드 초기화 완료"); + loadFileComments(); + } else { + Serial.println("✗ SD 카드 초기화 실패"); + } + + // WiFi 설정 + if (enableSTAMode && strlen(staSSID) > 0) { + Serial.println("\n📶 WiFi APSTA 모드 시작..."); + WiFi.mode(WIFI_AP_STA); + WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); // 채널1, 최대4개연결 + Serial.print("✓ AP SSID: "); + Serial.println(wifiSSID); + Serial.print("✓ AP IP: "); + Serial.println(WiFi.softAPIP()); + + Serial.printf("📡 WiFi 연결 시도: %s\n", staSSID); + WiFi.begin(staSSID, staPassword); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 20) { + delay(500); + Serial.print("."); + attempts++; + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) { + Serial.println("✓ WiFi 연결 성공!"); + Serial.print("✓ STA IP: "); + Serial.println(WiFi.localIP()); + } else { + Serial.println("✗ WiFi 연결 실패 (AP 모드는 정상 동작)"); + } + } else { + Serial.println("\n📶 WiFi AP 모드 시작..."); + WiFi.mode(WIFI_AP); + WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4); // 채널1, 최대4개연결 + Serial.print("✓ AP SSID: "); + Serial.println(wifiSSID); + Serial.print("✓ AP IP: "); + Serial.println(WiFi.softAPIP()); + } + + WiFi.setSleep(false); + esp_wifi_set_max_tx_power(84); + + // WebSocket 시작 + webSocket.begin(); + webSocket.onEvent(webSocketEvent); + + // 웹 서버 라우팅 + server.on("/", HTTP_GET, []() { + server.send_P(200, "text/html", index_html); + }); + + server.on("/transmit", HTTP_GET, []() { + server.send_P(200, "text/html", transmit_html); + }); + + server.on("/graph", HTTP_GET, []() { + server.send_P(200, "text/html", graph_html); + }); + + server.on("/graph-view", HTTP_GET, []() { + server.send_P(200, "text/html", graph_viewer_html); + }); + + server.on("/settings", HTTP_GET, []() { + server.send_P(200, "text/html", settings_html); + }); + + server.on("/serial", HTTP_GET, []() { // Serial 페이지 추가 + server.send_P(200, "text/html", serial_terminal_html); + }); + + server.on("/download", HTTP_GET, []() { + if (server.hasArg("file")) { + String filename = "/" + server.arg("file"); + + if (SD.exists(filename)) { + File file = SD.open(filename, FILE_READ); + if (file) { + String displayName = server.arg("file"); + server.sendHeader("Content-Disposition", + "attachment; filename=\"" + displayName + "\""); + server.sendHeader("Content-Type", "application/octet-stream"); + server.streamFile(file, "application/octet-stream"); + file.close(); + } else { + server.send(500, "text/plain", "Failed to open file"); + } + } else { + server.send(404, "text/plain", "File not found"); + } + } else { + server.send(400, "text/plain", "Bad request"); + } + }); + + 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 생성 + canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage)); + serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage)); // Serial Queue 생성 + + if (canQueue == NULL || serialQueue == NULL) { + Serial.println("✗ Queue 생성 실패!"); + while (1) delay(1000); + } + + // CAN 인터럽트 활성화 + attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); + + // Task 생성 (메모리 최적화) + xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8096, NULL, 5, &canRxTaskHandle, 1); // 4096 → 3072 + xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 4072, NULL, 4, &serialRxTaskHandle, 1); // 3072 → 2560 + xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 15240, NULL, 3, &sdWriteTaskHandle, 1); // 10240 → 8192 + xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 5072, NULL, 1, NULL, 1); // 3072 → 2560 + xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0); // 8192 → 6144 + xTaskCreatePinnedToCore(txTask, "TX_TASK", 5072, NULL, 2, NULL, 1); // 3072 → 2560 + xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4072, NULL, 2, NULL, 1); // 3072 → 2560 + + // RTC 동기화 Task + if (timeSyncStatus.rtcAvailable) { + xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 4096, NULL, 0, &rtcTaskHandle, 0); + Serial.println("✓ RTC 자동 동기화 Task 시작"); + } + + // 시퀀스 로드 + loadSequences(); + + Serial.println("✓ 모든 태스크 시작 완료"); + Serial.println("\n========================================"); + Serial.println(" 웹 인터페이스 접속 방법"); + Serial.println("========================================"); + 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. 페이지 메뉴:"); + Serial.println(" - Monitor : /"); + Serial.println(" - Transmit : /transmit"); + Serial.println(" - Graph : /graph"); + Serial.println(" - Settings : /settings"); + Serial.println(" - Serial : /serial ← NEW!"); + Serial.println("========================================\n"); +} + +void loop() { + server.handleClient(); + vTaskDelay(pdMS_TO_TICKS(10)); + + static uint32_t lastPrint = 0; + if (millis() - lastPrint > 30000) { + Serial.printf("[상태] CAN큐: %d/%d | Serial큐: %d/%d | CAN로깅: %s | Serial로깅: %s | SD: %s | CAN RX: %lu | CAN TX: %lu | Serial RX: %lu | Serial TX: %lu | 모드: %d\n", + uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, + uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE, + loggingEnabled ? "ON " : "OFF", + serialLoggingEnabled ? "ON " : "OFF", + sdCardReady ? "OK" : "NO", + totalMsgCount, totalTxCount, + totalSerialRxCount, totalSerialTxCount, + currentMcpMode); + lastPrint = millis(); + } +} diff --git a/graph.h b/graph.h new file mode 100644 index 0000000..6cfb38b --- /dev/null +++ b/graph.h @@ -0,0 +1,793 @@ +#ifndef GRAPH_H +#define GRAPH_H + +const char graph_html[] PROGMEM = R"rawliteral( + + + + + + CAN Signal Graph + + + +
+
+

CAN Signal Graph

+

Real-time Signal Visualization (Offline Mode)

+
+ + + +
+ + +

Upload DBC File

+
+
+ +

Click to upload DBC

+

No file loaded

+
+
+ + +
+
+ + + + +)rawliteral"; + +#endif \ No newline at end of file diff --git a/graph_viewer.h b/graph_viewer.h new file mode 100644 index 0000000..a8411e4 --- /dev/null +++ b/graph_viewer.h @@ -0,0 +1,877 @@ +#ifndef GRAPH_VIEWER_H +#define GRAPH_VIEWER_H + +const char graph_viewer_html[] PROGMEM = R"rawliteral( + + + + + + CAN Signal Graph Viewer + + + +
+

Real-time CAN Signal Graphs (Scatter Mode)

+

Viewing 0 signals

+
+ +
+
+ + + +
+
+ X-Axis Scale: + + +
+
+ X-Axis Range: + + +
+
+ Sort by: + + + +
+
+ + +
+
+ +
Connecting...
+ +
+ Data Points: 0 + Recording Time: 0s + Messages Received: 0 +
+ +
+ + + + +)rawliteral"; + +#endif \ No newline at end of file diff --git a/index.h b/index.h new file mode 100644 index 0000000..53f09e5 --- /dev/null +++ b/index.h @@ -0,0 +1,1395 @@ +#ifndef INDEX_H +#define INDEX_H + +const char index_html[] PROGMEM = R"rawliteral( + + + + + + Byun CAN Logger + + + +
+
+

🚗 Byun CAN Logger v2.0

+

Real-time CAN Bus Monitor & Logger + Phone Time Sync + MCP2515 Mode Control

+
+ + + +
+
+
+
+
CURRENT TIME
+
--:--:--
+
+
+
CONNECTION
+
연결 중...
+
+
+ +
+ +
+
+ + POWER STATUS +
+
+
+
CURRENT
+
-.--V
+
+
+
MIN (1s)
+
-.--V
+
+
+
+ +
+
+ 📦 + QUEUE STATUS +
+
+
+
0 / 1000
+
+
+ +
+
+

LOGGING

+
OFF
+
+
+

SD CARD

+
NOT READY
+
+
+

MESSAGES

+
0
+
+
+

SPEED

+
0/s
+
+
+

TIME SYNC

+
0
+
+
+

MCP MODE

+
NORMAL
+
+
+

CURRENT FILE

+
-
+
+
+

FILE SIZE

+
0 B
+
+
+ +
+

Control Panel

+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +

CAN Messages (by ID)

+
+ + + + + + + + + + + +
IDDLCDataCountTime(ms)
+
+ +

Log Files

+
+ + + + +
+
+

Loading...

+
+
+
+ + + + + + + +)rawliteral"; + +#endif \ No newline at end of file diff --git a/serial.h b/serial.h new file mode 100644 index 0000000..e9f0a4e --- /dev/null +++ b/serial.h @@ -0,0 +1,691 @@ +#ifndef SERIAL_H +#define SERIAL_H + +const char serial_html[] PROGMEM = R"rawliteral( + + + + + + Serial Monitor - Byun CAN Logger + + + +
+
+

📡 Serial Monitor

+

Real-time Serial Communication Interface

+
+ + + +
+
+
+ + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
+ +
+
+ Status: + Disconnected +
+
+ TX: + 0 +
+
+ RX: + 0 +
+
+ Errors: + 0 +
+
+ Logging: + OFF +
+
+ +
+
+
Serial Terminal
+
+ + +
+
+
+
+ + +
+
+
+
+ + + + +)rawliteral"; + +#endif \ No newline at end of file