1488 lines
57 KiB
C++
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();
|
|
}
|
|
}
|