/* * ESP32-S3 CAN FD Logger with Web Interface * Version: 4.0 - Seeed_Arduino_CAN (mcp2518fd) Library * * ★ 라이브러리 변경: ACAN2517FD → Seeed_Arduino_CAN * - SPI 속도 4MHz 고정 (브레드보드 안정적) * - 0x1000 OSC readback 에러 해결 * * 필요 라이브러리 (Arduino IDE 라이브러리 매니저): * - Seeed_Arduino_CAN (검색: "Seeed CAN") * - WebSockets (by Markus Sattler) * - ArduinoJson * - SoftWire (I2C for RTC) * * 하드웨어: * - ESP32-S3 N16R8 (OPI PSRAM) * - MCP2518FD (ATA6560 트랜시버 내장 모듈) * - DS3231 RTC * - MicroSD (SDIO 4-bit, Adafruit 4682) * * Arduino IDE 설정: * - Board: ESP32S3 Dev Module * - PSRAM: OPI PSRAM ★ 필수! * - Flash Size: 16MB (128Mb) * - Partition Scheme: 16MB Flash (3MB APP/9.9MB FATFS) * - USB Mode: Hardware CDC and JTAG */ #include #include #include // ★ Seeed_Arduino_CAN 라이브러리 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "canfd_index.h" #include "canfd_settings.h" #include "canfd_graph.h" #include "canfd_graph_viewer.h" // ============================================================ // 핀 정의 // ============================================================ #define HSPI_MISO 13 #define HSPI_MOSI 11 #define HSPI_SCLK 12 #define HSPI_CS 10 #define CAN_INT_PIN 4 // ★ GPIO3 스트래핑 핀 충돌 방지 → GPIO4 #define SDIO_CLK 39 #define SDIO_CMD 38 #define SDIO_D0 40 #define SDIO_D1 41 #define SDIO_D2 42 #define SDIO_D3 21 // OPI PSRAM 호환 #define RTC_SDA 8 #define RTC_SCL 9 #define DS3231_ADDRESS 0x68 // ============================================================ // 버퍼 / 큐 크기 // ============================================================ #define CAN_QUEUE_SIZE 10000 #define FILE_BUFFER_SIZE 131072 // 128 KB #define RECENT_MSG_COUNT 100 #define MAX_TX_MESSAGES 20 #define MAX_SEQUENCES 10 #define MAX_FILE_COMMENTS 50 #define MAX_FILENAME_LEN 64 #define MAX_COMMENT_LEN 128 // ============================================================ // 데이터 구조 // ============================================================ struct LoggedCANFrame { uint64_t timestamp_us; uint32_t id; uint8_t len; // 실제 데이터 길이 (CAN FD: 최대 64) uint8_t data[64]; bool fd; // FD 프레임 여부 bool brs; // Bit Rate Switch bool esi; // Error State Indicator bool ext; // Extended ID (29-bit) } __attribute__((packed)); struct RecentCANData { LoggedCANFrame msg; uint32_t count; }; struct TxMessage { uint32_t id; bool extended; uint8_t dlc; uint8_t data[64]; uint32_t interval; uint32_t lastSent; bool active; bool fd; bool brs; }; struct SequenceStep { uint32_t canId; bool extended; uint8_t dlc; uint8_t data[64]; uint32_t delayMs; bool fd; bool brs; }; struct CANSequence { char name[32]; SequenceStep steps[20]; uint8_t stepCount; uint8_t repeatMode; // 0=1회, 1=N회, 2=무한 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 = {}; // ★ CAN 설정 (Seeed 라이브러리 기반) struct CANFDSettings { bool fdMode; // true=CAN FD, false=Classic CAN uint8_t controllerMode; // 0=Normal, 1=ListenOnly, 2=Loopback uint8_t logFormat; // 0=PCAP, 1=CSV uint32_t arbRateKbps; // 중재 속도 (kbps) uint32_t dataRateKbps; // 데이터 속도 (kbps, FD 전용) } canSettings = { .fdMode = true, .controllerMode = 0, .logFormat = 0, .arbRateKbps = 500, .dataRateKbps = 2000 }; // PCAP 구조체 struct __attribute__((packed)) PCAPGlobalHeader { uint32_t magic_number; uint16_t version_major; uint16_t version_minor; int32_t thiszone; uint32_t sigfigs; uint32_t snaplen; uint32_t network; }; struct __attribute__((packed)) PCAPPacketHeader { uint32_t ts_sec; uint32_t ts_usec; uint32_t incl_len; uint32_t orig_len; }; struct __attribute__((packed)) SocketCANFDFrame { uint32_t can_id; uint8_t len; uint8_t flags; uint8_t res0; uint8_t res1; uint8_t data[64]; }; struct __attribute__((packed)) SocketCANFrame { uint32_t can_id; uint8_t can_dlc; uint8_t pad; uint8_t res0; uint8_t res1; uint8_t data[8]; }; #define CAN_EFF_FLAG 0x80000000U #define CANFD_BRS 0x01 #define CANFD_ESI 0x02 // ============================================================ // PSRAM 동적 할당 변수 // ============================================================ uint8_t *fileBuffer = nullptr; RecentCANData *recentData = nullptr; TxMessage *txMessages = nullptr; CANSequence *sequences = nullptr; FileComment *fileComments = nullptr; StaticQueue_t *canQueueBuffer = nullptr; uint8_t *canQueueStorage = nullptr; // ============================================================ // WiFi 설정 // ============================================================ char wifiSSID[32] = "Byun_CANFD_Logger"; char wifiPassword[64] = "12345678"; bool staEnable = false; char staSSID[32] = ""; char staPassword[64] = ""; IPAddress staIP; // ============================================================ // 전역 객체 // ============================================================ SPIClass hspi(HSPI); mcp2518fd CAN(HSPI_CS); // ★ Seeed 라이브러리 객체 SoftWire rtcWire(RTC_SDA, RTC_SCL); Preferences preferences; WebServer server(80); WebSocketsServer ws(81); // ============================================================ // 전역 변수 // ============================================================ QueueHandle_t canQueue = nullptr; SemaphoreHandle_t sdMutex = nullptr; SemaphoreHandle_t i2cMutex = nullptr; TaskHandle_t canRxTaskHandle = nullptr; TaskHandle_t sdWriteTaskHandle= nullptr; volatile bool canInterruptFlag = false; bool loggingEnabled = false; bool canInitialized = false; uint32_t canFrameCount = 0; uint32_t canErrorCount = 0; uint32_t sdWriteErrorCount= 0; File canLogFile; char currentCanFilename[MAX_FILENAME_LEN] = ""; uint32_t fileBufferPos = 0; uint8_t recentDataCount = 0; SequenceRuntime seqRuntime= {false, 0, 0, 0, -1}; // ============================================================ // 함수 선언 // ============================================================ void IRAM_ATTR canISR(); bool initCANFD(); bool readRTC(struct tm *t); void updateSystemTime(const struct tm *t); bool allocatePSRAM(); void initSDCard(); void setupWebRoutes(); uint64_t getMicros64(); void writeCANToBuffer(const LoggedCANFrame *msg); void flushBufferToSD(); void updateRecentData(const LoggedCANFrame *msg); uint8_t calcSeeedBaudrate(uint32_t arbKbps, uint32_t dataKbps, bool fd); void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length); void canRxTask (void *p); void sdWriteTask (void *p); void sdFlushTask (void *p); void webUpdateTask(void *p); void rtcSyncTask (void *p); void txTask (void *p); void sequenceTask (void *p); // ============================================================ // 인터럽트 // ============================================================ void IRAM_ATTR canISR() { canInterruptFlag = true; } // ============================================================ // ★ Seeed 라이브러리 속도 상수 매핑 // ============================================================ uint8_t calcSeeedBaudrate(uint32_t arbKbps, uint32_t dataKbps, bool fd) { if (!fd) { if (arbKbps >= 1000) return CAN_1000KBPS; if (arbKbps >= 500) return CAN_500KBPS; if (arbKbps >= 250) return CAN_250KBPS; return CAN_125KBPS; } // CAN FD 속도 상수 (mcp2518fd_can_dfs.h에 정의됨) if (arbKbps >= 1000) { return CAN_1000K_4M; } else if (arbKbps >= 500) { if (dataKbps >= 4000) return CAN_500K_4M; if (dataKbps >= 3000) return CAN_500K_3M; if (dataKbps >= 2000) return CAN_500K_2M; return CAN_500K_1M; } else if (arbKbps >= 250) { if (dataKbps >= 4000) return CAN_250K_4M; if (dataKbps >= 2000) return CAN_250K_2M; if (dataKbps >= 1000) return CAN_250K_1M; return CAN_250K_500K; } return CAN_125K_500K; } // ============================================================ // PSRAM 할당 // ============================================================ bool allocatePSRAM() { Serial.println("\n========================================"); Serial.println(" PSRAM 메모리 할당"); Serial.println("========================================"); size_t before = ESP.getFreePsram(); Serial.printf("가용 PSRAM: %u KB\n", before / 1024); fileBuffer = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE); if (!fileBuffer) { Serial.println("✗ fileBuffer 실패"); return false; } recentData = (RecentCANData*)ps_malloc(sizeof(RecentCANData) * RECENT_MSG_COUNT); if (!recentData) { Serial.println("✗ recentData 실패"); return false; } memset(recentData, 0, sizeof(RecentCANData) * RECENT_MSG_COUNT); txMessages = (TxMessage*)ps_malloc(sizeof(TxMessage) * MAX_TX_MESSAGES); if (!txMessages) { Serial.println("✗ txMessages 실패"); return false; } memset(txMessages, 0, sizeof(TxMessage) * MAX_TX_MESSAGES); sequences = (CANSequence*)ps_malloc(sizeof(CANSequence) * MAX_SEQUENCES); if (!sequences) { Serial.println("✗ sequences 실패"); return false; } memset(sequences, 0, sizeof(CANSequence) * MAX_SEQUENCES); fileComments = (FileComment*)ps_malloc(sizeof(FileComment) * MAX_FILE_COMMENTS); if (!fileComments) { Serial.println("✗ fileComments 실패"); return false; } memset(fileComments, 0, sizeof(FileComment) * MAX_FILE_COMMENTS); canQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t)); canQueueStorage = (uint8_t*)ps_malloc(CAN_QUEUE_SIZE * sizeof(LoggedCANFrame)); if (!canQueueBuffer || !canQueueStorage) { Serial.println("✗ Queue storage 실패"); return false; } Serial.printf("✓ 파일 버퍼: %d KB\n", FILE_BUFFER_SIZE / 1024); Serial.printf("✓ CAN 큐: %d 프레임 예약\n", CAN_QUEUE_SIZE); Serial.printf("✓ PSRAM 사용: %u KB / 잔여: %u KB\n", (before - ESP.getFreePsram()) / 1024, ESP.getFreePsram() / 1024); Serial.println("========================================\n"); return true; } // ============================================================ // ★ CAN FD 초기화 (Seeed_Arduino_CAN 라이브러리) // ============================================================ bool initCANFD() { Serial.println("\n========================================"); Serial.printf(" CAN 초기화 (%s)\n", canSettings.fdMode ? "CAN FD" : "Classic CAN"); Serial.println("========================================"); Serial.printf(" SCLK:GPIO%d MISO:GPIO%d MOSI:GPIO%d CS:GPIO%d INT:GPIO%d\n", HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS, CAN_INT_PIN); // CS 핀 초기 설정 pinMode(HSPI_CS, OUTPUT); digitalWrite(HSPI_CS, HIGH); delay(10); // HSPI 초기화 hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS); Serial.println("✓ HSPI 초기화 완료"); // ★ Seeed 라이브러리: SPI 객체 설정 (내부 4MHz 고정 사용) CAN.setSPI(&hspi); // 동작 모드 설정 // ★ CAN_BUS_Shield 라이브러리 실제 상수명: // CAN_NORMAL_MODE / CAN_LISTEN_ONLY_MODE (Loopback은 미지원) switch (canSettings.controllerMode) { case 1: CAN.setMode(CAN_LISTEN_ONLY_MODE); Serial.println(" 모드: Listen-Only (수신 전용)"); break; case 2: // ★ CAN_BUS_Shield 라이브러리는 Loopback 미지원 → Normal로 대체 CAN.setMode(CAN_NORMAL_MODE); Serial.println(" 모드: Normal (Loopback 미지원, Normal로 동작)"); break; default: CAN.setMode(CAN_NORMAL_MODE); Serial.println(" 모드: Normal (정상)"); break; } // 속도 상수 계산 uint8_t baudConst = calcSeeedBaudrate( canSettings.arbRateKbps, canSettings.dataRateKbps, canSettings.fdMode ); Serial.printf(" 속도: Arb %u Kbps / Data %u Kbps\n", canSettings.arbRateKbps, canSettings.dataRateKbps); Serial.printf(" Seeed 상수: 0x%02X\n", baudConst); // ★ begin() - SPI 4MHz로 MCP2518FD 초기화 (라이브러리 내부 고정) int ret = CAN.begin(baudConst); if (ret != CAN_OK) { Serial.printf("\n✗ CAN 초기화 실패 (오류코드: %d)\n", ret); Serial.println("─── 확인 사항 ───────────────────────"); Serial.println(" 1. MCP2518FD VDD = 3.3V 확인"); Serial.println(" 2. SDO(MISO)→GPIO13, SDI(MOSI)→GPIO11"); Serial.println(" 3. SCK→GPIO12, CS→GPIO10, INT→GPIO4"); Serial.println(" 4. STBY = GND 연결 확인"); Serial.println(" 5. 크리스탈 40MHz 탑재 확인"); Serial.println(" 6. 브레드보드 접촉 불량 확인"); Serial.println("─────────────────────────────────────"); return false; } canInitialized = true; Serial.println("✓ MCP2518FD 초기화 성공!"); Serial.println(" SPI: 4MHz (Seeed 라이브러리 내부 고정값)"); Serial.println("========================================\n"); return true; } // ============================================================ // RTC DS3231 (SoftWire) // ============================================================ bool readRTC(struct tm *t) { if (!timeSyncStatus.rtcAvailable) return false; if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false; rtcWire.beginTransmission(DS3231_ADDRESS); rtcWire.write(0x00); bool ok = (rtcWire.endTransmission() == 0); if (ok && rtcWire.requestFrom(DS3231_ADDRESS, 7) == 7) { uint8_t s = rtcWire.read(); uint8_t mn = rtcWire.read(); uint8_t h = rtcWire.read(); rtcWire.read(); // DOW uint8_t d = rtcWire.read(); uint8_t mo = rtcWire.read(); uint8_t y = rtcWire.read(); xSemaphoreGive(i2cMutex); t->tm_sec = ((s >> 4) * 10) + (s & 0x0F); t->tm_min = ((mn >> 4) * 10) + (mn & 0x0F); t->tm_hour = (((h & 0x30) >> 4) * 10) + (h & 0x0F); t->tm_mday = ((d >> 4) * 10) + (d & 0x0F); t->tm_mon = (((mo & 0x1F) >> 4) * 10) + (mo & 0x0F) - 1; t->tm_year = ((y >> 4) * 10) + (y & 0x0F) + 100; return true; } xSemaphoreGive(i2cMutex); return false; } void updateSystemTime(const struct tm *t) { struct timeval tv; tv.tv_sec = mktime((struct tm*)t); tv.tv_usec = 0; settimeofday(&tv, NULL); } // ============================================================ // 64비트 마이크로초 타이머 // ============================================================ uint64_t getMicros64() { static uint32_t last = 0, ovf = 0; uint32_t now = micros(); if (now < last) ovf++; last = now; return ((uint64_t)ovf << 32) | now; } // ============================================================ // 버퍼 관리 (PCAP / CSV) // ============================================================ void writeCANToPCAPBuffer(const LoggedCANFrame *msg) { uint32_t pktSz = msg->fd ? sizeof(SocketCANFDFrame) : sizeof(SocketCANFrame); if (fileBufferPos + sizeof(PCAPPacketHeader) + pktSz > FILE_BUFFER_SIZE) return; PCAPPacketHeader ph; ph.ts_sec = msg->timestamp_us / 1000000ULL; ph.ts_usec = msg->timestamp_us % 1000000ULL; ph.incl_len = pktSz; ph.orig_len = pktSz; memcpy(fileBuffer + fileBufferPos, &ph, sizeof(ph)); fileBufferPos += sizeof(ph); if (msg->fd) { SocketCANFDFrame f = {}; f.can_id = msg->id | (msg->ext ? CAN_EFF_FLAG : 0); f.len = msg->len; f.flags = (msg->brs ? CANFD_BRS : 0) | (msg->esi ? CANFD_ESI : 0); memcpy(f.data, msg->data, msg->len); memcpy(fileBuffer + fileBufferPos, &f, pktSz); } else { SocketCANFrame f = {}; f.can_id = msg->id | (msg->ext ? CAN_EFF_FLAG : 0); f.can_dlc = msg->len; memcpy(f.data, msg->data, min((int)msg->len, 8)); memcpy(fileBuffer + fileBufferPos, &f, pktSz); } fileBufferPos += pktSz; } void writeCANToCSVBuffer(const LoggedCANFrame *msg) { if (fileBufferPos + 512 > FILE_BUFFER_SIZE) return; char line[512]; int n = snprintf(line, sizeof(line), "%llu,%08X,%d,%d,%d,", msg->timestamp_us, msg->id, msg->len, msg->fd ? 1 : 0, msg->brs ? 1 : 0); for (int i = 0; i < msg->len; i++) n += snprintf(line + n, sizeof(line) - n, "%02X", msg->data[i]); line[n++] = '\n'; memcpy(fileBuffer + fileBufferPos, line, n); fileBufferPos += n; } void writeCANToBuffer(const LoggedCANFrame *msg) { if (canSettings.logFormat == 0) writeCANToPCAPBuffer(msg); else writeCANToCSVBuffer(msg); } void flushBufferToSD() { if (fileBufferPos == 0) return; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(50)) == pdTRUE) { if (canLogFile) { size_t written = canLogFile.write(fileBuffer, fileBufferPos); if (written != fileBufferPos) sdWriteErrorCount++; } fileBufferPos = 0; xSemaphoreGive(sdMutex); } } void updateRecentData(const LoggedCANFrame *msg) { for (int i = 0; i < recentDataCount; i++) { if (recentData[i].msg.id == msg->id && recentData[i].msg.ext == msg->ext) { recentData[i].msg = *msg; recentData[i].count++; return; } } if (recentDataCount < RECENT_MSG_COUNT) { recentData[recentDataCount].msg = *msg; recentData[recentDataCount].count = 1; recentDataCount++; } } // ============================================================ // SD 카드 초기화 // ============================================================ void initSDCard() { Serial.println("\nSD 카드 초기화 (SDIO 4-bit, 20MHz)..."); SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3); // ★ 20MHz 고정: WiFi DMA와 SDIO DMA 충돌 방지 (40MHz→0x109 에러) if (!SD_MMC.begin("/sdcard", false, false, 20000)) { Serial.println("⚠ 4-bit 실패, 1-bit 재시도..."); SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0); if (!SD_MMC.begin("/sdcard", true, false, 20000)) { Serial.println("✗ SD 초기화 실패!"); while (1) delay(1000); } Serial.println("✓ SD 초기화 (1-bit 20MHz)"); } else { Serial.println("✓ SD 초기화 (4-bit 20MHz)"); } Serial.printf(" 카드: %llu MB / %s\n", SD_MMC.cardSize()/(1024*1024), SD_MMC.cardType()==CARD_SDHC ? "SDHC" : SD_MMC.cardType()==CARD_SD ? "SD" : "기타"); } // ============================================================ // ★ CAN RX Task - Core 1, 최우선 (Priority 24) // // CAN_BUS_Shield 라이브러리 실제 API (컴파일 에러 수정): // CAN.checkReceive() → CAN_MSGAVAIL / CAN_NOMSG // CAN.readMsgBufID(&id, &len, buf) → 3인수 버전만 사용 // CAN.isExtendedFrame() → Extended ID 여부 // len > 8 → FD 프레임 감지 (FD=최대 64byte) // ============================================================ void canRxTask(void *p) { LoggedCANFrame msg; Serial.println("✓ CAN RX Task 시작 (Core1, Priority 24)"); while (1) { if (!canInitialized) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } // 인터럽트 플래그 or 직접 폴링 (인터럽트 놓친 경우 대비) if (canInterruptFlag || CAN.checkReceive() == CAN_MSGAVAIL) { canInterruptFlag = false; // 수신 큐가 빌 때까지 연속 처리 while (CAN.checkReceive() == CAN_MSGAVAIL) { unsigned long rxId = 0; byte rxLen = 0; uint8_t rxBuf[64] = {}; // ★ CAN_BUS_Shield 3인수 readMsgBufID 사용 byte res = CAN.readMsgBufID(&rxId, &rxLen, rxBuf); if (res != CAN_OK) { canErrorCount++; break; } // Extended ID / FD 감지 bool isExt = CAN.isExtendedFrame(); bool isFD = (rxLen > 8); // FD 프레임: 데이터 9~64바이트 // LoggedCANFrame 구성 msg.timestamp_us = getMicros64(); msg.id = (uint32_t)rxId; msg.len = rxLen; msg.ext = isExt; msg.fd = isFD; msg.brs = isFD; // BRS는 FD와 동일하게 처리 msg.esi = false; memcpy(msg.data, rxBuf, rxLen); if (xQueueSend(canQueue, &msg, 0) == pdTRUE) { canFrameCount++; updateRecentData(&msg); // WebSocket 실시간 전송 (연결된 클라이언트 있을 때만) if (ws.connectedClients() > 0) { char dataBuf[130] = {}; for (int i = 0; i < rxLen; i++) sprintf(dataBuf + i*2, "%02X", rxBuf[i]); char json[320]; snprintf(json, sizeof(json), "{\"type\":\"can\",\"ts\":%llu,\"id\":\"%lX\"," "\"len\":%d,\"fd\":%s,\"brs\":%s,\"ext\":%s,\"data\":\"%s\"}", msg.timestamp_us, msg.id, msg.len, msg.fd ? "true":"false", msg.brs ? "true":"false", msg.ext ? "true":"false", dataBuf); ws.broadcastTXT(json); } } else { canErrorCount++; // 큐 오버플로우 } } } vTaskDelay(pdMS_TO_TICKS(1)); } } // ============================================================ // SD Write Task - Core 0, Priority 8 // ============================================================ void sdWriteTask(void *p) { LoggedCANFrame msg; uint32_t batch = 0; Serial.println("✓ SD Write Task 시작 (Core0, Priority 8)"); while (1) { if (loggingEnabled && xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(10)) == pdTRUE) { writeCANToBuffer(&msg); batch++; // 버퍼 80% 이상 또는 배치 완료 시 플러시 if (fileBufferPos > FILE_BUFFER_SIZE * 80 / 100 || batch >= 50) { flushBufferToSD(); batch = 0; } } else { vTaskDelay(pdMS_TO_TICKS(5)); } } } // ============================================================ // SD Flush Task - Core 0, Priority 9 (1초마다 강제 플러시) // ============================================================ void sdFlushTask(void *p) { Serial.println("✓ SD Flush Task 시작 (Core0, Priority 9)"); while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); if (loggingEnabled && fileBufferPos > 0) { flushBufferToSD(); // 10초마다 파일 sync static uint32_t lastSync = 0; if (millis() - lastSync > 10000) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) { if (canLogFile) canLogFile.flush(); xSemaphoreGive(sdMutex); } lastSync = millis(); } } } } // ============================================================ // Web Update Task (placeholder) // ============================================================ void webUpdateTask(void *p) { while (1) { vTaskDelay(pdMS_TO_TICKS(50)); } } // ============================================================ // RTC 동기화 Task (60초 간격) // ============================================================ void rtcSyncTask(void *p) { Serial.println("✓ RTC Sync Task 시작"); while (1) { vTaskDelay(pdMS_TO_TICKS(60000)); struct tm t; if (readRTC(&t)) { updateSystemTime(&t); timeSyncStatus.rtcSyncCount++; // 필요시 로그: Serial.printf("RTC 동기화 #%d\n", timeSyncStatus.rtcSyncCount); } } } // ============================================================ // ★ TX Task - CAN_BUS_Shield 전송 API // CAN.sendMsgBuf(id, ext, len, buf) ← FD/Classic 통일 // DLC > 8 이면 라이브러리가 FD 프레임으로 자동 처리 // ============================================================ void txTask(void *p) { Serial.println("✓ TX Task 시작"); while (1) { if (canInitialized) { uint32_t now = millis(); for (int i = 0; i < MAX_TX_MESSAGES; i++) { TxMessage &tx = txMessages[i]; if (!tx.active) continue; if (now - tx.lastSent < tx.interval) continue; int8_t ret; // ★ CAN_BUS_Shield: sendMsgBufFD 없음 → sendMsgBuf로 통일 // 라이브러리가 DLC>8이면 FD로 자동 처리 ret = CAN.sendMsgBuf(tx.id, tx.extended ? 1 : 0, tx.dlc, tx.data); if (ret == CAN_OK) tx.lastSent = now; } } vTaskDelay(pdMS_TO_TICKS(5)); } } // ============================================================ // Sequence Task // ============================================================ void sequenceTask(void *p) { Serial.println("✓ Sequence Task 시작"); while (1) { if (canInitialized && seqRuntime.running && seqRuntime.activeSequenceIndex >= 0) { CANSequence &seq = sequences[seqRuntime.activeSequenceIndex]; SequenceStep &step = seq.steps[seqRuntime.currentStep]; if (millis() - seqRuntime.lastStepTime >= step.delayMs) { // ★ CAN_BUS_Shield: sendMsgBuf로 통일 (FD 자동 처리) CAN.sendMsgBuf(step.canId, step.extended?1:0, step.dlc, step.data); seqRuntime.lastStepTime = millis(); seqRuntime.currentStep++; if (seqRuntime.currentStep >= seq.stepCount) { seqRuntime.currentStep = 0; seqRuntime.currentRepeat++; if (seq.repeatMode == 0 || (seq.repeatMode == 1 && seqRuntime.currentRepeat >= seq.repeatCount)) seqRuntime.running = false; } } } vTaskDelay(pdMS_TO_TICKS(1)); } } // ============================================================ // WebSocket 이벤트 // ============================================================ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) { switch (type) { case WStype_DISCONNECTED: Serial.printf("[WS%u] 연결 해제\n", num); break; case WStype_CONNECTED: { Serial.printf("[WS%u] 연결: %s\n", num, ws.remoteIP(num).toString().c_str()); char buf[300]; snprintf(buf, sizeof(buf), "{\"type\":\"status\",\"canReady\":%s,\"sdReady\":%s," "\"frames\":%lu,\"errors\":%lu,\"psram\":%u,\"logging\":%s," "\"mode\":\"%s\",\"arb\":%u,\"data\":%u}", canInitialized ? "true":"false", SD_MMC.cardType()!=CARD_NONE ? "true":"false", canFrameCount, canErrorCount, ESP.getFreePsram()/1024, loggingEnabled ? "true":"false", canSettings.fdMode ? "fd":"classic", canSettings.arbRateKbps, canSettings.dataRateKbps); ws.sendTXT(num, buf); break; } case WStype_TEXT: { StaticJsonDocument<256> doc; if (!deserializeJson(doc, payload, length)) { const char *cmd = doc["cmd"]; if (!cmd) break; if (strcmp(cmd, "getStatus") == 0) { char buf[300]; snprintf(buf, sizeof(buf), "{\"type\":\"status\",\"canReady\":%s,\"sdReady\":%s," "\"frames\":%lu,\"errors\":%lu,\"psram\":%u,\"logging\":%s}", canInitialized ? "true":"false", SD_MMC.cardType()!=CARD_NONE ? "true":"false", canFrameCount, canErrorCount, ESP.getFreePsram()/1024, loggingEnabled ? "true":"false"); ws.sendTXT(num, buf); } } break; } default: break; } } // ============================================================ // Web Routes 설정 // ============================================================ void setupWebRoutes() { // 페이지들 server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", canfd_index_html); }); server.on("/settings", HTTP_GET, []() { server.send_P(200, "text/html", canfd_settings_html); }); server.on("/graph", HTTP_GET, []() { server.send_P(200, "text/html", canfd_graph_html); }); server.on("/graph-view", HTTP_GET, []() { server.send_P(200, "text/html", canfd_graph_viewer_html); }); // 상태 API server.on("/status", HTTP_GET, []() { char buf[512]; snprintf(buf, sizeof(buf), "{\"canReady\":%s,\"sdReady\":%s,\"frames\":%lu," "\"queueUsed\":%d,\"errors\":%lu,\"psram\":%u," "\"logging\":%s,\"mode\":\"%s\"," "\"arb\":%u,\"data\":%u,\"sdErr\":%lu}", canInitialized ? "true":"false", SD_MMC.cardType()!=CARD_NONE ? "true":"false", canFrameCount, uxQueueMessagesWaiting(canQueue), canErrorCount, ESP.getFreePsram()/1024, loggingEnabled ? "true":"false", canSettings.fdMode ? "fd":"classic", canSettings.arbRateKbps, canSettings.dataRateKbps, sdWriteErrorCount); server.send(200, "application/json", buf); }); // 설정 GET server.on("/settings/get", HTTP_GET, []() { StaticJsonDocument<1024> doc; doc["wifiSSID"] = wifiSSID; doc["wifiPassword"] = wifiPassword; doc["staEnable"] = staEnable; doc["staSSID"] = staSSID; doc["staPassword"] = staPassword; doc["staConnected"] = (WiFi.status() == WL_CONNECTED); doc["staIP"] = staIP.toString(); doc["fdMode"] = canSettings.fdMode; doc["arbRate"] = canSettings.arbRateKbps; doc["dataRate"] = canSettings.dataRateKbps; doc["controllerMode"] = canSettings.controllerMode; doc["logFormat"] = canSettings.logFormat; String resp; serializeJson(doc, resp); server.send(200, "application/json", resp); }); // 설정 저장 + 재부팅 (설정 반영) server.on("/settings/save", HTTP_POST, []() { StaticJsonDocument<1024> doc; bool ok = false; if (server.hasArg("plain") && !deserializeJson(doc, server.arg("plain"))) { preferences.begin("can-logger", false); String s; s = doc["wifiSSID"] | "Byun_CANFD_Logger"; strlcpy(wifiSSID, s.c_str(), sizeof(wifiSSID)); preferences.putString("wifi_ssid", wifiSSID); s = doc["wifiPassword"] | "12345678"; strlcpy(wifiPassword, s.c_str(), sizeof(wifiPassword)); preferences.putString("wifi_pass", wifiPassword); staEnable = doc["staEnable"] | false; preferences.putBool("sta_enable", staEnable); s = doc["staSSID"] | ""; strlcpy(staSSID, s.c_str(), sizeof(staSSID)); preferences.putString("sta_ssid", staSSID); s = doc["staPassword"] | ""; strlcpy(staPassword, s.c_str(), sizeof(staPassword)); preferences.putString("sta_pass", staPassword); canSettings.fdMode = doc["fdMode"] | true; canSettings.arbRateKbps = doc["arbRate"] | 500; canSettings.dataRateKbps = doc["dataRate"] | 2000; canSettings.controllerMode = doc["controllerMode"] | 0; canSettings.logFormat = doc["logFormat"] | 0; preferences.putBool("fd_mode", canSettings.fdMode); preferences.putUInt("arb_rate", canSettings.arbRateKbps); preferences.putUInt("data_rate", canSettings.dataRateKbps); preferences.putUChar("ctrl_mode", canSettings.controllerMode); preferences.putUChar("log_fmt", canSettings.logFormat); preferences.end(); Serial.printf("[설정저장] FD:%d Arb:%ukbps Data:%ukbps Mode:%d Fmt:%d\n", canSettings.fdMode, canSettings.arbRateKbps, canSettings.dataRateKbps, canSettings.controllerMode, canSettings.logFormat); ok = true; } server.send(200, "application/json", ok ? "{\"success\":true,\"message\":\"저장 완료. 재초기화하려면 /can/reinit 호출\"}" : "{\"success\":false}"); }); // 파일 목록 server.on("/files/list", HTTP_GET, []() { String json = "{\"files\":["; bool first = true; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { File root = SD_MMC.open("/"); if (root) { File f = root.openNextFile(); while (f) { if (!f.isDirectory()) { if (!first) json += ","; char cmt[MAX_COMMENT_LEN] = ""; for (int i = 0; i < MAX_FILE_COMMENTS; i++) { if (strcmp(fileComments[i].filename, f.name()) == 0) { strlcpy(cmt, fileComments[i].comment, sizeof(cmt)); break; } } json += "{\"name\":\""; json += f.name(); json += "\",\"size\":"; json += f.size(); json += ",\"comment\":\""; json += cmt; json += "\"}"; first = false; } f = root.openNextFile(); } } xSemaphoreGive(sdMutex); } json += "]}"; server.send(200, "application/json", json); }); // ★ 파일 다운로드 (SD DMA 충돌 방지 + 3회 재시도) server.on("/download", HTTP_GET, []() { if (!server.hasArg("file")) { server.send(400, "text/plain", "file 파라미터 필요"); return; } String filename = server.arg("file"); String filepath = "/" + filename; Serial.printf("다운로드: %s\n", filename.c_str()); bool wasLogging = loggingEnabled; if (wasLogging) { loggingEnabled = false; vTaskDelay(pdMS_TO_TICKS(200)); } if (fileBufferPos > 0) flushBufferToSD(); // 파일 크기 확인 size_t fileSize = 0; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) != pdTRUE) { server.send(503, "text/plain", "SD 사용 중"); if (wasLogging) loggingEnabled = true; return; } { File probe = SD_MMC.open(filepath.c_str(), FILE_READ); if (!probe) { xSemaphoreGive(sdMutex); server.send(404, "text/plain", "파일 없음"); if (wasLogging) loggingEnabled = true; return; } fileSize = probe.size(); probe.close(); } xSemaphoreGive(sdMutex); Serial.printf(" 크기: %u bytes\n", fileSize); WiFiClient client = server.client(); client.setTimeout(30); // HTTP 헤더 수동 전송 (server.setContentLength 방식 대신) String hdr = "HTTP/1.1 200 OK\r\n" "Content-Type: application/octet-stream\r\n" "Content-Disposition: attachment; filename=\"" + filename + "\"\r\n" "Content-Length: " + String(fileSize) + "\r\n" "Connection: close\r\n\r\n"; client.print(hdr); // PSRAM에서 전송 버퍼 할당 const size_t CHUNK = 8192; uint8_t *buf = (uint8_t*)ps_malloc(CHUNK); if (!buf) buf = (uint8_t*)malloc(CHUNK); if (!buf) { if (wasLogging) loggingEnabled = true; return; } size_t totalSent = 0; bool ok = true; uint32_t lastLog = 0; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) { File file = SD_MMC.open(filepath.c_str(), FILE_READ); if (file) { while (client.connected()) { // ★ SD 읽기 3회 재시도 (DMA 오류 0x109 대응) size_t bytesRead = 0; for (int retry = 0; retry < 3; retry++) { bytesRead = file.read(buf, CHUNK); if (bytesRead > 0) break; if (!file.available()) break; // 진짜 EOF xSemaphoreGive(sdMutex); vTaskDelay(pdMS_TO_TICKS(20)); xSemaphoreTake(sdMutex, pdMS_TO_TICKS(500)); Serial.printf(" SD 재시도 %d at %u\n", retry+1, totalSent); } if (bytesRead == 0) { if (totalSent < fileSize) { ok = false; Serial.printf("✗ SD 오류: %u/%u\n", totalSent, fileSize); } break; } // TCP 전송 (부분 전송 재시도) size_t written = 0; uint32_t txStart = millis(); while (written < bytesRead) { if (!client.connected() || millis()-txStart > 15000) { ok=false; break; } size_t sent = client.write(buf+written, bytesRead-written); if (sent > 0) { written += sent; totalSent += sent; txStart = millis(); } else { // WiFi TCP 버퍼 꽉 참 → DMA 충돌 방지 yield xSemaphoreGive(sdMutex); vTaskDelay(pdMS_TO_TICKS(5)); xSemaphoreTake(sdMutex, pdMS_TO_TICKS(200)); } esp_task_wdt_reset(); } if (!ok) break; // 진행 로그 (512KB마다) if (totalSent - lastLog >= 512*1024) { Serial.printf(" 진행: %u/%u (%.1f%%)\n", totalSent, fileSize, 100.0f*totalSent/fileSize); lastLog = totalSent; } // ★ SD DMA ↔ WiFi DMA 충돌 방지: 청크 사이 2ms yield xSemaphoreGive(sdMutex); vTaskDelay(pdMS_TO_TICKS(2)); xSemaphoreTake(sdMutex, pdMS_TO_TICKS(200)); } file.close(); } else { ok = false; } xSemaphoreGive(sdMutex); } else { ok = false; } free(buf); client.stop(); Serial.printf("%s 다운로드: %u/%u bytes\n", ok?"✓":"✗", totalSent, fileSize); if (wasLogging) loggingEnabled = true; }); // 파일 삭제 server.on("/files/delete", HTTP_POST, []() { StaticJsonDocument<512> doc; bool ok = false; if (server.hasArg("plain") && !deserializeJson(doc, server.arg("plain"))) { // 단일 또는 배열 삭제 지원 if (doc["filenames"].is()) { JsonArray arr = doc["filenames"].as(); for (const char *fn : arr) { if (loggingEnabled && String(fn) == String(currentCanFilename).substring(1)) continue; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(500)) == pdTRUE) { SD_MMC.remove(("/" + String(fn)).c_str()); xSemaphoreGive(sdMutex); } } ok = true; } else { String fn = doc["filename"] | ""; if (fn.length() > 0) { if (!loggingEnabled || fn != String(currentCanFilename).substring(1)) { if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { ok = SD_MMC.remove(("/" + fn).c_str()); xSemaphoreGive(sdMutex); } } } } } server.send(200, "application/json", ok ? "{\"success\":true}" : "{\"success\":false}"); }); // 코멘트 저장 server.on("/files/comment", HTTP_POST, []() { StaticJsonDocument<512> doc; if (server.hasArg("plain") && !deserializeJson(doc, server.arg("plain"))) { const char *fn = doc["filename"]; const char *cmt = doc["comment"]; if (fn && cmt) { for (int i = 0; i < MAX_FILE_COMMENTS; i++) { if (strcmp(fileComments[i].filename, fn) == 0 || strlen(fileComments[i].filename) == 0) { strlcpy(fileComments[i].filename, fn, MAX_FILENAME_LEN); strlcpy(fileComments[i].comment, cmt, MAX_COMMENT_LEN); break; } } } } server.send(200, "application/json", "{\"success\":true}"); }); // 로깅 시작 server.on("/logging/start", HTTP_POST, []() { if (loggingEnabled) { server.send(200,"application/json","{\"success\":false,\"message\":\"이미 로깅 중\"}"); return; } if (!canInitialized) { server.send(200,"application/json","{\"success\":false,\"message\":\"CAN 초기화 안됨\"}"); return; } char fn[MAX_FILENAME_LEN]; time_t now = time(nullptr); struct tm ti; localtime_r(&now, &ti); const char *ext = (canSettings.logFormat == 0) ? "pcap" : "csv"; snprintf(fn, sizeof(fn), "/%s_%04d%02d%02d_%02d%02d%02d.%s", canSettings.fdMode ? "canfd" : "can", ti.tm_year+1900, ti.tm_mon+1, ti.tm_mday, ti.tm_hour, ti.tm_min, ti.tm_sec, ext); bool ok = false; if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { canLogFile = SD_MMC.open(fn, FILE_WRITE); if (canLogFile) { if (canSettings.logFormat == 0) { // PCAP 글로벌 헤더 PCAPGlobalHeader h = {0xa1b2c3d4,2,4,0,0,65535,227}; canLogFile.write((uint8_t*)&h, sizeof(h)); canLogFile.flush(); } else { canLogFile.println("timestamp_us,id,len,fd,brs,data"); } strlcpy(currentCanFilename, fn, sizeof(currentCanFilename)); loggingEnabled = true; ok = true; Serial.printf("✓ 로깅 시작: %s (%s)\n", fn, canSettings.logFormat==0?"PCAP":"CSV"); } xSemaphoreGive(sdMutex); } server.send(200, "application/json", ok ? "{\"success\":true}" : "{\"success\":false,\"message\":\"파일 생성 실패\"}"); }); // 로깅 중지 server.on("/logging/stop", HTTP_POST, []() { if (!loggingEnabled) { server.send(200,"application/json","{\"success\":false,\"message\":\"로깅 중 아님\"}"); return; } loggingEnabled = false; vTaskDelay(pdMS_TO_TICKS(200)); flushBufferToSD(); if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) { if (canLogFile) { canLogFile.flush(); canLogFile.close(); } xSemaphoreGive(sdMutex); } Serial.println("✓ 로깅 중지"); server.send(200, "application/json", "{\"success\":true}"); }); // 최근 CAN 메시지 조회 server.on("/can/recent", HTTP_GET, []() { String json = "{\"messages\":["; for (int i = 0; i < recentDataCount; i++) { if (i > 0) json += ","; char dataBuf[130] = {}; for (int j = 0; j < recentData[i].msg.len; j++) sprintf(dataBuf + j*2, "%02X", recentData[i].msg.data[j]); char entry[256]; snprintf(entry, sizeof(entry), "{\"id\":\"%lX\",\"ext\":%s,\"len\":%d,\"fd\":%s,\"count\":%lu,\"data\":\"%s\"}", recentData[i].msg.id, recentData[i].msg.ext ? "true":"false", recentData[i].msg.len, recentData[i].msg.fd ? "true":"false", recentData[i].count, dataBuf); json += entry; } json += "]}"; server.send(200, "application/json", json); }); // TX 메시지 설정 server.on("/tx/set", HTTP_POST, []() { StaticJsonDocument<1024> doc; if (server.hasArg("plain") && !deserializeJson(doc, server.arg("plain"))) { int idx = doc["index"] | -1; if (idx >= 0 && idx < MAX_TX_MESSAGES) { TxMessage &tx = txMessages[idx]; String idStr = doc["id"] | "0"; tx.id = strtoul(idStr.c_str(), nullptr, 16); tx.extended = doc["extended"] | false; tx.dlc = doc["dlc"] | 8; tx.interval = doc["interval"] | 100; tx.active = doc["active"] | false; tx.fd = doc["fd"] | false; tx.brs = doc["brs"] | false; tx.lastSent = 0; String dataStr = doc["data"] | "0000000000000000"; for (int i = 0; i < tx.dlc && (i*2+1) < (int)dataStr.length(); i++) tx.data[i] = strtoul(dataStr.substring(i*2, i*2+2).c_str(), nullptr, 16); } } server.send(200, "application/json", "{\"success\":true}"); }); // TX 목록 조회 server.on("/tx/list", HTTP_GET, []() { String json = "{\"messages\":["; for (int i = 0; i < MAX_TX_MESSAGES; i++) { if (i > 0) json += ","; char dataBuf[130] = {}; for (int j = 0; j < txMessages[i].dlc; j++) sprintf(dataBuf + j*2, "%02X", txMessages[i].data[j]); char entry[256]; snprintf(entry, sizeof(entry), "{\"index\":%d,\"id\":\"%lX\",\"dlc\":%d,\"interval\":%lu," "\"active\":%s,\"fd\":%s,\"data\":\"%s\"}", i, txMessages[i].id, txMessages[i].dlc, txMessages[i].interval, txMessages[i].active ? "true":"false", txMessages[i].fd ? "true":"false", dataBuf); json += entry; } json += "]}"; server.send(200, "application/json", json); }); // 시간 동기화 (웹 클라이언트로부터) server.on("/time/sync", HTTP_POST, []() { StaticJsonDocument<128> doc; if (server.hasArg("plain") && !deserializeJson(doc, server.arg("plain"))) { uint64_t epochMs = doc["epochMs"] | 0; if (epochMs > 0) { struct timeval tv; tv.tv_sec = epochMs / 1000; tv.tv_usec = (epochMs % 1000) * 1000; settimeofday(&tv, NULL); timeSyncStatus.synchronized = true; Serial.printf("✓ 시간 동기화: %llu ms (웹)\n", epochMs); } } server.send(200, "application/json", "{\"success\":true}"); }); // ★ CAN 재초기화 (설정 변경 후 호출) server.on("/can/reinit", HTTP_POST, []() { bool wasLogging = loggingEnabled; if (wasLogging) { loggingEnabled = false; vTaskDelay(pdMS_TO_TICKS(300)); } canInitialized = false; detachInterrupt(digitalPinToInterrupt(CAN_INT_PIN)); delay(100); bool ok = initCANFD(); if (ok) attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); if (wasLogging && ok) loggingEnabled = true; server.send(200, "application/json", ok ? "{\"success\":true,\"message\":\"CAN 재초기화 성공\"}" : "{\"success\":false,\"message\":\"재초기화 실패\"}"); }); server.onNotFound([]() { server.send(404, "text/plain", "Not Found"); }); } // ============================================================ // Setup // ============================================================ void setup() { Serial.begin(115200); delay(1000); Serial.println("\n\n========================================"); Serial.println(" ESP32-S3 CAN FD Logger v4.0"); Serial.println(" ★ Seeed_Arduino_CAN (MCP2518FD)"); Serial.println(" SPI 4MHz 고정 - 브레드보드 안정적"); Serial.println("========================================\n"); // PSRAM 확인 if (!psramFound()) { Serial.println("✗ PSRAM 없음! Arduino IDE → PSRAM: OPI PSRAM 선택 필요"); while (1) delay(1000); } Serial.printf("✓ PSRAM: %u KB\n", ESP.getPsramSize()/1024); // PSRAM 할당 if (!allocatePSRAM()) { Serial.println("✗ PSRAM 할당 실패"); while(1) delay(1000); } // 뮤텍스 생성 sdMutex = xSemaphoreCreateMutex(); i2cMutex = xSemaphoreCreateMutex(); // RTC DS3231 초기화 rtcWire.begin(); rtcWire.setClock(400000); rtcWire.beginTransmission(DS3231_ADDRESS); if (rtcWire.endTransmission() == 0) { timeSyncStatus.rtcAvailable = true; Serial.println("✓ RTC DS3231 감지"); struct tm t; if (readRTC(&t)) { updateSystemTime(&t); Serial.println("✓ RTC → 시스템 시간 동기화 완료"); } } else { Serial.println("⚠ RTC 없음 → 웹 연결 시 시간 자동 동기화"); } // 설정 로드 preferences.begin("can-logger", true); preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID)); if (strlen(wifiSSID)==0) strlcpy(wifiSSID, "Byun_CANFD_Logger", sizeof(wifiSSID)); preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword)); if (strlen(wifiPassword)==0) strlcpy(wifiPassword, "12345678", sizeof(wifiPassword)); staEnable = preferences.getBool("sta_enable", false); preferences.getString("sta_ssid", staSSID, sizeof(staSSID)); preferences.getString("sta_pass", staPassword, sizeof(staPassword)); canSettings.fdMode = preferences.getBool("fd_mode", true); canSettings.arbRateKbps = preferences.getUInt("arb_rate", 500); canSettings.dataRateKbps = preferences.getUInt("data_rate", 2000); canSettings.controllerMode = preferences.getUChar("ctrl_mode", 0); canSettings.logFormat = preferences.getUChar("log_fmt", 0); preferences.end(); Serial.println("\n[로드된 설정]"); Serial.printf(" WiFi AP: %s\n", wifiSSID); Serial.printf(" STA: %s\n", staEnable ? staSSID : "비활성"); Serial.printf(" CAN: %s | Arb %u kbps | Data %u kbps\n", canSettings.fdMode ? "FD":"Classic", canSettings.arbRateKbps, canSettings.dataRateKbps); Serial.printf(" 로그: %s | 컨트롤러모드: %d\n", canSettings.logFormat==0 ? "PCAP":"CSV", canSettings.controllerMode); // SD 카드 초기화 initSDCard(); // ★ CAN FD 초기화 (Seeed 라이브러리) if (!initCANFD()) { Serial.println("⚠ CAN 없이 계속 동작"); Serial.println(" → http://192.168.4.1/can/reinit 으로 재시도 가능"); } // WiFi 초기화 Serial.println("\nWiFi 초기화..."); if (staEnable && strlen(staSSID) > 0) { WiFi.mode(WIFI_AP_STA); WiFi.softAP(wifiSSID, wifiPassword); Serial.printf("✓ AP: %s (%s)\n", wifiSSID, WiFi.softAPIP().toString().c_str()); WiFi.begin(staSSID, staPassword); Serial.printf("STA 연결: %s", staSSID); for (int i = 0; i < 20 && WiFi.status()!=WL_CONNECTED; i++) { delay(500); Serial.print("."); } if (WiFi.status()==WL_CONNECTED) { staIP = WiFi.localIP(); Serial.printf("\n✓ STA IP: %s\n", staIP.toString().c_str()); } else { Serial.println("\n✗ STA 실패 (AP만 동작)"); } } else { WiFi.mode(WIFI_AP); WiFi.softAP(wifiSSID, wifiPassword); Serial.printf("✓ AP: %s | IP: %s\n", wifiSSID, WiFi.softAPIP().toString().c_str()); } // 웹서버 & WebSocket setupWebRoutes(); server.begin(); ws.begin(); ws.onEvent(webSocketEvent); Serial.println("✓ WebServer:80 / WebSocket:81 시작"); // CAN 인터럽트 연결 pinMode(CAN_INT_PIN, INPUT_PULLUP); if (canInitialized) { attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); Serial.printf("✓ CAN 인터럽트 → GPIO%d (FALLING)\n", CAN_INT_PIN); } // CAN 큐 생성 canQueue = xQueueCreateStatic( CAN_QUEUE_SIZE, sizeof(LoggedCANFrame), canQueueStorage, canQueueBuffer); if (!canQueue) { Serial.println("✗ CAN 큐 생성 실패"); while(1) delay(1000); } Serial.printf("✓ CAN 큐: %d 슬롯\n", CAN_QUEUE_SIZE); // ───────────────────────────────────── // FreeRTOS Task 배분 // // Core 1 (CAN 실시간): // canRxTask Priority 24 ← 최우선, 인터럽트 처리 // txTask Priority 3 // sequenceTask Priority 2 // // Core 0 (I/O / WiFi): // sdFlushTask Priority 9 ← SD 먼저 플러시 // sdWriteTask Priority 8 // webUpdateTask Priority 4 // rtcSyncTask Priority 1 (RTC 있을 때만) // ───────────────────────────────────── xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 16384, NULL, 24, &canRxTaskHandle, 1); xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1); xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1); xTaskCreatePinnedToCore(sdFlushTask, "SD_F", 4096, NULL, 9, NULL, 0); xTaskCreatePinnedToCore(sdWriteTask, "SD_W", 20480, NULL, 8, &sdWriteTaskHandle, 0); xTaskCreatePinnedToCore(webUpdateTask,"WEB", 12288, NULL, 4, NULL, 0); if (timeSyncStatus.rtcAvailable) xTaskCreatePinnedToCore(rtcSyncTask, "RTC", 3072, NULL, 1, NULL, 0); Serial.println("\n========================================"); Serial.println(" ★ CAN FD Logger 준비 완료!"); Serial.printf(" 웹 인터페이스: http://%s\n", WiFi.softAPIP().toString().c_str()); Serial.printf(" CAN: %s / Arb %u kbps / Data %u kbps\n", canSettings.fdMode ? "FD":"Classic", canSettings.arbRateKbps, canSettings.dataRateKbps); Serial.printf(" 여유 PSRAM: %u KB\n", ESP.getFreePsram()/1024); Serial.println("========================================\n"); } // ============================================================ // Loop (서버 처리만 담당, 모든 로직은 Task에서 처리) // ============================================================ void loop() { server.handleClient(); ws.loop(); vTaskDelay(pdMS_TO_TICKS(10)); // 30초 상태 출력 static uint32_t lastPrint = 0; if (millis() - lastPrint > 30000) { Serial.printf("[상태] CAN:%lu 프레임 | 큐:%d/%d | 오류:%lu | SD오류:%lu | PSRAM:%u KB\n", canFrameCount, uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE, canErrorCount, sdWriteErrorCount, ESP.getFreePsram()/1024); lastPrint = millis(); } }