Files
esp32s3-mcp2518FD-logger/ESP32_CANFD_Logger.ino
2026-02-27 10:02:27 +00:00

1488 lines
57 KiB
C++

/*
* 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 <Arduino.h>
#include <SPI.h>
#include <mcp2518fd_can.h> // ★ Seeed_Arduino_CAN 라이브러리
#include <SoftWire.h>
#include <SD_MMC.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_task_wdt.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include <sys/time.h>
#include <time.h>
#include <Preferences.h>
#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>()) {
JsonArray arr = doc["filenames"].as<JsonArray>();
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();
}
}