Files
esp32s3_canlogger_mcp2515/ESP32_CAN_Logger-a.ino
2026-01-19 19:36:57 +00:00

3790 lines
152 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: 2.3 - PSRAM Optimized (Complete Version)
*
* PSRAM 최적화 완전판:
* - 원본 기능 100% 유지
* - 대용량 버퍼/Queue를 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> // ⭐ SDIO 4-bit
#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/queue.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" // ⭐ Serial2 페이지 추가
// GPIO 핀 정의
#define CAN_INT_PIN 4
#define SERIAL_TX_PIN 17
#define SERIAL_RX_PIN 18
// UART2 (Serial Logger 2) ⭐ 추가
#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 (ESP32-S3)
// CLK: GPIO39, CMD: GPIO38, D0-D3: GPIO40,41,42,21
#define SDIO_CLK 39
#define SDIO_CMD 38
#define SDIO_D0 40
#define SDIO_D1 41
#define SDIO_D2 42
#define SDIO_D3 21 // ⭐ OPI PSRAM 호환
// I2C2 핀 (RTC DS3231)
#define RTC_SDA 8
#define RTC_SCL 9
#define DS3231_ADDRESS 0x68
// ========================================
// PSRAM 최적화 설정
// ========================================
#define CAN_QUEUE_SIZE 6000 // 1000 → 6000 (PSRAM 사용)
#define FILE_BUFFER_SIZE 65536 // 16KB → 64KB (PSRAM 사용)
#define SERIAL_QUEUE_SIZE 1200 // 200 → 1200 (PSRAM 사용)
#define SERIAL_CSV_BUFFER_SIZE 32768
#define SERIAL2_QUEUE_SIZE 1200 // ⭐ Serial2 추가
#define SERIAL2_CSV_BUFFER_SIZE 32768 // ⭐ Serial2 추가 // 8KB → 32KB (PSRAM 사용)
#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 data[8];
} __attribute__((packed));
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];
};
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
};
// ========================================
// PSRAM 할당 변수 (포인터로 선언)
// ========================================
uint8_t *fileBuffer = nullptr;
char *serialCsvBuffer = nullptr;
char *serial2CsvBuffer = nullptr; // ⭐ Serial2 추가
RecentCANData *recentData = nullptr;
TxMessage *txMessages = nullptr;
CANSequence *sequences = nullptr;
FileComment *fileComments = nullptr;
// Queue 저장소 (PSRAM)
StaticQueue_t *canQueueBuffer = nullptr;
StaticQueue_t *serialQueueBuffer = nullptr;
StaticQueue_t *serial2QueueBuffer = nullptr; // ⭐ Serial2
uint8_t *canQueueStorage = nullptr;
uint8_t *serialQueueStorage = nullptr;
uint8_t *serial2QueueStorage = nullptr; // ⭐ Serial2
// WiFi 설정 (내부 SRAM)
char wifiSSID[32] = "Byun_CAN_Logger";
char wifiPassword[64] = "12345678";
bool enableSTAMode = false;
char staSSID[32] = "";
char staPassword[64] = "";
// ========================================
// Serial 설정 (2개)
// ========================================
SerialSettings serialSettings = {115200, 8, 0, 1}; // Serial1
SerialSettings serial2Settings = {115200, 8, 0, 1}; // ⭐ Serial2 추가
// 전역 객체 (내부 SRAM)
SPIClass hspi(HSPI);
// SPIClass vspi(FSPI); // ⭐ SDIO 사용으로 제거
MCP2515 mcp2515(HSPI_CS, 20000000, &hspi);
HardwareSerial SerialComm(1); // UART1
HardwareSerial Serial2Comm(2); // ⭐ UART2 추가
WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);
Preferences preferences;
// FreeRTOS 핸들
QueueHandle_t canQueue = NULL;
QueueHandle_t serialQueue = NULL;
QueueHandle_t serial2Queue = NULL; // ⭐ Serial2 추가
SemaphoreHandle_t sdMutex = NULL;
SemaphoreHandle_t rtcMutex = NULL;
SemaphoreHandle_t serialMutex = NULL;
SemaphoreHandle_t serial2Mutex = NULL; // ⭐ Serial2 추가
TaskHandle_t canRxTaskHandle = NULL;
TaskHandle_t sdWriteTaskHandle = NULL;
TaskHandle_t webTaskHandle = NULL;
TaskHandle_t rtcTaskHandle = NULL;
TaskHandle_t serialRxTaskHandle = NULL;
TaskHandle_t serial2RxTaskHandle = NULL; // ⭐ Serial2 추가
// 로깅 변수
volatile bool loggingEnabled = false;
// ============================================
// 🎯 Auto Trigger 전역 변수
// ============================================
// Auto Trigger 조건 구조체
struct TriggerCondition {
uint32_t canId; // CAN ID
uint8_t startBit; // 시작 비트 (0-63)
uint8_t bitLength; // 비트 길이 (1-64)
char op[3]; // 연산자: "==", "!=", ">", "<", ">=", "<="
int64_t value; // 비교 값
bool enabled; // 조건 활성화
};
#define MAX_TRIGGERS 8
TriggerCondition startTriggers[MAX_TRIGGERS];
TriggerCondition stopTriggers[MAX_TRIGGERS];
int startTriggerCount = 0;
int stopTriggerCount = 0;
String startFormula = ""; // Start 조건 수식
String stopFormula = ""; // Stop 조건 수식
bool autoTriggerEnabled = false;
char startLogicOp[4] = "OR";
char stopLogicOp[4] = "OR";
bool autoTriggerActive = false;
bool autoTriggerLogCSV = false; // 🆕 Auto Trigger용 CSV 형식 설정
// 🆕 File Format 저장 변수
bool savedCanLogFormatCSV = false; // 저장된 CAN 로그 형식 (BIN=false, CSV=true)
volatile bool serialLoggingEnabled = false;
volatile bool serial2LoggingEnabled = false; // ⭐ Serial2 추가
volatile bool sdCardReady = false;
File logFile;
File serialLogFile;
File serial2LogFile; // ⭐ Serial2 추가
char currentFilename[MAX_FILENAME_LEN];
char currentSerialFilename[MAX_FILENAME_LEN];
char currentSerial2Filename[MAX_FILENAME_LEN]; // ⭐ Serial2 추가
uint32_t bufferIndex = 0; // ⭐ uint16_t → uint32_t (오버플로우 방지)
uint32_t serialCsvIndex = 0; // ⭐ uint16_t → uint32_t
uint32_t serial2CsvIndex = 0; // ⭐ uint16_t → uint32_t // ⭐ Serial2 추가
volatile uint32_t currentFileSize = 0;
volatile uint32_t currentSerialFileSize = 0;
volatile uint32_t currentSerial2FileSize = 0; // ⭐ Serial2 추가
volatile bool canLogFormatCSV = false;
volatile bool serialLogFormatCSV = true;
volatile bool serial2LogFormatCSV = true; // ⭐ Serial2 추가
volatile uint64_t canLogStartTime = 0;
volatile uint64_t serialLogStartTime = 0;
volatile uint64_t serial2LogStartTime = 0; // ⭐ Serial2 추가
// 기타 전역 변수
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};
uint32_t totalMsgCount = 0;
uint32_t msgPerSecond = 0;
uint32_t lastMsgCountTime = 0;
uint32_t lastMsgCount = 0;
volatile uint32_t totalSerialRxCount = 0;
volatile uint32_t totalSerialTxCount = 0;
volatile uint32_t totalSerial2RxCount = 0; // ⭐ Serial2 추가
volatile uint32_t totalSerial2TxCount = 0; // ⭐ Serial2 추가
uint32_t totalTxCount = 0;
uint8_t sequenceCount = 0;
SequenceRuntime seqRuntime = {false, 0, 0, 0, -1};
int commentCount = 0;
// Forward declarations
void IRAM_ATTR canISR();
void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length);
void resetMCP2515();
void resetMCP2515() {
Serial.println("🔄 MCP2515 리셋 시작...");
// 1. 로깅 중지 (진행 중이면)
if (loggingEnabled) {
// 버퍼 플러시 및 파일 닫기
}
// 2. CAN 큐 비우기
CANMessage tempMsg;
while (xQueueReceive(canQueue, &tempMsg, 0) == pdTRUE) {
// 큐에서 모든 메시지 제거
}
// 3. 하드웨어 리셋 (CS 토글)
Serial.println(" 1. 하드웨어 리셋...");
digitalWrite(HSPI_CS, LOW);
delayMicroseconds(100);
digitalWrite(HSPI_CS, HIGH);
delay(100);
// 4. 소프트웨어 리셋 (Configuration 모드로 진입)
Serial.println(" 2. 소프트웨어 리셋...");
mcp2515.reset();
delay(100);
// ✅ Preferences에서 설정 불러오기
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();
Serial.printf(" 3. Speed=%d, Mode=%d\n", (int)currentCanSpeed, (int)currentMcpMode);
// 5. Bitrate 설정 (Configuration 모드에서)
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
delay(10);
// 6. 필터/마스크 설정 (모든 메시지 수신)
Serial.println(" 4. 필터 설정 (모든 메시지 수신)...");
mcp2515.setFilterMask(MCP2515::MASK0, false, 0x000);
mcp2515.setFilterMask(MCP2515::MASK1, false, 0x000);
mcp2515.setFilter(MCP2515::RXF0, false, 0x000);
mcp2515.setFilter(MCP2515::RXF1, false, 0x000);
mcp2515.setFilter(MCP2515::RXF2, false, 0x000);
mcp2515.setFilter(MCP2515::RXF3, false, 0x000);
mcp2515.setFilter(MCP2515::RXF4, false, 0x000);
mcp2515.setFilter(MCP2515::RXF5, false, 0x000);
delay(10);
// 7. 모드 설정 (마지막에!)
Serial.printf(" 5. 모드 설정: %d\n", (int)currentMcpMode);
if (currentMcpMode == MCP_MODE_NORMAL) {
mcp2515.setNormalMode();
} else if (currentMcpMode == MCP_MODE_LOOPBACK) {
mcp2515.setLoopbackMode();
} else {
mcp2515.setListenOnlyMode();
}
delay(50);
// 8. 버퍼 클리어 (모드 전환 후)
Serial.println(" 6. 버퍼 클리어...");
struct can_frame dummyFrame;
int clearCount = 0;
while (mcp2515.readMessage(&dummyFrame) == MCP2515::ERROR_OK) {
clearCount++;
if (clearCount > 100) break;
}
if (clearCount > 0) {
Serial.printf(" %d개 메시지 버림\n", clearCount);
}
// 9. 에러/오버플로우 플래그 클리어
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
mcp2515.clearTXInterrupts();
mcp2515.clearMERR();
mcp2515.clearERRIF();
delay(10);
// 10. 에러 상태 확인
uint8_t errorFlag = mcp2515.getErrorFlags();
uint8_t txErr = mcp2515.errorCountTX();
uint8_t rxErr = mcp2515.errorCountRX();
Serial.printf(" 7. 에러 상태: EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr);
if (errorFlag != 0 || txErr > 0 || rxErr > 0) {
Serial.println(" ⚠️ 에러 감지됨 - 추가 리셋 시도...");
// 에러가 있으면 완전 리셋
mcp2515.reset();
delay(50);
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
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();
}
// 11. 통계 리셋
totalMsgCount = 0;
lastMsgCount = 0;
msgPerSecond = 0;
// 12. 최근 메시지 테이블 클리어
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
recentData[i].count = 0;
}
Serial.println("✅ MCP2515 리셋 완료!");
}
// ========================================
// PSRAM 초기화 함수
// ========================================
bool initPSRAM() {
Serial.println("\n========================================");
Serial.println(" PSRAM 메모리 할당");
Serial.println("========================================");
if (!psramFound()) {
Serial.println("✗ PSRAM을 찾을 수 없습니다!");
Serial.println("✗ Arduino IDE 설정:");
Serial.println(" Tools → PSRAM → OPI PSRAM");
return false;
}
Serial.printf("✓ PSRAM 총 용량: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
Serial.printf("✓ PSRAM 여유: %d KB\n\n", ESP.getFreePsram() / 1024);
// 버퍼 할당
fileBuffer = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
if (!fileBuffer) {
Serial.println("✗ fileBuffer 할당 실패");
return false;
}
Serial.printf("✓ fileBuffer: %d KB\n", FILE_BUFFER_SIZE / 1024);
serialCsvBuffer = (char*)ps_malloc(SERIAL_CSV_BUFFER_SIZE);
if (!serialCsvBuffer) {
Serial.println("✗ serialCsvBuffer 할당 실패");
return false;
}
Serial.printf("✓ serialCsvBuffer: %d KB\n", SERIAL_CSV_BUFFER_SIZE / 1024);
// ⭐ Serial2 CSV Buffer
serial2CsvBuffer = (char*)ps_malloc(SERIAL2_CSV_BUFFER_SIZE);
if (!serial2CsvBuffer) {
Serial.println("✗ serial2CsvBuffer 할당 실패");
return false;
}
Serial.printf("✓ serial2CsvBuffer: %d KB\n", SERIAL2_CSV_BUFFER_SIZE / 1024);
recentData = (RecentCANData*)ps_calloc(RECENT_MSG_COUNT, sizeof(RecentCANData));
if (!recentData) {
Serial.println("✗ recentData 할당 실패");
return false;
}
Serial.printf("✓ recentData: %.2f KB\n", (float)(RECENT_MSG_COUNT * sizeof(RecentCANData)) / 1024.0);
txMessages = (TxMessage*)ps_calloc(MAX_TX_MESSAGES, sizeof(TxMessage));
if (!txMessages) {
Serial.println("✗ txMessages 할당 실패");
return false;
}
Serial.printf("✓ txMessages: %.2f KB\n", (float)(MAX_TX_MESSAGES * sizeof(TxMessage)) / 1024.0);
sequences = (CANSequence*)ps_calloc(MAX_SEQUENCES, sizeof(CANSequence));
if (!sequences) {
Serial.println("✗ sequences 할당 실패");
return false;
}
Serial.printf("✓ sequences: %.2f KB\n", (float)(MAX_SEQUENCES * sizeof(CANSequence)) / 1024.0);
fileComments = (FileComment*)ps_calloc(MAX_FILE_COMMENTS, sizeof(FileComment));
if (!fileComments) {
Serial.println("✗ fileComments 할당 실패");
return false;
}
Serial.printf("✓ fileComments: %.2f KB\n", (float)(MAX_FILE_COMMENTS * sizeof(FileComment)) / 1024.0);
// Queue 저장소 할당
Serial.println("\n📦 Queue 저장소 할당...");
canQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t));
canQueueStorage = (uint8_t*)ps_malloc(CAN_QUEUE_SIZE * sizeof(CANMessage));
if (!canQueueBuffer || !canQueueStorage) {
Serial.println("✗ CAN Queue 저장소 할당 실패");
return false;
}
Serial.printf("✓ CAN Queue: %d 개 × %d bytes = %.2f KB\n",
CAN_QUEUE_SIZE, sizeof(CANMessage),
(float)(CAN_QUEUE_SIZE * sizeof(CANMessage)) / 1024.0);
serialQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t));
serialQueueStorage = (uint8_t*)ps_malloc(SERIAL_QUEUE_SIZE * sizeof(SerialMessage));
if (!serialQueueBuffer || !serialQueueStorage) {
Serial.println("✗ Serial Queue 저장소 할당 실패");
return false;
}
Serial.printf("✓ Serial Queue: %d 개 × %d bytes = %.2f KB\n",
SERIAL_QUEUE_SIZE, sizeof(SerialMessage),
(float)(SERIAL_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0);
// ⭐ Serial2 Queue
serial2QueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t));
serial2QueueStorage = (uint8_t*)ps_malloc(SERIAL2_QUEUE_SIZE * sizeof(SerialMessage));
if (!serial2QueueBuffer || !serial2QueueStorage) {
Serial.println("✗ Serial2 Queue 저장소 할당 실패");
return false;
}
Serial.printf("✓ Serial2 Queue: %d 개 × %d bytes = %.2f KB\n",
SERIAL2_QUEUE_SIZE, sizeof(SerialMessage),
(float)(SERIAL2_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0);
Serial.println("========================================");
Serial.printf("✓ PSRAM 남은 용량: %.2f KB\n", (float)ESP.getFreePsram() / 1024.0);
Serial.println("========================================\n");
return true;
}
bool createQueues() {
Serial.println("Queue 생성 (PSRAM 사용)...");
canQueue = xQueueCreateStatic(
CAN_QUEUE_SIZE,
sizeof(CANMessage),
canQueueStorage,
canQueueBuffer
);
if (canQueue == NULL) {
Serial.println("✗ CAN Queue 생성 실패");
return false;
}
Serial.printf("✓ CAN Queue: %d 개\n", CAN_QUEUE_SIZE);
serialQueue = xQueueCreateStatic(
SERIAL_QUEUE_SIZE,
sizeof(SerialMessage),
serialQueueStorage,
serialQueueBuffer
);
if (serialQueue == NULL) {
Serial.println("✗ Serial Queue 생성 실패");
return false;
}
Serial.printf("✓ Serial Queue: %d 개\n", SERIAL_QUEUE_SIZE);
// ⭐ Serial2 Queue 생성 (중요!)
serial2Queue = xQueueCreateStatic(
SERIAL2_QUEUE_SIZE,
sizeof(SerialMessage),
serial2QueueStorage,
serial2QueueBuffer
);
if (serial2Queue == NULL) {
Serial.println("✗ Serial2 Queue 생성 실패");
return false;
}
Serial.printf("✓ Serial2 Queue: %d 개\n\n", SERIAL2_QUEUE_SIZE);
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);
// ⭐ Serial2
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);
// ⭐ Serial2
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 if (serialSettings.parity == 2) config = SERIAL_5O1;
} else if (serialSettings.dataBits == 6) {
if (serialSettings.parity == 0) config = SERIAL_6N1;
else if (serialSettings.parity == 1) config = SERIAL_6E1;
else if (serialSettings.parity == 2) config = SERIAL_6O1;
} else if (serialSettings.dataBits == 7) {
if (serialSettings.parity == 0) config = SERIAL_7N1;
else if (serialSettings.parity == 1) config = SERIAL_7E1;
else if (serialSettings.parity == 2) config = SERIAL_7O1;
} else {
if (serialSettings.parity == 0) config = SERIAL_8N1;
else if (serialSettings.parity == 1) config = SERIAL_8E1;
else if (serialSettings.parity == 2) config = SERIAL_8O1;
}
if (serialSettings.stopBits == 2) {
config |= 0x3000;
}
SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN);
SerialComm.setRxBufferSize(2048);
// ⭐ Serial2 설정
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 if (serial2Settings.parity == 2) config2 = SERIAL_5O1;
} else if (serial2Settings.dataBits == 6) {
if (serial2Settings.parity == 0) config2 = SERIAL_6N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_6E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_6O1;
} else if (serial2Settings.dataBits == 7) {
if (serial2Settings.parity == 0) config2 = SERIAL_7N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_7E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_7O1;
} else {
if (serial2Settings.parity == 0) config2 = SERIAL_8N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_8E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_8O1;
}
if (serial2Settings.stopBits == 2) config2 |= 0x3000;
Serial2Comm.begin(serial2Settings.baudRate, config2, SERIAL2_RX_PIN, SERIAL2_TX_PIN);
Serial2Comm.setRxBufferSize(2048);
}
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);
Serial.printf("📥 loadSettings: MCP Mode = %d\n", savedMode);
if (savedMode >= 0 && savedMode <= 3) {
currentMcpMode = (MCP2515Mode)savedMode;
}
// 🆕 File Format 불러오기
savedCanLogFormatCSV = preferences.getBool("can_format_csv", false);
Serial.printf("📥 loadSettings: CAN Format = %s\n", savedCanLogFormatCSV ? "CSV" : "BIN");
loadSerialSettings();
preferences.end();
}
// ============================================
// 🎯 Auto Trigger 함수
// ============================================
// 비트 추출 (Motorola/Big-endian)
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) {
if (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 extractedValue = extractBits(data, trigger.startBit, trigger.bitLength);
if (strcmp(trigger.op, "==") == 0) return extractedValue == trigger.value;
else if (strcmp(trigger.op, "!=") == 0) return extractedValue != trigger.value;
else if (strcmp(trigger.op, ">") == 0) return extractedValue > trigger.value;
else if (strcmp(trigger.op, "<") == 0) return extractedValue < trigger.value;
else if (strcmp(trigger.op, ">=") == 0) return extractedValue >= trigger.value;
else if (strcmp(trigger.op, "<=") == 0) return extractedValue <= trigger.value;
return false;
}
// Auto Trigger 체크
void checkAutoTriggers(struct can_frame &frame) {
if (!autoTriggerEnabled || !sdCardReady) return;
// 시작 조건 체크
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;
// 🆕 Auto Trigger 전용 형식 설정 적용
canLogFormatCSV = autoTriggerLogCSV;
const char* ext = canLogFormatCSV ? "csv" : "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();
}
logFile.close();
// APPEND 모드로 다시 열기
logFile = SD_MMC.open(currentFilename, FILE_APPEND);
if (logFile) {
loggingEnabled = true;
autoTriggerActive = true;
bufferIndex = 0;
currentFileSize = logFile.size();
Serial.println("🎯 Auto Trigger: 로깅 시작!");
Serial.printf(" 조건: ID 0x%03X 만족\n", frame.can_id);
Serial.printf(" 파일: %s\n", currentFilename);
Serial.printf(" 형식: %s\n", canLogFormatCSV ? "CSV" : "BIN");
} else {
Serial.println("✗ Auto Trigger: APPEND 모드 파일 열기 실패");
}
} else {
Serial.println("✗ Auto Trigger: 파일 생성 실패");
}
xSemaphoreGive(sdMutex);
} else {
Serial.println("✗ Auto Trigger: sdMutex 획득 실패");
}
}
}
// 중지 조건 체크
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) {
// 🎯 파일 닫기 로직 추가
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) {
// BIN 형식: 버퍼에 남은 데이터 강제 플러시
if (bufferIndex > 0 && logFile) {
size_t written = logFile.write(fileBuffer, bufferIndex);
logFile.flush();
Serial.printf("✓ Auto Trigger 최종 플러시: %d bytes\n", written);
bufferIndex = 0;
}
// CSV 형식: 최종 플러시
if (canLogFormatCSV && logFile) {
logFile.flush();
}
if (logFile) {
size_t finalSize = logFile.size();
logFile.close();
Serial.printf("✓ Auto Trigger 파일 닫힘: %s (%lu bytes)\n", currentFilename, finalSize);
}
loggingEnabled = false;
autoTriggerActive = false;
currentFilename[0] = '\0';
bufferIndex = 0;
Serial.println("🎯 Auto Trigger: 로깅 중지!");
Serial.printf(" 조건: ID 0x%03X 만족\n", frame.can_id);
xSemaphoreGive(sdMutex);
}
}
}
}
// Auto Trigger 설정 저장
void saveAutoTriggerSettings() {
preferences.begin("autotrigger", false);
preferences.putBool("enabled", autoTriggerEnabled);
preferences.putBool("logCSV", autoTriggerLogCSV); // 🆕 로그 형식 저장
Serial.printf("💾 Save: autoTriggerLogCSV = %d\n", autoTriggerLogCSV);
preferences.putString("start_logic", startLogicOp);
preferences.putString("stop_logic", stopLogicOp);
// 디버그: Start Triggers 저장
Serial.printf("💾 Save: startTriggerCount = %d\n", startTriggerCount);
for (int i = 0; i < startTriggerCount; i++) {
Serial.printf(" [%d] ID=0x%X, Bit=%d, Len=%d, Op=%s, Val=%ld, En=%d\n",
i, startTriggers[i].canId, startTriggers[i].startBit,
startTriggers[i].bitLength, startTriggers[i].op,
startTriggers[i].value, startTriggers[i].enabled);
}
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);
}
// Formula 저장
preferences.putString("start_formula", startFormula);
preferences.putString("stop_formula", stopFormula);
preferences.end();
Serial.println("💾 Auto Trigger 설정 저장 완료");
}
// Auto Trigger 설정 로드
void loadAutoTriggerSettings() {
preferences.begin("autotrigger", true);
autoTriggerEnabled = preferences.getBool("enabled", false);
autoTriggerLogCSV = preferences.getBool("logCSV", false); // 🆕 로그 형식 로드
Serial.printf("📥 Load: autoTriggerLogCSV = %d\n", autoTriggerLogCSV);
preferences.getString("start_logic", startLogicOp, sizeof(startLogicOp));
preferences.getString("stop_logic", stopLogicOp, sizeof(stopLogicOp));
// Formula 불러오기
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);
}
// 디버그: Start Triggers 출력
Serial.printf("📥 Load: startTriggerCount = %d\n", startTriggerCount);
for (int i = 0; i < startTriggerCount; i++) {
Serial.printf(" [%d] ID=0x%X, Bit=%d, Len=%d, Op=%s, Val=%ld, En=%d\n",
i, startTriggers[i].canId, startTriggers[i].startBit,
startTriggers[i].bitLength, startTriggers[i].op,
startTriggers[i].value, startTriggers[i].enabled);
}
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();
if (autoTriggerEnabled) {
Serial.println("✓ Auto Trigger 설정 로드 완료");
Serial.printf(" 시작 조건: %d개 (%s)\n", startTriggerCount, startLogicOp);
Serial.printf(" 중지 조건: %d개 (%s)\n", stopTriggerCount, stopLogicOp);
}
}
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);
Serial.printf("💾 MCP Mode 저장: %d\n", (int)currentMcpMode);
// 🆕 File Format 저장
preferences.putBool("can_format_csv", savedCanLogFormatCSV);
Serial.printf("💾 CAN Format 저장: %s\n", savedCanLogFormatCSV ? "CSV" : "BIN");
saveSerialSettings();
preferences.end();
}
// ========================================
// RTC 함수
// ========================================
void initRTC() {
rtcWire.begin();
rtcWire.setClock(100000);
rtcWire.beginTransmission(DS3231_ADDRESS);
if (rtcWire.endTransmission() == 0) {
timeSyncStatus.rtcAvailable = true;
Serial.println("✓ RTC(DS3231) 감지됨");
} else {
timeSyncStatus.rtcAvailable = false;
Serial.println("! RTC(DS3231) 없음");
}
}
uint8_t bcdToDec(uint8_t val) {
return (val >> 4) * 10 + (val & 0x0F);
}
uint8_t decToBcd(uint8_t val) {
return ((val / 10) << 4) | (val % 10);
}
bool readRTC(struct tm *timeinfo) {
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 buffer[7];
for (int i = 0; i < 7; i++) buffer[i] = rtcWire.read();
xSemaphoreGive(rtcMutex);
timeinfo->tm_sec = bcdToDec(buffer[0] & 0x7F);
timeinfo->tm_min = bcdToDec(buffer[1] & 0x7F);
timeinfo->tm_hour = bcdToDec(buffer[2] & 0x3F);
timeinfo->tm_wday = bcdToDec(buffer[3] & 0x07) - 1;
timeinfo->tm_mday = bcdToDec(buffer[4] & 0x3F);
timeinfo->tm_mon = bcdToDec(buffer[5] & 0x1F) - 1;
timeinfo->tm_year = bcdToDec(buffer[6]) + 100;
return true;
}
bool writeRTC(const struct tm *timeinfo) {
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(timeinfo->tm_sec));
rtcWire.write(decToBcd(timeinfo->tm_min));
rtcWire.write(decToBcd(timeinfo->tm_hour));
rtcWire.write(decToBcd(timeinfo->tm_wday + 1));
rtcWire.write(decToBcd(timeinfo->tm_mday));
rtcWire.write(decToBcd(timeinfo->tm_mon + 1));
rtcWire.write(decToBcd(timeinfo->tm_year - 100));
bool success = (rtcWire.endTransmission() == 0);
xSemaphoreGive(rtcMutex);
return success;
}
void timeSyncCallback(struct timeval *tv) {
Serial.println("✓ NTP 시간 동기화 완료");
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)tv->tv_sec * 1000000ULL + tv->tv_usec;
timeSyncStatus.syncCount++;
if (timeSyncStatus.rtcAvailable) {
struct tm timeinfo;
time_t now = tv->tv_sec;
localtime_r(&now, &timeinfo);
if (writeRTC(&timeinfo)) {
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) {
const TickType_t xDelay = pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS);
while (1) {
if (timeSyncStatus.rtcAvailable) {
struct tm timeinfo;
if (readRTC(&timeinfo)) {
time_t now = mktime(&timeinfo);
struct timeval tv = { .tv_sec = now, .tv_usec = 0 };
settimeofday(&tv, NULL);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)now * 1000000ULL;
timeSyncStatus.rtcSyncCount++;
}
}
vTaskDelay(xDelay);
}
}
// ========================================
// MCP2515 모드
// ========================================
bool setMCP2515Mode(MCP2515Mode mode) {
Serial.printf("🔧 MCP Mode 변경 요청: %d → ", (int)mode);
const char* modeName;
MCP2515::ERROR result;
switch (mode) {
case MCP_MODE_NORMAL:
result = mcp2515.setNormalMode();
modeName = "Normal";
break;
case MCP_MODE_LISTEN_ONLY:
result = mcp2515.setListenOnlyMode();
modeName = "Listen-Only";
break;
case MCP_MODE_LOOPBACK:
result = mcp2515.setLoopbackMode();
modeName = "Loopback";
break;
case MCP_MODE_TRANSMIT:
result = mcp2515.setListenOnlyMode();
modeName = "Transmit-Only";
break;
default:
return false;
}
if (result == MCP2515::ERROR_OK) {
currentMcpMode = mode;
Serial.printf("✓ MCP2515 모드: %s\n", modeName);
return true;
}
return false;
}
// ========================================
// 인터럽트 및 Task
// ========================================
void IRAM_ATTR canISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (canRxTaskHandle != NULL) {
vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
void serialRxTask(void *parameter) {
SerialMessage serialMsg;
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);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = lineIndex;
memcpy(serialMsg.data, lineBuffer, lineIndex);
serialMsg.isTx = false;
if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) {
totalSerialRxCount++;
}
lineIndex = 0;
}
}
if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0;
}
if (lineIndex > 0 && (millis() - lastActivity > 100)) {
struct timeval tv;
gettimeofday(&tv, NULL);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = lineIndex;
memcpy(serialMsg.data, lineBuffer, lineIndex);
serialMsg.isTx = false;
if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) {
totalSerialRxCount++;
}
lineIndex = 0;
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
// ⭐ Serial2 RX Task (우선순위 5)
void serial2RxTask(void *parameter) {
SerialMessage serialMsg;
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);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = lineIndex;
memcpy(serialMsg.data, lineBuffer, lineIndex);
serialMsg.isTx = false;
if (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크
totalSerial2RxCount++;
}
lineIndex = 0;
}
}
if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) lineIndex = 0;
}
if (lineIndex > 0 && (millis() - lastActivity > 100)) {
struct timeval tv;
gettimeofday(&tv, NULL);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = lineIndex;
memcpy(serialMsg.data, lineBuffer, lineIndex);
serialMsg.isTx = false;
if (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크
totalSerial2RxCount++;
}
lineIndex = 0;
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
void canRxTask(void *parameter) {
struct can_frame frame;
CANMessage msg;
uint32_t lastErrorCheck = 0;
uint32_t errorRecoveryCount = 0;
Serial.println("✓ CAN RX Task 시작 (Core 0, Priority 24 - 절대 최고!)");
// ⭐⭐⭐ 초기 버퍼 확인
if (digitalRead(CAN_INT_PIN) == LOW) {
Serial.println("⚠️ 초기 CAN 인터럽트 핀 LOW - 버퍼 클리어");
int readCount = 0;
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK && readCount < 100) {
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.dlc = frame.can_dlc;
memcpy(msg.data, frame.data, 8);
if (xQueueSend(canQueue, &msg, 0) == pdTRUE) {
totalMsgCount++;
readCount++;
}
}
Serial.printf("✓ 초기 버퍼에서 %d개 읽음\n", readCount);
}
while (1) {
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); // 100ms 타임아웃으로 변경
// 🆕 주기적 에러 체크 (1초마다)
uint32_t now = millis();
if (now - lastErrorCheck > 1000) {
lastErrorCheck = now;
uint8_t errorFlag = mcp2515.getErrorFlags();
uint8_t txErr = mcp2515.errorCountTX();
uint8_t rxErr = mcp2515.errorCountRX();
// 에러 감지 시 복구 시도
if (errorFlag & 0xC0) { // RX0OVR 또는 RX1OVR (오버플로우)
Serial.printf("⚠️ CAN 오버플로우 감지! EFLG=0x%02X - 클리어 중...\n", errorFlag);
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
errorRecoveryCount++;
}
if (errorFlag & 0x20) { // TXBO (Bus-Off)
Serial.printf("🚨 CAN Bus-Off 감지! EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr);
// Bus-Off 복구: 완전 리셋 필요
Serial.println(" → 자동 복구 시도...");
// 1. 완전 리셋 (Configuration 모드로 진입 + 에러 카운터 자동 리셋)
mcp2515.reset();
delay(100);
// 2. Bitrate 재설정
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
delay(10);
// 3. 모든 에러 플래그 클리어
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
mcp2515.clearTXInterrupts();
mcp2515.clearMERR();
mcp2515.clearERRIF();
delay(10);
// 4. 모드 재설정
if (currentMcpMode == MCP_MODE_NORMAL) {
mcp2515.setNormalMode();
} else if (currentMcpMode == MCP_MODE_LOOPBACK) {
mcp2515.setLoopbackMode();
} else {
mcp2515.setListenOnlyMode();
}
delay(50);
// 5. 버퍼 클리어
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {}
mcp2515.clearRXnOVRFlags();
errorRecoveryCount++;
Serial.printf(" ✓ Bus-Off 복구 완료 (총 %lu회 복구)\n", errorRecoveryCount);
}
// Error Passive 상태 감지
if ((errorFlag & 0x18) && rxErr > 96) {
Serial.printf("⚠️ CAN Error Passive! REC=%d - 주의 필요\n", rxErr);
}
}
// 메시지 읽기
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {
struct timeval tv;
// 🎯 Auto Trigger 체크
checkAutoTriggers(frame);
gettimeofday(&tv, NULL);
msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
msg.id = frame.can_id & 0x1FFFFFFF;
msg.dlc = frame.can_dlc;
memcpy(msg.data, frame.data, 8);
if (xQueueSend(canQueue, &msg, pdMS_TO_TICKS(10)) == pdTRUE) {
totalMsgCount++;
}
}
}
}
// ========================================
// SD Write Task
// ========================================
void sdWriteTask(void *parameter) {
CANMessage canMsg;
SerialMessage serialMsg;
while (1) {
bool hasWork = false;
// CAN 메시지 처리
if (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) {
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;
}
}
}
// CAN 로깅
if (loggingEnabled && sdCardReady) {
// ⭐⭐⭐ 뮤텍스 타임아웃 1ms로 감소 (블로킹 방지)
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1)) == pdTRUE) {
if (canLogFormatCSV) {
char csvLine[128];
uint64_t relativeTime = canMsg.timestamp_us - canLogStartTime;
char dataStr[32];
int dataLen = 0;
static uint32_t csvReopenCounter = 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",
relativeTime, canMsg.id, canMsg.dlc, dataStr);
if (logFile) {
size_t written = logFile.write((uint8_t*)csvLine, lineLen);
currentFileSize += lineLen;
static int csvFlushCounter = 0;
if (++csvFlushCounter >= 50) { // ⭐ 20 → 50 (너무 자주 플러시하면 느림)
logFile.flush();
csvFlushCounter = 0;
}
// ⭐⭐⭐ 2000개마다 파일 재오픈 (500 → 2000 변경, SDIO 안정성)
if (++csvReopenCounter >= 2000) {
logFile.close();
delay(50); // SD 카드 안정화 대기
// 파일 재오픈 (재시도 로직)
bool reopenSuccess = false;
for (int retry = 0; retry < 3; retry++) {
logFile = SD_MMC.open(currentFilename, FILE_APPEND);
if (logFile) {
Serial.printf("✓ CSV 파일 재오픈: %s (%lu bytes)\n", currentFilename, currentFileSize);
reopenSuccess = true;
break;
} else {
Serial.printf("⚠ CSV 파일 재오픈 실패, 재시도 %d/3\n", retry + 1);
delay(100); // 재시도 전 대기
}
}
if (!reopenSuccess) {
Serial.println("✗ CSV 파일 재오픈 완전 실패! 로깅 중지");
loggingEnabled = false;
}
csvReopenCounter = 0;
}
}
} else {
// BIN 형식
static uint32_t binMsgCounter = 0;
static uint32_t binReopenCounter = 0;
// ⭐⭐⭐ 1단계: 버퍼 가득 찼으면 먼저 플러시
if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) {
if (logFile) {
size_t written = logFile.write(fileBuffer, bufferIndex);
logFile.flush();
Serial.printf("✓ BIN 버퍼 플러시 (FULL): %d bytes\n", written);
bufferIndex = 0;
}
}
// ⭐⭐⭐ 2단계: 이제 공간 확보됨, 데이터 추가
memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage));
bufferIndex += sizeof(CANMessage);
currentFileSize += sizeof(CANMessage);
binMsgCounter++;
binReopenCounter++;
// ⭐⭐⭐ 3단계: 100개 메시지마다 주기적 플러시
if (binMsgCounter % 100 == 0) {
if (logFile && bufferIndex > 0) {
size_t written = logFile.write(fileBuffer, bufferIndex);
logFile.flush();
Serial.printf("✓ BIN 주기 플러시: %d bytes (메시지: %d)\n", written, binMsgCounter);
bufferIndex = 0;
}
}
// ⭐⭐⭐ 4단계: 2000개마다 파일 재오픈 (500 → 2000 변경, SDIO 안정성)
if (binReopenCounter >= 2000) {
// 버퍼 먼저 플러시
if (logFile && bufferIndex > 0) {
logFile.write(fileBuffer, bufferIndex);
logFile.flush();
bufferIndex = 0;
}
// 파일 닫기
logFile.close();
delay(50); // SD 카드 안정화 대기
// 파일 재오픈 (재시도 로직)
bool reopenSuccess = false;
for (int retry = 0; retry < 3; retry++) {
logFile = SD_MMC.open(currentFilename, FILE_APPEND);
if (logFile) {
Serial.printf("✓ BIN 파일 재오픈: %s (%lu bytes)\n", currentFilename, currentFileSize);
reopenSuccess = true;
break;
} else {
Serial.printf("⚠ BIN 파일 재오픈 실패, 재시도 %d/3\n", retry + 1);
delay(100); // 재시도 전 대기
}
}
if (!reopenSuccess) {
Serial.println("✗ BIN 파일 재오픈 완전 실패! 로깅 중지");
loggingEnabled = false;
}
binReopenCounter = 0;
}
}
xSemaphoreGive(sdMutex);
}
}
}
if (!hasWork) {
vTaskDelay(pdMS_TO_TICKS(1));
}
}
}
void sdMonitorTask(void *parameter) {
const TickType_t xDelay = pdMS_TO_TICKS(500);
while (1) {
uint32_t currentTime = millis();
// 메시지/초 계산
if (currentTime - lastMsgCountTime >= 1000) {
msgPerSecond = totalMsgCount - lastMsgCount;
lastMsgCount = totalMsgCount;
lastMsgCountTime = currentTime;
}
// 전압 체크
if (currentTime - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) {
float rawVoltage = analogRead(MONITORING_VOLT) * (3.3 / 4095.0);
powerStatus.voltage = rawVoltage * 1.0;
if (currentTime - powerStatus.lastMinReset >= 1000) {
powerStatus.minVoltage = powerStatus.voltage;
powerStatus.lastMinReset = currentTime;
} else {
if (powerStatus.voltage < powerStatus.minVoltage) {
powerStatus.minVoltage = powerStatus.voltage;
}
}
powerStatus.lowVoltage = (powerStatus.voltage < LOW_VOLTAGE_THRESHOLD);
powerStatus.lastCheck = currentTime;
}
vTaskDelay(xDelay);
}
}
// ========================================
// 파일 커멘트 관리
// ========================================
void saveFileComments() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File commentFile = SD_MMC.open("/comments.dat", FILE_WRITE);
if (commentFile) {
commentFile.write((uint8_t*)&commentCount, sizeof(commentCount));
commentFile.write((uint8_t*)fileComments, sizeof(FileComment) * commentCount);
commentFile.close();
}
xSemaphoreGive(sdMutex);
}
}
void loadFileComments() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD_MMC.exists("/comments.dat")) {
File commentFile = SD_MMC.open("/comments.dat", FILE_READ);
if (commentFile) {
commentFile.read((uint8_t*)&commentCount, sizeof(commentCount));
if (commentCount > MAX_FILE_COMMENTS) commentCount = MAX_FILE_COMMENTS;
commentFile.read((uint8_t*)fileComments, sizeof(FileComment) * commentCount);
commentFile.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 seqFile = SD_MMC.open("/sequences.dat", FILE_WRITE);
if (seqFile) {
seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount));
seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount);
seqFile.close();
}
xSemaphoreGive(sdMutex);
}
}
void loadSequences() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD_MMC.exists("/sequences.dat")) {
File seqFile = SD_MMC.open("/sequences.dat", FILE_READ);
if (seqFile) {
seqFile.read((uint8_t*)&sequenceCount, sizeof(sequenceCount));
if (sequenceCount > MAX_SEQUENCES) sequenceCount = MAX_SEQUENCES;
seqFile.read((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount);
seqFile.close();
}
}
xSemaphoreGive(sdMutex);
}
}
// ========================================
// TX 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));
}
}
// ========================================
// Sequence Task
// ========================================
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) {
seqRuntime.currentRepeat++;
if (seqRuntime.currentRepeat >= seq->repeatCount) {
seqRuntime.running = false;
} else {
seqRuntime.currentStep = 0;
seqRuntime.lastStepTime = now;
}
} else if (seq->repeatMode == 2) {
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) {
// 🆕 WebSocket 연결 시 현재 설정 전송
if (type == WStype_CONNECTED) {
Serial.printf("[%u] ✅ WebSocket 연결됨\n", num);
// 기본 설정 전송
DynamicJsonDocument settings(512);
settings["type"] = "currentSettings";
int speedIndex = 3;
for (int i = 0; i < 4; i++) {
if (canSpeedValues[i] == currentCanSpeed) {
speedIndex = i;
break;
}
}
settings["canSpeed"] = speedIndex;
settings["mcpMode"] = (int)currentMcpMode;
settings["autoTriggerEnabled"] = autoTriggerEnabled;
settings["autoTriggerLogCSV"] = autoTriggerLogCSV;
String json;
serializeJson(settings, json);
webSocket.sendTXT(num, json);
// Auto Trigger 설정도 전송
if (autoTriggerEnabled) {
delay(50); // 메시지 간격
DynamicJsonDocument autoTrigger(2048);
autoTrigger["type"] = "autoTriggers";
autoTrigger["enabled"] = autoTriggerEnabled;
autoTrigger["logFormat"] = autoTriggerLogCSV ? "csv" : "bin";
autoTrigger["startLogic"] = startLogicOp;
autoTrigger["stopLogic"] = stopLogicOp;
autoTrigger["startFormula"] = startFormula;
autoTrigger["stopFormula"] = stopFormula;
JsonArray startArray = autoTrigger.createNestedArray("startTriggers");
for (int i = 0; i < startTriggerCount; i++) {
JsonObject t = startArray.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 stopArray = autoTrigger.createNestedArray("stopTriggers");
for (int i = 0; i < stopTriggerCount; i++) {
JsonObject t = stopArray.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 autoJson;
serializeJson(autoTrigger, autoJson);
webSocket.sendTXT(num, autoJson);
}
}
else if (type == WStype_TEXT) {
DynamicJsonDocument doc(44384);
DeserializationError error = deserializeJson(doc, payload);
if (error) return;
const char* cmd = doc["cmd"];
if (strcmp(cmd, "getSettings") == 0) {
DynamicJsonDocument response(1024);
response["type"] = "settings";
response["ssid"] = wifiSSID;
response["password"] = wifiPassword;
response["staEnable"] = enableSTAMode;
response["staSSID"] = staSSID;
response["staPassword"] = staPassword;
response["staConnected"] = (WiFi.status() == WL_CONNECTED);
response["staIP"] = WiFi.localIP().toString();
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "saveSettings") == 0) {
const char* newSSID = doc["ssid"];
const char* newPassword = doc["password"];
bool newSTAEnable = doc["staEnable"];
const char* newSTASSID = doc["staSSID"];
const char* newSTAPassword = doc["staPassword"];
if (newSSID && strlen(newSSID) > 0) {
strncpy(wifiSSID, newSSID, sizeof(wifiSSID) - 1);
wifiSSID[sizeof(wifiSSID) - 1] = '\0';
}
if (newPassword) {
strncpy(wifiPassword, newPassword, sizeof(wifiPassword) - 1);
wifiPassword[sizeof(wifiPassword) - 1] = '\0';
}
enableSTAMode = newSTAEnable;
if (newSTASSID) {
strncpy(staSSID, newSTASSID, sizeof(staSSID) - 1);
staSSID[sizeof(staSSID) - 1] = '\0';
}
if (newSTAPassword) {
strncpy(staPassword, newSTAPassword, sizeof(staPassword) - 1);
staPassword[sizeof(staPassword) - 1] = '\0';
}
saveSettings();
DynamicJsonDocument response(256);
response["type"] = "settingsSaved";
response["success"] = true;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "startLogging") == 0) {
if (!loggingEnabled && sdCardReady) {
const char* format = doc["format"];
if (format && strcmp(format, "csv") == 0) {
canLogFormatCSV = true;
} else {
canLogFormatCSV = false;
}
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
struct tm timeinfo;
time_t now;
time(&now);
localtime_r(&now, &timeinfo);
struct timeval tv;
gettimeofday(&tv, NULL);
canLogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
const char* ext = canLogFormatCSV ? "csv" : "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();
}
logFile.close(); // ⭐ 헤더 쓰고 닫기
// ⭐⭐⭐ APPEND 모드로 다시 열기
logFile = SD_MMC.open(currentFilename, FILE_APPEND);
if (logFile) {
loggingEnabled = true;
bufferIndex = 0;
currentFileSize = logFile.size();
Serial.printf("✅ 로깅 시작!\n");
Serial.printf(" 파일: %s\n", currentFilename);
Serial.printf(" 형식: %s\n", canLogFormatCSV ? "CSV" : "BIN");
Serial.printf(" 초기 크기: %lu bytes\n", currentFileSize);
Serial.printf(" sdCardReady: %d\n", sdCardReady);
} else {
Serial.println("✗ APPEND 모드로 파일 열기 실패");
Serial.printf(" 파일명: %s\n", currentFilename);
}
} else {
Serial.println("✗ 파일 생성 실패");
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopLogging") == 0) {
if (loggingEnabled) {
Serial.println("🛑 로깅 중지 요청...");
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) {
// ⭐⭐⭐ BIN 형식: 버퍼에 남은 데이터 강제 플러시
if (bufferIndex > 0 && logFile) {
size_t written = logFile.write(fileBuffer, bufferIndex);
logFile.flush();
Serial.printf("✓ 최종 플러시: %d bytes\n", written);
bufferIndex = 0;
}
// ⭐⭐⭐ CSV 형식: 최종 플러시
if (canLogFormatCSV && logFile) {
logFile.flush();
Serial.println("✓ CSV 최종 플러시");
}
if (logFile) {
size_t finalSize = logFile.size();
logFile.close();
Serial.printf("✓ 파일 닫힘: %s (%lu bytes)\n", currentFilename, finalSize);
}
loggingEnabled = false;
currentFilename[0] = '\0';
bufferIndex = 0;
xSemaphoreGive(sdMutex);
} else {
Serial.println("✗ sdMutex 획득 실패!");
}
}
}
else if (strcmp(cmd, "startSerialLogging") == 0) {
if (!serialLoggingEnabled && sdCardReady) {
const char* format = doc["format"];
if (format && strcmp(format, "bin") == 0) {
serialLogFormatCSV = false;
} else {
serialLogFormatCSV = true;
}
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
struct tm timeinfo;
time_t now;
time(&now);
localtime_r(&now, &timeinfo);
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",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.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);
serialCsvIndex = 0;
}
if (serialLogFile) {
serialLogFile.close();
}
serialLoggingEnabled = false;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
SerialComm.println(data);
SerialMessage serialMsg;
struct timeval tv;
gettimeofday(&tv, NULL);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = strlen(data) + 2;
if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) {
serialMsg.length = MAX_SERIAL_LINE_LEN - 1;
}
snprintf((char*)serialMsg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data);
serialMsg.isTx = true;
if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) {
totalSerialTxCount++;
}
}
}
else if (strcmp(cmd, "setSerialConfig") == 0) {
uint32_t baud = doc["baudRate"] | 115200;
uint8_t data = doc["dataBits"] | 8;
uint8_t parity = doc["parity"] | 0;
uint8_t stop = doc["stopBits"] | 1;
serialSettings.baudRate = baud;
serialSettings.dataBits = data;
serialSettings.parity = parity;
serialSettings.stopBits = stop;
saveSerialSettings();
applySerialSettings();
}
else if (strcmp(cmd, "getSerialConfig") == 0) {
DynamicJsonDocument response(512);
response["type"] = "serialConfig";
response["baudRate"] = serialSettings.baudRate;
response["dataBits"] = serialSettings.dataBits;
response["parity"] = serialSettings.parity;
response["stopBits"] = serialSettings.stopBits;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "setSpeed") == 0) {
int speedIndex = doc["speed"];
if (speedIndex >= 0 && speedIndex < 4) {
currentCanSpeed = canSpeedValues[speedIndex];
// ⭐⭐⭐ MCP2515 리셋 제거! (CAN 수신 중단 방지)
// 속도 변경은 로깅 중지 후 수동으로만 가능하도록 변경
// mcp2515.reset();
// mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
// setMCP2515Mode(currentMcpMode);
saveSettings();
// 사용자에게 안내 메시지
StaticJsonDocument<256> response;
response["type"] = "info";
response["message"] = "CAN speed saved. Stop logging and restart to apply.";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
else if (strcmp(cmd, "startSerial2Logging") == 0) {
if (!serial2LoggingEnabled && sdCardReady) {
const char* format = doc["format"];
if (format && strcmp(format, "bin") == 0) {
serial2LogFormatCSV = false;
} else {
serial2LogFormatCSV = true;
}
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
struct tm timeinfo;
time_t now;
time(&now);
localtime_r(&now, &timeinfo);
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",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.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);
serial2CsvIndex = 0;
}
if (serial2LogFile) {
serial2LogFile.close();
}
serial2LoggingEnabled = false;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial2") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
Serial2Comm.println(data);
SerialMessage serialMsg;
struct timeval tv;
gettimeofday(&tv, NULL);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = strlen(data) + 2;
if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) {
serialMsg.length = MAX_SERIAL_LINE_LEN - 1;
}
snprintf((char*)serialMsg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data);
serialMsg.isTx = true;
if (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크
totalSerial2TxCount++;
}
}
}
else if (strcmp(cmd, "setSerial2Config") == 0) {
uint32_t baud = doc["baudRate"] | 115200;
uint8_t data = doc["dataBits"] | 8;
uint8_t parity = doc["parity"] | 0;
uint8_t stop = doc["stopBits"] | 1;
serial2Settings.baudRate = baud;
serial2Settings.dataBits = data;
serial2Settings.parity = parity;
serial2Settings.stopBits = stop;
saveSerialSettings();
applySerialSettings();
}
else if (strcmp(cmd, "getSerial2Config") == 0) {
DynamicJsonDocument response(512);
response["type"] = "serial2Config";
response["baudRate"] = serial2Settings.baudRate;
response["dataBits"] = serial2Settings.dataBits;
response["parity"] = serial2Settings.parity;
response["stopBits"] = serial2Settings.stopBits;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "setSpeed") == 0) {
int speedIndex = doc["speed"];
if (speedIndex >= 0 && speedIndex < 4) {
currentCanSpeed = canSpeedValues[speedIndex];
// ⭐⭐⭐ MCP2515 리셋 제거! (CAN 수신 중단 방지)
// 속도 변경은 로깅 중지 후 수동으로만 가능하도록 변경
// mcp2515.reset();
// mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
// setMCP2515Mode(currentMcpMode);
saveSettings();
// 사용자에게 안내 메시지
StaticJsonDocument<256> response;
response["type"] = "info";
response["message"] = "CAN speed saved. Stop logging and restart to apply.";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
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) {
int year = doc["year"] | 2024;
int month = doc["month"] | 1;
int day = doc["day"] | 1;
int hour = doc["hour"] | 0;
int minute = doc["minute"] | 0;
int second = doc["second"] | 0;
struct tm timeinfo;
timeinfo.tm_year = year - 1900;
timeinfo.tm_mon = month - 1;
timeinfo.tm_mday = day;
timeinfo.tm_hour = hour;
timeinfo.tm_min = minute;
timeinfo.tm_sec = second;
time_t t = mktime(&timeinfo);
struct timeval tv = {t, 0};
settimeofday(&tv, NULL);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)t * 1000000ULL;
timeSyncStatus.syncCount++;
if (timeSyncStatus.rtcAvailable) {
writeRTC(&timeinfo);
}
}
else if (strcmp(cmd, "getFiles") == 0) {
if (sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
DynamicJsonDocument response(6144);
response["type"] = "files";
JsonArray files = response.createNestedArray("files");
File root = SD_MMC.open("/");
if (root) {
File file = root.openNextFile();
int fileCount = 0;
while (file && fileCount < 50) {
if (!file.isDirectory()) {
const char* filename = file.name();
// ⭐ 파일명이 '/'로 시작하면 건너뛰기
if (filename[0] == '/') {
filename++; // 슬래시 제거
}
// 숨김 파일과 시스템 폴더 제외
if (filename[0] != '.' &&
strcmp(filename, "System Volume Information") != 0 &&
strlen(filename) > 0) {
JsonObject fileObj = files.createNestedObject();
fileObj["name"] = filename;
fileObj["size"] = file.size();
const char* comment = getFileComment(filename);
if (strlen(comment) > 0) {
fileObj["comment"] = comment;
}
fileCount++;
}
}
file.close();
file = root.openNextFile();
}
root.close();
// ⭐ 디버그 로그
Serial.printf("getFiles: Found %d files\n", fileCount);
} else {
Serial.println("getFiles: Failed to open root directory");
}
xSemaphoreGive(sdMutex);
String json;
size_t jsonSize = serializeJson(response, json);
Serial.printf("getFiles: JSON size = %d bytes\n", jsonSize);
webSocket.sendTXT(num, json);
} else {
Serial.println("getFiles: Failed to acquire sdMutex");
// Mutex 실패 시에도 응답 전송
DynamicJsonDocument response(256);
response["type"] = "files";
response["error"] = "SD busy";
JsonArray files = response.createNestedArray("files");
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
} else {
Serial.println("getFiles: SD card not ready");
// SD 카드 없을 때 빈 목록 전송
DynamicJsonDocument response(256);
response["type"] = "files";
JsonArray files = response.createNestedArray("files");
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
else if (strcmp(cmd, "deleteFile") == 0) {
const char* filename = doc["filename"];
if (filename && strlen(filename) > 0) {
String fullPath = "/" + String(filename);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
bool success = false;
if (SD_MMC.exists(fullPath)) {
if (SD_MMC.remove(fullPath)) {
success = true;
}
}
xSemaphoreGive(sdMutex);
DynamicJsonDocument response(256);
response["type"] = "deleteResult";
response["success"] = success;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
}
else if (strcmp(cmd, "addComment") == 0) {
const char* filename = doc["filename"];
const char* comment = doc["comment"];
if (filename && comment) {
addFileComment(filename, comment);
}
}
// 🎯 Auto Trigger 명령 추가
else if (strcmp(cmd, "setAutoTrigger") == 0) {
autoTriggerEnabled = doc["enabled"] | false;
// 🆕 로그 형식 설정
const char* logFormat = doc["logFormat"];
if (logFormat) {
autoTriggerLogCSV = (strcmp(logFormat, "csv") == 0);
}
saveAutoTriggerSettings();
DynamicJsonDocument response(256);
response["type"] = "autoTriggerSet";
response["enabled"] = autoTriggerEnabled;
response["logFormat"] = autoTriggerLogCSV ? "csv" : "bin";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
// 🆕 Auto Trigger 형식 설정 명령
else if (strcmp(cmd, "setAutoTriggerFormat") == 0) {
const char* logFormat = doc["logFormat"];
if (logFormat) {
autoTriggerLogCSV = (strcmp(logFormat, "csv") == 0);
saveAutoTriggerSettings();
Serial.printf("🎯 Auto Trigger 형식 설정: %s\n", autoTriggerLogCSV ? "CSV" : "BIN");
}
DynamicJsonDocument response(128);
response["type"] = "autoTriggerFormatSet";
response["logFormat"] = autoTriggerLogCSV ? "csv" : "bin";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
// 🆕 CAN File Format 저장 명령 (메인 페이지용)
else if (strcmp(cmd, "saveCanFormat") == 0) {
const char* format = doc["format"];
if (format) {
savedCanLogFormatCSV = (strcmp(format, "csv") == 0);
saveSettings();
Serial.printf("💾 CAN File Format 저장: %s\n", savedCanLogFormatCSV ? "CSV" : "BIN");
}
DynamicJsonDocument response(128);
response["type"] = "canFormatSaved";
response["format"] = savedCanLogFormatCSV ? "csv" : "bin";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
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 response(256);
response["type"] = "startTriggersSet";
response["count"] = startTriggerCount;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
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 response(256);
response["type"] = "stopTriggersSet";
response["count"] = stopTriggerCount;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "setStartFormula") == 0) {
const char* formula = doc["formula"];
if (formula) {
startFormula = String(formula);
saveAutoTriggerSettings();
Serial.println("📐 Start Formula 저장: " + startFormula);
}
}
else if (strcmp(cmd, "setStopFormula") == 0) {
const char* formula = doc["formula"];
if (formula) {
stopFormula = String(formula);
saveAutoTriggerSettings();
Serial.println("📐 Stop Formula 저장: " + stopFormula);
}
}
else if (strcmp(cmd, "getAutoTriggers") == 0) {
DynamicJsonDocument response(2048);
response["type"] = "autoTriggers";
response["enabled"] = autoTriggerEnabled;
response["logFormat"] = autoTriggerLogCSV ? "csv" : "bin"; // 🆕 로그 형식 전송
response["startLogic"] = startLogicOp;
response["stopLogic"] = stopLogicOp;
response["startFormula"] = startFormula; // Formula 전송
response["stopFormula"] = stopFormula; // Formula 전송
JsonArray startArray = response.createNestedArray("startTriggers");
for (int i = 0; i < startTriggerCount; i++) {
JsonObject t = startArray.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 stopArray = response.createNestedArray("stopTriggers");
for (int i = 0; i < stopTriggerCount; i++) {
JsonObject t = stopArray.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(response, json);
webSocket.sendTXT(num, json);
}
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 dataArray = doc["data"];
for (int i = 0; i < 8; i++) {
frame.data[i] = dataArray[i] | 0;
}
if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) {
totalTxCount++;
}
if (currentMcpMode == MCP_MODE_TRANSMIT) {
mcp2515.setListenOnlyMode();
}
}
else if (strcmp(cmd, "addSequence") == 0) {
// ⭐⭐⭐ Sequence 저장 기능
if (sequenceCount >= MAX_SEQUENCES) {
Serial.println("✗ Sequence 저장 실패: 최대 개수 초과");
DynamicJsonDocument response(256);
response["type"] = "error";
response["message"] = "Maximum sequences reached";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
} else {
const char* name = doc["name"];
int repeatMode = doc["repeatMode"] | 0;
int repeatCount = doc["repeatCount"] | 1;
JsonArray stepsArray = doc["steps"];
if (name && stepsArray.size() > 0) {
CANSequence* seq = &sequences[sequenceCount];
// 이름 복사
strncpy(seq->name, name, sizeof(seq->name) - 1);
seq->name[sizeof(seq->name) - 1] = '\0';
// Repeat 설정
seq->repeatMode = repeatMode;
seq->repeatCount = repeatCount;
// Steps 복사
seq->stepCount = 0;
for (JsonObject stepObj : stepsArray) {
if (seq->stepCount >= 20) break; // 최대 20개
SequenceStep* step = &seq->steps[seq->stepCount];
// ID 파싱 (0x 제거)
const char* idStr = stepObj["id"];
if (idStr) {
if (strncmp(idStr, "0x", 2) == 0 || strncmp(idStr, "0X", 2) == 0) {
step->canId = strtoul(idStr + 2, NULL, 16);
} else {
step->canId = strtoul(idStr, NULL, 16);
}
}
step->extended = stepObj["ext"] | false;
step->dlc = stepObj["dlc"] | 8;
// Data 배열 복사
JsonArray dataArray = stepObj["data"];
for (int i = 0; i < 8 && i < dataArray.size(); i++) {
step->data[i] = dataArray[i];
}
step->delayMs = stepObj["delay"] | 0;
seq->stepCount++;
}
sequenceCount++;
// SD 카드에 저장
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File seqFile = SD_MMC.open("/sequences.dat", FILE_WRITE);
if (seqFile) {
seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount));
seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount);
seqFile.close();
Serial.printf("✓ Sequence 저장 완료: %s (Steps: %d)\n", name, seq->stepCount);
} else {
Serial.println("✗ sequences.dat 열기 실패");
}
xSemaphoreGive(sdMutex);
}
// 성공 응답
DynamicJsonDocument response(256);
response["type"] = "sequenceSaved";
response["name"] = name;
response["steps"] = seq->stepCount;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
// Sequence 목록 다시 전송
delay(100);
webSocket.sendTXT(num, "{\"cmd\":\"getSequences\"}");
} else {
Serial.println("✗ Sequence 저장 실패: 잘못된 데이터");
}
}
}
else if (strcmp(cmd, "getSequences") == 0) {
DynamicJsonDocument response(3072);
response["type"] = "sequences";
JsonArray seqArray = response.createNestedArray("list");
for (int i = 0; i < sequenceCount; i++) {
JsonObject seqObj = seqArray.createNestedObject();
seqObj["name"] = sequences[i].name;
seqObj["steps"] = sequences[i].stepCount;
seqObj["repeatMode"] = sequences[i].repeatMode;
seqObj["repeatCount"] = sequences[i].repeatCount;
}
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "startSequence") == 0) {
// ⭐⭐⭐ Sequence 실행
int index = doc["index"] | -1;
if (index >= 0 && index < sequenceCount) {
seqRuntime.running = true;
seqRuntime.activeSequenceIndex = index;
seqRuntime.currentStep = 0;
seqRuntime.currentRepeat = 0;
seqRuntime.lastStepTime = millis();
Serial.printf("✓ Sequence 시작: %s (index: %d)\n", sequences[index].name, index);
// 성공 응답
DynamicJsonDocument response(256);
response["type"] = "sequenceStarted";
response["index"] = index;
response["name"] = sequences[index].name;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
} else {
Serial.printf("✗ Sequence 시작 실패: 잘못된 index %d\n", index);
}
}
else if (strcmp(cmd, "stopSequence") == 0) {
// ⭐⭐⭐ Sequence 중지
if (seqRuntime.running) {
Serial.println("✓ Sequence 중지됨");
seqRuntime.running = false;
seqRuntime.activeSequenceIndex = -1;
// 성공 응답
DynamicJsonDocument response(256);
response["type"] = "sequenceStopped";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
else if (strcmp(cmd, "removeSequence") == 0) {
// ⭐⭐⭐ Sequence 삭제
int index = doc["index"] | -1;
if (index >= 0 && index < sequenceCount) {
Serial.printf("✓ Sequence 삭제: %s (index: %d)\n", sequences[index].name, index);
// 배열에서 제거 (뒤의 항목들을 앞으로 이동)
for (int i = index; i < sequenceCount - 1; i++) {
memcpy(&sequences[i], &sequences[i + 1], sizeof(CANSequence));
}
sequenceCount--;
// SD 카드에 저장
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File seqFile = SD_MMC.open("/sequences.dat", FILE_WRITE);
if (seqFile) {
seqFile.write((uint8_t*)&sequenceCount, sizeof(sequenceCount));
seqFile.write((uint8_t*)sequences, sizeof(CANSequence) * sequenceCount);
seqFile.close();
Serial.printf("✓ SD 카드 업데이트: %d개 sequence\n", sequenceCount);
}
xSemaphoreGive(sdMutex);
}
// 성공 응답
DynamicJsonDocument response(256);
response["type"] = "sequenceDeleted";
response["index"] = index;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
// Sequence 목록 업데이트
delay(100);
webSocket.sendTXT(num, "{\"cmd\":\"getSequences\"}");
} else {
Serial.printf("✗ Sequence 삭제 실패: 잘못된 index %d\n", index);
}
}
else if (strcmp(cmd, "getSequenceDetail") == 0) {
// ⭐⭐⭐ Sequence 상세 정보 (Edit용)
int index = doc["index"] | -1;
if (index >= 0 && index < sequenceCount) {
DynamicJsonDocument response(4096);
response["type"] = "sequenceDetail";
response["index"] = index;
response["name"] = sequences[index].name;
response["repeatMode"] = sequences[index].repeatMode;
response["repeatCount"] = sequences[index].repeatCount;
JsonArray stepsArray = response.createNestedArray("steps");
for (int i = 0; i < sequences[index].stepCount; i++) {
SequenceStep* step = &sequences[index].steps[i];
JsonObject stepObj = stepsArray.createNestedObject();
char idStr[12];
sprintf(idStr, "0x%X", step->canId);
stepObj["id"] = idStr;
stepObj["ext"] = step->extended;
stepObj["dlc"] = step->dlc;
JsonArray dataArray = stepObj.createNestedArray("data");
for (int j = 0; j < 8; j++) {
dataArray.add(step->data[j]);
}
stepObj["delay"] = step->delayMs;
}
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
Serial.printf("✓ Sequence 상세 전송: %s (index: %d)\n", sequences[index].name, index);
} else {
Serial.printf("✗ Sequence 상세 조회 실패: 잘못된 index %d\n", index);
}
}else if (strcmp(cmd, "hwReset") == 0) {
// ⭐⭐⭐ ESP32 하드웨어 리셋 (재부팅)
Serial.println("📨 하드웨어 리셋 요청 수신");
Serial.println("🔄 ESP32 재부팅 중...");
Serial.println("");
Serial.println("========================================");
Serial.println(" ESP32 REBOOTING...");
Serial.println("========================================");
// 응답 전송
DynamicJsonDocument response(256);
response["type"] = "hwReset";
response["success"] = true;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
// 약간의 지연 후 재부팅 (응답 전송 시간 확보)
delay(100);
// ESP32 재부팅
ESP.restart();
}
}
}
// ========================================
// Web Update Task
// ========================================
void webUpdateTask(void *parameter) {
const TickType_t xDelay = pdMS_TO_TICKS(200); // ⭐ 100ms → 200ms (WiFi 안정성 향상)
// 🆕 초기화 대기 (부팅 직후 안정화)
vTaskDelay(pdMS_TO_TICKS(2000));
while (1) {
webSocket.loop();
if (webSocket.connectedClients() > 0) {
// 🆕 큐가 초기화되었는지 확인
if (!canQueue || !serialQueue) {
vTaskDelay(xDelay);
continue;
}
DynamicJsonDocument doc(16384); // ⭐ 4096 → 8192로 증가
doc["type"] = "update";
doc["logging"] = loggingEnabled;
// 🎯 Auto Trigger 상태
doc["autoTriggerEnabled"] = autoTriggerEnabled;
doc["autoTriggerActive"] = autoTriggerActive;
doc["startTriggerCount"] = startTriggerCount;
doc["stopTriggerCount"] = stopTriggerCount;
doc["serialLogging"] = serialLoggingEnabled;
doc["serial2Logging"] = serial2LoggingEnabled;
doc["totalSerial2Rx"] = totalSerial2RxCount;
doc["totalSerial2Tx"] = totalSerial2TxCount;
doc["serial2QueueUsed"] = serial2Queue ? uxQueueMessagesWaiting(serial2Queue) : 0; // ⭐ NULL 체크
doc["serial2QueueSize"] = SERIAL2_QUEUE_SIZE;
doc["serial2FileSize"] = currentSerial2FileSize;
if (serial2LoggingEnabled && currentSerial2Filename[0] != '\0') {
doc["currentSerial2File"] = String(currentSerial2Filename);
} else {
doc["currentSerial2File"] = "";
}
doc["sdReady"] = sdCardReady;
doc["totalMsg"] = totalMsgCount;
doc["msgPerSec"] = msgPerSecond;
doc["totalTx"] = totalTxCount;
doc["totalSerialRx"] = totalSerialRxCount;
doc["totalSerialTx"] = totalSerialTxCount;
doc["fileSize"] = currentFileSize;
doc["serialFileSize"] = currentSerialFileSize;
doc["queueUsed"] = canQueue ? uxQueueMessagesWaiting(canQueue) : 0; // 🆕 NULL 체크
doc["queueSize"] = CAN_QUEUE_SIZE;
doc["serialQueueUsed"] = serialQueue ? uxQueueMessagesWaiting(serialQueue) : 0; // 🆕 NULL 체크
doc["serialQueueSize"] = SERIAL_QUEUE_SIZE;
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;
// 🆕 저장된 File Format 전송
doc["savedCanFormat"] = savedCanLogFormatCSV ? "csv" : "bin";
if (loggingEnabled && currentFilename[0] != '\0') {
doc["currentFile"] = String(currentFilename);
} else {
doc["currentFile"] = "";
}
if (serialLoggingEnabled && currentSerialFilename[0] != '\0') {
doc["currentSerialFile"] = String(currentSerialFilename);
} else {
doc["currentSerialFile"] = "";
}
time_t now;
time(&now);
doc["timestamp"] = (uint64_t)now;
// CAN 메시지 배열 (최근 20개만 전송)
JsonArray messages = doc.createNestedArray("messages");
int msgCount = 0;
for (int i = 0; i < RECENT_MSG_COUNT && msgCount < 20; i++) { // ⭐ 최대 20개
if (recentData[i].count > 0) {
JsonObject msgObj = messages.createNestedObject();
msgObj["id"] = recentData[i].msg.id;
msgObj["dlc"] = recentData[i].msg.dlc;
msgObj["count"] = recentData[i].count;
JsonArray dataArray = msgObj.createNestedArray("data");
for (int j = 0; j < recentData[i].msg.dlc; j++) {
dataArray.add(recentData[i].msg.data[j]);
}
msgCount++;
}
}
// Serial 메시지 배열
SerialMessage serialMsg;
JsonArray serialMessages = doc.createNestedArray("serialMessages");
int serialCount = 0;
while (serialCount < 10 && xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) {
JsonObject serMsgObj = serialMessages.createNestedObject();
serMsgObj["timestamp"] = serialMsg.timestamp_us;
serMsgObj["isTx"] = serialMsg.isTx;
char dataStr[MAX_SERIAL_LINE_LEN + 1];
memcpy(dataStr, serialMsg.data, serialMsg.length);
dataStr[serialMsg.length] = '\0';
serMsgObj["data"] = dataStr;
serialCount++;
// Serial 로깅
if (serialLoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serialLogFormatCSV) {
uint64_t relativeTime = serialMsg.timestamp_us - serialLogStartTime;
char csvLine[256];
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,%s,\"%s\"\n",
relativeTime,
serialMsg.isTx ? "TX" : "RX",
dataStr);
if (serialCsvIndex + lineLen <= SERIAL_CSV_BUFFER_SIZE) { // < → <=
memcpy(&serialCsvBuffer[serialCsvIndex], csvLine, lineLen);
serialCsvIndex += lineLen;
currentSerialFileSize += lineLen;
}
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 binFlushCounter = 0;
if (++binFlushCounter >= 50) {
serialLogFile.flush();
binFlushCounter = 0;
}
}
}
xSemaphoreGive(sdMutex);
}
}
}
// ⭐ Serial2 메시지 배열 처리
SerialMessage serial2Msg;
JsonArray serial2Messages = doc.createNestedArray("serial2Messages");
int serial2Count = 0;
while (serial2Queue && serial2Count < 10 && xQueueReceive(serial2Queue, &serial2Msg, 0) == pdTRUE) { // ⭐ NULL 체크
JsonObject serMsgObj = serial2Messages.createNestedObject();
serMsgObj["timestamp"] = serial2Msg.timestamp_us;
serMsgObj["isTx"] = serial2Msg.isTx;
char dataStr[MAX_SERIAL_LINE_LEN + 1];
memcpy(dataStr, serial2Msg.data, serial2Msg.length);
dataStr[serial2Msg.length] = '\0';
serMsgObj["data"] = dataStr;
serial2Count++;
// Serial2 로깅
if (serial2LoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serial2LogFormatCSV) {
uint64_t relativeTime = serial2Msg.timestamp_us - serial2LogStartTime;
char csvLine[256];
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,%s,\"%s\"\n",
relativeTime,
serial2Msg.isTx ? "TX" : "RX",
dataStr);
if (serial2CsvIndex + lineLen <= SERIAL2_CSV_BUFFER_SIZE) { // < → <=
memcpy(&serial2CsvBuffer[serial2CsvIndex], csvLine, lineLen);
serial2CsvIndex += lineLen;
currentSerial2FileSize += lineLen;
}
if (serial2CsvIndex >= SERIAL2_CSV_BUFFER_SIZE - 256) {
if (serial2LogFile) {
serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex);
serial2LogFile.flush();
serial2CsvIndex = 0;
}
}
} else {
// BIN 형식
if (serial2LogFile) {
serial2LogFile.write((uint8_t*)&serial2Msg, sizeof(SerialMessage));
currentSerial2FileSize += sizeof(SerialMessage);
static int binFlushCounter2 = 0;
if (++binFlushCounter2 >= 50) {
serial2LogFile.flush();
binFlushCounter2 = 0;
}
}
}
xSemaphoreGive(sdMutex);
}
}
}
String json;
size_t jsonSize = serializeJson(doc, json);
// JSON 크기 확인 (8KB 이하만 전송)
if (jsonSize > 0 && jsonSize < 8192) {
webSocket.broadcastTXT(json);
} else {
Serial.printf("! JSON 크기 초과: %d bytes\n", jsonSize);
}
}
vTaskDelay(xDelay);
}
}
// ========================================
// Setup
// ========================================
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.setSleep(false);
Serial.println("\n========================================");
Serial.println(" Byun CAN Logger + Serial Terminal");
Serial.println(" Version 2.3 - PSRAM Optimized");
Serial.println(" ESP32-S3 Complete Edition");
Serial.println("========================================\n");
// ★★★ PSRAM 초기화 (가장 먼저!) ★★★
if (!initPSRAM()) {
Serial.println("\n✗ PSRAM 초기화 실패!");
Serial.println("✗ Arduino IDE 설정:");
Serial.println(" Tools → PSRAM → OPI PSRAM");
while (1) {
delay(1000);
Serial.println("✗ 설정 후 재업로드 필요!");
}
}
loadSettings();
// 🎯 Auto Trigger 설정 로드
loadAutoTriggerSettings();
analogSetPinAttenuation(MONITORING_VOLT, ADC_11db);
pinMode(CAN_INT_PIN, INPUT_PULLUP);
analogSetAttenuation(ADC_11db);
// SPI 초기화
Serial.println("SPI 초기화...");
hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS);
hspi.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0));
hspi.endTransaction();
// ⭐ VSPI 제거: SDIO는 별도 SPI 초기화 불필요
Serial.println("✓ SPI 초기화 완료 (HSPI only)");
// Watchdog 비활성화
esp_task_wdt_deinit();
// ========================================
// MCP2515 초기화 (개선된 시퀀스)
// ========================================
Serial.println("MCP2515 초기화...");
// 1. CS 핀 초기 상태 설정
pinMode(HSPI_CS, OUTPUT);
digitalWrite(HSPI_CS, HIGH);
delay(10);
// 2. 하드웨어 리셋 (CS 토글)
Serial.println(" 1. 하드웨어 리셋...");
digitalWrite(HSPI_CS, LOW);
delayMicroseconds(100);
digitalWrite(HSPI_CS, HIGH);
delay(100); // 리셋 후 충분한 대기
// 3. 소프트웨어 리셋 (Configuration 모드로 진입)
Serial.println(" 2. 소프트웨어 리셋...");
mcp2515.reset();
delay(100); // Configuration 모드 안정화 대기
// 4. Configuration 모드에서 설정 (중요: 이 순서대로!)
Serial.println(" 3. Bitrate 설정...");
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
delay(10);
// 5. 필터/마스크 설정 (모든 메시지 수신)
Serial.println(" 4. 필터 설정 (모든 메시지 수신)...");
mcp2515.setFilterMask(MCP2515::MASK0, false, 0x000);
mcp2515.setFilterMask(MCP2515::MASK1, false, 0x000);
mcp2515.setFilter(MCP2515::RXF0, false, 0x000);
mcp2515.setFilter(MCP2515::RXF1, false, 0x000);
mcp2515.setFilter(MCP2515::RXF2, false, 0x000);
mcp2515.setFilter(MCP2515::RXF3, false, 0x000);
mcp2515.setFilter(MCP2515::RXF4, false, 0x000);
mcp2515.setFilter(MCP2515::RXF5, false, 0x000);
delay(10);
// 6. 수신 버퍼 비우기 (Configuration 모드에서)
Serial.println(" 5. 수신 버퍼 클리어...");
struct can_frame dummyFrame;
int clearCount = 0;
while (clearCount < 10) {
if (mcp2515.readMessage(&dummyFrame) != MCP2515::ERROR_OK) break;
clearCount++;
}
if (clearCount > 0) {
Serial.printf(" %d개 메시지 버림\n", clearCount);
}
// 7. 에러/오버플로우 플래그 클리어
Serial.println(" 6. 에러 플래그 클리어...");
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
mcp2515.clearTXInterrupts();
mcp2515.clearMERR();
mcp2515.clearERRIF();
delay(10);
// 8. 모드 전환 (마지막에!)
Serial.printf(" 7. 모드 설정: %d\n", (int)currentMcpMode);
if (currentMcpMode == MCP_MODE_NORMAL) {
mcp2515.setNormalMode();
Serial.println(" → Normal Mode");
} else if (currentMcpMode == MCP_MODE_LOOPBACK) {
mcp2515.setLoopbackMode();
Serial.println(" → Loopback Mode");
} else if (currentMcpMode == MCP_MODE_LISTEN_ONLY) {
mcp2515.setListenOnlyMode();
Serial.println(" → Listen-Only Mode");
} else {
// TRANSMIT 모드는 Listen-Only로 시작 (TX 시에만 Normal로 전환)
mcp2515.setListenOnlyMode();
Serial.println(" → Listen-Only Mode (TX mode)");
}
delay(50); // 모드 전환 안정화
// 9. 최종 버퍼 클리어 (모드 전환 후)
Serial.println(" 8. 최종 버퍼 클리어...");
clearCount = 0;
while (mcp2515.readMessage(&dummyFrame) == MCP2515::ERROR_OK) {
clearCount++;
if (clearCount > 100) break;
}
if (clearCount > 0) {
Serial.printf(" %d개 메시지 버림\n", clearCount);
}
mcp2515.clearRXnOVRFlags();
// 10. 에러 상태 확인
uint8_t errorFlag = mcp2515.getErrorFlags();
uint8_t txErr = mcp2515.errorCountTX();
uint8_t rxErr = mcp2515.errorCountRX();
Serial.printf(" 9. 에러 상태: EFLG=0x%02X, TEC=%d, REC=%d\n", errorFlag, txErr, rxErr);
if (errorFlag != 0 || txErr > 0 || rxErr > 0) {
Serial.println(" ⚠️ 에러 감지됨 - 추가 리셋 시도...");
// 에러가 있으면 완전 리셋 재시도
mcp2515.reset();
delay(50);
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
delay(10);
mcp2515.clearRXnOVRFlags();
mcp2515.clearInterrupts();
if (currentMcpMode == MCP_MODE_NORMAL) {
mcp2515.setNormalMode();
} else if (currentMcpMode == MCP_MODE_LOOPBACK) {
mcp2515.setLoopbackMode();
} else {
mcp2515.setListenOnlyMode();
}
delay(50);
}
Serial.println("✓ MCP2515 초기화 완료");
// Serial 통신 초기화
applySerialSettings();
Serial.println("✓ Serial1 통신 초기화 (GPIO 17/18)");
Serial.println("✓ Serial2 통신 초기화 (GPIO 6/7)"); // ⭐ Serial2
// Mutex 생성
sdMutex = xSemaphoreCreateMutex();
rtcMutex = xSemaphoreCreateMutex();
serialMutex = xSemaphoreCreateMutex();
serial2Mutex = xSemaphoreCreateMutex(); // ⭐ Serial2
if (!sdMutex || !rtcMutex || !serialMutex) {
Serial.println("✗ Mutex 생성 실패!");
while (1) delay(1000);
}
// RTC 초기화
initRTC();
// ⭐⭐⭐ SD 카드 초기화 (SDIO 4-bit Mode)
Serial.println("SD 카드 초기화 (SDIO 4-bit)...");
// setPins() 호출: clk, cmd, d0, d1, d2, d3
if (!SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3)) {
Serial.println("✗ SD_MMC.setPins() 실패!");
sdCardReady = false;
} else if (SD_MMC.begin("/sdcard", false)) { // false = 4-bit mode
sdCardReady = true;
Serial.println("✓ SD 카드 초기화 완료 (SDIO 4-bit)");
Serial.printf(" 카드 크기: %llu MB\n", SD_MMC.cardSize() / (1024 * 1024));
Serial.printf(" 핀: CLK=%d, CMD=%d, D0=%d, D1=%d, D2=%d, D3=%d\n",
SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3);
loadFileComments();
loadSequences();
} else {
sdCardReady = false;
Serial.println("✗ SD 카드 초기화 실패");
Serial.println(" 확인 사항:");
Serial.println(" - 배선: CLK=39, CMD=38, D0=40, D1=41, D2=42, D3=21");
Serial.println(" - 10kΩ 풀업 저항 확인 (CMD, D0-D3)");
Serial.println(" - SD 카드 포맷 (FAT32)");
Serial.println(" - SDIO 지원 SD 카드 모듈 사용");
}
// WiFi 설정
WiFi.setSleep(false); // ⭐ WiFi 절전 모드 비활성화 (연결 안정성 향상)
if (enableSTAMode && strlen(staSSID) > 0) {
Serial.println("\n📶 WiFi APSTA 모드...");
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
Serial.printf("✓ AP: %s\n", wifiSSID);
Serial.printf("✓ AP IP: %s\n", WiFi.softAPIP().toString().c_str());
WiFi.begin(staSSID, staPassword);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("✓ STA IP: %s\n", WiFi.localIP().toString().c_str());
initNTP();
}
} else {
Serial.println("\n📶 WiFi AP 모드...");
WiFi.mode(WIFI_AP);
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
Serial.printf("✓ AP: %s\n", wifiSSID);
Serial.printf("✓ AP IP: %s\n", WiFi.softAPIP().toString().c_str());
}
WiFi.setSleep(false);
esp_wifi_set_max_tx_power(84);
// WebSocket 시작
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, []() {
if (!server.hasArg("file")) {
server.send(400, "text/plain", "Bad request");
return;
}
String filename = "/" + server.arg("file");
Serial.println("\n========================================");
Serial.println("📥 다운로드 준비");
Serial.println("========================================");
// ⭐ 1단계: 모든 로깅 완전 중지
bool wasLogging = loggingEnabled;
if (wasLogging) {
loggingEnabled = false;
delay(200);
Serial.println("⏸ 모든 로깅 중지");
}
// ⭐ 2단계: 모든 SD 관련 Task 중단
if (sdWriteTaskHandle != NULL) {
vTaskSuspend(sdWriteTaskHandle);
delay(100);
Serial.println("⏸ SD 쓰기 Task 중단");
}
if (webTaskHandle != NULL) {
vTaskSuspend(webTaskHandle);
delay(50);
Serial.println("⏸ 웹 업데이트 Task 중단");
}
// ⭐ 3단계: SD Mutex 획득
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10000)) != pdTRUE) {
if (webTaskHandle != NULL) vTaskResume(webTaskHandle);
if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle);
if (wasLogging) loggingEnabled = true;
server.send(503, "text/plain", "SD card busy");
Serial.println("✗ SD Mutex 획득 실패");
return;
}
// ⭐ 4단계: SD 카드 재마운트 (1-bit 모드로)
Serial.println("🔄 SD 카드 재마운트 중...");
SD_MMC.end();
delay(200);
// 1-bit 모드로 재시작 (더 안정적)
if (!SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0)) {
Serial.println("✗ SD_MMC.setPins() 실패");
xSemaphoreGive(sdMutex);
if (webTaskHandle != NULL) vTaskResume(webTaskHandle);
if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle);
if (wasLogging) loggingEnabled = true;
server.send(500, "text/plain", "SD remount failed");
return;
}
if (!SD_MMC.begin("/sdcard", true)) { // true = 1-bit mode
Serial.println("✗ SD 카드 1-bit 재마운트 실패");
xSemaphoreGive(sdMutex);
if (webTaskHandle != NULL) vTaskResume(webTaskHandle);
if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle);
if (wasLogging) loggingEnabled = true;
server.send(500, "text/plain", "SD 1-bit mode failed");
return;
}
Serial.println("✓ SD 카드 1-bit 모드 활성화");
delay(100);
// 파일 존재 확인
if (!SD_MMC.exists(filename)) {
Serial.println("✗ 파일 없음");
// 4-bit 모드로 복구
SD_MMC.end();
delay(100);
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3);
SD_MMC.begin("/sdcard", false);
xSemaphoreGive(sdMutex);
if (webTaskHandle != NULL) vTaskResume(webTaskHandle);
if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle);
if (wasLogging) loggingEnabled = true;
server.send(404, "text/plain", "File not found");
return;
}
File file = SD_MMC.open(filename, FILE_READ);
if (!file) {
Serial.println("✗ 파일 열기 실패");
// 4-bit 모드로 복구
SD_MMC.end();
delay(100);
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3);
SD_MMC.begin("/sdcard", false);
xSemaphoreGive(sdMutex);
if (webTaskHandle != NULL) vTaskResume(webTaskHandle);
if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle);
if (wasLogging) loggingEnabled = true;
server.send(500, "text/plain", "Failed to open file");
return;
}
size_t fileSize = file.size();
String displayName = server.arg("file");
Serial.printf("📥 다운로드 시작: %s (%u bytes)\n", displayName.c_str(), fileSize);
// 헤더 전송
server.setContentLength(fileSize);
server.sendHeader("Content-Disposition", "attachment; filename=\"" + displayName + "\"");
server.sendHeader("Content-Type", "application/octet-stream");
server.sendHeader("Connection", "close");
server.send(200, "application/octet-stream", "");
// 512바이트 섹터 단위 전송
const size_t CHUNK_SIZE = 512;
uint8_t *buffer = (uint8_t*)heap_caps_aligned_alloc(32, CHUNK_SIZE, MALLOC_CAP_DMA);
if (!buffer) {
buffer = (uint8_t*)malloc(CHUNK_SIZE);
}
if (!buffer) {
Serial.println("✗ 버퍼 할당 실패");
file.close();
// 4-bit 모드로 복구
SD_MMC.end();
delay(100);
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3);
SD_MMC.begin("/sdcard", false);
xSemaphoreGive(sdMutex);
if (webTaskHandle != NULL) vTaskResume(webTaskHandle);
if (sdWriteTaskHandle != NULL) vTaskResume(sdWriteTaskHandle);
if (wasLogging) loggingEnabled = true;
return;
}
size_t totalSent = 0;
bool downloadSuccess = true;
WiFiClient client = server.client();
unsigned long lastPrint = millis();
while (file.available() && totalSent < fileSize && downloadSuccess) {
// SD 읽기
size_t bytesRead = file.read(buffer, CHUNK_SIZE);
if (bytesRead == 0) {
Serial.printf("✗ 읽기 실패 (위치: %u)\n", totalSent);
downloadSuccess = false;
break;
}
// WiFi 전송
if (!client.connected()) {
Serial.println("✗ 클라이언트 연결 끊김");
downloadSuccess = false;
break;
}
size_t totalWritten = 0;
while (totalWritten < bytesRead && client.connected()) {
size_t written = client.write(buffer + totalWritten, bytesRead - totalWritten);
totalWritten += written;
if (written == 0) delay(5);
}
totalSent += bytesRead;
// 진행상황 (1초마다)
if (millis() - lastPrint > 1000) {
float percent = (totalSent * 100.0) / fileSize;
Serial.printf("📤 %.1f%% (%u/%u)\n", percent, totalSent, fileSize);
lastPrint = millis();
}
yield();
}
free(buffer);
file.close();
Serial.println("========================================");
if (downloadSuccess && totalSent == fileSize) {
Serial.printf("✓ 완료: %u bytes (100.0%%)\n", totalSent);
} else {
Serial.printf("⚠ 불완전: %u/%u bytes (%.1f%%)\n",
totalSent, fileSize, (totalSent * 100.0) / fileSize);
}
Serial.println("========================================");
// ⭐ SD 카드를 4-bit 모드로 복구
Serial.println("🔄 SD 카드 4-bit 모드로 복구...");
SD_MMC.end();
delay(200);
if (SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0, SDIO_D1, SDIO_D2, SDIO_D3)) {
if (SD_MMC.begin("/sdcard", false)) { // false = 4-bit mode
Serial.println("✓ SD 카드 4-bit 모드 복구 완료");
} else {
Serial.println("⚠ 4-bit 모드 복구 실패, 1-bit 유지");
SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0);
SD_MMC.begin("/sdcard", true);
}
}
xSemaphoreGive(sdMutex);
// Task 재개
if (webTaskHandle != NULL) {
vTaskResume(webTaskHandle);
Serial.println("▶ 웹 업데이트 Task 재개");
}
if (sdWriteTaskHandle != NULL) {
vTaskResume(sdWriteTaskHandle);
Serial.println("▶ SD 쓰기 Task 재개");
}
// 로깅 재개
if (wasLogging) {
loggingEnabled = true;
Serial.println("▶ 로깅 재개");
}
Serial.println("\n");
});
server.begin();
Serial.println("✓ 웹 서버 시작 완료");
// ★★★ Queue 생성 (PSRAM 사용) ★★★
if (!createQueues()) {
Serial.println("✗ Queue 생성 실패!");
while (1) delay(1000);
}
// CAN 인터럽트 활성화
attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING);
// ========================================
// Task 생성 (최적화 버전)
// ========================================
Serial.println("\nTask 생성 중...");
// Core 1: 실시간 처리 + 웹 서비스
// - CAN RX: 최고 우선순위로 메시지 수신
// - WEB: Core 0의 SD 쓰기와 분리하여 응답성 확보
// - TX: CAN 전송
// - SEQ: 시퀀스 재생
// - MONITOR: 상태 모니터링
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 12288, NULL, 24, &canRxTaskHandle, 1); // ⭐ 8KB → 12KB, Pri 24 (최고)
xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 12288, NULL, 4, &webTaskHandle, 1); // ⭐ Core 0 → 1, Pri 4 (SD와 분리)
xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1); // Pri 3
xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1); // Pri 2
xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 1); // Pri 1
// Core 0: I/O 전용 (SD, Serial, RTC)
// - SD 쓰기와 Serial 수신만 처리
// - 웹은 Core 1에서 처리하므로 SD 지연 영향 없음
xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 18576, NULL, 8, &sdWriteTaskHandle, 0); // Pri 8 (최우선)
xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 6, &serialRxTaskHandle, 0); // Pri 6
xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 6, &serial2RxTaskHandle,0); // Pri 6
if (timeSyncStatus.rtcAvailable) {
xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0); // Pri 0 (최저)
}
Serial.println("✓ 모든 Task 시작 완료");
Serial.println("\n========================================");
Serial.println(" Task 구성 (Core 분리 전략)");
Serial.println("========================================");
Serial.println("Core 1 (실시간 + 웹 서비스):");
Serial.println(" - CAN_RX: 12KB, Pri 24 (최고)");
Serial.println(" - WEB_UPDATE: 12KB, Pri 4 ⭐ SD와 분리");
Serial.println(" - TX: 4KB, Pri 3");
Serial.println(" - SEQ: 4KB, Pri 2");
Serial.println(" - SD_MONITOR: 4KB, Pri 1");
Serial.println("Core 0 (I/O 전용):");
Serial.println(" - SD_WRITE: 18KB, Pri 8 (최우선)");
Serial.println(" - SERIAL_RX: 6KB, Pri 6");
Serial.println(" - SERIAL2_RX: 6KB, Pri 6");
if (timeSyncStatus.rtcAvailable) {
Serial.println(" - RTC_SYNC: 3KB, Pri 0");
}
Serial.println("========================================");
Serial.println("📌 웹을 Core 1로 배치 → SD 쓰기 지연 영향 없음");
Serial.println("========================================");
Serial.println("\n========================================");
Serial.println(" 접속 방법");
Serial.println("========================================");
Serial.printf(" WiFi SSID: %s\n", wifiSSID);
Serial.printf(" URL: http://%s\n", WiFi.softAPIP().toString().c_str());
Serial.println("========================================");
Serial.println(" PSRAM 상태");
Serial.println("========================================");
Serial.printf(" 여유 PSRAM: %d KB\n", ESP.getFreePsram() / 1024);
Serial.println("========================================\n");
}
// ========================================
// Loop
// ========================================
void loop() {
server.handleClient();
vTaskDelay(pdMS_TO_TICKS(10));
// 주기적 상태 출력 (30초)
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 30000) {
Serial.printf("[상태] CAN: %d/%d | S1: %d/%d | S2: %d/%d | PSRAM: %d KB\n",
uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE,
uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE,
uxQueueMessagesWaiting(serial2Queue), SERIAL2_QUEUE_SIZE,
ESP.getFreePsram() / 1024);
lastPrint = millis();
}
// 스택 사용량 모니터링 (5분마다)
static uint32_t lastStackCheck = 0;
if (millis() - lastStackCheck > 300000) {
Serial.println("\n========== Task Stack Usage ==========");
if (canRxTaskHandle) {
UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(canRxTaskHandle);
Serial.printf("CAN_RX: %5u bytes free (alloc: 12288)\n", stackLeft * 4);
if (stackLeft * 4 < 2000) Serial.println(" ⚠️ 스택 부족 위험!");
}
if (sdWriteTaskHandle) {
UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(sdWriteTaskHandle);
Serial.printf("SD_WRITE: %5u bytes free (alloc: 18576)\n", stackLeft * 4);
if (stackLeft * 4 < 3000) Serial.println(" ⚠️ 스택 부족 위험!");
}
if (webTaskHandle) {
UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(webTaskHandle);
Serial.printf("WEB_UPDATE: %5u bytes free (alloc: 12288)\n", stackLeft * 4);
if (stackLeft * 4 < 2000) Serial.println(" ⚠️ 스택 부족 위험!");
}
if (serialRxTaskHandle) {
UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(serialRxTaskHandle);
Serial.printf("SERIAL_RX: %5u bytes free (alloc: 6144)\n", stackLeft * 4);
}
if (serial2RxTaskHandle) {
UBaseType_t stackLeft = uxTaskGetStackHighWaterMark(serial2RxTaskHandle);
Serial.printf("SERIAL2_RX: %5u bytes free (alloc: 6144)\n", stackLeft * 4);
}
Serial.printf("Free Heap: %u bytes\n", ESP.getFreeHeap());
Serial.printf("Free PSRAM: %u KB\n", ESP.getFreePsram() / 1024);
Serial.println("======================================\n");
lastStackCheck = millis();
}
}