Files
esp32s3_canlogger_mcp2515/ESP32_CAN_Logger-a.ino
2026-03-11 21:36:35 +00:00

2718 lines
124 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 CAN Logger with Web Interface + Serial Terminal
* Version: 3.0 - RingBuffer Optimized (SPSC Lock-Free)
*
* ★ RingBuffer 핵심 변경사항 (v2.3 → v3.0):
* - FreeRTOS Queue (xQueueSend/xQueueReceive) → SPSC Lock-Free RingBuffer
* - CAN Queue 6000개 → CAN RingBuffer 8192개 (power-of-2, 비트마스크 연산)
* - Serial/Serial2 Queue 1200개 → RingBuffer 2048개
* - Producer(canRxTask) 완전 non-blocking: 큐 full 시 drop 없이 즉시 복귀
* - Consumer(sdWriteTask) 뮤텍스 없이 BIN 버퍼 처리 (hot path 최적화)
* - dropped 카운터: 웹 UI에서 손실 메시지 수 실시간 확인
* - __sync_synchronize() 메모리 배리어: 멀티코어 안전
*
* PSRAM 최적화 완전판:
* - 원본 기능 100% 유지
* - 대용량 버퍼/RingBuffer를 PSRAM에 할당
* - 웹서버, WebSocket, 모든 Task 포함
*
* Arduino IDE 설정:
* - Board: ESP32S3 Dev Module
* - PSRAM: OPI PSRAM ⭐ 필수!
* - Flash Size: 16MB (128Mb)
* - Partition: 16MB Flash (3MB APP/9.9MB FATFS)
*/
#include <Arduino.h>
#include <SPI.h>
#include <mcp2515.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"
#include "serial_terminal.h"
#include "serial2_terminal.h"
// GPIO 핀 정의
#define CAN_INT_PIN 4
#define SERIAL_TX_PIN 17
#define SERIAL_RX_PIN 18
#define SERIAL2_TX_PIN 6
#define SERIAL2_RX_PIN 7
// HSPI 핀 (CAN)
#define HSPI_MISO 13
#define HSPI_MOSI 11
#define HSPI_SCLK 12
#define HSPI_CS 10
// SDIO 4-bit Pins
#define SDIO_CLK 39
#define SDIO_CMD 38
#define SDIO_D0 40
#define SDIO_D1 41
#define SDIO_D2 42
#define SDIO_D3 21
// I2C2 핀 (RTC DS3231)
#define RTC_SDA 8
#define RTC_SCL 9
#define DS3231_ADDRESS 0x68
// ========================================
// ★ RingBuffer 크기 설정 (반드시 2의 거듭제곱!)
// ========================================
#define CAN_RING_SIZE 8192 // 이전 Queue 6000 → 8192 (2^13, ~1초@8Kfps)
#define SERIAL_RING_SIZE 2048 // 이전 Queue 1200 → 2048 (2^11)
// 기타 버퍼 크기
#define FILE_BUFFER_SIZE 65536 // 64KB 더블버퍼 (SD BIN write)
#define SERIAL_CSV_BUFFER_SIZE 32768 // 32KB Serial CSV 버퍼
#define SERIAL2_CSV_BUFFER_SIZE 32768 // 32KB Serial2 CSV 버퍼
#define MAX_FILENAME_LEN 64
#define RECENT_MSG_COUNT 100
#define MAX_TX_MESSAGES 20
#define MAX_COMMENT_LEN 128
#define MAX_SERIAL_LINE_LEN 64
#define RTC_SYNC_INTERVAL_MS 60000
#define VOLTAGE_CHECK_INTERVAL_MS 5000
#define LOW_VOLTAGE_THRESHOLD 3.0
#define MONITORING_VOLT 5
#define MAX_GRAPH_SIGNALS 20
#define MAX_SEQUENCES 10
#define MAX_FILE_COMMENTS 50
// ========================================
// 구조체 정의
// ========================================
struct CANMessage {
uint64_t timestamp_us;
uint32_t id;
uint8_t dlc;
uint8_t flags; // bit7=EFF(29bit확장), bit6=RTR, bit5=ERR ← ★ PCAP EFF 복원용
uint8_t data[8];
} __attribute__((packed)); // 22 bytes
// ========================================
// ★ PCAP 구조체 (Wireshark 호환)
// LINKTYPE_CAN_SOCKETCAN = 227
// ========================================
struct PcapGlobalHeader {
uint32_t magic_number; // 0xa1b2c3d4
uint16_t version_major; // 2
uint16_t version_minor; // 4
int32_t thiszone; // 0
uint32_t sigfigs; // 0
uint32_t snaplen; // 65535
uint32_t network; // 227 = LINKTYPE_CAN_SOCKETCAN
} __attribute__((packed)); // 24 bytes
struct PcapPacketHeader {
uint32_t ts_sec; // 타임스탬프 초
uint32_t ts_usec; // 타임스탬프 마이크로초
uint32_t incl_len; // 캡처 길이 (16)
uint32_t orig_len; // 원본 길이 (16)
} __attribute__((packed)); // 16 bytes
struct SocketCANFrame {
uint32_t can_id; // CAN ID (bit31=EFF, bit30=RTR)
uint8_t can_dlc; // 데이터 길이
uint8_t pad;
uint8_t res0;
uint8_t res1;
uint8_t data[8];
} __attribute__((packed)); // 16 bytes
// PCAP 패킷 1개 = PcapPacketHeader(16) + SocketCANFrame(16) = 32 bytes
struct PcapRecord {
PcapPacketHeader hdr;
SocketCANFrame frame;
} __attribute__((packed)); // 32 bytes
// ★ 컴파일 타임 크기 검증 (틀리면 빌드 에러로 즉시 알 수 있음)
static_assert(sizeof(PcapGlobalHeader) == 24, "PcapGlobalHeader must be 24 bytes");
static_assert(sizeof(PcapPacketHeader) == 16, "PcapPacketHeader must be 16 bytes");
static_assert(sizeof(SocketCANFrame) == 16, "SocketCANFrame must be 16 bytes");
static_assert(sizeof(PcapRecord) == 32, "PcapRecord must be 32 bytes");
struct SerialMessage {
uint64_t timestamp_us;
uint16_t length;
uint8_t data[MAX_SERIAL_LINE_LEN];
bool isTx;
} __attribute__((packed));
struct SerialSettings {
uint32_t baudRate;
uint8_t dataBits;
uint8_t parity;
uint8_t stopBits;
};
struct RecentCANData {
CANMessage msg;
uint32_t count;
};
struct TxMessage {
uint32_t id;
bool extended;
uint8_t dlc;
uint8_t data[8];
uint32_t interval;
uint32_t lastSent;
bool active;
};
struct SequenceStep {
uint32_t canId;
bool extended;
uint8_t dlc;
uint8_t data[8];
uint32_t delayMs;
};
struct CANSequence {
char name[32];
SequenceStep steps[20];
uint8_t stepCount;
uint8_t repeatMode;
uint32_t repeatCount;
};
struct SequenceRuntime {
bool running;
uint8_t currentStep;
uint32_t currentRepeat;
uint32_t lastStepTime;
int8_t activeSequenceIndex;
};
struct FileComment {
char filename[MAX_FILENAME_LEN];
char comment[MAX_COMMENT_LEN];
};
// ★ Arduino IDE auto-prototype 오류 방지: TriggerCondition을 상단에 정의
struct TriggerCondition {
uint32_t canId;
uint8_t startBit;
uint8_t bitLength;
char op[3];
int64_t value;
bool enabled;
};
struct TimeSyncStatus {
bool synchronized;
uint64_t lastSyncTime;
int32_t offsetUs;
uint32_t syncCount;
bool rtcAvailable;
uint32_t rtcSyncCount;
} timeSyncStatus = {false, 0, 0, 0, false, 0};
struct PowerStatus {
float voltage;
float minVoltage;
bool lowVoltage;
uint32_t lastCheck;
uint32_t lastMinReset;
} powerStatus = {0.0, 999.9, false, 0, 0};
enum MCP2515Mode {
MCP_MODE_NORMAL = 0,
MCP_MODE_LISTEN_ONLY = 1,
MCP_MODE_LOOPBACK = 2,
MCP_MODE_TRANSMIT = 3
};
// ========================================
// ★★★ SPSC Lock-Free RingBuffer 정의 ★★★
//
// 설계 원칙:
// - head: Producer만 쓰기 (canRxTask / serialRxTask)
// - tail: Consumer만 쓰기 (sdWriteTask / webUpdateTask)
// - __sync_synchronize(): 멀티코어 메모리 배리어
// - 2의 거듭제곱 size + bitmask: 나눗셈 연산 제거
// - dropped: 오버플로우 시 drop 카운트 (진단용)
// ========================================
struct CANRingBuffer {
volatile uint32_t head; // Producer write index (절대값, 증가만)
volatile uint32_t tail; // Consumer read index (절대값, 증가만)
uint32_t size; // 버퍼 용량 (반드시 2의 거듭제곱)
uint32_t mask; // size - 1 (비트마스크 인덱싱용)
CANMessage* buf; // PSRAM 할당 데이터 배열
volatile uint32_t dropped; // 버퍼 full 시 drop된 메시지 수
};
struct SerialRingBuffer {
volatile uint32_t head;
volatile uint32_t tail;
uint32_t size;
uint32_t mask;
SerialMessage* buf;
volatile uint32_t dropped;
};
// RingBuffer 전역 인스턴스
CANRingBuffer canRing;
SerialRingBuffer serialRing;
SerialRingBuffer serial2Ring;
// ── Producer Push (IRAM에 배치: ISR 경로 최적화) ──────────────────────────
// 반환: true=성공, false=버퍼 full(drop)
static inline bool IRAM_ATTR ring_can_push(CANRingBuffer* rb, const CANMessage* msg) {
uint32_t h = rb->head;
if ((h - rb->tail) >= rb->size) { // 버퍼 full 체크
rb->dropped++;
return false; // drop: 손실 카운트 후 즉시 반환
}
// ★ packed struct(22bytes) → 비정렬 PSRAM 주소에 안전하게 memcpy
memcpy(&rb->buf[h & rb->mask], msg, sizeof(CANMessage));
__sync_synchronize(); // ★ 메모리 배리어: buf 쓰기 완료 후 head 갱신
rb->head = h + 1;
return true;
}
// ── Consumer Pop ──────────────────────────────────────────────────────────
static inline bool ring_can_pop(CANRingBuffer* rb, CANMessage* msg) {
uint32_t t = rb->tail;
if (rb->head == t) return false; // 빈 버퍼
// ★ packed struct(22bytes) → 비정렬 PSRAM 주소에서 안전하게 memcpy
memcpy(msg, &rb->buf[t & rb->mask], sizeof(CANMessage));
__sync_synchronize(); // ★ 메모리 배리어: 데이터 읽기 완료 후 tail 갱신
rb->tail = t + 1;
return true;
}
// ── 현재 사용 중인 슬롯 수 ────────────────────────────────────────────────
static inline uint32_t ring_can_count(const CANRingBuffer* rb) {
return rb->head - rb->tail; // uint32_t 언더플로우 자동 처리
}
// ── Serial RingBuffer (동일한 SPSC 패턴) ─────────────────────────────────
static inline bool ring_serial_push(SerialRingBuffer* rb, const SerialMessage* msg) {
uint32_t h = rb->head;
if ((h - rb->tail) >= rb->size) {
rb->dropped++;
return false;
}
rb->buf[h & rb->mask] = *msg;
__sync_synchronize();
rb->head = h + 1;
return true;
}
static inline bool ring_serial_pop(SerialRingBuffer* rb, SerialMessage* msg) {
uint32_t t = rb->tail;
if (rb->head == t) return false;
*msg = rb->buf[t & rb->mask];
__sync_synchronize();
rb->tail = t + 1;
return true;
}
static inline uint32_t ring_serial_count(const SerialRingBuffer* rb) {
return rb->head - rb->tail;
}
// ── RingBuffer 초기화/클리어 ─────────────────────────────────────────────
static inline void ring_can_clear(CANRingBuffer* rb) {
rb->tail = rb->head; // head == tail → 빈 상태
rb->dropped = 0;
}
static inline void ring_serial_clear(SerialRingBuffer* rb) {
rb->tail = rb->head;
rb->dropped = 0;
}
// ========================================
// PSRAM 할당 변수
// ========================================
char *serialCsvBuffer = nullptr;
char *serial2CsvBuffer = nullptr;
RecentCANData *recentData = nullptr;
TxMessage *txMessages = nullptr;
CANSequence *sequences = nullptr;
FileComment *fileComments = nullptr;
// WiFi 설정
char wifiSSID[32] = "Byun_CAN_Logger";
char wifiPassword[64]= "12345678";
bool enableSTAMode = false;
char staSSID[32] = "";
char staPassword[64] = "";
// Serial 설정
SerialSettings serialSettings = {115200, 8, 0, 1};
SerialSettings serial2Settings = {115200, 8, 0, 1};
// 전역 객체
SPIClass hspi(HSPI);
#define MCP_CRYSTAL MCP_16MHZ
MCP2515 mcp2515(HSPI_CS, 40000000, &hspi);
HardwareSerial SerialComm(1);
HardwareSerial Serial2Comm(2);
WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);
Preferences preferences;
// FreeRTOS 핸들 (Mutex / Task만 유지, Queue 핸들 제거됨)
SemaphoreHandle_t sdMutex = NULL;
SemaphoreHandle_t rtcMutex = NULL;
SemaphoreHandle_t serialMutex = NULL;
SemaphoreHandle_t serial2Mutex = NULL;
TaskHandle_t canRxTaskHandle = NULL;
TaskHandle_t sdWriteTaskHandle = NULL;
TaskHandle_t webTaskHandle = NULL;
TaskHandle_t rtcTaskHandle = NULL;
TaskHandle_t serialRxTaskHandle = NULL;
TaskHandle_t serial2RxTaskHandle= NULL;
// 로깅 상태
volatile bool loggingEnabled = false;
volatile bool serialLoggingEnabled = false;
volatile bool serial2LoggingEnabled = false;
volatile bool sdCardReady = false;
uint32_t lastBroadcast = 0; // webUpdateTask broadcast 주기 제어
// Auto Trigger
#define MAX_TRIGGERS 8
TriggerCondition startTriggers[MAX_TRIGGERS];
TriggerCondition stopTriggers[MAX_TRIGGERS];
int startTriggerCount = 0;
int stopTriggerCount = 0;
String startFormula = "";
String stopFormula = "";
bool autoTriggerEnabled = false;
char startLogicOp[4] = "OR";
char stopLogicOp[4] = "OR";
bool autoTriggerActive = false;
bool autoTriggerLogCSV = false;
bool autoTriggerLogPCAP = false; // ★ PCAP 추가
bool savedCanLogFormatCSV= false;
bool savedCanLogFormatPCAP=false; // ★ PCAP 추가
// 파일 핸들
File logFile;
File serialLogFile;
File serial2LogFile;
char currentFilename[MAX_FILENAME_LEN];
char currentSerialFilename[MAX_FILENAME_LEN];
char currentSerial2Filename[MAX_FILENAME_LEN];
// ★ 더블 버퍼링 (SD BIN write 전용)
uint8_t* fileBuffer1 = NULL;
uint8_t* fileBuffer2 = NULL;
uint8_t* currentWriteBuffer = NULL;
uint8_t* currentFlushBuffer = NULL;
uint32_t writeBufferIndex = 0;
uint32_t flushBufferSize = 0;
volatile bool flushInProgress = false;
TaskHandle_t sdFlushTaskHandle = NULL;
uint32_t serialCsvIndex = 0;
uint32_t serial2CsvIndex = 0;
volatile uint32_t currentFileSize = 0;
volatile uint32_t currentSerialFileSize = 0;
volatile uint32_t currentSerial2FileSize= 0;
volatile bool canLogFormatCSV = false;
volatile bool canLogFormatPCAP = false; // ★ PCAP 추가
uint32_t pcapDbgCount = 0; // ★ PCAP 시리얼 디버그 카운터
volatile bool serialLogFormatCSV = true;
volatile bool serial2LogFormatCSV = true;
volatile uint64_t canLogStartTime = 0;
volatile uint64_t serialLogStartTime = 0;
volatile uint64_t serial2LogStartTime = 0;
// 통계
MCP2515Mode currentMcpMode;
SoftWire rtcWire(RTC_SDA, RTC_SCL);
char rtcSyncBuffer[20];
CAN_SPEED currentCanSpeed = CAN_1000KBPS;
const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"};
CAN_SPEED canSpeedValues[]= {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS};
volatile uint32_t totalMsgCount = 0; // Core 1 write / Core 0 read → volatile 필수
volatile uint32_t msgPerSecond = 0; // Core 0 webUpdateTask에서 계산
uint32_t lastMsgCountTime= 0;
uint32_t lastMsgCount = 0;
volatile uint32_t totalSerialRxCount = 0;
volatile uint32_t totalSerialTxCount = 0;
volatile uint32_t totalSerial2RxCount = 0;
volatile uint32_t totalSerial2TxCount = 0;
uint32_t totalTxCount = 0;
uint8_t sequenceCount = 0;
SequenceRuntime seqRuntime = {false, 0, 0, 0, -1};
int commentCount = 0;
// Forward declarations
void IRAM_ATTR canISR(); // 초경량 ISR 전방선언
void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length);
void resetMCP2515();
// ========================================
// MCP2515 리셋
// ========================================
void resetMCP2515() {
Serial.println("🔄 MCP2515 리셋 시작...");
if (loggingEnabled) { /* 필요시 버퍼 플러시 */ }
// ★ 큐 대신 RingBuffer 클리어
ring_can_clear(&canRing);
Serial.println(" 1. 하드웨어 리셋...");
digitalWrite(HSPI_CS, LOW);
delayMicroseconds(100);
digitalWrite(HSPI_CS, HIGH);
delay(100);
Serial.println(" 2. 소프트웨어 리셋...");
mcp2515.reset();
delay(100);
preferences.begin("can-logger", true);
int speedIndex = preferences.getInt("can_speed", 3);
if (speedIndex >= 0 && speedIndex < 4) currentCanSpeed = canSpeedValues[speedIndex];
int savedMode = preferences.getInt("mcp_mode", 1);
if (savedMode >= 0 && savedMode <= 3) currentMcpMode = (MCP2515Mode)savedMode;
preferences.end();
mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL);
delay(10);
mcp2515.setFilterMask(MCP2515::MASK0, false, 0x00000000);
mcp2515.setFilter(MCP2515::RXF0, false, 0x00000000);
mcp2515.setFilter(MCP2515::RXF1, false, 0x00000000);
mcp2515.setFilterMask(MCP2515::MASK1, true, 0x00000000);
mcp2515.setFilter(MCP2515::RXF2, true, 0x00000000);
mcp2515.setFilter(MCP2515::RXF3, true, 0x00000000);
mcp2515.setFilter(MCP2515::RXF4, true, 0x00000000);
mcp2515.setFilter(MCP2515::RXF5, true, 0x00000000);
delay(10);
if (currentMcpMode == MCP_MODE_NORMAL) mcp2515.setNormalMode();
else if (currentMcpMode == MCP_MODE_LOOPBACK) mcp2515.setLoopbackMode();
else mcp2515.setListenOnlyMode();
delay(50);
struct can_frame dummyFrame;
int clearCount = 0;
while (mcp2515.readMessage(&dummyFrame) == MCP2515::ERROR_OK) {
if (clearCount++ > 100) break;
}
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
mcp2515.clearTXInterrupts();
mcp2515.clearMERR();
mcp2515.clearERRIF();
uint8_t errorFlag = mcp2515.getErrorFlags();
uint8_t txErr = mcp2515.errorCountTX();
uint8_t rxErr = mcp2515.errorCountRX();
Serial.printf(" 에러 상태: EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr);
if (errorFlag != 0 || txErr > 0 || rxErr > 0) {
mcp2515.reset(); delay(50);
mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10);
if (currentMcpMode == MCP_MODE_NORMAL) mcp2515.setNormalMode();
else if (currentMcpMode == MCP_MODE_LOOPBACK) mcp2515.setLoopbackMode();
else mcp2515.setListenOnlyMode();
delay(50);
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
}
totalMsgCount = 0;
lastMsgCount = 0;
msgPerSecond = 0;
for (int i = 0; i < RECENT_MSG_COUNT; i++) recentData[i].count = 0;
Serial.println("✅ MCP2515 리셋 완료!");
}
// ========================================
// PSRAM 초기화 (RingBuffer 버전)
// ========================================
bool initPSRAM() {
Serial.println("\n========================================");
Serial.println(" PSRAM 메모리 할당 (RingBuffer 버전)");
Serial.println("========================================");
if (!psramFound()) {
Serial.println("✗ PSRAM을 찾을 수 없습니다!");
return false;
}
Serial.printf("✓ PSRAM 총 용량: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
Serial.printf("✓ PSRAM 여유: %d KB\n\n", ESP.getFreePsram() / 1024);
// ── 더블 버퍼 (SD BIN write) ──────────────────────────────
fileBuffer1 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
fileBuffer2 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
if (!fileBuffer1 || !fileBuffer2) { Serial.println("✗ fileBuffer 할당 실패"); return false; }
currentWriteBuffer = fileBuffer1;
currentFlushBuffer = fileBuffer2;
writeBufferIndex = 0;
flushBufferSize = 0;
Serial.printf("✓ Double Buffer: 2 × %d KB\n", FILE_BUFFER_SIZE / 1024);
// ── Serial CSV 버퍼 ────────────────────────────────────────
serialCsvBuffer = (char*)ps_malloc(SERIAL_CSV_BUFFER_SIZE);
serial2CsvBuffer = (char*)ps_malloc(SERIAL2_CSV_BUFFER_SIZE);
if (!serialCsvBuffer || !serial2CsvBuffer) { Serial.println("✗ SerialCsvBuffer 할당 실패"); return false; }
Serial.printf("✓ Serial CSV Buffer: 2 × %d KB\n", SERIAL_CSV_BUFFER_SIZE / 1024);
// ── 기타 PSRAM 데이터 ────────────────────────────────────
recentData = (RecentCANData*)ps_calloc(RECENT_MSG_COUNT, sizeof(RecentCANData));
txMessages = (TxMessage*) ps_calloc(MAX_TX_MESSAGES, sizeof(TxMessage));
sequences = (CANSequence*) ps_calloc(MAX_SEQUENCES, sizeof(CANSequence));
fileComments = (FileComment*) ps_calloc(MAX_FILE_COMMENTS,sizeof(FileComment));
if (!recentData || !txMessages || !sequences || !fileComments) {
Serial.println("✗ 데이터 버퍼 할당 실패"); return false;
}
// ========================================
// ★★★ RingBuffer 할당 (FreeRTOS Queue 대체) ★★★
// ========================================
Serial.println("\n📦 RingBuffer 할당 (Lock-Free SPSC)...");
// CAN RingBuffer
canRing.buf = (CANMessage*)ps_malloc(CAN_RING_SIZE * sizeof(CANMessage));
if (!canRing.buf) { Serial.println("✗ CAN RingBuffer 할당 실패"); return false; }
canRing.size = CAN_RING_SIZE;
canRing.mask = CAN_RING_SIZE - 1;
canRing.head = 0;
canRing.tail = 0;
canRing.dropped = 0;
Serial.printf("✓ CAN RingBuffer: %d 슬롯 × %d bytes = %.2f KB\n",
CAN_RING_SIZE, sizeof(CANMessage),
(float)(CAN_RING_SIZE * sizeof(CANMessage)) / 1024.0f);
// Serial RingBuffer
serialRing.buf = (SerialMessage*)ps_malloc(SERIAL_RING_SIZE * sizeof(SerialMessage));
if (!serialRing.buf) { Serial.println("✗ Serial RingBuffer 할당 실패"); return false; }
serialRing.size = SERIAL_RING_SIZE;
serialRing.mask = SERIAL_RING_SIZE - 1;
serialRing.head = 0;
serialRing.tail = 0;
serialRing.dropped = 0;
Serial.printf("✓ SER RingBuffer: %d 슬롯 × %d bytes = %.2f KB\n",
SERIAL_RING_SIZE, sizeof(SerialMessage),
(float)(SERIAL_RING_SIZE * sizeof(SerialMessage)) / 1024.0f);
// Serial2 RingBuffer
serial2Ring.buf = (SerialMessage*)ps_malloc(SERIAL_RING_SIZE * sizeof(SerialMessage));
if (!serial2Ring.buf) { Serial.println("✗ Serial2 RingBuffer 할당 실패"); return false; }
serial2Ring.size = SERIAL_RING_SIZE;
serial2Ring.mask = SERIAL_RING_SIZE - 1;
serial2Ring.head = 0;
serial2Ring.tail = 0;
serial2Ring.dropped = 0;
Serial.printf("✓ SER2 RingBuffer: %d 슬롯 × %d bytes = %.2f KB\n",
SERIAL_RING_SIZE, sizeof(SerialMessage),
(float)(SERIAL_RING_SIZE * sizeof(SerialMessage)) / 1024.0f);
Serial.println("========================================");
Serial.printf("✓ PSRAM 남은 용량: %.2f KB\n", (float)ESP.getFreePsram() / 1024.0f);
Serial.println("========================================\n");
return true;
}
// ========================================
// 설정 저장/로드
// ========================================
void loadSerialSettings() {
serialSettings.baudRate = preferences.getUInt("ser_baud", 115200);
serialSettings.dataBits = preferences.getUChar("ser_data", 8);
serialSettings.parity = preferences.getUChar("ser_parity", 0);
serialSettings.stopBits = preferences.getUChar("ser_stop", 1);
serial2Settings.baudRate = preferences.getUInt("ser2_baud", 115200);
serial2Settings.dataBits = preferences.getUChar("ser2_data", 8);
serial2Settings.parity = preferences.getUChar("ser2_parity", 0);
serial2Settings.stopBits = preferences.getUChar("ser2_stop", 1);
}
void saveSerialSettings() {
preferences.putUInt("ser_baud", serialSettings.baudRate);
preferences.putUChar("ser_data", serialSettings.dataBits);
preferences.putUChar("ser_parity", serialSettings.parity);
preferences.putUChar("ser_stop", serialSettings.stopBits);
preferences.putUInt("ser2_baud", serial2Settings.baudRate);
preferences.putUChar("ser2_data", serial2Settings.dataBits);
preferences.putUChar("ser2_parity", serial2Settings.parity);
preferences.putUChar("ser2_stop", serial2Settings.stopBits);
}
void applySerialSettings() {
uint32_t config = SERIAL_8N1;
if (serialSettings.dataBits == 5) {
if (serialSettings.parity == 0) config = SERIAL_5N1;
else if (serialSettings.parity == 1) config = SERIAL_5E1;
else config = SERIAL_5O1;
} else if (serialSettings.dataBits == 6) {
if (serialSettings.parity == 0) config = SERIAL_6N1;
else if (serialSettings.parity == 1) config = SERIAL_6E1;
else config = SERIAL_6O1;
} else if (serialSettings.dataBits == 7) {
if (serialSettings.parity == 0) config = SERIAL_7N1;
else if (serialSettings.parity == 1) config = SERIAL_7E1;
else config = SERIAL_7O1;
} else {
if (serialSettings.parity == 0) config = SERIAL_8N1;
else if (serialSettings.parity == 1) config = SERIAL_8E1;
else config = SERIAL_8O1;
}
if (serialSettings.stopBits == 2) config |= 0x3000;
SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN);
SerialComm.setRxBufferSize(2048);
uint32_t config2 = SERIAL_8N1;
if (serial2Settings.dataBits == 5) {
if (serial2Settings.parity == 0) config2 = SERIAL_5N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_5E1;
else config2 = SERIAL_5O1;
} else if (serial2Settings.dataBits == 6) {
if (serial2Settings.parity == 0) config2 = SERIAL_6N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_6E1;
else config2 = SERIAL_6O1;
} else if (serial2Settings.dataBits == 7) {
if (serial2Settings.parity == 0) config2 = SERIAL_7N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_7E1;
else config2 = SERIAL_7O1;
} else {
if (serial2Settings.parity == 0) config2 = SERIAL_8N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_8E1;
else config2 = SERIAL_8O1;
}
if (serial2Settings.stopBits == 2) config2 |= 0x3000;
Serial2Comm.begin(serial2Settings.baudRate, config2, SERIAL2_RX_PIN, SERIAL2_TX_PIN);
Serial2Comm.setRxBufferSize(2048);
}
// ========================================
// ★ PCAP 전역 헤더 쓰기 (파일 오픈 직후 1회 호출)
// ========================================
bool writePcapGlobalHeader(File &f) {
PcapGlobalHeader gh;
gh.magic_number = 0xa1b2c3d4;
gh.version_major = 2;
gh.version_minor = 4;
gh.thiszone = 0;
gh.sigfigs = 0;
gh.snaplen = 65535;
gh.network = 227; // LINKTYPE_CAN_SOCKETCAN
return f.write((uint8_t*)&gh, sizeof(gh)) == sizeof(gh);
}
// ========================================
void loadSettings() {
preferences.begin("can-logger", false);
preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID));
preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword));
enableSTAMode = preferences.getBool("sta_enable", false);
preferences.getString("sta_ssid", staSSID, sizeof(staSSID));
preferences.getString("sta_pass", staPassword, sizeof(staPassword));
if (strlen(wifiSSID) == 0) strcpy(wifiSSID, "Byun_CAN_Logger");
if (strlen(wifiPassword)== 0) strcpy(wifiPassword,"12345678");
int speedIndex = preferences.getInt("can_speed", 3);
if (speedIndex >= 0 && speedIndex < 4) currentCanSpeed = canSpeedValues[speedIndex];
int savedMode = preferences.getInt("mcp_mode", 1);
if (savedMode >= 0 && savedMode <= 3) currentMcpMode = (MCP2515Mode)savedMode;
savedCanLogFormatCSV = preferences.getBool("can_format_csv", false);
savedCanLogFormatPCAP = preferences.getBool("can_format_pcap", false);
// 둘 다 false면 BIN이 기본값
loadSerialSettings();
preferences.end();
}
void saveSettings() {
preferences.begin("can-logger", false);
preferences.putString("wifi_ssid", wifiSSID);
preferences.putString("wifi_pass", wifiPassword);
preferences.putBool("sta_enable", enableSTAMode);
preferences.putString("sta_ssid", staSSID);
preferences.putString("sta_pass", staPassword);
for (int i = 0; i < 4; i++) {
if (canSpeedValues[i] == currentCanSpeed) { preferences.putInt("can_speed", i); break; }
}
preferences.putInt("mcp_mode", (int)currentMcpMode);
preferences.putBool("can_format_csv", savedCanLogFormatCSV);
preferences.putBool("can_format_pcap", savedCanLogFormatPCAP);
saveSerialSettings();
preferences.end();
}
// ========================================
// Auto Trigger
// ========================================
int64_t extractBits(uint8_t *data, uint8_t startBit, uint8_t bitLength) {
if (bitLength == 0 || bitLength > 64 || startBit >= 64) return 0;
int64_t result = 0;
for (int i = 0; i < bitLength; i++) {
uint8_t bitPos = startBit + i;
uint8_t byteIdx = bitPos / 8;
uint8_t bitIdx = 7 - (bitPos % 8);
if (byteIdx < 8 && (data[byteIdx] & (1 << bitIdx)))
result |= (1LL << (bitLength - 1 - i));
}
return result;
}
bool checkCondition(TriggerCondition &trigger, uint8_t *data) {
if (!trigger.enabled) return false;
int64_t v = extractBits(data, trigger.startBit, trigger.bitLength);
if (strcmp(trigger.op, "==") == 0) return v == trigger.value;
if (strcmp(trigger.op, "!=") == 0) return v != trigger.value;
if (strcmp(trigger.op, ">") == 0) return v > trigger.value;
if (strcmp(trigger.op, "<") == 0) return v < trigger.value;
if (strcmp(trigger.op, ">=") == 0) return v >= trigger.value;
if (strcmp(trigger.op, "<=") == 0) return v <= trigger.value;
return false;
}
void checkAutoTriggers(struct can_frame &frame) {
if (!autoTriggerEnabled || !sdCardReady) return;
// Start 조건
if (!loggingEnabled && startTriggerCount > 0) {
bool result = (strcmp(startLogicOp, "AND") == 0);
bool anyMatch = false;
for (int i = 0; i < startTriggerCount; i++) {
if (startTriggers[i].canId == frame.can_id && startTriggers[i].enabled) {
bool match = checkCondition(startTriggers[i], frame.data);
anyMatch = true;
if (strcmp(startLogicOp, "AND") == 0) {
result = result && match; if (!match) break;
} else { if (match) { result = true; break; } }
}
}
if (anyMatch && result) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
time_t now; struct tm timeinfo;
time(&now); localtime_r(&now, &timeinfo);
struct timeval tv; gettimeofday(&tv, NULL);
canLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
canLogFormatCSV = autoTriggerLogCSV;
canLogFormatPCAP = autoTriggerLogPCAP;
const char* ext;
if (canLogFormatPCAP) ext = "pcap";
else if (canLogFormatCSV) ext = "csv";
else ext = "bin";
snprintf(currentFilename, sizeof(currentFilename),
"/CAN_%04d%02d%02d_%02d%02d%02d.%s",
timeinfo.tm_year+1900, timeinfo.tm_mon+1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, ext);
logFile = SD_MMC.open(currentFilename, FILE_WRITE);
if (logFile) {
if (canLogFormatCSV) { logFile.println("Time_us,CAN_ID,DLC,Data"); logFile.flush(); }
else if (canLogFormatPCAP) { writePcapGlobalHeader(logFile); logFile.flush(); }
logFile.close();
logFile = SD_MMC.open(currentFilename, FILE_APPEND);
if (logFile) {
loggingEnabled = true;
autoTriggerActive = true;
writeBufferIndex = 0;
flushBufferSize = 0;
flushInProgress = false;
currentFileSize = logFile.size();
pcapDbgCount = 0; // ★ PCAP 디버그 카운터 리셋
Serial.printf("🎯 Auto Trigger 시작: %s\n", currentFilename);
}
}
xSemaphoreGive(sdMutex);
}
}
}
// Stop 조건
if (loggingEnabled && stopTriggerCount > 0) {
bool result = (strcmp(stopLogicOp, "AND") == 0);
bool anyMatch = false;
for (int i = 0; i < stopTriggerCount; i++) {
if (stopTriggers[i].canId == frame.can_id && stopTriggers[i].enabled) {
bool match = checkCondition(stopTriggers[i], frame.data);
anyMatch = true;
if (strcmp(stopLogicOp, "AND") == 0) {
result = result && match; if (!match) break;
} else { if (match) { result = true; break; } }
}
}
if (anyMatch && result) {
loggingEnabled = false; // 먼저 중단 → sdFlushTask 새 flush 안 함
int waitCount = 0;
while (flushInProgress && waitCount++ < 500) vTaskDelay(10);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) {
if (writeBufferIndex > 0 && logFile) {
logFile.write(currentWriteBuffer, writeBufferIndex);
writeBufferIndex = 0;
}
if (logFile) { logFile.flush(); logFile.close(); }
autoTriggerActive = false;
currentFilename[0]= '\0';
flushBufferSize = 0;
Serial.println("🎯 Auto Trigger 중지");
xSemaphoreGive(sdMutex);
}
}
}
}
void saveAutoTriggerSettings() {
preferences.begin("autotrigger", false);
preferences.putBool("enabled", autoTriggerEnabled);
preferences.putBool("logCSV", autoTriggerLogCSV);
preferences.putBool("logPCAP", autoTriggerLogPCAP);
preferences.putString("start_logic", startLogicOp);
preferences.putString("stop_logic", stopLogicOp);
preferences.putInt("start_count", startTriggerCount);
for (int i = 0; i < startTriggerCount; i++) {
char key[32];
sprintf(key, "s%d_id", i); preferences.putUInt(key, startTriggers[i].canId);
sprintf(key, "s%d_bit", i); preferences.putUChar(key, startTriggers[i].startBit);
sprintf(key, "s%d_len", i); preferences.putUChar(key, startTriggers[i].bitLength);
sprintf(key, "s%d_op", i); preferences.putString(key, startTriggers[i].op);
sprintf(key, "s%d_val", i); preferences.putLong(key, startTriggers[i].value);
sprintf(key, "s%d_en", i); preferences.putBool(key, startTriggers[i].enabled);
}
preferences.putInt("stop_count", stopTriggerCount);
for (int i = 0; i < stopTriggerCount; i++) {
char key[32];
sprintf(key, "p%d_id", i); preferences.putUInt(key, stopTriggers[i].canId);
sprintf(key, "p%d_bit", i); preferences.putUChar(key, stopTriggers[i].startBit);
sprintf(key, "p%d_len", i); preferences.putUChar(key, stopTriggers[i].bitLength);
sprintf(key, "p%d_op", i); preferences.putString(key, stopTriggers[i].op);
sprintf(key, "p%d_val", i); preferences.putLong(key, stopTriggers[i].value);
sprintf(key, "p%d_en", i); preferences.putBool(key, stopTriggers[i].enabled);
}
preferences.putString("start_formula", startFormula);
preferences.putString("stop_formula", stopFormula);
preferences.end();
}
void loadAutoTriggerSettings() {
preferences.begin("autotrigger", true);
autoTriggerEnabled = preferences.getBool("enabled", false);
autoTriggerLogCSV = preferences.getBool("logCSV", false);
autoTriggerLogPCAP = preferences.getBool("logPCAP", false);
preferences.getString("start_logic", startLogicOp, sizeof(startLogicOp));
preferences.getString("stop_logic", stopLogicOp, sizeof(stopLogicOp));
startFormula = preferences.getString("start_formula", "");
stopFormula = preferences.getString("stop_formula", "");
if (strlen(startLogicOp) == 0) strcpy(startLogicOp, "OR");
if (strlen(stopLogicOp) == 0) strcpy(stopLogicOp, "OR");
startTriggerCount = preferences.getInt("start_count", 0);
if (startTriggerCount > MAX_TRIGGERS) startTriggerCount = MAX_TRIGGERS;
for (int i = 0; i < startTriggerCount; i++) {
char key[32];
sprintf(key, "s%d_id", i); startTriggers[i].canId = preferences.getUInt(key, 0);
sprintf(key, "s%d_bit", i); startTriggers[i].startBit = preferences.getUChar(key, 0);
sprintf(key, "s%d_len", i); startTriggers[i].bitLength = preferences.getUChar(key, 8);
sprintf(key, "s%d_op", i); preferences.getString(key, startTriggers[i].op, sizeof(startTriggers[i].op));
if (strlen(startTriggers[i].op) == 0) strcpy(startTriggers[i].op, "==");
sprintf(key, "s%d_val", i); startTriggers[i].value = preferences.getLong(key, 0);
sprintf(key, "s%d_en", i); startTriggers[i].enabled = preferences.getBool(key, true);
}
stopTriggerCount = preferences.getInt("stop_count", 0);
if (stopTriggerCount > MAX_TRIGGERS) stopTriggerCount = MAX_TRIGGERS;
for (int i = 0; i < stopTriggerCount; i++) {
char key[32];
sprintf(key, "p%d_id", i); stopTriggers[i].canId = preferences.getUInt(key, 0);
sprintf(key, "p%d_bit", i); stopTriggers[i].startBit = preferences.getUChar(key, 0);
sprintf(key, "p%d_len", i); stopTriggers[i].bitLength = preferences.getUChar(key, 8);
sprintf(key, "p%d_op", i); preferences.getString(key, stopTriggers[i].op, sizeof(stopTriggers[i].op));
if (strlen(stopTriggers[i].op) == 0) strcpy(stopTriggers[i].op, "==");
sprintf(key, "p%d_val", i); stopTriggers[i].value = preferences.getLong(key, 0);
sprintf(key, "p%d_en", i); stopTriggers[i].enabled = preferences.getBool(key, true);
}
preferences.end();
}
// ========================================
// RTC
// ========================================
void initRTC() {
rtcWire.begin();
rtcWire.setClock(100000);
rtcWire.beginTransmission(DS3231_ADDRESS);
timeSyncStatus.rtcAvailable = (rtcWire.endTransmission() == 0);
Serial.printf("%s RTC(DS3231) %s\n",
timeSyncStatus.rtcAvailable ? "" : "!",
timeSyncStatus.rtcAvailable ? "감지됨" : "없음");
}
uint8_t bcdToDec(uint8_t v) { return (v >> 4) * 10 + (v & 0x0F); }
uint8_t decToBcd(uint8_t v) { return ((v / 10) << 4) | (v % 10); }
bool readRTC(struct tm *t) {
if (!timeSyncStatus.rtcAvailable) return false;
if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false;
rtcWire.beginTransmission(DS3231_ADDRESS);
rtcWire.write(0x00);
if (rtcWire.endTransmission() != 0) { xSemaphoreGive(rtcMutex); return false; }
if (rtcWire.requestFrom(DS3231_ADDRESS, 7) != 7) { xSemaphoreGive(rtcMutex); return false; }
uint8_t buf[7];
for (int i = 0; i < 7; i++) buf[i] = rtcWire.read();
xSemaphoreGive(rtcMutex);
t->tm_sec = bcdToDec(buf[0] & 0x7F);
t->tm_min = bcdToDec(buf[1] & 0x7F);
t->tm_hour = bcdToDec(buf[2] & 0x3F);
t->tm_wday = bcdToDec(buf[3] & 0x07) - 1;
t->tm_mday = bcdToDec(buf[4] & 0x3F);
t->tm_mon = bcdToDec(buf[5] & 0x1F) - 1;
t->tm_year = bcdToDec(buf[6]) + 100;
return true;
}
bool writeRTC(const struct tm *t) {
if (!timeSyncStatus.rtcAvailable) return false;
if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false;
rtcWire.beginTransmission(DS3231_ADDRESS);
rtcWire.write(0x00);
rtcWire.write(decToBcd(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 timeSyncCallback(struct timeval *tv) {
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)tv->tv_sec * 1000000ULL + tv->tv_usec;
timeSyncStatus.syncCount++;
if (timeSyncStatus.rtcAvailable) {
struct tm t; time_t now = tv->tv_sec; localtime_r(&now, &t);
if (writeRTC(&t)) timeSyncStatus.rtcSyncCount++;
}
}
void initNTP() {
configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov");
sntp_set_time_sync_notification_cb(timeSyncCallback);
}
void rtcSyncTask(void *parameter) {
while (1) {
if (timeSyncStatus.rtcAvailable) {
struct tm t;
if (readRTC(&t)) {
time_t now = mktime(&t);
struct timeval tv = {now, 0};
settimeofday(&tv, NULL);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)now * 1000000ULL;
timeSyncStatus.rtcSyncCount++;
}
}
vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS));
}
}
// ========================================
// MCP2515 모드
// ========================================
bool setMCP2515Mode(MCP2515Mode mode) {
MCP2515::ERROR result;
switch (mode) {
case MCP_MODE_NORMAL: result = mcp2515.setNormalMode(); break;
case MCP_MODE_LISTEN_ONLY: result = mcp2515.setListenOnlyMode(); break;
case MCP_MODE_LOOPBACK: result = mcp2515.setLoopbackMode(); break;
case MCP_MODE_TRANSMIT: result = mcp2515.setListenOnlyMode(); break;
default: return false;
}
if (result == MCP2515::ERROR_OK) { currentMcpMode = mode; return true; }
return false;
}
// ========================================
// ★★★ CAN 수신: 초경량 ISR + 레벨 폴링 하이브리드 ★★★
//
// [문제 분석]
// 기존 ISR: vTaskNotifyGiveFromISR + portYIELD_FROM_ISR
// = FreeRTOS 컨텍스트 스위치 × 8000회/s → 시스템 부하
// 순수 폴링: vTaskDelay(1~2ms) 공백 동안 MCP2515 RX버퍼(2개) 오버플로우 → 손실
//
// [해결책: 하이브리드]
// ISR → volatile 플래그 세팅만 (FreeRTOS 호출 0, 컨텍스트 스위치 0)
// Task → 플래그 + INT 레벨 폴링으로 즉시 읽기, 빈 상태엔 taskYIELD()만 사용
// ★ vTaskDelay 사용 금지: delay 중 HW 오버플로우 발생 가능
// ========================================
volatile bool IRAM_DATA_ATTR canIntFlag = false;
void IRAM_ATTR canISR() {
canIntFlag = true; // FreeRTOS 호출 없음 → 컨텍스트 스위치 없음
}
void canRxTask(void *parameter) {
struct can_frame frame;
CANMessage msg;
uint32_t lastErrorCheck = 0;
uint32_t errorRecoveryCount= 0;
Serial.println("✓ CAN RX Task 시작 (초경량ISR+레벨폴링 하이브리드, Core 1, Pri 24)");
// ★ setup() 완료 대기 (이중 방어: 태스크가 마지막 생성되더라도 안전하게)
vTaskDelay(pdMS_TO_TICKS(500));
// 초기 버퍼 클리어
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {}
canIntFlag = false;
while (1) {
// ── 주기적 에러 체크 (1초마다) ──────────────────────────────────
uint32_t now = millis();
if (now - lastErrorCheck > 1000) {
lastErrorCheck = now;
uint8_t errorFlag = mcp2515.getErrorFlags();
uint8_t rxErr = mcp2515.errorCountRX();
if (errorFlag & 0xC0) {
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
errorRecoveryCount++;
}
if (errorFlag & 0x20) {
mcp2515.reset(); delay(100);
mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10);
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
mcp2515.clearTXInterrupts();
mcp2515.clearMERR();
mcp2515.clearERRIF(); delay(10);
if (currentMcpMode == MCP_MODE_NORMAL) mcp2515.setNormalMode();
else if (currentMcpMode == MCP_MODE_LOOPBACK) mcp2515.setLoopbackMode();
else mcp2515.setListenOnlyMode();
delay(50);
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {}
mcp2515.clearRXnOVRFlags();
errorRecoveryCount++;
Serial.printf("✓ Bus-Off 복구 (총 %lu회)\n", errorRecoveryCount);
}
if ((errorFlag & 0x18) && rxErr > 96)
Serial.printf("⚠️ Error Passive! REC=%d\n", rxErr);
}
// ── ★ 엣지(ISR 플래그) + 레벨(INT핀) 이중 감지 ─────────────────
if (canIntFlag || digitalRead(CAN_INT_PIN) == LOW) {
canIntFlag = false;
// INT LOW 동안 RX 버퍼 완전히 비우기 (손실 없음)
while (digitalRead(CAN_INT_PIN) == LOW &&
mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {
checkAutoTriggers(frame);
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.id = frame.can_id & 0x1FFFFFFF;
msg.flags = (uint8_t)((frame.can_id >> 24) & 0xE0);
msg.dlc = frame.can_dlc;
memcpy(msg.data, frame.data, 8);
if (ring_can_push(&canRing, &msg)) totalMsgCount++;
}
// 읽기 완료 후 즉시 다음 폴링 (loop 재진입)
} else {
// INT HIGH, 플래그도 없음 → 타 태스크에 CPU 양보
// ★ vTaskDelay 사용 금지: delay 중 HW 오버플로우 가능
taskYIELD();
}
}
}
// ========================================
// Serial RX Tasks (RingBuffer 버전)
// ========================================
void serialRxTask(void *parameter) {
SerialMessage msg;
uint8_t lineBuffer[MAX_SERIAL_LINE_LEN];
uint16_t lineIndex = 0;
uint32_t lastActivity = millis();
while (1) {
while (SerialComm.available()) {
uint8_t c = SerialComm.read();
lineBuffer[lineIndex++] = c;
lastActivity = millis();
if (c == '\n' || c == '\r' || lineIndex >= MAX_SERIAL_LINE_LEN - 1) {
if (lineIndex > 0) {
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.length = lineIndex;
memcpy(msg.data, lineBuffer, lineIndex);
msg.isTx = false;
if (ring_serial_push(&serialRing, &msg)) totalSerialRxCount++;
lineIndex = 0;
}
}
if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0;
}
if (lineIndex > 0 && (millis() - lastActivity > 100)) {
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.length = lineIndex;
memcpy(msg.data, lineBuffer, lineIndex);
msg.isTx = false;
if (ring_serial_push(&serialRing, &msg)) totalSerialRxCount++;
lineIndex = 0;
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
void serial2RxTask(void *parameter) {
SerialMessage msg;
uint8_t lineBuffer[MAX_SERIAL_LINE_LEN];
uint16_t lineIndex = 0;
uint32_t lastActivity = millis();
while (1) {
while (Serial2Comm.available()) {
uint8_t c = Serial2Comm.read();
lineBuffer[lineIndex++] = c;
lastActivity = millis();
if (c == '\n' || c == '\r' || lineIndex >= MAX_SERIAL_LINE_LEN - 1) {
if (lineIndex > 0) {
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.length = lineIndex;
memcpy(msg.data, lineBuffer, lineIndex);
msg.isTx = false;
if (ring_serial_push(&serial2Ring, &msg)) totalSerial2RxCount++;
lineIndex = 0;
}
}
if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0;
}
if (lineIndex > 0 && (millis() - lastActivity > 100)) {
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.length = lineIndex;
memcpy(msg.data, lineBuffer, lineIndex);
msg.isTx = false;
if (ring_serial_push(&serial2Ring, &msg)) totalSerial2RxCount++;
lineIndex = 0;
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
// ========================================
// ★★★ SD Flush Task (더블 버퍼 비동기 flush) ★★★
// ========================================
void sdFlushTask(void *parameter) {
Serial.println("✓ sdFlushTask 시작 (더블 버퍼링 + RingBuffer)");
uint32_t flushCount = 0; // flush 횟수 카운터 (flush() 호출 빈도 조절용)
while (1) {
uint32_t flushSize;
if (xTaskNotifyWait(0, 0xFFFFFFFF, &flushSize, portMAX_DELAY) == pdTRUE) {
if (flushSize > 0 && flushSize <= FILE_BUFFER_SIZE) {
flushInProgress = true;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(5000)) == pdTRUE) {
if (logFile && sdCardReady && loggingEnabled) {
uint32_t t0 = millis();
logFile.write(currentFlushBuffer, flushSize);
flushCount++;
// ★ logFile.flush()는 FAT 디렉토리 엔트리 2회 섹터 쓰기 유발
// 매번 호출하면 300ms+ 지연 → 4번(~256KB)마다 1회로 줄임
// 전원 차단 시 최대 256KB 손실 가능하나, 로깅 완료 후 stopLogging에서 반드시 flush
if (flushCount % 4 == 0) {
logFile.flush();
}
uint32_t elapsed = millis() - t0;
if (elapsed > 150)
Serial.printf("⚠️ SD Flush 지연: %d ms (%d bytes)\n", elapsed, (int)flushSize);
}
xSemaphoreGive(sdMutex);
} else {
Serial.println("✗ SD Flush: Mutex 타임아웃");
}
flushInProgress = false;
}
}
}
}
// ========================================
// ★★★ SD Write Task (RingBuffer Consumer) ★★★
//
// 변경점:
// - xQueueReceive() → ring_can_pop() (non-blocking, no mutex)
// - BIN hot path: sdMutex 제거 (sdWriteTask만 쓰는 버퍼)
// - 버퍼 스왑 시에만 sdMutex 획득 (최소화)
// ========================================
void sdWriteTask(void *parameter) {
CANMessage canMsg;
while (1) {
bool hasWork = false;
// ★ RingBuffer에서 pop (mutex 없음, 완전 non-blocking)
if (ring_can_pop(&canRing, &canMsg)) {
hasWork = true;
// 실시간 모니터링 테이블 업데이트
bool found = false;
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].msg.id == canMsg.id) {
recentData[i].msg = canMsg;
recentData[i].count++;
found = true;
break;
}
}
if (!found) {
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].count == 0) {
recentData[i].msg = canMsg;
recentData[i].count = 1;
break;
}
}
}
// SD 로깅
if (loggingEnabled && sdCardReady) {
if (canLogFormatCSV) {
// ── CSV: logFile 직접 쓰기 → sdMutex 필요 ────────────────
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1)) == pdTRUE) {
char csvLine[128];
uint64_t relTime = canMsg.timestamp_us - canLogStartTime;
char dataStr[32]; int dataLen = 0;
for (int i = 0; i < canMsg.dlc; i++) {
dataLen += sprintf(&dataStr[dataLen], "%02X", canMsg.data[i]);
if (i < canMsg.dlc - 1) dataStr[dataLen++] = ' ';
}
dataStr[dataLen] = '\0';
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,0x%X,%d,%s\n", relTime, canMsg.id, canMsg.dlc, dataStr);
if (logFile) {
logFile.write((uint8_t*)csvLine, lineLen);
currentFileSize += lineLen;
static int csvFlushCnt = 0;
if (++csvFlushCnt >= 2000) { logFile.flush(); csvFlushCnt = 0; }
}
xSemaphoreGive(sdMutex);
}
} else if (canLogFormatPCAP) {
// ── PCAP: 32bytes/record → 더블 버퍼 (BIN과 동일 구조) ──
if (writeBufferIndex + sizeof(PcapRecord) > FILE_BUFFER_SIZE) {
// ★ flush 완전 완료까지 대기 (타임아웃 없음 - 데이터 손상 방지)
while (flushInProgress) vTaskDelay(1);
uint8_t* tmp = currentWriteBuffer;
currentWriteBuffer = currentFlushBuffer;
currentFlushBuffer = tmp;
flushBufferSize = writeBufferIndex;
writeBufferIndex = 0;
if (sdFlushTaskHandle)
xTaskNotify(sdFlushTaskHandle, flushBufferSize, eSetValueWithOverwrite);
}
// PcapRecord 조립 후 버퍼에 기록
PcapRecord rec;
memset(&rec, 0, sizeof(PcapRecord)); // ★ 안전 초기화
uint32_t sec = (uint32_t)(canMsg.timestamp_us / 1000000ULL);
uint32_t usec = (uint32_t)(canMsg.timestamp_us % 1000000ULL);
rec.hdr.ts_sec = sec;
rec.hdr.ts_usec = usec;
rec.hdr.incl_len = sizeof(SocketCANFrame); // 16
rec.hdr.orig_len = sizeof(SocketCANFrame); // 16
// ★ flags 필드로 EFF/RTR 정확히 복원 (id>0x7FF 휴리스틱 불필요)
rec.frame.can_id = canMsg.id;
if (canMsg.flags & 0x80) rec.frame.can_id |= 0x80000000U; // CAN_EFF_FLAG
if (canMsg.flags & 0x40) rec.frame.can_id |= 0x40000000U; // CAN_RTR_FLAG
rec.frame.can_dlc = canMsg.dlc;
// data: dlc 바이트만 유효, 나머지는 memset으로 이미 0
memcpy(rec.frame.data, canMsg.data,
(canMsg.dlc <= 8) ? canMsg.dlc : 8);
memcpy(&currentWriteBuffer[writeBufferIndex], &rec, sizeof(PcapRecord));
writeBufferIndex += sizeof(PcapRecord);
currentFileSize += sizeof(PcapRecord);
// ★ 최초 3패킷 시리얼 디버그 (PCAP ID=0 확인용)
if (pcapDbgCount < 3) {
Serial.printf("[PCAP DBG #%lu] id=0x%X flags=0x%02X dlc=%d data=%02X%02X...\n",
pcapDbgCount, canMsg.id, canMsg.flags, canMsg.dlc,
canMsg.data[0], canMsg.data[1]);
pcapDbgCount++;
}
} else {
// ── BIN: 더블 버퍼 (hot path, sdMutex 없음!) ─────────────
// writeBuffer는 sdWriteTask만 쓰므로 mutex 불필요
if (writeBufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) {
// ★ flush 완전 완료까지 대기 (타임아웃 없음)
// 이유: 100ms 타임아웃 후 강제 스왑하면 sdFlushTask가 읽는 중인
// 버퍼에 sdWriteTask가 덮어써서 SD 파일 데이터 손상 발생
// 안전성: RingBuffer 8192슬롯(~1초@8kfps)이 대기 중 메시지 흡수
while (flushInProgress) vTaskDelay(1);
uint8_t* tmp = currentWriteBuffer;
currentWriteBuffer = currentFlushBuffer;
currentFlushBuffer = tmp;
flushBufferSize = writeBufferIndex;
writeBufferIndex = 0;
if (sdFlushTaskHandle)
xTaskNotify(sdFlushTaskHandle, flushBufferSize, eSetValueWithOverwrite);
}
// 현재 쓰기 버퍼에 메시지 추가 (순수 memcpy)
memcpy(&currentWriteBuffer[writeBufferIndex], &canMsg, sizeof(CANMessage));
writeBufferIndex += sizeof(CANMessage);
currentFileSize += sizeof(CANMessage);
}
}
}
if (!hasWork) vTaskDelay(pdMS_TO_TICKS(1));
}
}
// ========================================
// 모니터 Task
// ========================================
void sdMonitorTask(void *parameter) {
while (1) {
uint32_t now = millis();
// ★ msgPerSecond는 Core 0 webUpdateTask에서 계산 (Core 1 스타베이션 방지)
if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) {
float rawV = analogRead(MONITORING_VOLT) * (3.3f / 4095.0f);
powerStatus.voltage = rawV;
if (now - powerStatus.lastMinReset >= 1000) {
powerStatus.minVoltage = rawV;
powerStatus.lastMinReset= now;
} else {
if (rawV < powerStatus.minVoltage) powerStatus.minVoltage = rawV;
}
powerStatus.lowVoltage = (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD);
powerStatus.lastCheck = now;
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
// ========================================
// 파일 코멘트 / 시퀀스 관리
// ========================================
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* getFileComment(const char* filename) {
for (int i = 0; i < commentCount; i++)
if (strcmp(fileComments[i].filename, filename) == 0)
return fileComments[i].comment;
return "";
}
void addFileComment(const char* filename, const char* comment) {
for (int i = 0; i < commentCount; i++) {
if (strcmp(fileComments[i].filename, filename) == 0) {
strncpy(fileComments[i].comment, comment, MAX_COMMENT_LEN - 1);
fileComments[i].comment[MAX_COMMENT_LEN-1] = '\0';
saveFileComments(); return;
}
}
if (commentCount < MAX_FILE_COMMENTS) {
strncpy(fileComments[commentCount].filename, filename, MAX_FILENAME_LEN-1);
fileComments[commentCount].filename[MAX_FILENAME_LEN-1] = '\0';
strncpy(fileComments[commentCount].comment, comment, MAX_COMMENT_LEN-1);
fileComments[commentCount].comment[MAX_COMMENT_LEN-1] = '\0';
commentCount++;
saveFileComments();
}
}
void saveSequences() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File f = SD_MMC.open("/sequences.dat", FILE_WRITE);
if (f) {
f.write((uint8_t*)&sequenceCount, sizeof(sequenceCount));
f.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount);
f.close();
}
xSemaphoreGive(sdMutex);
}
}
void loadSequences() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD_MMC.exists("/sequences.dat")) {
File f = SD_MMC.open("/sequences.dat", FILE_READ);
if (f) {
f.read((uint8_t*)&sequenceCount, sizeof(sequenceCount));
if (sequenceCount > MAX_SEQUENCES) sequenceCount = MAX_SEQUENCES;
f.read((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount);
f.close();
}
}
xSemaphoreGive(sdMutex);
}
}
// ========================================
// TX / Sequence Task
// ========================================
void txTask(void *parameter) {
struct can_frame frame;
while (1) {
uint32_t now = millis();
bool anyActive = false;
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
if (txMessages[i].active && txMessages[i].interval > 0) {
anyActive = true;
if (now - txMessages[i].lastSent >= txMessages[i].interval) {
if (currentMcpMode == MCP_MODE_TRANSMIT) mcp2515.setNormalMode();
frame.can_id = txMessages[i].id;
if (txMessages[i].extended) frame.can_id |= CAN_EFF_FLAG;
frame.can_dlc = txMessages[i].dlc;
memcpy(frame.data, txMessages[i].data, 8);
if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) {
totalTxCount++;
txMessages[i].lastSent = now;
}
if (currentMcpMode == MCP_MODE_TRANSMIT) mcp2515.setListenOnlyMode();
}
}
}
vTaskDelay(anyActive ? pdMS_TO_TICKS(1) : pdMS_TO_TICKS(10));
}
}
void sequenceTask(void *parameter) {
while (1) {
if (seqRuntime.running && seqRuntime.activeSequenceIndex >= 0 &&
seqRuntime.activeSequenceIndex < sequenceCount) {
CANSequence* seq = &sequences[seqRuntime.activeSequenceIndex];
uint32_t now = millis();
if (seqRuntime.currentStep < seq->stepCount) {
SequenceStep* step = &seq->steps[seqRuntime.currentStep];
if (now - seqRuntime.lastStepTime >= step->delayMs) {
if (currentMcpMode == MCP_MODE_TRANSMIT) mcp2515.setNormalMode();
struct can_frame frame;
frame.can_id = step->canId;
if (step->extended) frame.can_id |= CAN_EFF_FLAG;
frame.can_dlc = step->dlc;
memcpy(frame.data, step->data, 8);
if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) totalTxCount++;
if (currentMcpMode == MCP_MODE_TRANSMIT) mcp2515.setListenOnlyMode();
seqRuntime.currentStep++;
seqRuntime.lastStepTime = now;
}
} else {
if (seq->repeatMode == 0) {
seqRuntime.running = false;
} else if (seq->repeatMode == 1) {
if (++seqRuntime.currentRepeat >= seq->repeatCount) seqRuntime.running = false;
else { seqRuntime.currentStep = 0; seqRuntime.lastStepTime = now; }
} else {
seqRuntime.currentStep = 0; seqRuntime.lastStepTime = now;
}
}
vTaskDelay(pdMS_TO_TICKS(1));
} else {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
}
// ========================================
// WebSocket 이벤트
// ========================================
void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
if (type == WStype_CONNECTED) {
DynamicJsonDocument allData(3072);
allData["type"] = "initialData";
int speedIndex = 3;
for (int i = 0; i < 4; i++) {
if (canSpeedValues[i] == currentCanSpeed) { speedIndex = i; break; }
}
allData["canSpeed"] = speedIndex;
allData["mcpMode"] = (int)currentMcpMode;
allData["autoTriggerEnabled"]= autoTriggerEnabled;
allData["autoTriggerLogCSV"] = autoTriggerLogCSV;
allData["autoTriggerLogPCAP"]= autoTriggerLogPCAP;
if (autoTriggerEnabled) {
allData["logFormat"] = autoTriggerLogPCAP ? "pcap" : (autoTriggerLogCSV ? "csv" : "bin");
allData["startLogic"] = startLogicOp;
allData["stopLogic"] = stopLogicOp;
allData["startFormula"]= startFormula;
allData["stopFormula"] = stopFormula;
JsonArray sa = allData.createNestedArray("startTriggers");
for (int i = 0; i < startTriggerCount; i++) {
JsonObject t = sa.createNestedObject();
char idStr[10]; sprintf(idStr, "0x%03X", startTriggers[i].canId);
t["canId"] = idStr; t["startBit"] = startTriggers[i].startBit;
t["bitLength"] = startTriggers[i].bitLength; t["op"] = startTriggers[i].op;
t["value"] = (long)startTriggers[i].value; t["enabled"] = startTriggers[i].enabled;
}
JsonArray ea = allData.createNestedArray("stopTriggers");
for (int i = 0; i < stopTriggerCount; i++) {
JsonObject t = ea.createNestedObject();
char idStr[10]; sprintf(idStr, "0x%03X", stopTriggers[i].canId);
t["canId"] = idStr; t["startBit"] = stopTriggers[i].startBit;
t["bitLength"] = stopTriggers[i].bitLength; t["op"] = stopTriggers[i].op;
t["value"] = (long)stopTriggers[i].value; t["enabled"] = stopTriggers[i].enabled;
}
}
String json; serializeJson(allData, json);
webSocket.sendTXT(num, json);
// ★ 연결 즉시 update 브로드캐스트 트리거 (다음 루프에서 500ms 기다리지 않고 즉시 전송)
lastBroadcast = 0;
}
else if (type == WStype_TEXT) {
DynamicJsonDocument doc(44384);
if (deserializeJson(doc, payload)) return;
const char* cmd = doc["cmd"];
if (strcmp(cmd, "getSettings") == 0) {
DynamicJsonDocument r(1024);
r["type"] = "settings"; r["ssid"] = wifiSSID; r["password"] = wifiPassword;
r["staEnable"] = enableSTAMode; r["staSSID"] = staSSID; r["staPassword"] = staPassword;
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) > 0) { strncpy(wifiSSID, s, sizeof(wifiSSID)-1); wifiSSID[sizeof(wifiSSID)-1]='\0'; }
const char* p = doc["password"];
if (p) { strncpy(wifiPassword, p, sizeof(wifiPassword)-1); wifiPassword[sizeof(wifiPassword)-1]='\0'; }
enableSTAMode = doc["staEnable"];
const char* ss = doc["staSSID"];
if (ss) { strncpy(staSSID, ss, sizeof(staSSID)-1); staSSID[sizeof(staSSID)-1]='\0'; }
const char* sp = doc["staPassword"];
if (sp) { strncpy(staPassword, sp, sizeof(staPassword)-1); staPassword[sizeof(staPassword)-1]='\0'; }
saveSettings();
DynamicJsonDocument r(256); r["type"]="settingsSaved"; r["success"]=true;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "startLogging") == 0) {
if (!loggingEnabled && sdCardReady) {
const char* fmt = doc["format"];
canLogFormatCSV = (fmt && strcmp(fmt, "csv") == 0);
canLogFormatPCAP = (fmt && strcmp(fmt, "pcap") == 0);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
time_t now; struct tm ti; time(&now); localtime_r(&now, &ti);
struct timeval tv; gettimeofday(&tv, NULL);
canLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
const char* ext;
if (canLogFormatPCAP) ext = "pcap";
else if (canLogFormatCSV) ext = "csv";
else ext = "bin";
snprintf(currentFilename, sizeof(currentFilename),
"/CAN_%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(currentFilename, FILE_WRITE);
if (logFile) {
if (canLogFormatCSV) { logFile.println("Time_us,CAN_ID,DLC,Data"); logFile.flush(); }
else if (canLogFormatPCAP) { writePcapGlobalHeader(logFile); logFile.flush(); }
logFile.close();
logFile = SD_MMC.open(currentFilename, FILE_APPEND);
if (logFile) {
loggingEnabled = true;
writeBufferIndex = 0; flushBufferSize = 0; flushInProgress = false;
currentFileSize = logFile.size();
pcapDbgCount = 0; // ★ PCAP 디버그 카운터 리셋
const char* fmtName = canLogFormatPCAP ? "PCAP" : (canLogFormatCSV ? "CSV" : "BIN");
Serial.printf("✅ 로깅 시작 [%s]: %s\n", fmtName, currentFilename);
}
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopLogging") == 0) {
if (loggingEnabled) {
// ① 로깅 중단 신호 → sdWriteTask/sdFlushTask가 새 데이터 쓰기 중단
loggingEnabled = false;
// ② sdFlushTask가 진행 중인 flush 완료 대기 (뮤텍스 잡기 전에!)
// sdMutex를 먼저 잡으면 sdFlushTask가 뮤텍스 못 얻어 데드락
int wc = 0;
while (flushInProgress && wc++ < 500) vTaskDelay(10);
// ③ 이제 sdMutex 안전하게 획득
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) {
// ④ writeBuffer에 남은 데이터 마지막으로 쓰기
if (writeBufferIndex > 0 && logFile) {
logFile.write(currentWriteBuffer, writeBufferIndex);
writeBufferIndex = 0;
}
// ⑤ 최종 flush (4번에 1번 규칙 무관하게 반드시 호출)
if (logFile) { logFile.flush(); logFile.close(); }
currentFilename[0] = '\0';
flushBufferSize = 0;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "startSerialLogging") == 0) {
if (!serialLoggingEnabled && sdCardReady) {
const char* fmt = doc["format"];
serialLogFormatCSV = !(fmt && strcmp(fmt, "bin") == 0);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
time_t now; struct tm ti; time(&now); localtime_r(&now, &ti);
struct timeval tv; gettimeofday(&tv, NULL);
serialLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
const char* ext = serialLogFormatCSV ? "csv" : "bin";
snprintf(currentSerialFilename, sizeof(currentSerialFilename),
"/SER_%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);
serialLogFile = SD_MMC.open(currentSerialFilename, FILE_WRITE);
if (serialLogFile) {
if (serialLogFormatCSV) serialLogFile.println("Time_us,Direction,Data");
serialLoggingEnabled = true;
serialCsvIndex = 0;
currentSerialFileSize = serialLogFile.size();
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopSerialLogging") == 0) {
if (serialLoggingEnabled) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (serialCsvIndex > 0 && serialLogFile)
serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex);
if (serialLogFile) serialLogFile.close();
serialLoggingEnabled = false; serialCsvIndex = 0;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
SerialComm.println(data);
SerialMessage msg;
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.length = min((int)(strlen(data)+2), MAX_SERIAL_LINE_LEN-1);
snprintf((char*)msg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data);
msg.isTx = true;
if (ring_serial_push(&serialRing, &msg)) totalSerialTxCount++;
}
}
else if (strcmp(cmd, "setSerialConfig") == 0) {
serialSettings.baudRate = doc["baudRate"] | 115200;
serialSettings.dataBits = doc["dataBits"] | 8;
serialSettings.parity = doc["parity"] | 0;
serialSettings.stopBits = doc["stopBits"] | 1;
saveSerialSettings(); applySerialSettings();
}
else if (strcmp(cmd, "getSerialConfig") == 0) {
DynamicJsonDocument r(512);
r["type"]="serialConfig"; r["baudRate"]=serialSettings.baudRate;
r["dataBits"]=serialSettings.dataBits; r["parity"]=serialSettings.parity;
r["stopBits"]=serialSettings.stopBits;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "startSerial2Logging") == 0) {
if (!serial2LoggingEnabled && sdCardReady) {
const char* fmt = doc["format"];
serial2LogFormatCSV = !(fmt && strcmp(fmt, "bin") == 0);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
time_t now; struct tm ti; time(&now); localtime_r(&now, &ti);
struct timeval tv; gettimeofday(&tv, NULL);
serial2LogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
const char* ext = serial2LogFormatCSV ? "csv" : "bin";
snprintf(currentSerial2Filename, sizeof(currentSerial2Filename),
"/SER2_%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);
serial2LogFile = SD_MMC.open(currentSerial2Filename, FILE_WRITE);
if (serial2LogFile) {
if (serial2LogFormatCSV) serial2LogFile.println("Time_us,Direction,Data");
serial2LoggingEnabled = true;
serial2CsvIndex = 0;
currentSerial2FileSize = serial2LogFile.size();
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopSerial2Logging") == 0) {
if (serial2LoggingEnabled) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (serial2CsvIndex > 0 && serial2LogFile)
serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex);
if (serial2LogFile) serial2LogFile.close();
serial2LoggingEnabled = false; serial2CsvIndex = 0;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial2") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
Serial2Comm.println(data);
SerialMessage msg;
struct timeval tv; gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.length = min((int)(strlen(data)+2), MAX_SERIAL_LINE_LEN-1);
snprintf((char*)msg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data);
msg.isTx = true;
if (ring_serial_push(&serial2Ring, &msg)) totalSerial2TxCount++;
}
}
else if (strcmp(cmd, "setSerial2Config") == 0) {
serial2Settings.baudRate = doc["baudRate"] | 115200;
serial2Settings.dataBits = doc["dataBits"] | 8;
serial2Settings.parity = doc["parity"] | 0;
serial2Settings.stopBits = doc["stopBits"] | 1;
saveSerialSettings(); applySerialSettings();
}
else if (strcmp(cmd, "getSerial2Config") == 0) {
DynamicJsonDocument r(512);
r["type"]="serial2Config"; r["baudRate"]=serial2Settings.baudRate;
r["dataBits"]=serial2Settings.dataBits; r["parity"]=serial2Settings.parity;
r["stopBits"]=serial2Settings.stopBits;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "setSpeed") == 0) {
int si = doc["speed"];
if (si >= 0 && si < 4) {
currentCanSpeed = canSpeedValues[si];
saveSettings();
StaticJsonDocument<256> r;
r["type"]="info"; r["message"]="CAN speed saved. Restart to apply.";
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
}
else if (strcmp(cmd, "setMcpMode") == 0) {
int mode = doc["mode"];
if (mode >= 0 && mode <= 3) { setMCP2515Mode((MCP2515Mode)mode); saveSettings(); }
}
else if (strcmp(cmd, "syncTimeFromPhone") == 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);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)t * 1000000ULL;
timeSyncStatus.syncCount++;
if (timeSyncStatus.rtcAvailable) writeRTC(&ti);
}
else if (strcmp(cmd, "getFiles") == 0) {
if (sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
DynamicJsonDocument r(6144);
r["type"] = "files";
JsonArray files = r.createNestedArray("files");
File root = SD_MMC.open("/");
if (root) {
File file = root.openNextFile();
int cnt = 0;
while (file && cnt < 50) {
if (!file.isDirectory()) {
const char* fn = file.name();
if (fn[0] == '/') fn++;
if (fn[0] != '.' && strcmp(fn,"System Volume Information")!=0 && strlen(fn)>0) {
JsonObject fo = files.createNestedObject();
fo["name"] = fn; fo["size"] = file.size();
const char* cm = getFileComment(fn);
if (strlen(cm) > 0) fo["comment"] = cm;
cnt++;
}
}
file.close();
file = 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);
DynamicJsonDocument r(256); r["type"]="deleteResult"; r["success"]=ok;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
}
}
else if (strcmp(cmd, "addComment") == 0) {
const char* fn = doc["filename"], *cm = doc["comment"];
if (fn && cm) addFileComment(fn, cm);
}
else if (strcmp(cmd, "setAutoTrigger") == 0) {
autoTriggerEnabled = doc["enabled"] | false;
const char* lf = doc["logFormat"];
if (lf) {
autoTriggerLogCSV = (strcmp(lf,"csv") == 0);
autoTriggerLogPCAP = (strcmp(lf,"pcap") == 0);
}
saveAutoTriggerSettings();
DynamicJsonDocument r(256); r["type"]="autoTriggerSet";
r["enabled"]=autoTriggerEnabled;
r["logFormat"] = autoTriggerLogPCAP ? "pcap" : (autoTriggerLogCSV ? "csv" : "bin");
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "setAutoTriggerFormat") == 0) {
const char* lf = doc["logFormat"];
if (lf) {
autoTriggerLogCSV = (strcmp(lf,"csv") == 0);
autoTriggerLogPCAP = (strcmp(lf,"pcap") == 0);
saveAutoTriggerSettings();
}
DynamicJsonDocument r(128); r["type"]="autoTriggerFormatSet";
r["logFormat"] = autoTriggerLogPCAP ? "pcap" : (autoTriggerLogCSV ? "csv" : "bin");
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "saveCanFormat") == 0) {
const char* fmt = doc["format"];
if (fmt) {
savedCanLogFormatCSV = (strcmp(fmt, "csv") == 0);
savedCanLogFormatPCAP = (strcmp(fmt, "pcap") == 0);
saveSettings();
Serial.printf("💾 CAN Format 저장: %s\n",
savedCanLogFormatPCAP ? "PCAP" : (savedCanLogFormatCSV ? "CSV" : "BIN"));
}
DynamicJsonDocument r(128); r["type"]="canFormatSaved";
r["format"] = savedCanLogFormatPCAP ? "pcap" : (savedCanLogFormatCSV ? "csv" : "bin");
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "setStartTriggers") == 0) {
JsonArray triggers = doc["triggers"];
strcpy(startLogicOp, doc["logic"] | "OR");
startTriggerCount = 0;
for (JsonObject t : triggers) {
if (startTriggerCount >= MAX_TRIGGERS) break;
String idStr = t["canId"] | "0x0";
startTriggers[startTriggerCount].canId = strtoul(idStr.c_str(),NULL,16);
startTriggers[startTriggerCount].startBit = t["startBit"] | 0;
startTriggers[startTriggerCount].bitLength = t["bitLength"] | 8;
strcpy(startTriggers[startTriggerCount].op, t["op"] | "==");
startTriggers[startTriggerCount].value = t["value"] | 0;
startTriggers[startTriggerCount].enabled = t["enabled"] | true;
startTriggerCount++;
}
saveAutoTriggerSettings();
DynamicJsonDocument r(256); r["type"]="startTriggersSet"; r["count"]=startTriggerCount;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "setStopTriggers") == 0) {
JsonArray triggers = doc["triggers"];
strcpy(stopLogicOp, doc["logic"] | "OR");
stopTriggerCount = 0;
for (JsonObject t : triggers) {
if (stopTriggerCount >= MAX_TRIGGERS) break;
String idStr = t["canId"] | "0x0";
stopTriggers[stopTriggerCount].canId = strtoul(idStr.c_str(),NULL,16);
stopTriggers[stopTriggerCount].startBit = t["startBit"] | 0;
stopTriggers[stopTriggerCount].bitLength = t["bitLength"] | 8;
strcpy(stopTriggers[stopTriggerCount].op, t["op"] | "==");
stopTriggers[stopTriggerCount].value = t["value"] | 0;
stopTriggers[stopTriggerCount].enabled = t["enabled"] | true;
stopTriggerCount++;
}
saveAutoTriggerSettings();
DynamicJsonDocument r(256); r["type"]="stopTriggersSet"; r["count"]=stopTriggerCount;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "setStartFormula") == 0) {
const char* f = doc["formula"];
if (f) { startFormula = String(f); saveAutoTriggerSettings(); }
}
else if (strcmp(cmd, "setStopFormula") == 0) {
const char* f = doc["formula"];
if (f) { stopFormula = String(f); saveAutoTriggerSettings(); }
}
else if (strcmp(cmd, "getAutoTriggers") == 0) {
DynamicJsonDocument r(2048);
r["type"]="autoTriggers"; r["enabled"]=autoTriggerEnabled;
r["logFormat"] = autoTriggerLogPCAP ? "pcap" : (autoTriggerLogCSV ? "csv" : "bin");
r["startLogic"]=startLogicOp; r["stopLogic"]=stopLogicOp;
r["startFormula"]=startFormula; r["stopFormula"]=stopFormula;
JsonArray sa = r.createNestedArray("startTriggers");
for (int i = 0; i < startTriggerCount; i++) {
JsonObject t = sa.createNestedObject();
char ids[10]; sprintf(ids,"0x%03X",startTriggers[i].canId);
t["canId"]=ids; t["startBit"]=startTriggers[i].startBit;
t["bitLength"]=startTriggers[i].bitLength; t["op"]=startTriggers[i].op;
t["value"]=(long)startTriggers[i].value; t["enabled"]=startTriggers[i].enabled;
}
JsonArray ea = r.createNestedArray("stopTriggers");
for (int i = 0; i < stopTriggerCount; i++) {
JsonObject t = ea.createNestedObject();
char ids[10]; sprintf(ids,"0x%03X",stopTriggers[i].canId);
t["canId"]=ids; t["startBit"]=stopTriggers[i].startBit;
t["bitLength"]=stopTriggers[i].bitLength; t["op"]=stopTriggers[i].op;
t["value"]=(long)stopTriggers[i].value; t["enabled"]=stopTriggers[i].enabled;
}
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "sendOnce") == 0) {
if (currentMcpMode == MCP_MODE_TRANSMIT) mcp2515.setNormalMode();
struct can_frame frame;
frame.can_id = strtoul(doc["id"], NULL, 16);
if (doc["ext"] | false) frame.can_id |= CAN_EFF_FLAG;
frame.can_dlc = doc["dlc"] | 8;
JsonArray da = doc["data"];
for (int i = 0; i < 8; i++) frame.data[i] = da[i] | 0;
if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) totalTxCount++;
if (currentMcpMode == MCP_MODE_TRANSMIT) mcp2515.setListenOnlyMode();
}
else if (strcmp(cmd, "addSequence") == 0) {
if (sequenceCount >= MAX_SEQUENCES) {
DynamicJsonDocument r(256); r["type"]="error"; r["message"]="Max sequences reached";
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
} else {
const char* name = doc["name"];
JsonArray stepsArr = doc["steps"];
if (name && stepsArr.size() > 0) {
CANSequence* seq = &sequences[sequenceCount];
strncpy(seq->name, name, sizeof(seq->name)-1);
seq->name[sizeof(seq->name)-1]='\0';
seq->repeatMode = doc["repeatMode"] | 0;
seq->repeatCount = doc["repeatCount"] | 1;
seq->stepCount = 0;
for (JsonObject so : stepsArr) {
if (seq->stepCount >= 20) break;
SequenceStep* step = &seq->steps[seq->stepCount];
const char* ids = so["id"];
if (ids) step->canId = strtoul(
(strncmp(ids,"0x",2)==0||strncmp(ids,"0X",2)==0) ? ids+2 : ids, NULL, 16);
step->extended = so["ext"] | false;
step->dlc = so["dlc"] | 8;
JsonArray da = so["data"];
for (int i = 0; i < 8 && i < (int)da.size(); i++) step->data[i] = da[i];
step->delayMs = so["delay"] | 0;
seq->stepCount++;
}
sequenceCount++;
saveSequences();
DynamicJsonDocument r(256); r["type"]="sequenceSaved";
r["name"]=name; r["steps"]=seq->stepCount;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
delay(100);
webSocket.sendTXT(num, "{\"cmd\":\"getSequences\"}");
}
}
}
else if (strcmp(cmd, "getSequences") == 0) {
DynamicJsonDocument r(3072); r["type"]="sequences";
JsonArray sa = r.createNestedArray("list");
for (int i = 0; i < sequenceCount; i++) {
JsonObject so = sa.createNestedObject();
so["name"]=sequences[i].name; so["steps"]=sequences[i].stepCount;
so["repeatMode"]=sequences[i].repeatMode; so["repeatCount"]=sequences[i].repeatCount;
}
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
else if (strcmp(cmd, "startSequence") == 0) {
int idx = doc["index"] | -1;
if (idx >= 0 && idx < sequenceCount) {
seqRuntime = {true, 0, 0, millis(), (int8_t)idx};
DynamicJsonDocument r(256); r["type"]="sequenceStarted";
r["index"]=idx; r["name"]=sequences[idx].name;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
}
else if (strcmp(cmd, "stopSequence") == 0) {
if (seqRuntime.running) {
seqRuntime.running = false; seqRuntime.activeSequenceIndex = -1;
DynamicJsonDocument r(256); r["type"]="sequenceStopped";
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
}
else if (strcmp(cmd, "removeSequence") == 0) {
int idx = doc["index"] | -1;
if (idx >= 0 && idx < sequenceCount) {
for (int i = idx; i < sequenceCount-1; i++)
memcpy(&sequences[i], &sequences[i+1], sizeof(CANSequence));
sequenceCount--;
saveSequences();
DynamicJsonDocument r(256); r["type"]="sequenceDeleted"; r["index"]=idx;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
delay(100);
webSocket.sendTXT(num, "{\"cmd\":\"getSequences\"}");
}
}
else if (strcmp(cmd, "getSequenceDetail") == 0) {
int idx = doc["index"] | -1;
if (idx >= 0 && idx < sequenceCount) {
DynamicJsonDocument r(4096); r["type"]="sequenceDetail";
r["index"]=idx; r["name"]=sequences[idx].name;
r["repeatMode"]=sequences[idx].repeatMode; r["repeatCount"]=sequences[idx].repeatCount;
JsonArray sa = r.createNestedArray("steps");
for (int i = 0; i < sequences[idx].stepCount; i++) {
SequenceStep* s = &sequences[idx].steps[i];
JsonObject so = sa.createNestedObject();
char ids[12]; sprintf(ids,"0x%X",s->canId);
so["id"]=ids; so["ext"]=s->extended; so["dlc"]=s->dlc;
JsonArray da = so.createNestedArray("data");
for (int j=0;j<8;j++) da.add(s->data[j]);
so["delay"]=s->delayMs;
}
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
}
}
else if (strcmp(cmd, "hwReset") == 0) {
DynamicJsonDocument r(256); r["type"]="hwReset"; r["success"]=true;
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
delay(100);
ESP.restart();
}
}
}
// ========================================
// ★★★ Web Update Task (RingBuffer Serial Consumer) ★★★
// ========================================
void webUpdateTask(void *parameter) {
vTaskDelay(pdMS_TO_TICKS(500));
// ★ 핵심 개선: server.handleClient() + webSocket.loop()를
// 10ms 루프로 빠르게 폴링 → WebSocket 핸드셰이크 지연 대폭 감소
// (기존 500ms 루프 → 최악 500ms 대기, 개선 후 최악 10ms 대기)
// broadcastTXT(500ms 주기)와 분리해서 연결 수립만 먼저 빠르게 처리
while (1) {
// ── 10ms마다: HTTP + WebSocket 핸드셰이크/수신 처리 ──────
server.handleClient();
webSocket.loop();
// ★ msgPerSecond를 Core 0에서 직접 계산
{
uint32_t now = millis();
if (now - lastMsgCountTime >= 1000) {
uint32_t cur = totalMsgCount;
msgPerSecond = cur - lastMsgCount;
lastMsgCount = cur;
lastMsgCountTime = now;
}
}
// ── 500ms마다: JSON 직렬화 + broadcastTXT (무거운 작업) ─
if (millis() - lastBroadcast < 500) {
vTaskDelay(pdMS_TO_TICKS(10)); // 10ms 대기 후 루프 재진입
continue;
}
lastBroadcast = millis();
if (webSocket.connectedClients() > 0) {
DynamicJsonDocument doc(16384);
doc["type"] = "update";
doc["logging"] = loggingEnabled;
// Auto Trigger 상태
doc["autoTriggerEnabled"] = autoTriggerEnabled;
doc["autoTriggerActive"] = autoTriggerActive;
doc["startTriggerCount"] = startTriggerCount;
doc["stopTriggerCount"] = stopTriggerCount;
// Serial2
doc["serialLogging"] = serialLoggingEnabled;
doc["serial2Logging"] = serial2LoggingEnabled;
doc["totalSerial2Rx"] = totalSerial2RxCount;
doc["totalSerial2Tx"] = totalSerial2TxCount;
// ★ RingBuffer 사용량 (queueUsed 호환 필드명 유지)
doc["queueUsed"] = ring_can_count(&canRing);
doc["queueSize"] = CAN_RING_SIZE;
doc["serialQueueUsed"] = ring_serial_count(&serialRing);
doc["serialQueueSize"] = SERIAL_RING_SIZE;
doc["serial2QueueUsed"] = ring_serial_count(&serial2Ring);
doc["serial2QueueSize"] = SERIAL_RING_SIZE;
// ★ 드랍 카운터 (새로 추가: 웹 UI에서 손실 감지용)
doc["canDropped"] = canRing.dropped;
doc["serDropped"] = serialRing.dropped;
doc["ser2Dropped"] = serial2Ring.dropped;
doc["serial2FileSize"] = currentSerial2FileSize;
doc["currentSerial2File"] =
(serial2LoggingEnabled && currentSerial2Filename[0]) ?
String(currentSerial2Filename) : "";
doc["sdReady"] = sdCardReady;
doc["totalMsg"] = totalMsgCount;
doc["msgPerSec"] = msgPerSecond;
// 버스 부하율
uint32_t maxMPS;
switch(currentCanSpeed) {
case CAN_125KBPS: maxMPS=1000; break;
case CAN_250KBPS: maxMPS=2000; break;
case CAN_500KBPS: maxMPS=4000; break;
default: maxMPS=8000; break;
}
float busLoad = (msgPerSecond * 100.0f) / maxMPS;
if (busLoad > 100.0f) busLoad = 100.0f;
doc["busLoad"] = (int)busLoad;
doc["totalTx"] = totalTxCount;
doc["totalSerialRx"] = totalSerialRxCount;
doc["totalSerialTx"] = totalSerialTxCount;
doc["fileSize"] = currentFileSize;
doc["serialFileSize"] = currentSerialFileSize;
doc["timeSync"] = timeSyncStatus.synchronized;
doc["rtcAvail"] = timeSyncStatus.rtcAvailable;
doc["rtcSyncCnt"] = timeSyncStatus.rtcSyncCount;
doc["syncCount"] = timeSyncStatus.syncCount;
doc["voltage"] = powerStatus.voltage;
doc["minVoltage"] = powerStatus.minVoltage;
doc["lowVoltage"] = powerStatus.lowVoltage;
doc["mcpMode"] = (int)currentMcpMode;
doc["savedCanFormat"] = savedCanLogFormatPCAP ? "pcap" : (savedCanLogFormatCSV ? "csv" : "bin");
doc["currentFile"] =
(loggingEnabled && currentFilename[0]) ? String(currentFilename) : "";
doc["currentSerialFile"]=
(serialLoggingEnabled && currentSerialFilename[0]) ? String(currentSerialFilename) : "";
time_t now; time(&now);
doc["timestamp"] = (uint64_t)now;
// CAN 최근 메시지 (최대 20개)
JsonArray messages = doc.createNestedArray("messages");
int msgCnt = 0;
for (int i = 0; i < RECENT_MSG_COUNT && msgCnt < 20; i++) {
if (recentData[i].count > 0) {
JsonObject mo = messages.createNestedObject();
mo["id"] = recentData[i].msg.id;
mo["dlc"] = recentData[i].msg.dlc;
mo["count"] = recentData[i].count;
JsonArray da = mo.createNestedArray("data");
for (int j = 0; j < recentData[i].msg.dlc; j++) da.add(recentData[i].msg.data[j]);
msgCnt++;
}
}
// ★ Serial RingBuffer pop (최대 10개)
SerialMessage serialMsg;
JsonArray serialMessages = doc.createNestedArray("serialMessages");
int serialCnt = 0;
while (serialCnt < 10 && ring_serial_pop(&serialRing, &serialMsg)) {
JsonObject so = serialMessages.createNestedObject();
so["timestamp"] = serialMsg.timestamp_us;
so["isTx"] = serialMsg.isTx;
char ds[MAX_SERIAL_LINE_LEN+1];
memcpy(ds, serialMsg.data, serialMsg.length);
ds[serialMsg.length] = '\0';
so["data"] = ds;
serialCnt++;
// Serial SD 로깅
if (serialLoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serialLogFormatCSV) {
uint64_t rt = serialMsg.timestamp_us - serialLogStartTime;
char csvLine[256];
int ll = snprintf(csvLine, sizeof(csvLine),
"%llu,%s,\"%s\"\n", rt, serialMsg.isTx?"TX":"RX", ds);
if (serialCsvIndex + ll <= SERIAL_CSV_BUFFER_SIZE) {
memcpy(&serialCsvBuffer[serialCsvIndex], csvLine, ll);
serialCsvIndex += ll;
currentSerialFileSize += ll;
}
if (serialCsvIndex >= SERIAL_CSV_BUFFER_SIZE - 256) {
if (serialLogFile) { serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex); serialLogFile.flush(); serialCsvIndex=0; }
}
} else {
if (serialLogFile) {
serialLogFile.write((uint8_t*)&serialMsg, sizeof(SerialMessage));
currentSerialFileSize += sizeof(SerialMessage);
static int bfc=0; if (++bfc>=50){serialLogFile.flush();bfc=0;}
}
}
xSemaphoreGive(sdMutex);
}
}
}
// ★ Serial2 RingBuffer pop
SerialMessage serial2Msg;
JsonArray serial2Messages = doc.createNestedArray("serial2Messages");
int s2Cnt = 0;
while (s2Cnt < 10 && ring_serial_pop(&serial2Ring, &serial2Msg)) {
JsonObject so = serial2Messages.createNestedObject();
so["timestamp"] = serial2Msg.timestamp_us;
so["isTx"] = serial2Msg.isTx;
char ds[MAX_SERIAL_LINE_LEN+1];
memcpy(ds, serial2Msg.data, serial2Msg.length);
ds[serial2Msg.length] = '\0';
so["data"] = ds;
s2Cnt++;
if (serial2LoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serial2LogFormatCSV) {
uint64_t rt = serial2Msg.timestamp_us - serial2LogStartTime;
char csvLine[256];
int ll = snprintf(csvLine, sizeof(csvLine),
"%llu,%s,\"%s\"\n", rt, serial2Msg.isTx?"TX":"RX", ds);
if (serial2CsvIndex + ll <= SERIAL2_CSV_BUFFER_SIZE) {
memcpy(&serial2CsvBuffer[serial2CsvIndex], csvLine, ll);
serial2CsvIndex += ll;
currentSerial2FileSize += ll;
}
if (serial2CsvIndex >= SERIAL2_CSV_BUFFER_SIZE - 256) {
if (serial2LogFile) { serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex); serial2LogFile.flush(); serial2CsvIndex=0; }
}
} else {
if (serial2LogFile) {
serial2LogFile.write((uint8_t*)&serial2Msg, sizeof(SerialMessage));
currentSerial2FileSize += sizeof(SerialMessage);
static int bfc2=0; if (++bfc2>=50){serial2LogFile.flush();bfc2=0;}
}
}
xSemaphoreGive(sdMutex);
}
}
}
String json;
size_t jsonSize = serializeJson(doc, json);
if (jsonSize > 0 && jsonSize < 8192) webSocket.broadcastTXT(json);
}
// broadcastTXT 완료 후 즉시 루프 재진입 (10ms 대기는 위 continue에서)
}
}
// ========================================
// Setup
// ========================================
void setup() {
Serial.begin(115200);
delay(1000);
esp_reset_reason_t rr = esp_reset_reason();
if (rr == ESP_RST_BROWNOUT) {
Serial.println("\n🚨 브라운아웃 리셋 감지! 전원 공급 부족.");
delay(5000);
}
WiFi.setSleep(false);
WiFi.setTxPower(WIFI_POWER_15dBm);
Serial.println("\n========================================");
Serial.println(" Byun CAN Logger v3.0");
Serial.println(" SPSC Lock-Free RingBuffer Edition");
Serial.println("========================================\n");
// PSRAM 초기화 (RingBuffer 포함)
if (!initPSRAM()) {
Serial.println("✗ PSRAM 초기화 실패! Tools→PSRAM→OPI PSRAM 확인");
while (1) { delay(1000); Serial.println("✗ 재업로드 필요!"); }
}
loadSettings();
loadAutoTriggerSettings();
analogSetPinAttenuation(MONITORING_VOLT, ADC_11db);
pinMode(CAN_INT_PIN, INPUT_PULLUP);
analogSetAttenuation(ADC_11db);
hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS);
hspi.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0));
hspi.endTransaction();
esp_task_wdt_deinit();
// MCP2515 초기화
Serial.println("MCP2515 초기화...");
pinMode(HSPI_CS, OUTPUT);
digitalWrite(HSPI_CS, HIGH); delay(10);
digitalWrite(HSPI_CS, LOW); delayMicroseconds(100);
digitalWrite(HSPI_CS, HIGH); delay(100);
mcp2515.reset(); delay(100);
mcp2515.setBitrate(currentCanSpeed, MCP_CRYSTAL); delay(10);
mcp2515.setFilterMask(MCP2515::MASK0, false, 0); mcp2515.setFilter(MCP2515::RXF0, false, 0); mcp2515.setFilter(MCP2515::RXF1, false, 0);
mcp2515.setFilterMask(MCP2515::MASK1, true, 0); mcp2515.setFilter(MCP2515::RXF2, true, 0); mcp2515.setFilter(MCP2515::RXF3, true, 0);
mcp2515.setFilter(MCP2515::RXF4, true, 0); mcp2515.setFilter(MCP2515::RXF5, true, 0);
mcp2515.clearRXnOVRFlags(); mcp2515.clearInterrupts(); mcp2515.clearTXInterrupts();
mcp2515.clearMERR(); mcp2515.clearERRIF();
if (currentMcpMode == MCP_MODE_NORMAL) { mcp2515.setNormalMode(); Serial.println(" → Normal Mode"); }
else if (currentMcpMode == MCP_MODE_LOOPBACK) { mcp2515.setLoopbackMode(); Serial.println(" → Loopback Mode"); }
else { mcp2515.setListenOnlyMode();Serial.println(" → Listen-Only Mode"); }
delay(50);
struct can_frame df; int cc=0;
while (mcp2515.readMessage(&df)==MCP2515::ERROR_OK && cc++<100);
mcp2515.clearRXnOVRFlags();
Serial.println("✓ MCP2515 초기화 완료");
applySerialSettings();
sdMutex = xSemaphoreCreateMutex();
rtcMutex = xSemaphoreCreateMutex();
serialMutex = xSemaphoreCreateMutex();
serial2Mutex = xSemaphoreCreateMutex();
if (!sdMutex || !rtcMutex || !serialMutex) {
Serial.println("✗ Mutex 생성 실패!"); while(1) delay(1000);
}
initRTC();
// ★ 로깅: SDIO 4-bit (D0~D3) - 쓰기 속도 우선
// 다운로드: SDIO 1-bit (D0만) - WiFi RF 간섭 방지
Serial.println("SD 카드 초기화 (SDIO 4-bit)...");
if (!SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3)) {
sdCardReady = false;
} else if (SD_MMC.begin("/sdcard", false, false, 10000000)) { // false = 4-bit
sdCardReady = true;
Serial.printf("✓ SD 카드 초기화 완료: %llu MB\n", SD_MMC.cardSize()/(1024*1024));
loadFileComments();
loadSequences();
} else {
sdCardReady = false;
Serial.println("✗ SD 카드 초기화 실패");
}
// WiFi
WiFi.setSleep(false);
if (enableSTAMode && strlen(staSSID) > 0) {
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
WiFi.begin(staSSID, staPassword);
int att = 0;
while (WiFi.status() != WL_CONNECTED && att++ < 20) { delay(500); Serial.print("."); }
Serial.println();
if (WiFi.status() == WL_CONNECTED) { Serial.printf("✓ STA IP: %s\n", WiFi.localIP().toString().c_str()); initNTP(); }
} else {
WiFi.mode(WIFI_AP);
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
Serial.printf("✓ AP: %s | IP: %s\n", wifiSSID, WiFi.softAPIP().toString().c_str());
}
esp_wifi_set_max_tx_power(84);
webSocket.begin();
webSocket.onEvent(webSocketEvent);
server.on("/", HTTP_GET, [](){server.send_P(200,"text/html",index_html);});
server.on("/transmit", HTTP_GET, [](){server.send_P(200,"text/html",transmit_html);});
server.on("/graph", HTTP_GET, [](){server.send_P(200,"text/html",graph_html);});
server.on("/graph-view",HTTP_GET, [](){server.send_P(200,"text/html",graph_viewer_html);});
server.on("/settings", HTTP_GET, [](){server.send_P(200,"text/html",settings_html);});
server.on("/serial", HTTP_GET, [](){server.send_P(200,"text/html",serial_terminal_html);});
server.on("/serial2", HTTP_GET, [](){server.send_P(200,"text/html",serial2_terminal_html);});
server.on("/download", HTTP_GET, []() {
// ★★★ 주의: 이 핸들러는 webUpdateTask 컨텍스트에서 실행됨
// → webTaskHandle을 vTaskSuspend 하면 자기 자신을 정지시켜 영원히 멈춤
// → SD_MMC.end()/재마운트 불필요 (같은 SD 인스턴스 그대로 사용)
// → CHUNK를 충분히 크게 해서 Watchdog 타임아웃 방지
if (!server.hasArg("file")) {
server.send(400, "text/plain", "Bad request"); return;
}
String filename = "/" + server.arg("file");
// 로깅 중이면 완전 중단 후 SD 버스 정리
bool wasLogging = loggingEnabled;
if (wasLogging) {
loggingEnabled = false;
// ① sdWriteTask가 버퍼에 쓰던 것 완료 대기
// ② sdFlushTask가 SD에 flush 완료 대기 (여기가 핵심 - 안 기다리면 SD 버스 충돌)
int waitMs = 0;
while (flushInProgress && waitMs < 2000) { delay(10); waitMs += 10; }
delay(50); // SD 버스 안정화
}
// sdMutex 획득 (sdFlushTask와 동시 접근 차단)
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(3000)) != pdTRUE) {
if (wasLogging) loggingEnabled = true;
server.send(503, "text/plain", "SD busy"); return;
}
if (!SD_MMC.exists(filename)) {
xSemaphoreGive(sdMutex);
if (wasLogging) loggingEnabled = true;
server.send(404, "text/plain", "File not found"); return;
}
// ★ 다운로드 중 1-bit 모드 전환 (WiFi TX와 SD DMA 간섭 방지)
// 4-bit: D0~D3 동시 → WiFi RF 노이즈에 취약 → 0x109 DMA 타임아웃
// 1-bit: D0만 사용 → 노이즈 내성 대폭 향상
// 전환 절차: end() → setPins(D0만) → begin(1-bit)
{
SD_MMC.end();
delay(50);
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0);
if (!SD_MMC.begin("/sdcard", true, false, 10000000)) {
xSemaphoreGive(sdMutex);
if (wasLogging) loggingEnabled = true;
server.send(500, "text/plain", "SD 1-bit init failed"); return;
}
Serial.println("✓ SD 1-bit 모드 전환 (다운로드용)");
}
File file = SD_MMC.open(filename, FILE_READ);
if (!file) {
xSemaphoreGive(sdMutex);
if (wasLogging) loggingEnabled = true;
server.send(500, "text/plain", "Failed to open file"); return;
}
size_t fileSize = file.size();
String dispName = server.arg("file");
server.setContentLength(fileSize);
server.sendHeader("Content-Disposition", "attachment; filename=\"" + dispName + "\"");
server.sendHeader("Content-Type", "application/octet-stream");
server.sendHeader("Connection", "close");
server.send(200, "application/octet-stream", "");
// ★ CHUNK 4KB: DMA 전송 크기를 줄여 SD 내부 타이밍 부담 감소
const size_t CHUNK = 4096;
uint8_t* buf = (uint8_t*)heap_caps_malloc(CHUNK, MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
if (!buf) buf = (uint8_t*)malloc(CHUNK);
if (buf) {
size_t total = 0;
WiFiClient client = server.client();
client.setNoDelay(true);
// SD 읽기 실패 복구 람다 (SDMMC 페리페럴 완전 리셋)
// 0x109(ESP_ERR_TIMEOUT) 발생 후 seek+read 재시도는 무의미:
// SDMMC 하드웨어가 에러 상태로 굳어 있어서 end()+begin() 필수
auto sdReinit = [&]() -> bool {
Serial.println("⚠️ SDMMC 페리페럴 리셋 시도...");
file.close();
xSemaphoreGive(sdMutex); // 뮤텍스 해제 후 reinit
delay(200);
SD_MMC.end();
delay(100);
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0); // 1-bit
if (!SD_MMC.begin("/sdcard", true, false, 10000000)) { // true = 1-bit
Serial.println("✗ SDMMC 재초기화 실패");
sdCardReady = false;
return false;
}
sdCardReady = true;
// 뮤텍스 재획득
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(3000)) != pdTRUE) {
Serial.println("✗ 뮤텍스 재획득 실패");
return false;
}
// 파일 재오픈 후 이어받기 위치로 seek
file = SD_MMC.open(filename, FILE_READ);
if (!file) {
Serial.println("✗ 파일 재오픈 실패");
xSemaphoreGive(sdMutex);
return false;
}
if (!file.seek(total)) {
Serial.println("✗ 파일 seek 실패");
file.close();
xSemaphoreGive(sdMutex);
return false;
}
Serial.printf("✓ SDMMC 리셋 완료, offset=%u 에서 재개", (unsigned)total);
return true;
};
int reinitCount = 0;
while (total < fileSize && client.connected()) {
size_t toRead = min((size_t)(fileSize - total), CHUNK);
size_t r = file.read(buf, toRead);
if (r == 0) {
// SDMMC 에러 상태 → 페리페럴 완전 리셋
if (reinitCount >= 3) {
Serial.println("✗ Download: SDMMC 리셋 3회 실패, 중단");
goto download_done;
}
reinitCount++;
Serial.printf("⚠️ SD 읽기 실패 (reinit %d), offset=%u",
reinitCount, (unsigned)total);
if (!sdReinit()) goto download_done;
continue; // 리셋 성공 → 같은 offset에서 재시도
}
reinitCount = 0; // 읽기 성공 시 카운터 리셋
size_t w = 0;
uint32_t writeStart = millis();
while (w < r) {
if (!client.connected()) {
Serial.println("⚠️ Download: 클라이언트 연결 끊김");
goto download_done;
}
size_t wr = client.write(buf + w, r - w);
if (wr > 0) {
w += wr;
writeStart = millis();
} else {
if (millis() - writeStart > 30000) {
Serial.println("⚠️ Download: 30초 타임아웃");
goto download_done;
}
yield();
delay(1);
}
}
total += r;
yield();
}
download_done:
free(buf);
Serial.printf("Download %s: %s (%u / %u bytes)\n",
(total == fileSize) ? "OK" : "PARTIAL",
filename.c_str(), (unsigned)total, (unsigned)fileSize);
}
if (file) file.close();
// ★ 다운로드 완료 후 4-bit 모드 복원 (로깅용)
{
SD_MMC.end();
delay(50);
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3);
if (SD_MMC.begin("/sdcard", false, false, 10000000)) {
sdCardReady = true;
Serial.println("✓ SD 4-bit 모드 복원 (로깅용)");
} else {
sdCardReady = false;
Serial.println("✗ SD 4-bit 복원 실패");
}
}
xSemaphoreGive(sdMutex);
// 로깅 재개
if (wasLogging) loggingEnabled = true;
});
server.begin();
Serial.println("✓ 웹 서버 시작");
// ★ 초경량 ISR 등록 (플래그 세팅만, 컨텍스트 스위치 없음)
attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING);
// ========================================
// Task 생성
// ========================================
Serial.println("\nTask 생성 중...");
// ★ 중요: canRxTask(Core 1, Pri 24)는 반드시 마지막에 생성
// → 먼저 생성하면 setup()이 Core 1에서 선점당해 이후 태스크 생성 불가
xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 12288, NULL, 8, &webTaskHandle, 0);
xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1);
xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(sdFlushTask, "SD_FLUSH", 4096, NULL, 9, &sdFlushTaskHandle, 0);
xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 18576, NULL, 8, &sdWriteTaskHandle, 0);
xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 6, &serialRxTaskHandle, 0);
xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 6, &serial2RxTaskHandle, 0);
if (timeSyncStatus.rtcAvailable)
xTaskCreatePinnedToCore(rtcSyncTask,"RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0);
// ★ canRxTask 마지막 생성 (Core 1 Pri 24 → 이 시점엔 setup() 완료 직전)
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 12288, NULL, 24, &canRxTaskHandle, 1);
Serial.println("\n========================================");
Serial.println(" Task 구성 (RingBuffer v3.0)");
Serial.println("========================================");
Serial.println("Core 1:");
Serial.printf(" CAN_RX Pri 24 → canRing (SPSC push, no mutex)\n");
Serial.printf(" WEB_UPDATE Pri 8 ← serialRing (SPSC pop, no mutex)\n");
Serial.printf(" TX/SEQ/MON Pri 3/2/1\n");
Serial.println("Core 0:");
Serial.printf(" SD_FLUSH Pri 9 (Double Buffer 비동기 flush)\n");
Serial.printf(" SD_WRITE Pri 8 ← canRing (SPSC pop, BIN no mutex)\n");
Serial.printf(" SERIAL_RX Pri 6 → serialRing (SPSC push)\n");
Serial.printf(" SERIAL2_RX Pri 6 → serial2Ring(SPSC push)\n");
Serial.println("========================================");
Serial.printf("RingBuffer: CAN %d슬롯, SER %d슬롯 (PSRAM)\n",
CAN_RING_SIZE, SERIAL_RING_SIZE);
Serial.println("========================================\n");
}
// ========================================
// Loop
// ========================================
void loop() {
// ★ server.handleClient()는 webUpdateTask(Core 0)에서 처리
// loop()는 Core 1, Pri 1 → canRxTask(Pri 24)에 선점당해 실질적으로 실행 안됨
// 통계 출력만 여기서 하지 않고 vTaskDelay로 양보
vTaskDelay(pdMS_TO_TICKS(10));
// 30초마다 상태 출력 (RingBuffer 통계 포함)
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 30000) {
Serial.printf("[RingBuf] CAN: %u/%u (drop:%u) | SER: %u/%u (drop:%u) | SER2: %u/%u (drop:%u) | PSRAM: %d KB\n",
ring_can_count(&canRing), CAN_RING_SIZE, canRing.dropped,
ring_serial_count(&serialRing), SERIAL_RING_SIZE, serialRing.dropped,
ring_serial_count(&serial2Ring), SERIAL_RING_SIZE, serial2Ring.dropped,
ESP.getFreePsram() / 1024);
lastPrint = millis();
}
// 5분마다 스택 사용량
static uint32_t lastStack = 0;
if (millis() - lastStack > 300000) {
Serial.println("\n========== Task Stack Usage ==========");
if (canRxTaskHandle) Serial.printf("CAN_RX: %5u bytes free\n", uxTaskGetStackHighWaterMark(canRxTaskHandle)*4);
if (sdWriteTaskHandle) Serial.printf("SD_WRITE: %5u bytes free\n", uxTaskGetStackHighWaterMark(sdWriteTaskHandle)*4);
if (webTaskHandle) Serial.printf("WEB_UPDATE: %5u bytes free\n", uxTaskGetStackHighWaterMark(webTaskHandle)*4);
if (serialRxTaskHandle) Serial.printf("SERIAL_RX: %5u bytes free\n", uxTaskGetStackHighWaterMark(serialRxTaskHandle)*4);
if (serial2RxTaskHandle) Serial.printf("SERIAL2_RX: %5u bytes free\n", uxTaskGetStackHighWaterMark(serial2RxTaskHandle)*4);
Serial.printf("Free Heap: %u bytes\n", ESP.getFreeHeap());
Serial.printf("Free PSRAM: %u KB\n", ESP.getFreePsram()/1024);
Serial.printf("CAN Dropped Total: %u\n", canRing.dropped);
Serial.println("======================================\n");
lastStack = millis();
}
}