3929 lines
158 KiB
C++
3929 lines
158 KiB
C++
/*
|
||
* 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 크리스탈 설정
|
||
// ========================================
|
||
// 8MHz 크리스탈: 500 Kbps까지 안정적
|
||
// 16MHz 크리스탈: 1 Mbps 고속 통신 권장 (고부하 대응)
|
||
//
|
||
// 사용법:
|
||
// - 하드웨어에 16MHz 크리스탈 장착 시: MCP_16MHZ
|
||
// - 하드웨어에 8MHz 크리스탈 장착 시: MCP_8MHZ
|
||
//
|
||
//#define MCP_CRYSTAL MCP_16MHZ // ⭐ 16MHz 크리스탈 (1 Mbps 권장)
|
||
#define MCP_CRYSTAL MCP_8MHZ // 8MHz 크리스탈 (500 Kbps까지)
|
||
|
||
// SPI 속도: 20MHz (MCP2515 최대 속도와 균형)
|
||
// 10MHz: 안정적이지만 느림
|
||
// 20MHz: 권장 (고속 + 안정성)
|
||
// 25MHz: 불안정할 수 있음
|
||
MCP2515 mcp2515(HSPI_CS, 80000000, &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 추가
|
||
|
||
// ⭐⭐⭐ 더블 버퍼링 변수
|
||
uint8_t* fileBuffer1 = NULL; // 쓰기 버퍼
|
||
uint8_t* fileBuffer2 = NULL; // flush 버퍼
|
||
uint8_t* currentWriteBuffer = NULL; // 현재 쓰기용
|
||
uint8_t* currentFlushBuffer = NULL; // 현재 flush용
|
||
uint32_t writeBufferIndex = 0; // 쓰기 버퍼 인덱스
|
||
uint32_t flushBufferSize = 0; // flush할 크기
|
||
volatile bool flushInProgress = false; // flush 진행중 플래그
|
||
TaskHandle_t sdFlushTaskHandle = NULL; // flush 전용 태스크
|
||
|
||
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_CRYSTAL);
|
||
delay(10);
|
||
|
||
// 6. 필터/마스크 설정 (모든 메시지 수신)
|
||
Serial.println(" 4. 필터 설정 (Standard + Extended ID 모두 수신)...");
|
||
// ⭐ RXB0: Standard ID 수신 (false)
|
||
mcp2515.setFilterMask(MCP2515::MASK0, false, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF0, false, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF1, false, 0x00000000);
|
||
|
||
// ⭐ RXB1: Extended ID 수신 (true)
|
||
mcp2515.setFilterMask(MCP2515::MASK1, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF2, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF3, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF4, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF5, true, 0x00000000);
|
||
delay(10);
|
||
|
||
// 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_CRYSTAL);
|
||
delay(10);
|
||
if (currentMcpMode == MCP_MODE_NORMAL) {
|
||
mcp2515.setNormalMode();
|
||
} else if (currentMcpMode == MCP_MODE_LOOPBACK) {
|
||
mcp2515.setLoopbackMode();
|
||
} else {
|
||
mcp2515.setListenOnlyMode();
|
||
}
|
||
delay(50);
|
||
mcp2515.clearRXnOVRFlags();
|
||
mcp2515.clearInterrupts();
|
||
}
|
||
|
||
// 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);
|
||
|
||
// ⭐⭐⭐ 더블 버퍼 할당
|
||
fileBuffer1 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
|
||
if (!fileBuffer1) {
|
||
Serial.println("✗ fileBuffer1 할당 실패");
|
||
return false;
|
||
}
|
||
Serial.printf("✓ fileBuffer1: %d KB\n", FILE_BUFFER_SIZE / 1024);
|
||
|
||
fileBuffer2 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
|
||
if (!fileBuffer2) {
|
||
Serial.println("✗ fileBuffer2 할당 실패");
|
||
return false;
|
||
}
|
||
Serial.printf("✓ fileBuffer2: %d KB\n", FILE_BUFFER_SIZE / 1024);
|
||
|
||
// 초기 버퍼 포인터 설정
|
||
currentWriteBuffer = fileBuffer1;
|
||
currentFlushBuffer = fileBuffer2;
|
||
writeBufferIndex = 0;
|
||
flushBufferSize = 0;
|
||
Serial.println("✓ 더블 버퍼링 초기화 완료");
|
||
|
||
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;
|
||
|
||
// ⭐⭐⭐ 더블 버퍼 초기화
|
||
writeBufferIndex = 0;
|
||
flushBufferSize = 0;
|
||
flushInProgress = false;
|
||
|
||
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) {
|
||
// 🎯 파일 닫기 로직
|
||
Serial.println("🎯 Auto Trigger: 중지 조건 만족");
|
||
|
||
// ⭐⭐⭐ 1단계: 로깅 중지 (새 데이터 수신 차단)
|
||
loggingEnabled = false;
|
||
autoTriggerActive = false;
|
||
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(5000)) == pdTRUE) {
|
||
|
||
// ⭐⭐⭐ 2단계: flush 태스크 완료 대기
|
||
Serial.println("⏳ Flush 태스크 완료 대기 중...");
|
||
int waitCount = 0;
|
||
while (flushInProgress && waitCount < 1000) { // 최대 10초
|
||
vTaskDelay(10);
|
||
waitCount++;
|
||
}
|
||
if (waitCount > 0) {
|
||
Serial.printf("✓ Flush 완료: %d ms 대기\n", waitCount * 10);
|
||
}
|
||
|
||
// ⭐⭐⭐ 3단계: 현재 쓰기 버퍼의 남은 데이터 flush
|
||
if (writeBufferIndex > 0 && logFile) {
|
||
size_t written = logFile.write(currentWriteBuffer, writeBufferIndex);
|
||
Serial.printf("✓ 쓰기 버퍼 최종 플러시: %d bytes\n", written);
|
||
writeBufferIndex = 0;
|
||
}
|
||
|
||
// ⭐⭐⭐ 4단계: 최종 flush
|
||
if (logFile) {
|
||
logFile.flush();
|
||
Serial.println("✓ 최종 flush 완료");
|
||
}
|
||
|
||
// ⭐⭐⭐ 5단계: 파일 닫기
|
||
if (logFile) {
|
||
size_t finalSize = logFile.size();
|
||
logFile.close();
|
||
Serial.printf("✓ Auto Trigger 파일 닫힘: %s (%lu bytes)\n", currentFilename, finalSize);
|
||
}
|
||
|
||
currentFilename[0] = '\0';
|
||
writeBufferIndex = 0;
|
||
flushBufferSize = 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_CRYSTAL);
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 메시지 배치 읽기 (한 번에 최대 20개 처리)
|
||
int batchCount = 0;
|
||
while (batchCount < 20 && 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, 0) == pdTRUE) { // ⭐ 타임아웃 10ms → 0 (블로킹 제거)
|
||
totalMsgCount++;
|
||
}
|
||
batchCount++;
|
||
}
|
||
|
||
// 배치 처리 후 짧은 yield (다른 Task에 CPU 양보)
|
||
if (batchCount == 0) {
|
||
vTaskDelay(pdMS_TO_TICKS(1));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// SD Write Task
|
||
// ========================================
|
||
// ========================================
|
||
// ⭐⭐⭐ SD Flush 전용 태스크 (더블 버퍼링)
|
||
// ========================================
|
||
void sdFlushTask(void *parameter) {
|
||
Serial.println("✓ sdFlushTask 시작 (더블 버퍼링 모드)");
|
||
|
||
while (1) {
|
||
uint32_t notifyValue;
|
||
|
||
// flush 알림 대기 (무한 대기)
|
||
if (xTaskNotifyWait(0, 0xFFFFFFFF, ¬ifyValue, portMAX_DELAY) == pdTRUE) {
|
||
uint32_t flushSize = notifyValue;
|
||
|
||
if (flushSize > 0 && flushSize <= FILE_BUFFER_SIZE) {
|
||
flushInProgress = true;
|
||
|
||
uint32_t startTime = millis();
|
||
|
||
// ⭐ SD 뮤텍스 획득 (최대 5초 대기)
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(5000)) == pdTRUE) {
|
||
// ⭐⭐⭐ loggingEnabled 체크 제거! (중지 시에도 flush 필요)
|
||
if (logFile && sdCardReady) {
|
||
// ⭐ flush 버퍼를 SD에 쓰기
|
||
size_t written = logFile.write(currentFlushBuffer, flushSize);
|
||
|
||
// ⭐ 여기서 flush 호출 (200ms 블로킹되어도 CAN 수신은 계속!)
|
||
logFile.flush();
|
||
|
||
uint32_t elapsed = millis() - startTime;
|
||
Serial.printf("✓ SD Flush 완료: %d bytes, %d ms\n", written, elapsed);
|
||
|
||
// 통계
|
||
if (elapsed > 100) {
|
||
Serial.printf("⚠️ Flush 지연: %d ms (하지만 CAN은 정상!)\n", elapsed);
|
||
}
|
||
}
|
||
xSemaphoreGive(sdMutex);
|
||
} else {
|
||
Serial.println("✗ SD Flush: 뮤텍스 타임아웃");
|
||
}
|
||
|
||
flushInProgress = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// 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 >= 2000) { // ⭐ 500 → 2000 (SD 블로킹 최소화)
|
||
logFile.flush();
|
||
csvFlushCounter = 0;
|
||
}
|
||
|
||
// ⭐⭐⭐ 파일 재오픈 제거 (고부하 환경 최적화)
|
||
// if (++csvReopenCounter >= 2000) { ... } // 제거됨
|
||
|
||
|
||
}
|
||
} else {
|
||
// ⭐⭐⭐ BIN 형식 - 더블 버퍼링
|
||
|
||
// ⭐ 1단계: 버퍼 가득 찼으면 스왑
|
||
if (writeBufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) {
|
||
// flush가 진행중이면 잠시 대기 (매우 짧음, 버퍼 스왑만 기다림)
|
||
int waitCount = 0;
|
||
while (flushInProgress && waitCount < 100) {
|
||
vTaskDelay(1);
|
||
waitCount++;
|
||
}
|
||
|
||
if (waitCount >= 100) {
|
||
Serial.println("⚠️ Flush 대기 타임아웃 (버퍼 손실 방지)");
|
||
}
|
||
|
||
// ⭐ 버퍼 스왑 (즉시, 블로킹 없음!)
|
||
uint8_t* temp = currentWriteBuffer;
|
||
currentWriteBuffer = currentFlushBuffer;
|
||
currentFlushBuffer = temp;
|
||
flushBufferSize = writeBufferIndex;
|
||
writeBufferIndex = 0;
|
||
|
||
// ⭐ flush 태스크에 알림 (비동기)
|
||
if (sdFlushTaskHandle != NULL) {
|
||
xTaskNotify(sdFlushTaskHandle, flushBufferSize, eSetValueWithOverwrite);
|
||
}
|
||
|
||
Serial.printf("✓ 버퍼 스왑: %d bytes → flush 태스크\n", flushBufferSize);
|
||
}
|
||
|
||
// ⭐ 2단계: 현재 쓰기 버퍼에 데이터 추가
|
||
memcpy(¤tWriteBuffer[writeBufferIndex], &canMsg, sizeof(CANMessage));
|
||
writeBufferIndex += sizeof(CANMessage);
|
||
currentFileSize += sizeof(CANMessage);
|
||
|
||
}
|
||
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);
|
||
|
||
// ⭐ 모든 데이터를 하나의 JSON으로 통합 전송 (빠른 연결)
|
||
DynamicJsonDocument allData(3072); // 통합 JSON
|
||
allData["type"] = "initialData";
|
||
|
||
// 기본 설정
|
||
int speedIndex = 3;
|
||
for (int i = 0; i < 4; i++) {
|
||
if (canSpeedValues[i] == currentCanSpeed) {
|
||
speedIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
allData["canSpeed"] = speedIndex;
|
||
allData["mcpMode"] = (int)currentMcpMode;
|
||
allData["autoTriggerEnabled"] = autoTriggerEnabled;
|
||
allData["autoTriggerLogCSV"] = autoTriggerLogCSV;
|
||
|
||
// Auto Trigger 설정도 포함
|
||
if (autoTriggerEnabled) {
|
||
allData["logFormat"] = autoTriggerLogCSV ? "csv" : "bin";
|
||
allData["startLogic"] = startLogicOp;
|
||
allData["stopLogic"] = stopLogicOp;
|
||
allData["startFormula"] = startFormula;
|
||
allData["stopFormula"] = stopFormula;
|
||
|
||
JsonArray startArray = allData.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 = allData.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;
|
||
}
|
||
}
|
||
|
||
// ⭐ 하나의 메시지로 전송 (delay 제거)
|
||
String json;
|
||
serializeJson(allData, json);
|
||
webSocket.sendTXT(num, json);
|
||
}
|
||
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;
|
||
|
||
// ⭐⭐⭐ 더블 버퍼 초기화
|
||
writeBufferIndex = 0;
|
||
flushBufferSize = 0;
|
||
flushInProgress = false;
|
||
|
||
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("🛑 로깅 중지 요청...");
|
||
|
||
// ⭐⭐⭐ 1단계: 로깅 중지 (새 데이터 수신 차단)
|
||
loggingEnabled = false;
|
||
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(5000)) == pdTRUE) {
|
||
|
||
// ⭐⭐⭐ 2단계: flush 태스크 완료 대기 (진행 중인 flush)
|
||
Serial.println("⏳ Flush 태스크 완료 대기 중...");
|
||
int waitCount = 0;
|
||
while (flushInProgress && waitCount < 1000) { // 최대 10초
|
||
vTaskDelay(10);
|
||
waitCount++;
|
||
}
|
||
if (waitCount > 0) {
|
||
Serial.printf("✓ Flush 태스크 완료: %d ms 대기\n", waitCount * 10);
|
||
}
|
||
|
||
// ⭐⭐⭐ 3단계: 현재 쓰기 버퍼의 남은 데이터 flush
|
||
if (writeBufferIndex > 0 && logFile) {
|
||
size_t written = logFile.write(currentWriteBuffer, writeBufferIndex);
|
||
Serial.printf("✓ 쓰기 버퍼 최종 플러시: %d bytes\n", written);
|
||
writeBufferIndex = 0;
|
||
}
|
||
|
||
// ⭐⭐⭐ 4단계: 최종 flush (메타데이터 동기화)
|
||
if (logFile) {
|
||
logFile.flush();
|
||
Serial.println("✓ 최종 flush 완료");
|
||
}
|
||
|
||
// ⭐⭐⭐ 5단계: 파일 닫기
|
||
if (logFile) {
|
||
size_t finalSize = logFile.size();
|
||
logFile.close();
|
||
Serial.printf("✓ 파일 닫힘: %s (%lu bytes)\n", currentFilename, finalSize);
|
||
}
|
||
|
||
currentFilename[0] = '\0';
|
||
writeBufferIndex = 0;
|
||
flushBufferSize = 0;
|
||
|
||
xSemaphoreGive(sdMutex);
|
||
|
||
Serial.println("✅ 로깅 중지 완료!");
|
||
} 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_CRYSTAL);
|
||
// 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_CRYSTAL);
|
||
// 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(500); // ⭐ 100ms → 500ms (CPU 부하 감소)
|
||
|
||
// 🆕 초기화 대기 단축 (부팅 직후 안정화)
|
||
vTaskDelay(pdMS_TO_TICKS(500)); // ⭐ 2000ms → 500ms (빠른 연결)
|
||
|
||
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;
|
||
|
||
// 🆕 CAN 버스 부하율 계산
|
||
// CAN 속도별 이론적 최대 메시지/초 (8바이트 데이터 기준)
|
||
// 125 Kbps: ~1,000 msg/s
|
||
// 250 Kbps: ~2,000 msg/s
|
||
// 500 Kbps: ~4,000 msg/s
|
||
// 1 Mbps: ~8,000 msg/s
|
||
uint32_t maxMsgPerSec;
|
||
switch(currentCanSpeed) {
|
||
case CAN_125KBPS: maxMsgPerSec = 1000; break;
|
||
case CAN_250KBPS: maxMsgPerSec = 2000; break;
|
||
case CAN_500KBPS: maxMsgPerSec = 4000; break;
|
||
case CAN_1000KBPS:
|
||
default: maxMsgPerSec = 8000; break;
|
||
}
|
||
|
||
// 부하율 계산 (0~100%)
|
||
float busLoad = (msgPerSecond * 100.0) / maxMsgPerSec;
|
||
if (busLoad > 100.0) busLoad = 100.0;
|
||
doc["busLoad"] = (int)busLoad; // 정수로 전송
|
||
|
||
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);
|
||
|
||
// ⭐ 리셋 원인 확인 (전원 부족 감지)
|
||
esp_reset_reason_t reset_reason = esp_reset_reason();
|
||
if (reset_reason == ESP_RST_BROWNOUT) {
|
||
Serial.println("");
|
||
Serial.println("╔════════════════════════════════════════════╗");
|
||
Serial.println("║ 🚨 브라운아웃 리셋 감지! ║");
|
||
Serial.println("║ ║");
|
||
Serial.println("║ 원인: 전원 공급 부족 ║");
|
||
Serial.println("║ ║");
|
||
Serial.println("║ 필요 전류: ║");
|
||
Serial.println("║ - 평균: 300 mA ║");
|
||
Serial.println("║ - 피크: 550 mA (WiFi TX + SD Write) ║");
|
||
Serial.println("║ ║");
|
||
Serial.println("║ 해결 방법: ║");
|
||
Serial.println("║ 1. 5V 1A USB 어댑터 사용 (권장) ║");
|
||
Serial.println("║ 2. USB 케이블 교체 (짧고 굵은 케이블) ║");
|
||
Serial.println("║ 3. USB 3.0 포트 사용 (900mA 지원) ║");
|
||
Serial.println("╚════════════════════════════════════════════╝");
|
||
Serial.println("");
|
||
delay(5000); // 경고 메시지 읽을 시간
|
||
}
|
||
|
||
// ⭐ WiFi 전력 최적화 (전류 소비 감소)
|
||
WiFi.setSleep(false);
|
||
WiFi.setTxPower(WIFI_POWER_15dBm); // 19.5dBm → 15dBm (전류 절감)
|
||
|
||
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_CRYSTAL);
|
||
delay(10);
|
||
|
||
// 5. 필터/마스크 설정 (모든 메시지 수신)
|
||
Serial.println(" 4. 필터 설정 (Standard + Extended ID 모두 수신)...");
|
||
// ⭐ RXB0: Standard ID 수신 (false)
|
||
mcp2515.setFilterMask(MCP2515::MASK0, false, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF0, false, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF1, false, 0x00000000);
|
||
|
||
// ⭐ RXB1: Extended ID 수신 (true)
|
||
mcp2515.setFilterMask(MCP2515::MASK1, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF2, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF3, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF4, true, 0x00000000);
|
||
mcp2515.setFilter(MCP2515::RXF5, true, 0x00000000);
|
||
delay(10);
|
||
|
||
// 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_CRYSTAL);
|
||
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, 8, &webTaskHandle, 0); // ⭐ 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(sdFlushTask, "SD_FLUSH", 4096, NULL, 9, &sdFlushTaskHandle, 0); // ⭐ Pri 9 (최우선, flush 전용)
|
||
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();
|
||
}
|
||
} |