Files
esp32s3_CANFD_logger/CANFD_Logger.ino
2026-04-30 19:00:06 +00:00

1262 lines
54 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Byun CANFD Logger with Web Interface
* Version: 1.0
* Hardware: ESP32-S3 + MCP2518FD (ACAN2517FD 라이브러리)
*
* 라이브러리: ACAN2517FD by Pierre Molinaro (라이브러리 매니저에서 설치)
*
* 핀 구성:
* CAN SPI : SCK=12, MOSI=11, MISO=13, CS=10, INT=9(폴링255)
* SD SDIO : CLK=39, CMD=38, D0=40, D1=41, D2=42, D3=2
* RTC I2C : SDA=8, SCL=18 (DS3231)
*
* Arduino IDE 설정:
* Board: ESP32S3 Dev Module
* PSRAM: OPI PSRAM (필수!)
* Flash: 16MB / Partition: 16MB(3MB APP/9.9MB FATFS)
*/
#include <Arduino.h>
#include <SPI.h>
#include <ACAN2517FD.h>
#include <SoftWire.h>
#include <SD_MMC.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_task_wdt.h>
#include <esp_sntp.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#include <sys/time.h>
#include <time.h>
#include <Preferences.h>
#include "index.h"
#include "transmit.h"
#include "graph.h"
#include "graph_viewer.h"
#include "settings.h"
// ─────────────────────────────────────────────
// 핀 정의
// ─────────────────────────────────────────────
#define PIN_CAN_SCK 12
#define PIN_CAN_MOSI 11
#define PIN_CAN_MISO 13
#define PIN_CAN_CS 10
#define PIN_CAN_INT 255 // 255 = 폴링모드 (INT핀 미사용)
#define PIN_SD_CLK 39
#define PIN_SD_CMD 38
#define PIN_SD_D0 40
#define PIN_SD_D1 41
#define PIN_SD_D2 42
#define PIN_SD_D3 2
#define PIN_I2C_SDA 8
#define PIN_I2C_SCL 18
#define DS3231_ADDR 0x68
// ─────────────────────────────────────────────
// CAN FD 설정
// ─────────────────────────────────────────────
// OSC: 20MHz (알리 보드 확인)
// 변경 필요시: OSC_40MHz, OSC_4MHz10xPLL 등
#define CANFD_OSC ACAN2517FDSettings::OSC_20MHz
// ── CAN SPI 클럭 설정 ──────────────────────────────────
// MCP2518FD 최대 SPI = 0.85 × Fosc
// OSC_20MHz → 최대 17 MHz
// OSC_40MHz → 최대 34 MHz
// 배선이 길거나 불안정하면 낮추세요 (최소 1 MHz)
// 5 MHz : 안전 (노이즈에 강함)
// 10 MHz : 권장 (안정성/속도 균형)
// 15 MHz : 고속 (짧은 배선에서)
// 17 MHz : 최대 (OSC_20MHz 기준)
#define CAN_SPI_CLOCK 10000000UL // ← 여기서 변경
// 최대 데이터 길이 (CAN FD = 64, Classic CAN = 8)
#define CANFD_MAX_DATA 64
// DLC → 실제 바이트 수 변환 테이블
static const uint8_t dlcToLen[16] = {
0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64
};
// ─────────────────────────────────────────────
// 버퍼 크기 (2의 거듭제곱 필수)
// ─────────────────────────────────────────────
#define CAN_RING_SIZE 4096 // CAN FD 메시지 링버퍼 슬롯 수
#define FILE_BUFFER_SIZE 65536 // 64KB 더블버퍼 (SD 쓰기)
#define MAX_FILENAME_LEN 64
#define RECENT_MSG_COUNT 128
#define MAX_TX_MESSAGES 16
#define MAX_COMMENT_LEN 128
#define MAX_FILE_COMMENTS 50
#define RTC_SYNC_INTERVAL 60000
// ─────────────────────────────────────────────
// CAN FD 속도 프리셋
// ─────────────────────────────────────────────
struct CANFDPreset {
const char* name;
uint32_t arbBPS;
DataBitRateFactor factor;
bool isFD;
};
static const CANFDPreset speedPresets[] = {
{"Classic 125K", 125000UL, DataBitRateFactor::x1, false},
{"Classic 250K", 250000UL, DataBitRateFactor::x1, false},
{"Classic 500K", 500000UL, DataBitRateFactor::x1, false},
{"Classic 1M", 1000000UL, DataBitRateFactor::x1, false},
{"FD 500K/2M", 500000UL, DataBitRateFactor::x4, true},
{"FD 500K/4M", 500000UL, DataBitRateFactor::x8, true},
{"FD 1M/4M", 1000000UL, DataBitRateFactor::x4, true},
{"FD 1M/8M", 1000000UL, DataBitRateFactor::x8, true},
};
#define NUM_SPEED_PRESETS (sizeof(speedPresets)/sizeof(speedPresets[0]))
// ─────────────────────────────────────────────
// PCAP 구조체 (Wireshark, LINKTYPE_CAN_SOCKETCAN=227)
// ─────────────────────────────────────────────
struct PcapGlobalHeader {
uint32_t magic; // 0xa1b2c3d4
uint16_t ver_major; // 2
uint16_t ver_minor; // 4
int32_t thiszone;
uint32_t sigfigs;
uint32_t snaplen;
uint32_t network; // 227 = LINKTYPE_CAN_SOCKETCAN
} __attribute__((packed));
struct PcapPktHdr {
uint32_t ts_sec;
uint32_t ts_usec;
uint32_t incl_len;
uint32_t orig_len;
} __attribute__((packed));
// Classic CAN SocketCAN frame (16 bytes)
struct SCFrame {
uint32_t can_id;
uint8_t can_dlc;
uint8_t pad, res0, res1;
uint8_t data[8];
} __attribute__((packed));
// CAN FD SocketCAN frame (72 bytes)
struct SCFDFrame {
uint32_t can_id;
uint8_t len;
uint8_t flags; // CANFD_BRS=1, CANFD_ESI=2, CANFD_FDF=4
uint8_t res0, res1;
uint8_t data[64];
} __attribute__((packed));
static_assert(sizeof(SCFrame) == 16, "SCFrame 16 bytes");
static_assert(sizeof(SCFDFrame) == 72, "SCFDFrame 72 bytes");
// ─────────────────────────────────────────────
// 로깅용 메시지 구조체 (PSRAM 링버퍼 원소)
// ─────────────────────────────────────────────
struct CANFDLog {
uint64_t timestamp_us;
uint32_t id;
uint8_t dlc; // DLC 코드 (0~15)
uint8_t len; // 실제 바이트 수 (0~64)
uint8_t flags; // bit7=EFF, bit6=RTR, bit5=FDF, bit4=BRS, bit3=ESI
uint8_t data[CANFD_MAX_DATA];
} __attribute__((packed)); // 8+4+1+1+1+64 = 79 bytes
// ─────────────────────────────────────────────
// SPSC 링버퍼
// ─────────────────────────────────────────────
struct CANRingBuffer {
volatile uint32_t head;
volatile uint32_t tail;
uint32_t size;
uint32_t mask;
CANFDLog* buf;
volatile uint32_t dropped;
};
CANRingBuffer canRing;
static inline bool IRAM_ATTR ring_push(CANRingBuffer* rb, const CANFDLog* msg) {
uint32_t h = rb->head;
if ((h - rb->tail) >= rb->size) { rb->dropped++; return false; }
memcpy(&rb->buf[h & rb->mask], msg, sizeof(CANFDLog));
__sync_synchronize();
rb->head = h + 1;
return true;
}
static inline bool ring_pop(CANRingBuffer* rb, CANFDLog* msg) {
uint32_t t = rb->tail;
if (rb->head == t) return false;
memcpy(msg, &rb->buf[t & rb->mask], sizeof(CANFDLog));
__sync_synchronize();
rb->tail = t + 1;
return true;
}
static inline uint32_t ring_count(const CANRingBuffer* rb) {
return rb->head - rb->tail;
}
static inline void ring_clear(CANRingBuffer* rb) {
rb->tail = rb->head; rb->dropped = 0;
}
// ─────────────────────────────────────────────
// 최근 메시지 (웹 모니터링 테이블)
// ─────────────────────────────────────────────
struct RecentMsg {
CANFDLog msg;
uint32_t count;
};
// ─────────────────────────────────────────────
// TX 메시지
// ─────────────────────────────────────────────
struct TxMessage {
uint32_t id;
bool extended;
bool isFD;
bool brs;
uint8_t dlc;
uint8_t data[CANFD_MAX_DATA];
uint32_t intervalMs;
uint32_t lastSent;
bool active;
};
struct FileComment {
char filename[MAX_FILENAME_LEN];
char comment[MAX_COMMENT_LEN];
};
// ─────────────────────────────────────────────
// 시간 동기화 상태
// ─────────────────────────────────────────────
struct TimeSyncStatus {
bool synced;
uint64_t lastSyncUs;
uint32_t syncCount;
bool rtcAvail;
uint32_t rtcSyncCount;
} timeSyncSt = {};
// ─────────────────────────────────────────────
// 전역 객체
// ─────────────────────────────────────────────
SPIClass gSPI(HSPI);
ACAN2517FD gCAN(PIN_CAN_CS, gSPI, PIN_CAN_INT);
SoftWire rtcWire(PIN_I2C_SDA, PIN_I2C_SCL);
WebServer server(80);
WebSocketsServer webSocket(81);
Preferences prefs;
// PSRAM 버퍼
RecentMsg* recentData = nullptr;
TxMessage* txMessages = nullptr;
FileComment* fileComments = nullptr;
uint8_t* fileBuf1 = nullptr;
uint8_t* fileBuf2 = nullptr;
// ─────────────────────────────────────────────
// 상태 변수
// ─────────────────────────────────────────────
char wifiSSID[32] = "CANFD_Logger";
char wifiPassword[64] = "12345678";
bool staMode = false;
char staSSID[32] = "";
char staPassword[64] = "";
volatile bool loggingEnabled = false;
volatile bool sdCardReady = false;
volatile uint32_t totalMsgCount = 0;
volatile uint32_t msgPerSecond = 0;
uint32_t lastMsgCountMs = 0;
uint32_t lastMsgCount = 0;
uint32_t totalTxCount = 0;
uint32_t lastBroadcast = 0;
// 현재 CAN 설정
int speedPresetIdx = 2; // 기본: Classic 500K (안전한 기본값)
bool listenOnly = false;
volatile bool canInitOK = false; // CAN 초기화 성공 여부
// 더블 버퍼
uint8_t* curWriteBuf = nullptr;
uint8_t* curFlushBuf = nullptr;
uint32_t writeBufIdx = 0;
uint32_t flushBufSize = 0;
volatile bool flushInProg = false;
// 로그 파일
File logFile;
char curFilename[MAX_FILENAME_LEN];
volatile uint32_t curFileSize = 0;
volatile bool canLogCSV = false;
volatile bool canLogPCAP = false;
volatile uint64_t logStartUs = 0;
// 파일 코멘트
int commentCount = 0;
// ─────────────────────────────────────────────
// FreeRTOS 핸들
// ─────────────────────────────────────────────
SemaphoreHandle_t sdMutex = NULL;
SemaphoreHandle_t rtcMutex = NULL;
TaskHandle_t canRxTaskH = NULL;
TaskHandle_t sdWriteTaskH = NULL;
TaskHandle_t sdFlushTaskH = NULL;
TaskHandle_t webTaskH = NULL;
TaskHandle_t rtcTaskH = NULL;
// ─────────────────────────────────────────────
// 유틸리티
// ─────────────────────────────────────────────
uint8_t bcdToDec(uint8_t v) { return (v >> 4) * 10 + (v & 0x0F); }
uint8_t decToBcd(uint8_t v) { return ((v / 10) << 4) | (v % 10); }
// CANFDMessage → 내부 플래그 변환
static inline uint8_t makeFlags(const CANFDMessage& m) {
uint8_t f = 0;
if (m.ext) f |= 0x80;
if (m.type == CANFDMessage::CAN_REMOTE) f |= 0x40;
if (m.type == CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH ||
m.type == CANFDMessage::CANFD_NO_BIT_RATE_SWITCH) f |= 0x20; // FDF
if (m.type == CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH) f |= 0x10; // BRS
return f;
}
bool writePcapGlobalHeader(File& f) {
PcapGlobalHeader h;
h.magic = 0xa1b2c3d4; h.ver_major = 2; h.ver_minor = 4;
h.thiszone = 0; h.sigfigs = 0; h.snaplen = 65535; h.network = 227;
return f.write((uint8_t*)&h, sizeof(h)) == sizeof(h);
}
// ─────────────────────────────────────────────
// PSRAM 초기화
// ─────────────────────────────────────────────
bool initPSRAM() {
Serial.println("\n=== PSRAM 초기화 (CAN FD Logger) ===");
if (!psramFound()) { Serial.println("✗ PSRAM 없음!"); return false; }
Serial.printf("✓ PSRAM: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
// CAN 링버퍼 (4096 × 79 bytes ≈ 316 KB)
canRing.buf = (CANFDLog*)ps_malloc(CAN_RING_SIZE * sizeof(CANFDLog));
if (!canRing.buf) { Serial.println("✗ CAN RingBuffer 실패"); return false; }
canRing.size = CAN_RING_SIZE; canRing.mask = CAN_RING_SIZE - 1;
canRing.head = canRing.tail = canRing.dropped = 0;
Serial.printf("✓ CAN RingBuffer: %d슬롯 × %d bytes = %.1f KB\n",
CAN_RING_SIZE, sizeof(CANFDLog),
(float)(CAN_RING_SIZE * sizeof(CANFDLog)) / 1024.0f);
// 더블 버퍼 (2 × 64KB = 128 KB)
fileBuf1 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
fileBuf2 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
if (!fileBuf1 || !fileBuf2) { Serial.println("✗ 더블버퍼 실패"); return false; }
curWriteBuf = fileBuf1; curFlushBuf = fileBuf2;
Serial.printf("✓ Double Buffer: 2 × %d KB\n", FILE_BUFFER_SIZE / 1024);
// 기타 데이터
recentData = (RecentMsg*) ps_calloc(RECENT_MSG_COUNT, sizeof(RecentMsg));
txMessages = (TxMessage*) ps_calloc(MAX_TX_MESSAGES, sizeof(TxMessage));
fileComments = (FileComment*) ps_calloc(MAX_FILE_COMMENTS,sizeof(FileComment));
if (!recentData || !txMessages || !fileComments) {
Serial.println("✗ 데이터 버퍼 실패"); return false;
}
Serial.printf("✓ PSRAM 남은 용량: %.1f KB\n\n", (float)ESP.getFreePsram() / 1024.0f);
return true;
}
// ─────────────────────────────────────────────
// 설정 저장/로드
// ─────────────────────────────────────────────
void loadSettings() {
prefs.begin("canfd-logger", true);
prefs.getString("ssid", wifiSSID, sizeof(wifiSSID));
prefs.getString("pass", wifiPassword, sizeof(wifiPassword));
staMode = prefs.getBool("sta_en", false);
prefs.getString("sta_ssid", staSSID, sizeof(staSSID));
prefs.getString("sta_pass", staPassword, sizeof(staPassword));
if (!strlen(wifiSSID)) strcpy(wifiSSID, "CANFD_Logger");
if (!strlen(wifiPassword)) strcpy(wifiPassword,"12345678");
speedPresetIdx = prefs.getInt("speed_idx", 2);
if (speedPresetIdx < 0 || speedPresetIdx >= (int)NUM_SPEED_PRESETS) speedPresetIdx = 2;
listenOnly = prefs.getBool("listen_only", false);
prefs.end();
}
void saveSettings() {
prefs.begin("canfd-logger", false);
prefs.putString("ssid", wifiSSID);
prefs.putString("pass", wifiPassword);
prefs.putBool ("sta_en", staMode);
prefs.putString("sta_ssid", staSSID);
prefs.putString("sta_pass", staPassword);
prefs.putInt ("speed_idx",speedPresetIdx);
prefs.putBool ("listen_only", listenOnly);
prefs.end();
}
// ─────────────────────────────────────────────
// CAN FD 초기화 (ACAN2517FD)
// ─────────────────────────────────────────────
bool initCANFD() {
const CANFDPreset& p = speedPresets[speedPresetIdx];
Serial.printf("CAN FD 초기화: %s (Listen:%s, SPI:%luMHz)\n",
p.name, listenOnly ? "ON" : "OFF", CAN_SPI_CLOCK / 1000000UL);
ACAN2517FDSettings settings(CANFD_OSC, p.arbBPS, p.factor);
if (listenOnly) {
settings.mRequestedMode = ACAN2517FDSettings::ListenOnly;
} else if (!p.isFD) {
settings.mRequestedMode = ACAN2517FDSettings::Normal20B;
} else {
settings.mRequestedMode = ACAN2517FDSettings::NormalFD;
}
// ★ TX FIFO 크기 미설정 (기본값 유지) → 0x1000(kTXQSizeTooLarge) 에러 방지
// ★ RX FIFO 16 = 동작 확인된 값
settings.mControllerReceiveFIFOSize = 16;
Serial.printf(" Arb : %lu bps (실제 %lu bps)\n",
p.arbBPS, settings.actualArbitrationBitRate());
if (p.isFD) {
Serial.printf(" Data : %lu bps (실제 %lu bps)\n",
p.arbBPS * (uint32_t)p.factor, settings.actualDataBitRate());
}
// ★ CS 핀 명시적으로 HIGH 보장 (이전 작업 잔류 상태 초기화)
pinMode(PIN_CAN_CS, OUTPUT);
digitalWrite(PIN_CAN_CS, HIGH);
delay(10);
// ★ 최대 3회 재시도 (0x2=readback오류는 타이밍 문제로 재시도하면 해결됨)
uint32_t err = 0xFFFFFFFF;
for (int attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) {
Serial.printf(" 재시도 %d/3 (SPI 재초기화)...\n", attempt + 1);
gSPI.end();
delay(50);
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, PIN_CAN_CS);
gSPI.setFrequency(CAN_SPI_CLOCK);
digitalWrite(PIN_CAN_CS, HIGH);
delay(100);
}
err = gCAN.begin(settings, NULL); // NULL = 폴링모드
if (err == 0) break;
Serial.printf(" 시도 %d 실패: 0x%08X\n", attempt + 1, err);
delay(200);
}
if (err == 0) {
Serial.printf("✓ CAN FD 초기화 성공: %s\n", p.name);
canInitOK = true;
return true;
} else {
canInitOK = false;
Serial.printf("✗ CAN FD 초기화 최종 실패: 0x%08X\n", err);
if (err & 0x00000001) Serial.println(" 0x1 → Configuration모드 타임아웃 (SPI배선 확인)");
if (err & 0x00000002) Serial.println(" 0x2 → 레지스터 readback 오류 (SPI노이즈/속도)");
if (err & 0x00001000) Serial.println(" 0x1000 → FIFO크기 초과 (TX FIFO 설정 확인)");
if (err & 0x00010000) Serial.println(" 0x10000 → 모드전환 타임아웃 (CS/INT 확인)");
// ★ FD 설정 실패 시 Classic 500K(index=2)로 자동 폴백 (1회)
if (speedPresets[speedPresetIdx].isFD && speedPresetIdx != 2) {
Serial.println(" → Classic 500K으로 자동 폴백 시도...");
speedPresetIdx = 2;
saveSettings();
return initCANFD(); // 1회 재귀 (Classic은 isFD=false → 재귀 없음)
}
return false;
}
}
// ─────────────────────────────────────────────
// RTC (DS3231)
// ─────────────────────────────────────────────
void initRTC() {
rtcWire.begin();
rtcWire.setClock(100000);
rtcWire.beginTransmission(DS3231_ADDR);
timeSyncSt.rtcAvail = (rtcWire.endTransmission() == 0);
Serial.printf("%s RTC(DS3231) %s\n",
timeSyncSt.rtcAvail ? "" : "!", timeSyncSt.rtcAvail ? "감지됨" : "없음");
}
bool readRTC(struct tm* t) {
if (!timeSyncSt.rtcAvail) return false;
if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false;
rtcWire.beginTransmission(DS3231_ADDR); rtcWire.write(0x00);
if (rtcWire.endTransmission() != 0) { xSemaphoreGive(rtcMutex); return false; }
if (rtcWire.requestFrom(DS3231_ADDR, 7) != 7) { xSemaphoreGive(rtcMutex); return false; }
uint8_t b[7]; for (int i=0;i<7;i++) b[i]=rtcWire.read();
xSemaphoreGive(rtcMutex);
t->tm_sec = bcdToDec(b[0]&0x7F); t->tm_min = bcdToDec(b[1]&0x7F);
t->tm_hour = bcdToDec(b[2]&0x3F); t->tm_mday = bcdToDec(b[4]&0x3F);
t->tm_mon = bcdToDec(b[5]&0x1F)-1; t->tm_year = bcdToDec(b[6])+100;
return true;
}
bool writeRTC(const struct tm* t) {
if (!timeSyncSt.rtcAvail) return false;
if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false;
rtcWire.beginTransmission(DS3231_ADDR); rtcWire.write(0x00);
rtcWire.write(decToBcd(t->tm_sec)); rtcWire.write(decToBcd(t->tm_min));
rtcWire.write(decToBcd(t->tm_hour)); rtcWire.write(decToBcd(t->tm_wday+1));
rtcWire.write(decToBcd(t->tm_mday)); rtcWire.write(decToBcd(t->tm_mon+1));
rtcWire.write(decToBcd(t->tm_year-100));
bool ok = (rtcWire.endTransmission() == 0);
xSemaphoreGive(rtcMutex);
return ok;
}
void ntpCallback(struct timeval* tv) {
timeSyncSt.synced = true;
timeSyncSt.lastSyncUs= (uint64_t)tv->tv_sec*1000000ULL + tv->tv_usec;
timeSyncSt.syncCount++;
struct tm t; time_t now = tv->tv_sec; localtime_r(&now, &t);
if (timeSyncSt.rtcAvail && writeRTC(&t)) timeSyncSt.rtcSyncCount++;
}
void rtcSyncTask(void* pv) {
while (1) {
if (timeSyncSt.rtcAvail) {
struct tm t;
if (readRTC(&t)) {
time_t now = mktime(&t);
struct timeval tv = {now, 0}; settimeofday(&tv, NULL);
timeSyncSt.synced = true;
timeSyncSt.lastSyncUs = (uint64_t)now * 1000000ULL;
timeSyncSt.rtcSyncCount++;
}
}
vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL));
}
}
// ─────────────────────────────────────────────
// 파일 코멘트
// ─────────────────────────────────────────────
void saveFileComments() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File f = SD_MMC.open("/comments.dat", FILE_WRITE);
if (f) {
f.write((uint8_t*)&commentCount, sizeof(commentCount));
f.write((uint8_t*)fileComments, sizeof(FileComment)*commentCount);
f.close();
}
xSemaphoreGive(sdMutex);
}
}
void loadFileComments() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD_MMC.exists("/comments.dat")) {
File f = SD_MMC.open("/comments.dat", FILE_READ);
if (f) {
f.read((uint8_t*)&commentCount, sizeof(commentCount));
if (commentCount > MAX_FILE_COMMENTS) commentCount = MAX_FILE_COMMENTS;
f.read((uint8_t*)fileComments, sizeof(FileComment)*commentCount);
f.close();
}
}
xSemaphoreGive(sdMutex);
}
}
const char* getComment(const char* fn) {
for (int i=0;i<commentCount;i++)
if (strcmp(fileComments[i].filename, fn)==0) return fileComments[i].comment;
return "";
}
void addComment(const char* fn, const char* cm) {
for (int i=0;i<commentCount;i++) {
if (strcmp(fileComments[i].filename,fn)==0) {
strncpy(fileComments[i].comment,cm,MAX_COMMENT_LEN-1); saveFileComments(); return;
}
}
if (commentCount < MAX_FILE_COMMENTS) {
strncpy(fileComments[commentCount].filename,fn,MAX_FILENAME_LEN-1);
strncpy(fileComments[commentCount].comment,cm,MAX_COMMENT_LEN-1);
commentCount++; saveFileComments();
}
}
// ─────────────────────────────────────────────
// CAN RX Task (ACAN2517FD 폴링)
// ─────────────────────────────────────────────
void canRxTask(void* pv) {
Serial.println("✓ CAN RX Task 시작 (Core 1, Pri 24)");
vTaskDelay(pdMS_TO_TICKS(500));
while (1) {
// ★ CAN 미초기화 시 sleep (CPU 독점 방지)
if (!canInitOK) {
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
// ★ 폴링 모드: isr() 호출 후 receive
gCAN.isr();
bool gotMsg = false;
CANFDMessage rxMsg;
while (gCAN.receive(rxMsg)) {
gotMsg = true;
CANFDLog log;
struct timeval tv; gettimeofday(&tv, NULL);
log.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
log.id = rxMsg.id;
log.flags = makeFlags(rxMsg);
log.dlc = rxMsg.len; // ACAN2517FD: len 필드에 DLC가 들어옴
log.len = rxMsg.len <= 15 ? dlcToLen[rxMsg.len] : rxMsg.len;
if (log.len > CANFD_MAX_DATA) log.len = CANFD_MAX_DATA;
memcpy(log.data, rxMsg.data, log.len);
if (ring_push(&canRing, &log)) totalMsgCount++;
}
// ★ 메시지 없을 때 5ms 대기 → Core1 idle task가 WDT 리셋 가능
// ★ 메시지 있을 때 2ms → 연속 수신 처리 유지
vTaskDelay(pdMS_TO_TICKS(gotMsg ? 2 : 5));
}
}
// ─────────────────────────────────────────────
// SD Flush Task (더블 버퍼 비동기 flush)
// ─────────────────────────────────────────────
void sdFlushTask(void* pv) {
Serial.println("✓ SD Flush Task 시작 (Core 0, Pri 9)");
uint32_t flushCnt = 0;
while (1) {
uint32_t flushSize;
if (xTaskNotifyWait(0, 0xFFFFFFFF, &flushSize, portMAX_DELAY) == pdTRUE) {
if (flushSize > 0 && flushSize <= FILE_BUFFER_SIZE) {
flushInProg = true;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(5000)) == pdTRUE) {
if (logFile && sdCardReady && loggingEnabled) {
uint32_t t0 = millis();
logFile.write(curFlushBuf, flushSize);
if (++flushCnt % 4 == 0) logFile.flush();
uint32_t el = millis()-t0;
if (el > 200) Serial.printf("⚠ SD Flush 지연: %dms (%d bytes)\n",el,(int)flushSize);
}
xSemaphoreGive(sdMutex);
}
flushInProg = false;
}
}
}
}
// ─────────────────────────────────────────────
// SD Write Task (링버퍼 Consumer)
// ─────────────────────────────────────────────
void sdWriteTask(void* pv) {
Serial.println("✓ SD Write Task 시작 (Core 0, Pri 8)");
CANFDLog msg;
while (1) {
bool worked = false;
if (ring_pop(&canRing, &msg)) {
worked = true;
// 모니터링 테이블 업데이트
bool found = false;
for (int i=0;i<RECENT_MSG_COUNT;i++) {
if (recentData[i].msg.id == msg.id) {
recentData[i].msg = msg; 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 = msg; recentData[i].count = 1; break;
}
}
}
// SD 로깅
if (loggingEnabled && sdCardReady) {
if (canLogCSV) {
// CSV 직접 쓰기
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1)) == pdTRUE) {
char line[256];
uint64_t rel = msg.timestamp_us - logStartUs;
char dat[CANFD_MAX_DATA*3+1]; int di=0;
for (int i=0;i<msg.len;i++) {
di += sprintf(&dat[di], "%02X", msg.data[i]);
if (i<msg.len-1) dat[di++]=' ';
}
dat[di]='\0';
bool isFD = (msg.flags & 0x20) != 0;
bool isBRS = (msg.flags & 0x10) != 0;
int ll = snprintf(line, sizeof(line),
"%llu,0x%08X,%d,%s,%s,%d,%s\n",
rel, msg.id, msg.dlc,
isFD?"FD":"CL", isBRS?"BRS":"-",
msg.len, dat);
if (logFile) { logFile.write((uint8_t*)line, ll); curFileSize += ll;
static int cc=0; if(++cc>=1000){logFile.flush();cc=0;} }
xSemaphoreGive(sdMutex);
}
} else if (canLogPCAP) {
// PCAP 더블 버퍼
bool isFD = (msg.flags & 0x20) != 0;
size_t recSize = sizeof(PcapPktHdr) + (isFD ? sizeof(SCFDFrame) : sizeof(SCFrame));
if (writeBufIdx + recSize > FILE_BUFFER_SIZE) {
while (flushInProg) vTaskDelay(1);
uint8_t* tmp=curWriteBuf; curWriteBuf=curFlushBuf; curFlushBuf=tmp;
flushBufSize=writeBufIdx; writeBufIdx=0;
if (sdFlushTaskH) xTaskNotify(sdFlushTaskH, flushBufSize, eSetValueWithOverwrite);
}
PcapPktHdr ph;
ph.ts_sec = (uint32_t)(msg.timestamp_us/1000000ULL);
ph.ts_usec = (uint32_t)(msg.timestamp_us%1000000ULL);
ph.incl_len= ph.orig_len = isFD ? sizeof(SCFDFrame) : sizeof(SCFrame);
memcpy(&curWriteBuf[writeBufIdx], &ph, sizeof(ph)); writeBufIdx+=sizeof(ph);
if (isFD) {
SCFDFrame fr; memset(&fr,0,sizeof(fr));
fr.can_id = msg.id;
if (msg.flags & 0x80) fr.can_id |= 0x80000000U;
fr.len = msg.len;
fr.flags = 0x04; // CANFD_FDF
if (msg.flags & 0x10) fr.flags |= 0x01; // BRS
memcpy(fr.data, msg.data, msg.len);
memcpy(&curWriteBuf[writeBufIdx], &fr, sizeof(fr)); writeBufIdx+=sizeof(fr);
} else {
SCFrame fr; memset(&fr,0,sizeof(fr));
fr.can_id = msg.id;
if (msg.flags & 0x80) fr.can_id |= 0x80000000U;
if (msg.flags & 0x40) fr.can_id |= 0x40000000U;
fr.can_dlc = msg.dlc;
memcpy(fr.data, msg.data, msg.len < 8 ? msg.len : 8);
memcpy(&curWriteBuf[writeBufIdx], &fr, sizeof(fr)); writeBufIdx+=sizeof(fr);
}
curFileSize += recSize;
} else {
// BIN 더블 버퍼 (가장 빠름, 손실 없음)
if (writeBufIdx + sizeof(CANFDLog) > FILE_BUFFER_SIZE) {
while (flushInProg) vTaskDelay(1);
uint8_t* tmp=curWriteBuf; curWriteBuf=curFlushBuf; curFlushBuf=tmp;
flushBufSize=writeBufIdx; writeBufIdx=0;
if (sdFlushTaskH) xTaskNotify(sdFlushTaskH, flushBufSize, eSetValueWithOverwrite);
}
memcpy(&curWriteBuf[writeBufIdx], &msg, sizeof(CANFDLog));
writeBufIdx += sizeof(CANFDLog);
curFileSize += sizeof(CANFDLog);
}
}
}
if (!worked) vTaskDelay(1);
}
}
// ─────────────────────────────────────────────
// TX Task
// ─────────────────────────────────────────────
void txTask(void* pv) {
while (1) {
uint32_t now = millis();
bool any = false;
for (int i=0;i<MAX_TX_MESSAGES;i++) {
TxMessage& tx = txMessages[i];
if (!tx.active || tx.intervalMs == 0) continue;
any = true;
if (now - tx.lastSent >= tx.intervalMs) {
CANFDMessage m;
m.id = tx.id;
m.ext = tx.extended;
m.len = tx.dlc < 16 ? dlcToLen[tx.dlc] : tx.dlc;
if (tx.isFD && tx.brs) m.type = CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH;
else if (tx.isFD) m.type = CANFDMessage::CANFD_NO_BIT_RATE_SWITCH;
else m.type = CANFDMessage::CAN_DATA;
memcpy(m.data, tx.data, m.len);
if (gCAN.tryToSend(m)) { totalTxCount++; tx.lastSent = now; }
}
}
vTaskDelay(any ? pdMS_TO_TICKS(1) : pdMS_TO_TICKS(10));
}
}
// ─────────────────────────────────────────────
// WebSocket 이벤트
// ─────────────────────────────────────────────
void wsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (type == WStype_CONNECTED) {
lastBroadcast = 0; return;
}
if (type != WStype_TEXT) return;
DynamicJsonDocument doc(4096);
if (deserializeJson(doc, payload)) return;
const char* cmd = doc["cmd"];
if (!cmd) return;
// ── 설정 ────────────────────────────────
if (strcmp(cmd, "getSettings") == 0) {
DynamicJsonDocument r(1024);
r["type"]="settings"; r["ssid"]=wifiSSID; r["password"]=wifiPassword;
r["staEnable"]=staMode; r["staSSID"]=staSSID;
r["speedIdx"]=speedPresetIdx; r["listenOnly"]=listenOnly;
r["staConnected"]=(WiFi.status()==WL_CONNECTED);
r["staIP"]=WiFi.localIP().toString();
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "saveSettings") == 0) {
const char* s=doc["ssid"]; if(s&&strlen(s)) strncpy(wifiSSID,s,31);
const char* p=doc["password"]; if(p) strncpy(wifiPassword,p,63);
staMode=doc["staEnable"]|false;
const char* ss=doc["staSSID"]; if(ss) strncpy(staSSID,ss,31);
const char* sp=doc["staPassword"]; if(sp) strncpy(staPassword,sp,63);
int si=doc["speedIdx"]|-1;
if(si>=0&&si<(int)NUM_SPEED_PRESETS) speedPresetIdx=si;
listenOnly=doc["listenOnly"]|false;
saveSettings();
webSocket.sendTXT(num,"{\"type\":\"settingsSaved\",\"success\":true}");
}
// ── CAN FD 재초기화 ─────────────────────
else if (strcmp(cmd, "reinitCAN") == 0) {
int si = doc["speedIdx"]|-1;
if (si>=0&&si<(int)NUM_SPEED_PRESETS) speedPresetIdx=si;
listenOnly = doc["listenOnly"]|false;
saveSettings();
bool ok = initCANFD();
DynamicJsonDocument r(256);
r["type"]="reinitResult";
r["success"]=ok;
r["preset"]=speedPresets[speedPresetIdx].name;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
// ── 로깅 ────────────────────────────────
else if (strcmp(cmd, "startLogging") == 0) {
if (!loggingEnabled && sdCardReady) {
const char* fmt=doc["format"];
canLogCSV = (fmt && strcmp(fmt,"csv") ==0);
canLogPCAP = (fmt && strcmp(fmt,"pcap")==0);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
time_t now2; struct tm ti; time(&now2); localtime_r(&now2,&ti);
struct timeval tv; gettimeofday(&tv,NULL);
logStartUs = (uint64_t)tv.tv_sec*1000000ULL+tv.tv_usec;
const char* ext = canLogPCAP?"pcap":(canLogCSV?"csv":"bin");
snprintf(curFilename,sizeof(curFilename),
"/CANFD_%04d%02d%02d_%02d%02d%02d.%s",
ti.tm_year+1900,ti.tm_mon+1,ti.tm_mday,
ti.tm_hour,ti.tm_min,ti.tm_sec,ext);
logFile = SD_MMC.open(curFilename, FILE_WRITE);
if (logFile) {
if (canLogCSV) {
logFile.println("Time_us,CAN_ID,DLC,Type,BRS,DataLen,Data");
logFile.flush();
} else if (canLogPCAP) {
writePcapGlobalHeader(logFile); logFile.flush();
}
logFile.close();
logFile = SD_MMC.open(curFilename, FILE_APPEND);
if (logFile) {
loggingEnabled=true; writeBufIdx=0; flushBufSize=0;
flushInProg=false; curFileSize=logFile.size();
Serial.printf("✓ 로깅 시작 [%s]: %s\n",
canLogPCAP?"PCAP":(canLogCSV?"CSV":"BIN"), curFilename);
}
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopLogging") == 0) {
if (loggingEnabled) {
loggingEnabled = false;
int wc=0; while(flushInProg&&wc++<500) vTaskDelay(10);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) {
if (writeBufIdx>0&&logFile) { logFile.write(curWriteBuf,writeBufIdx); writeBufIdx=0; }
if (logFile) { logFile.flush(); logFile.close(); }
curFilename[0]='\0'; flushBufSize=0;
xSemaphoreGive(sdMutex);
}
Serial.println("✓ 로깅 중지");
}
}
// ── 파일 목록 ────────────────────────────
else if (strcmp(cmd, "getFiles") == 0) {
if (sdCardReady && xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
DynamicJsonDocument r(8192);
r["type"]="files";
JsonArray fa = r.createNestedArray("files");
File root = SD_MMC.open("/");
if (root) {
File f = root.openNextFile(); int cnt=0;
while (f && cnt<60) {
if (!f.isDirectory()) {
const char* fn=f.name(); if(fn[0]=='/') fn++;
if(fn[0]!='.'&&strlen(fn)>0) {
JsonObject fo=fa.createNestedObject();
fo["name"]=fn; fo["size"]=f.size();
const char* cm=getComment(fn);
if(strlen(cm)>0) fo["comment"]=cm;
cnt++;
}
}
f.close(); f=root.openNextFile();
}
root.close();
}
xSemaphoreGive(sdMutex);
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
}
else if (strcmp(cmd, "deleteFile") == 0) {
const char* fn=doc["filename"];
if (fn&&strlen(fn)>0) {
String fp="/"+String(fn);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
bool ok=SD_MMC.exists(fp)&&SD_MMC.remove(fp);
xSemaphoreGive(sdMutex);
char r[64]; snprintf(r,sizeof(r),"{\"type\":\"deleteResult\",\"success\":%s}",ok?"true":"false");
webSocket.sendTXT(num,r);
}
}
}
else if (strcmp(cmd, "addComment") == 0) {
const char* fn=doc["filename"], *cm=doc["comment"];
if(fn&&cm) addComment(fn,cm);
}
// ── TX ───────────────────────────────────
else if (strcmp(cmd, "sendOnce") == 0) {
CANFDMessage m;
m.id = strtoul(doc["id"]|"0x0", NULL, 16);
m.ext = doc["ext"]|false;
bool isFD=doc["fd"]|false, brs=doc["brs"]|false;
if(isFD&&brs) m.type=CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH;
else if(isFD) m.type=CANFDMessage::CANFD_NO_BIT_RATE_SWITCH;
else m.type=CANFDMessage::CAN_DATA;
uint8_t dlc=doc["dlc"]|8;
m.len = dlc<16?dlcToLen[dlc]:dlc;
JsonArray da=doc["data"];
for(int i=0;i<(int)m.len&&i<64;i++) m.data[i]=da[i]|0;
if(gCAN.tryToSend(m)) totalTxCount++;
}
else if (strcmp(cmd, "setTxMsg") == 0) {
int idx=doc["idx"]|-1;
if(idx>=0&&idx<MAX_TX_MESSAGES) {
TxMessage& tx=txMessages[idx];
const char* ids=doc["id"]; tx.id=ids?strtoul(ids,NULL,16):0;
tx.extended=doc["ext"]|false;
tx.isFD=doc["fd"]|false; tx.brs=doc["brs"]|false;
uint8_t dlc=doc["dlc"]|8; tx.dlc=dlc;
JsonArray da=doc["data"];
for(int i=0;i<64;i++) tx.data[i]=(i<(int)da.size())?((uint8_t)da[i]):0;
tx.intervalMs=doc["interval"]|0;
tx.active=doc["active"]|false;
tx.lastSent=0;
}
}
// ── 시간 동기화 ──────────────────────────
else if (strcmp(cmd, "syncTime") == 0) {
struct tm ti;
ti.tm_year=((int)doc["year"])-1900; ti.tm_mon=((int)doc["month"])-1;
ti.tm_mday=doc["day"]|1; ti.tm_hour=doc["hour"]|0;
ti.tm_min=doc["minute"]|0; ti.tm_sec=doc["second"]|0;
time_t t=mktime(&ti);
struct timeval tv={t,0}; settimeofday(&tv,NULL);
timeSyncSt.synced=true; timeSyncSt.lastSyncUs=(uint64_t)t*1000000ULL;
timeSyncSt.syncCount++;
if(timeSyncSt.rtcAvail) writeRTC(&ti);
}
else if (strcmp(cmd, "hwReset") == 0) {
webSocket.sendTXT(num,"{\"type\":\"hwReset\"}");
delay(200); ESP.restart();
}
else if (strcmp(cmd, "clearStats") == 0) {
ring_clear(&canRing);
totalMsgCount=0; totalTxCount=0; msgPerSecond=0; lastMsgCount=0;
for(int i=0;i<RECENT_MSG_COUNT;i++) recentData[i].count=0;
}
}
// ─────────────────────────────────────────────
// Web Update Task
// ─────────────────────────────────────────────
void webUpdateTask(void* pv) {
vTaskDelay(pdMS_TO_TICKS(1000));
while (1) {
server.handleClient();
webSocket.loop();
// 초당 메시지 수 계산
uint32_t now2 = millis();
if (now2 - lastMsgCountMs >= 1000) {
uint32_t cur=totalMsgCount;
msgPerSecond = cur - lastMsgCount;
lastMsgCount = cur;
lastMsgCountMs= now2;
}
if (now2 - lastBroadcast < 500) { vTaskDelay(10); continue; }
lastBroadcast = now2;
if (webSocket.connectedClients() == 0) { vTaskDelay(10); continue; }
DynamicJsonDocument doc(12288);
doc["type"] = "update";
doc["logging"] = loggingEnabled;
doc["sdReady"] = sdCardReady;
doc["totalMsg"] = totalMsgCount;
doc["msgPerSec"] = msgPerSecond;
doc["totalTx"] = totalTxCount;
doc["fileSize"] = curFileSize;
doc["ringUsed"] = ring_count(&canRing);
doc["ringSize"] = CAN_RING_SIZE;
doc["dropped"] = canRing.dropped;
doc["timeSync"] = timeSyncSt.synced;
doc["rtcAvail"] = timeSyncSt.rtcAvail;
doc["rtcSyncCnt"]= timeSyncSt.rtcSyncCount;
doc["syncCount"] = timeSyncSt.syncCount;
doc["speedIdx"] = speedPresetIdx;
doc["apIP"] = WiFi.softAPIP().toString();
doc["staIP"] = WiFi.localIP().toString();
doc["staConnected"]= (WiFi.status()==WL_CONNECTED);
doc["speedName"] = speedPresets[speedPresetIdx].name;
doc["listenOnly"]= listenOnly;
doc["currentFile"]= (loggingEnabled&&curFilename[0]) ? String(curFilename) : "";
doc["logFormat"] = canLogPCAP?"pcap":(canLogCSV?"csv":"bin");
// 버스 부하율 추정 (FD는 가변이므로 근사)
const CANFDPreset& pr = speedPresets[speedPresetIdx];
uint32_t maxMPS = pr.arbBPS / 100; // 대략적 추정
float busLoad = maxMPS>0?(msgPerSecond*100.0f)/maxMPS:0;
if(busLoad>100.0f) busLoad=100.0f;
doc["busLoad"] = (int)busLoad;
time_t ts; time(&ts); doc["timestamp"]=(uint64_t)ts;
// 최근 메시지 (최대 30개)
JsonArray msgs = doc.createNestedArray("messages");
int cnt=0;
for(int i=0;i<RECENT_MSG_COUNT&&cnt<30;i++) {
if(recentData[i].count==0) continue;
const CANFDLog& m=recentData[i].msg;
JsonObject mo=msgs.createNestedObject();
mo["id"]=m.id; mo["dlc"]=m.dlc; mo["len"]=m.len;
mo["count"]=recentData[i].count;
bool isFD=(m.flags&0x20)!=0, isBRS=(m.flags&0x10)!=0, isExt=(m.flags&0x80)!=0;
mo["fd"]=isFD; mo["brs"]=isBRS; mo["ext"]=isExt;
JsonArray da=mo.createNestedArray("data");
for(int j=0;j<m.len&&j<16;j++) da.add(m.data[j]);
cnt++;
}
String json; size_t sz=serializeJson(doc,json);
if(sz>0&&sz<8192) webSocket.broadcastTXT(json);
}
}
// ─────────────────────────────────────────────
// Download Handler
// ─────────────────────────────────────────────
void handleDownload() {
if (!server.hasArg("file")) { server.send(400,"text/plain","Bad request"); return; }
String fname = "/" + server.arg("file");
bool wasLog = loggingEnabled;
if (wasLog) {
loggingEnabled=false;
int w=0; while(flushInProg&&w++<200) delay(10);
delay(50);
}
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(3000)) != pdTRUE) {
if(wasLog) loggingEnabled=true;
server.send(503,"text/plain","SD busy"); return;
}
if (!SD_MMC.exists(fname)) {
xSemaphoreGive(sdMutex); if(wasLog) loggingEnabled=true;
server.send(404,"text/plain","Not found"); return;
}
// 1-bit 모드로 전환 (WiFi 간섭 방지)
SD_MMC.end(); delay(50);
SD_MMC.setPins(PIN_SD_CLK, PIN_SD_CMD, PIN_SD_D0);
if (!SD_MMC.begin("/sdcard", true, false, 10000000)) {
xSemaphoreGive(sdMutex); if(wasLog) loggingEnabled=true;
server.send(500,"text/plain","SD 1-bit init fail"); return;
}
File file = SD_MMC.open(fname, FILE_READ);
if (!file) {
xSemaphoreGive(sdMutex); if(wasLog) loggingEnabled=true;
server.send(500,"text/plain","Open fail"); return;
}
size_t fsz = file.size();
server.setContentLength(fsz);
server.sendHeader("Content-Disposition","attachment; filename=\""+server.arg("file")+"\"");
server.sendHeader("Content-Type","application/octet-stream");
server.sendHeader("Connection","close");
server.send(200,"application/octet-stream","");
const size_t CHUNK=4096;
uint8_t* buf=(uint8_t*)malloc(CHUNK);
if(buf) {
size_t tot=0; WiFiClient cl=server.client(); cl.setNoDelay(true);
while(tot<fsz&&cl.connected()) {
size_t r=file.read(buf,min((size_t)(fsz-tot),CHUNK));
if(r==0) break;
size_t w=0;
while(w<r) { size_t wr=cl.write(buf+w,r-w); if(wr>0) w+=wr; else { yield();delay(1); } }
tot+=r; yield();
}
free(buf);
}
file.close();
// 4-bit 모드 복원
SD_MMC.end(); delay(50);
SD_MMC.setPins(PIN_SD_CLK,PIN_SD_CMD,PIN_SD_D0,PIN_SD_D1,PIN_SD_D2,PIN_SD_D3);
if(SD_MMC.begin("/sdcard",false,false,10000000)) sdCardReady=true;
xSemaphoreGive(sdMutex);
if(wasLog) loggingEnabled=true;
}
// ─────────────────────────────────────────────
// Setup
// ─────────────────────────────────────────────
void setup() {
Serial.begin(115200); delay(1000);
Serial.println("\n========================================");
Serial.println(" Byun CANFD Logger v1.0");
Serial.println(" MCP2518FD + ACAN2517FD 라이브러리");
Serial.println("========================================\n");
if (!initPSRAM()) {
Serial.println("✗ PSRAM 실패! Tools→PSRAM→OPI PSRAM 설정 확인");
while(1) { delay(1000); Serial.println("재업로드 필요"); }
}
loadSettings();
// SPI + CAN FD 초기화
// ★ CS는 라이브러리가 직접 관리 → SPI.begin에 CS 미전달 (HW SS 충돌 방지)
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, -1);
gSPI.setFrequency(CAN_SPI_CLOCK); // ★ SPI 클럭 설정
pinMode(PIN_CAN_CS, OUTPUT);
digitalWrite(PIN_CAN_CS, HIGH);
delay(50); // SPI 버스 안정화 대기
Serial.printf("SPI 클럭: %lu MHz\n", CAN_SPI_CLOCK / 1000000UL);
if (!initCANFD()) {
Serial.println("⚠ CAN FD 초기화 실패 → 설정 페이지에서 속도 변경 후 재초기화 하세요");
}
// Mutex
sdMutex = xSemaphoreCreateMutex();
rtcMutex = xSemaphoreCreateMutex();
initRTC();
esp_task_wdt_deinit();
// SD 카드 (SDIO 4-bit)
Serial.println("SD 카드 초기화...");
if (SD_MMC.setPins(PIN_SD_CLK,PIN_SD_CMD,PIN_SD_D0,PIN_SD_D1,PIN_SD_D2,PIN_SD_D3) &&
SD_MMC.begin("/sdcard",false,false,10000000)) {
sdCardReady=true;
Serial.printf("✓ SD: %llu MB\n", SD_MMC.cardSize()/1024/1024);
loadFileComments();
} else {
Serial.println("✗ SD 초기화 실패");
}
// WiFi: AP는 항상 켜짐 (STA 성공 여부 무관)
WiFi.setSleep(false);
if (staMode && strlen(staSSID)>0) {
WiFi.mode(WIFI_AP_STA);
} else {
WiFi.mode(WIFI_AP);
}
// ★ AP 먼저 기동 (STA 결과 무관하게 항상 접속 가능)
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
delay(200);
Serial.println();
Serial.println("╔══════════════════════════════════╗");
Serial.printf ("║ AP SSID : %-22s║\n", wifiSSID);
Serial.printf ("║ AP IP : %-22s║\n", WiFi.softAPIP().toString().c_str());
Serial.printf ("║ AP PW : %-22s║\n", wifiPassword);
Serial.println("╚══════════════════════════════════╝");
Serial.println(" ★ 위 AP로 항상 접속 가능합니다.");
if (staMode && strlen(staSSID)>0) {
Serial.printf(" STA 연결 시도: %s\n", staSSID);
WiFi.begin(staSSID, staPassword);
int at=0;
while(WiFi.status()!=WL_CONNECTED && at++<20) {
delay(500); Serial.print(".");
}
Serial.println();
if(WiFi.status()==WL_CONNECTED){
Serial.printf(" ✓ STA 연결 성공 IP: %s\n", WiFi.localIP().toString().c_str());
configTime(9*3600,0,"pool.ntp.org");
sntp_set_time_sync_notification_cb(ntpCallback);
} else {
Serial.println(" ✗ STA 연결 실패 → AP로만 접속하세요");
}
}
esp_wifi_set_max_tx_power(84);
webSocket.begin();
webSocket.onEvent(wsEvent);
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("/download", HTTP_GET, handleDownload);
server.begin();
Serial.println("✓ 웹 서버 시작");
// ── Task 생성 ────────────────────────────
xTaskCreatePinnedToCore(webUpdateTask,"WEB", 12288, NULL, 8, &webTaskH, 0);
xTaskCreatePinnedToCore(sdFlushTask, "FLUSH", 4096, NULL, 9, &sdFlushTaskH,0);
xTaskCreatePinnedToCore(sdWriteTask, "WRITE",12288, NULL, 8, &sdWriteTaskH,0);
xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1);
if(timeSyncSt.rtcAvail)
xTaskCreatePinnedToCore(rtcSyncTask,"RTC",3072, NULL, 0, &rtcTaskH, 0);
// canRxTask는 마지막에 생성 (Core 1 최고 우선순위)
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8192, NULL, 24, &canRxTaskH, 1);
Serial.println("\n========================================");
Serial.println(" Task 구성 완료");
Serial.println(" Core1: CAN_RX(24) TX(3)");
Serial.println(" Core0: FLUSH(9) WRITE(8) WEB(8)");
Serial.println("========================================\n");
}
// ─────────────────────────────────────────────
// Loop (Core 1, 저우선순위 - 상태 출력만)
// ─────────────────────────────────────────────
void loop() {
vTaskDelay(pdMS_TO_TICKS(30));
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 30000) {
lastPrint = millis();
Serial.printf("[상태] CAN:%s Ring:%u/%u drop:%u Msg/s:%u PSRAM:%dKB\n",
canInitOK?"OK":"FAIL",
ring_count(&canRing), CAN_RING_SIZE, canRing.dropped,
msgPerSecond, ESP.getFreePsram()/1024);
Serial.printf(" AP접속: SSID=%s IP=%s\n",
wifiSSID, WiFi.softAPIP().toString().c_str());
}
}