Files
esp32s3_canlogger_mcp2515/ESP32_CAN_Logger-a.ino
byun fd797791d3 메모리 rtos psram 효율화
ESP32-S3 성능 최적화 완료!
 주요 개선 사항
1. Queue 크기 대폭 증가 (PSRAM 활용)

CAN Queue: 2,000 → 6,000개 (+200%)
Serial Queue: 200 → 1,200개 (+500%)
File Buffer: 16 KB → 32 KB (+100%)
Serial CSV Buffer: 8 KB → 20 KB (+150%)

2. Task 우선순위 최적화
Core 1 (고성능 작업 전용):

CAN_RX: Priority 6 (최고) - 인터럽트 직후 즉시 처리
SD_WRITE: Priority 4 (높음), Stack 24 KB - 버퍼 넘침 방지
SEQ_TASK: Priority 2

Core 0 (WiFi/시스템 공유):

SERIAL_RX: Priority 5 (높음), Stack 6 KB - 921600 bps 고속 처리
TX_TASK: Priority 3 (보통-높음)
WEB_UPDATE: Priority 2, Stack 10 KB - 큰 JSON 버퍼
SD_MONITOR: Priority 1
RTC_SYNC: Priority 0
2025-11-29 19:34:42 +00:00

2278 lines
85 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

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

/*
* Byun CAN Logger with Web Interface + Serial Terminal
* Version: 2.2 - RTC Time Sync & Settings Page Fixed
* Fixed: RTC synchronization with WiFi time + Settings page loading
*/
#include <Arduino.h>
#include <SPI.h>
#include <mcp2515.h>
#include <SoftWire.h>
#include <SD.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_task_wdt.h>
#include <esp_sntp.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/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"
// GPIO 핀 정의
#define CAN_INT_PIN 4
// Serial 통신 핀 (추가)
#define SERIAL_TX_PIN 17
#define SERIAL_RX_PIN 18
// HSPI 핀 (CAN)
#define HSPI_MISO 13
#define HSPI_MOSI 11
#define HSPI_SCLK 12
#define HSPI_CS 10
// VSPI 핀 (SD Card)
#define VSPI_MISO 41
#define VSPI_MOSI 40
#define VSPI_SCLK 39
#define VSPI_CS 42
// I2C2 핀 (RTC DS3231) - SoftWire 사용
#define RTC_SDA 8
#define RTC_SCL 9
#define DS3231_ADDRESS 0x68
// 버퍼 설정 (ESP32-S3 N16R8 - DRAM 제한 회피)
// 주의: PSRAM 할당이 안 되는 경우 크기 축소
#define CAN_QUEUE_SIZE 1000 // 6000 → 1000 (DRAM 절약)
#define FILE_BUFFER_SIZE 16384 // 32768 → 16384 (16KB)
#define MAX_FILENAME_LEN 64
#define RECENT_MSG_COUNT 100
#define MAX_TX_MESSAGES 20
#define MAX_COMMENT_LEN 128
// Serial 버퍼 설정 (추가)
#define SERIAL_QUEUE_SIZE 200 // 1200 → 200 (DRAM 절약)
#define SERIAL_CSV_BUFFER_SIZE 8192 // 20480 → 8192 (8KB)
#define MAX_SERIAL_LINE_LEN 64 // 128 → 64 (FreeRTOS Queue 제한 회피)
// RTC 동기화 설정
#define RTC_SYNC_INTERVAL_MS 60000
// 전력 모니터링 설정
#define VOLTAGE_CHECK_INTERVAL_MS 5000
#define LOW_VOLTAGE_THRESHOLD 3.0
#define MONITORING_VOLT 5
// CAN 메시지 구조체
struct CANMessage {
uint64_t timestamp_us;
uint32_t id;
uint8_t dlc;
uint8_t data[8];
} __attribute__((packed));
// Serial 메시지 구조체 (추가)
struct SerialMessage {
uint64_t timestamp_us;
uint16_t length;
uint8_t data[MAX_SERIAL_LINE_LEN];
bool isTx; // true=송신, false=수신
} __attribute__((packed));
// Serial 설정 구조체 (추가)
struct SerialSettings {
uint32_t baudRate;
uint8_t dataBits; // 5, 6, 7, 8
uint8_t parity; // 0=None, 1=Even, 2=Odd
uint8_t stopBits; // 1, 2
} serialSettings = {115200, 8, 0, 1};
// 실시간 모니터링용 구조체
struct RecentCANData {
CANMessage msg;
uint32_t count;
};
// CAN 송신용 구조체
struct TxMessage {
uint32_t id;
bool extended;
uint8_t dlc;
uint8_t data[8];
uint32_t interval;
uint32_t lastSent;
bool active;
};
// CAN 시퀀스 스텝 구조체
struct SequenceStep {
uint32_t canId;
bool extended;
uint8_t dlc;
uint8_t data[8];
uint32_t delayMs;
};
// CAN 시퀀스 구조체
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};
// MCP2515 레지스터 주소 정의
#ifndef MCP_CANCTRL
#define MCP_CANCTRL 0x0F
#endif
#ifndef MCP_CANSTAT
#define MCP_CANSTAT 0x0E
#endif
// MCP2515 모드 정의
enum MCP2515Mode {
MCP_MODE_NORMAL = 0,
MCP_MODE_LISTEN_ONLY = 1,
MCP_MODE_LOOPBACK = 2,
MCP_MODE_TRANSMIT = 3
};
// WiFi AP 기본 설정
char wifiSSID[32] = "Byun_CAN_Logger";
char wifiPassword[64] = "12345678";
// WiFi Station 모드 설정
bool enableSTAMode = false;
char staSSID[32] = "";
char staPassword[64] = "";
// 전역 변수
SPIClass hspi(HSPI);
SPIClass vspi(FSPI);
MCP2515 mcp2515(HSPI_CS, 20000000, &hspi);
// Serial 통신용 (추가)
HardwareSerial SerialComm(1); // UART1 사용
WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);
Preferences preferences;
// Forward declaration
void IRAM_ATTR canISR();
// Queue 핸들 (FreeRTOS가 자동 할당)
QueueHandle_t canQueue;
QueueHandle_t serialQueue;
SemaphoreHandle_t sdMutex;
SemaphoreHandle_t rtcMutex;
SemaphoreHandle_t serialMutex;
TaskHandle_t canRxTaskHandle = NULL;
TaskHandle_t sdWriteTaskHandle = NULL;
TaskHandle_t webTaskHandle = NULL;
TaskHandle_t rtcTaskHandle = NULL;
TaskHandle_t serialRxTaskHandle = NULL;
volatile bool loggingEnabled = false;
volatile bool serialLoggingEnabled = false;
volatile bool sdCardReady = false;
File logFile;
File serialLogFile;
char currentFilename[MAX_FILENAME_LEN];
char currentSerialFilename[MAX_FILENAME_LEN];
uint8_t fileBuffer[FILE_BUFFER_SIZE];
char serialCsvBuffer[SERIAL_CSV_BUFFER_SIZE];
uint16_t bufferIndex = 0;
uint16_t serialCsvIndex = 0;
// 로깅 파일 크기 추적
volatile uint32_t currentFileSize = 0;
volatile uint32_t currentSerialFileSize = 0;
// 로깅 형식 선택 (false=BIN, true=CSV)
volatile bool canLogFormatCSV = false;
volatile bool serialLogFormatCSV = true;
// 로깅 시작 타임스탬프 (상대시간 계산용)
volatile uint64_t canLogStartTime = 0;
volatile uint64_t serialLogStartTime = 0;
// 현재 MCP2515 모드
MCP2515Mode currentMcpMode = MCP_MODE_NORMAL;
// RTC 관련
SoftWire rtcWire(RTC_SDA, RTC_SCL);
char rtcSyncBuffer[20];
// CAN 속도 설정
CAN_SPEED currentCanSpeed = CAN_1000KBPS;
const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"};
CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS};
// 실시간 모니터링용
RecentCANData recentData[RECENT_MSG_COUNT];
uint32_t totalMsgCount = 0;
uint32_t msgPerSecond = 0;
uint32_t lastMsgCountTime = 0;
uint32_t lastMsgCount = 0;
// Serial 통신 카운터 (추가)
volatile uint32_t totalSerialRxCount = 0;
volatile uint32_t totalSerialTxCount = 0;
// 그래프 최대 개수
#define MAX_GRAPH_SIGNALS 20
// CAN 송신용
TxMessage txMessages[MAX_TX_MESSAGES];
uint32_t totalTxCount = 0;
// CAN 시퀀스
#define MAX_SEQUENCES 10
CANSequence sequences[MAX_SEQUENCES];
uint8_t sequenceCount = 0;
SequenceRuntime seqRuntime = {false, 0, 0, 0, -1};
// 파일 커멘트 저장
#define MAX_FILE_COMMENTS 50
FileComment fileComments[MAX_FILE_COMMENTS];
int commentCount = 0;
// ========================================
// Serial 설정 저장/로드 함수 (추가)
// ========================================
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);
Serial.printf("✓ Serial 설정 로드: %u-%u-%u-%u\n",
serialSettings.baudRate, serialSettings.dataBits,
serialSettings.parity, serialSettings.stopBits);
}
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);
Serial.printf("✓ Serial 설정 저장: %u-%u-%u-%u\n",
serialSettings.baudRate, serialSettings.dataBits,
serialSettings.parity, serialSettings.stopBits);
}
void applySerialSettings() {
uint32_t config = SERIAL_8N1;
// Data bits + Parity 설정
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 { // 8 bits
if (serialSettings.parity == 0) config = SERIAL_8N1;
else if (serialSettings.parity == 1) config = SERIAL_8E1;
else if (serialSettings.parity == 2) config = SERIAL_8O1;
}
// Stop bits 설정
if (serialSettings.stopBits == 2) {
config |= 0x3000; // 2 stop bits
}
SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN);
SerialComm.setRxBufferSize(2048);
Serial.printf("✓ Serial 설정 적용: %u baud, config=0x%X\n",
serialSettings.baudRate, config);
}
// ========================================
// 설정 저장/로드 함수
// ========================================
void loadSettings() {
preferences.begin("can-logger", false);
// WiFi AP 설정 로드
preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID));
preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword));
// WiFi STA 모드 설정 로드
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");
}
// CAN 속도 로드
int speedIndex = preferences.getInt("can_speed", 3);
if (speedIndex >= 0 && speedIndex < 4) {
currentCanSpeed = canSpeedValues[speedIndex];
Serial.printf("✓ 저장된 CAN 속도 로드: %s\n", canSpeedNames[speedIndex]);
}
// MCP2515 모드 로드
int savedMode = preferences.getInt("mcp_mode", 0);
if (savedMode >= 0 && savedMode <= 3) {
currentMcpMode = (MCP2515Mode)savedMode;
Serial.printf("✓ 저장된 MCP 모드 로드: %d\n", savedMode);
}
// Serial 설정 로드 (추가)
loadSerialSettings();
preferences.end();
}
void saveSettings() {
preferences.begin("can-logger", false);
preferences.putString("wifi_ssid", wifiSSID);
preferences.putString("wifi_pass", wifiPassword);
preferences.putBool("sta_enable", enableSTAMode);
preferences.putString("sta_ssid", staSSID);
preferences.putString("sta_pass", staPassword);
// CAN 속도 저장
for (int i = 0; i < 4; i++) {
if (canSpeedValues[i] == currentCanSpeed) {
preferences.putInt("can_speed", i);
Serial.printf("✓ CAN 속도 저장: %s (인덱스 %d)\n", canSpeedNames[i], i);
break;
}
}
// MCP2515 모드 저장
preferences.putInt("mcp_mode", (int)currentMcpMode);
Serial.printf("✓ MCP 모드 저장: %d\n", (int)currentMcpMode);
// Serial 설정 저장 (추가)
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;
}
// NTP 시간 동기화 콜백
void timeSyncCallback(struct timeval *tv) {
Serial.println("✓ NTP 시간 동기화 완료");
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)tv->tv_sec * 1000000ULL + tv->tv_usec;
timeSyncStatus.syncCount++;
// RTC에 시간 저장
if (timeSyncStatus.rtcAvailable) {
struct tm timeinfo;
time_t now = tv->tv_sec;
localtime_r(&now, &timeinfo);
if (writeRTC(&timeinfo)) {
Serial.println("✓ NTP → RTC 시간 동기화 완료");
timeSyncStatus.rtcSyncCount++;
} else {
Serial.println("✗ RTC 쓰기 실패");
}
}
}
void initNTP() {
// NTP 서버 설정
configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov", "time.google.com");
// NTP 동기화 콜백 등록
sntp_set_time_sync_notification_cb(timeSyncCallback);
Serial.println("✓ NTP 클라이언트 초기화 완료");
}
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) {
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(); // Listen-Only 기본 상태
modeName = "Transmit-Only (Listen base)";
break;
default:
return false;
}
if (result == MCP2515::ERROR_OK) {
currentMcpMode = mode;
Serial.printf("✓ MCP2515 모드 변경: %s\n", modeName);
return true;
} else {
Serial.printf("✗ MCP2515 모드 변경 실패: %s (error=%d)\n", modeName, result);
return false;
}
}
// ========================================
// CAN 인터럽트 핸들러
// ========================================
void IRAM_ATTR canISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (canRxTaskHandle != NULL) {
vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
// ========================================
// Serial 수신 Task (추가)
// ========================================
void serialRxTask(void *parameter) {
SerialMessage serialMsg;
uint8_t lineBuffer[MAX_SERIAL_LINE_LEN];
uint16_t lineIndex = 0;
uint32_t lastActivity = millis();
Serial.println("✓ Serial RX Task 시작");
while (1) {
bool hasData = false;
while (SerialComm.available()) {
hasData = true;
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++;
} else {
Serial.println("! Serial Queue 전송 실패");
}
lineIndex = 0;
}
}
// 버퍼 오버플로우 방지
if (lineIndex >= MAX_SERIAL_LINE_LEN - 1) {
lineIndex = 0;
}
}
// 타임아웃: 100ms 동안 데이터가 없으면 버퍼 내용 전송
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));
}
}
// ========================================
// CAN 수신 Task
// ========================================
void canRxTask(void *parameter) {
struct can_frame frame;
CANMessage msg;
Serial.println("✓ CAN RX Task 시작");
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {
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++;
}
}
}
}
// ========================================
// SD 쓰기 Task (CAN + Serial 동시 지원)
// ========================================
void sdWriteTask(void *parameter) {
CANMessage canMsg;
SerialMessage serialMsg;
Serial.println("✓ SD Write Task 시작");
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) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (canLogFormatCSV) {
// CSV 형식 로깅
char csvLine[128];
uint64_t relativeTime = canMsg.timestamp_us - canLogStartTime;
// 데이터를 16진수 문자열로 변환
char dataStr[32];
int dataLen = 0;
for (int i = 0; i < canMsg.dlc; i++) {
dataLen += sprintf(&dataStr[dataLen], "%02X", canMsg.data[i]);
if (i < canMsg.dlc - 1) dataStr[dataLen++] = ' ';
}
dataStr[dataLen] = '\0';
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,0x%X,%d,%s\n",
relativeTime, canMsg.id, canMsg.dlc, dataStr);
if (logFile) {
logFile.write((uint8_t*)csvLine, lineLen);
currentFileSize += lineLen;
// 주기적으로 플러시 (100개마다)
static int csvFlushCounter = 0;
if (++csvFlushCounter >= 100) {
logFile.flush();
csvFlushCounter = 0;
}
}
} else {
// BIN 형식 로깅 (기존 방식)
if (bufferIndex + sizeof(CANMessage) <= FILE_BUFFER_SIZE) {
memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage));
bufferIndex += sizeof(CANMessage);
currentFileSize += sizeof(CANMessage);
}
if (bufferIndex >= FILE_BUFFER_SIZE - sizeof(CANMessage)) {
if (logFile) {
logFile.write(fileBuffer, bufferIndex);
logFile.flush();
bufferIndex = 0;
}
}
}
xSemaphoreGive(sdMutex);
}
}
}
// Serial 메시지 처리 - Queue에서는 항상 빼내고, 로깅이 활성화된 경우에만 저장
// 이렇게 해야 webUpdateTask에서 메시지를 받을 수 있음
// NOTE: Serial Queue는 webUpdateTask에서도 읽으므로 여기서는 로깅만 처리
if (!hasWork) {
vTaskDelay(pdMS_TO_TICKS(1));
}
}
}
// ========================================
// SD 모니터 Task
// ========================================
void sdMonitorTask(void *parameter) {
const TickType_t xDelay = pdMS_TO_TICKS(1000);
uint32_t lastStatusPrint = 0;
Serial.println("✓ SD Monitor Task 시작");
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;
}
// 10초마다 상태 출력 (시간 동기화 및 Queue 사용률 확인용)
if (currentTime - lastStatusPrint >= 10000) {
time_t now;
time(&now);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
uint32_t canQueueUsed = uxQueueMessagesWaiting(canQueue);
uint32_t serialQueueUsed = uxQueueMessagesWaiting(serialQueue);
float canQueuePercent = (float)canQueueUsed / CAN_QUEUE_SIZE * 100.0;
float serialQueuePercent = (float)serialQueueUsed / SERIAL_QUEUE_SIZE * 100.0;
Serial.printf("[상태] %04d-%02d-%02d %02d:%02d:%02d | CAN: %u msg/s | CAN큐: %u/%u (%.1f%%) | Serial큐: %u/%u (%.1f%%) | TimeSync: %s | RTC동기: %u회\n",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec,
msgPerSecond,
canQueueUsed, CAN_QUEUE_SIZE, canQueuePercent,
serialQueueUsed, SERIAL_QUEUE_SIZE, serialQueuePercent,
timeSyncStatus.synchronized ? "OK" : "NO",
timeSyncStatus.rtcSyncCount);
// Queue 사용률 경고 (90% 이상)
if (canQueuePercent >= 90.0) {
Serial.printf("⚠️ 경고: CAN Queue 사용률 %.1f%% - SD 카드 속도 확인 필요!\n", canQueuePercent);
}
if (serialQueuePercent >= 90.0) {
Serial.printf("⚠️ 경고: Serial Queue 사용률 %.1f%% - SD 카드 속도 확인 필요!\n", serialQueuePercent);
}
lastStatusPrint = currentTime;
}
vTaskDelay(xDelay);
}
}
// ========================================
// 파일 커멘트 관리
// ========================================
void saveFileComments() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File commentFile = SD.open("/comments.dat", FILE_WRITE);
if (commentFile) {
commentFile.write((uint8_t*)&commentCount, sizeof(commentCount));
commentFile.write((uint8_t*)fileComments, sizeof(FileComment) * commentCount);
commentFile.close();
Serial.println("✓ 파일 커멘트 저장 완료");
}
xSemaphoreGive(sdMutex);
}
}
void loadFileComments() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD.exists("/comments.dat")) {
File commentFile = SD.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();
Serial.printf("✓ 파일 커멘트 %d개 로드 완료\n", commentCount);
}
}
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();
}
}
// ========================================
// CAN 시퀀스 관리
// ========================================
void saveSequences() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File seqFile = SD.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.println("✓ 시퀀스 저장 완료");
}
xSemaphoreGive(sdMutex);
}
}
void loadSequences() {
if (!sdCardReady) return;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD.exists("/sequences.dat")) {
File seqFile = SD.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();
Serial.printf("✓ 시퀀스 %d개 로드 완료\n", sequenceCount);
}
}
xSemaphoreGive(sdMutex);
}
}
// ========================================
// CAN TX Task
// ========================================
void txTask(void *parameter) {
struct can_frame frame;
Serial.println("✓ TX Task 시작");
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) {
// Transmit-Only 모드: 송신 전 Normal 모드로 전환
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;
}
// Transmit-Only 모드: 송신 후 Listen-Only로 복귀
if (currentMcpMode == MCP_MODE_TRANSMIT) {
mcp2515.setListenOnlyMode();
}
}
}
}
if (anyActive) {
vTaskDelay(pdMS_TO_TICKS(1));
} else {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
}
// ========================================
// 시퀀스 Task
// ========================================
void sequenceTask(void *parameter) {
Serial.println("✓ Sequence Task 시작");
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) {
// Transmit-Only 모드: 송신 전 Normal 모드로 전환
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++;
}
// Transmit-Only 모드: 송신 후 Listen-Only로 복귀
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 이벤트 처리 (Settings 명령 추가)
// ========================================
void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
if (type == WStype_TEXT) {
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("✗ JSON 파싱 실패: ");
Serial.println(error.c_str());
return;
}
const char* cmd = doc["cmd"];
// ========================================
// Settings 페이지 명령 처리 (추가)
// ========================================
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);
Serial.println("✓ 설정 전송 완료");
}
else if (strcmp(cmd, "saveSettings") == 0) {
// WiFi 설정 저장
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);
Serial.println("✓ 설정 저장 완료 (재부팅 필요)");
}
// ========================================
// 기존 명령들
// ========================================
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.open(currentFilename, FILE_WRITE);
if (logFile) {
// CSV 형식이면 헤더 작성
if (canLogFormatCSV) {
logFile.println("Time_us,CAN_ID,DLC,Data");
}
loggingEnabled = true;
bufferIndex = 0;
currentFileSize = logFile.size();
Serial.printf("✓ CAN 로깅 시작: %s (%s)\n",
currentFilename, canLogFormatCSV ? "CSV" : "BIN");
} else {
Serial.println("✗ 파일 생성 실패");
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopLogging") == 0) {
if (loggingEnabled) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (bufferIndex > 0 && logFile) {
logFile.write(fileBuffer, bufferIndex);
bufferIndex = 0;
}
if (logFile) {
logFile.close();
}
loggingEnabled = false;
Serial.printf("✓ CAN 로깅 종료: %s (%u bytes)\n", currentFilename, currentFileSize);
xSemaphoreGive(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; // 기본값 CSV
}
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.open(currentSerialFilename, FILE_WRITE);
if (serialLogFile) {
// CSV 형식이면 헤더 작성
if (serialLogFormatCSV) {
serialLogFile.println("Time_us,Direction,Data");
}
serialLoggingEnabled = true;
serialCsvIndex = 0;
currentSerialFileSize = serialLogFile.size();
Serial.printf("✓ Serial 로깅 시작: %s (%s)\n",
currentSerialFilename, serialLogFormatCSV ? "CSV" : "BIN");
} else {
Serial.println("✗ Serial 파일 생성 실패");
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopSerialLogging") == 0) {
if (serialLoggingEnabled) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
// 남은 CSV 버퍼 내용 쓰기
if (serialCsvIndex > 0 && serialLogFile) {
serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex);
serialCsvIndex = 0;
}
if (serialLogFile) {
serialLogFile.close();
}
serialLoggingEnabled = false;
Serial.printf("✓ Serial 로깅 종료: %s (%u bytes)\n",
currentSerialFilename, currentSerialFileSize);
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
// UART로 데이터 전송
SerialComm.println(data);
// Queue에 TX 메시지 추가 (모니터링용)
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; // \r\n 포함
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++;
Serial.printf("✓ Serial TX Queue 전송: %s\n", data);
} else {
Serial.printf("✗ Serial TX Queue 전송 실패: %s\n", data);
}
}
}
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();
Serial.printf("✓ Serial 설정 변경: %u-%u-%u-%u\n", baud, data, parity, stop);
}
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.reset();
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
setMCP2515Mode(currentMcpMode);
saveSettings();
Serial.printf("✓ CAN 속도 변경: %s\n", canSpeedNames[speedIndex]);
}
}
else if (strcmp(cmd, "setMcpMode") == 0) {
int mode = doc["mode"];
if (mode >= 0 && mode <= 3) {
setMCP2515Mode((MCP2515Mode)mode);
saveSettings();
}
}
else if (strcmp(cmd, "syncTime") == 0) {
uint64_t phoneTime = doc["time"];
if (phoneTime > 0) {
time_t seconds = phoneTime / 1000;
suseconds_t microseconds = (phoneTime % 1000) * 1000;
struct timeval tv = {seconds, microseconds};
settimeofday(&tv, NULL);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = phoneTime * 1000;
timeSyncStatus.syncCount++;
if (timeSyncStatus.rtcAvailable) {
struct tm timeinfo;
localtime_r(&seconds, &timeinfo);
writeRTC(&timeinfo);
Serial.println("✓ 시간 동기화 완료 (Phone → ESP32 → RTC)");
} else {
Serial.println("✓ 시간 동기화 완료 (Phone → ESP32)");
}
}
}
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);
Serial.printf("✓ 시간 동기화 완료: %04d-%02d-%02d %02d:%02d:%02d (Phone → ESP32 → RTC)\n",
year, month, day, hour, minute, second);
} else {
Serial.printf("✓ 시간 동기화 완료: %04d-%02d-%02d %02d:%02d:%02d (Phone → ESP32)\n",
year, month, day, hour, minute, second);
}
}
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("list");
File root = SD.open("/");
File file = root.openNextFile();
while (file) {
if (!file.isDirectory()) {
const char* filename = file.name();
if (filename[0] != '.' &&
strcmp(filename, "System Volume Information") != 0) {
JsonObject fileObj = files.createNestedObject();
fileObj["name"] = filename;
fileObj["size"] = file.size();
const char* comment = getFileComment(filename);
if (strlen(comment) > 0) {
fileObj["comment"] = comment;
}
}
}
file = root.openNextFile();
}
xSemaphoreGive(sdMutex);
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) {
if (loggingEnabled && currentFilename[0] != '\0') {
String currentFileStr = String(currentFilename);
if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1);
if (strcmp(filename, currentFileStr.c_str()) == 0) {
DynamicJsonDocument response(256);
response["type"] = "deleteResult";
response["success"] = false;
response["message"] = "Cannot delete file currently being logged";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
return;
}
}
String fullPath = "/" + String(filename);
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
bool success = false;
String message = "";
if (SD.exists(fullPath)) {
if (SD.remove(fullPath)) {
success = true;
message = "File deleted successfully";
Serial.printf("✓ 파일 삭제: %s\n", filename);
} else {
message = "Failed to delete file";
}
} else {
message = "File not found";
}
xSemaphoreGive(sdMutex);
DynamicJsonDocument response(256);
response["type"] = "deleteResult";
response["success"] = success;
response["message"] = message;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
if (success) {
vTaskDelay(pdMS_TO_TICKS(100));
DynamicJsonDocument filesDoc(6144);
filesDoc["type"] = "files";
JsonArray files = filesDoc.createNestedArray("list");
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
File root = SD.open("/");
File file = root.openNextFile();
while (file) {
if (!file.isDirectory()) {
const char* fname = file.name();
if (fname[0] != '.' &&
strcmp(fname, "System Volume Information") != 0) {
JsonObject fileObj = files.createNestedObject();
fileObj["name"] = fname;
fileObj["size"] = file.size();
const char* comment = getFileComment(fname);
if (strlen(comment) > 0) {
fileObj["comment"] = comment;
}
}
}
file = root.openNextFile();
}
xSemaphoreGive(sdMutex);
}
String filesJson;
serializeJson(filesDoc, filesJson);
webSocket.sendTXT(num, filesJson);
}
}
}
}
else if (strcmp(cmd, "addComment") == 0) {
const char* filename = doc["filename"];
const char* comment = doc["comment"];
if (filename && comment) {
addFileComment(filename, comment);
Serial.printf("✓ 커멘트 추가: %s\n", filename);
vTaskDelay(pdMS_TO_TICKS(100));
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
DynamicJsonDocument response(6144);
response["type"] = "files";
JsonArray files = response.createNestedArray("list");
File root = SD.open("/");
File file = root.openNextFile();
while (file) {
if (!file.isDirectory()) {
const char* fname = file.name();
if (fname[0] != '.' &&
strcmp(fname, "System Volume Information") != 0) {
JsonObject fileObj = files.createNestedObject();
fileObj["name"] = fname;
fileObj["size"] = file.size();
const char* fcomment = getFileComment(fname);
if (strlen(fcomment) > 0) {
fileObj["comment"] = fcomment;
}
}
}
file = root.openNextFile();
}
xSemaphoreGive(sdMutex);
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
}
else if (strcmp(cmd, "addTx") == 0) {
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
if (!txMessages[i].active) {
txMessages[i].id = strtoul(doc["id"], NULL, 16);
txMessages[i].extended = doc["ext"] | false;
txMessages[i].dlc = doc["dlc"] | 8;
JsonArray dataArray = doc["data"];
for (int j = 0; j < 8; j++) {
txMessages[i].data[j] = dataArray[j] | 0;
}
txMessages[i].interval = doc["interval"] | 1000;
txMessages[i].active = true;
txMessages[i].lastSent = 0;
Serial.printf("✓ TX 메시지 추가: ID=0x%X\n", txMessages[i].id);
break;
}
}
}
else if (strcmp(cmd, "removeTx") == 0) {
uint32_t id = strtoul(doc["id"], NULL, 16);
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
if (txMessages[i].active && txMessages[i].id == id) {
txMessages[i].active = false;
Serial.printf("✓ TX 메시지 제거: ID=0x%X\n", id);
break;
}
}
}
else if (strcmp(cmd, "updateTx") == 0) {
uint32_t id = strtoul(doc["id"], NULL, 16);
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
if (txMessages[i].active && txMessages[i].id == id) {
txMessages[i].interval = doc["interval"];
Serial.printf("✓ TX 주기 변경: ID=0x%X, Interval=%u\n",
id, txMessages[i].interval);
break;
}
}
}
else if (strcmp(cmd, "sendOnce") == 0) {
// Transmit-Only 모드: 송신 전 Normal 모드로 전환
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++;
Serial.printf("✓ CAN 메시지 전송: ID=0x%X\n", frame.can_id & 0x1FFFFFFF);
}
// Transmit-Only 모드: 송신 후 Listen-Only로 복귀
if (currentMcpMode == MCP_MODE_TRANSMIT) {
mcp2515.setListenOnlyMode();
}
}
else if (strcmp(cmd, "addSequence") == 0) {
if (sequenceCount < MAX_SEQUENCES) {
CANSequence* seq = &sequences[sequenceCount];
strncpy(seq->name, doc["name"] | "Unnamed", sizeof(seq->name) - 1);
seq->name[sizeof(seq->name) - 1] = '\0';
JsonArray stepsArray = doc["steps"];
seq->stepCount = min((int)stepsArray.size(), 20);
for (int i = 0; i < seq->stepCount; i++) {
JsonObject stepObj = stepsArray[i];
seq->steps[i].canId = strtoul(stepObj["id"], NULL, 16);
seq->steps[i].extended = stepObj["ext"] | false;
seq->steps[i].dlc = stepObj["dlc"] | 8;
JsonArray dataArray = stepObj["data"];
for (int j = 0; j < 8; j++) {
seq->steps[i].data[j] = dataArray[j] | 0;
}
seq->steps[i].delayMs = stepObj["delay"] | 0;
}
seq->repeatMode = doc["repeatMode"] | 0;
seq->repeatCount = doc["repeatCount"] | 1;
sequenceCount++;
saveSequences();
Serial.printf("✓ 시퀀스 추가: %s (%d steps)\n", seq->name, seq->stepCount);
}
}
else if (strcmp(cmd, "removeSequence") == 0) {
int index = doc["index"];
if (index >= 0 && index < sequenceCount) {
for (int i = index; i < sequenceCount - 1; i++) {
sequences[i] = sequences[i + 1];
}
sequenceCount--;
saveSequences();
Serial.printf("✓ 시퀀스 삭제: index=%d\n", index);
}
}
else if (strcmp(cmd, "startSequence") == 0) {
int index = doc["index"];
if (index >= 0 && index < sequenceCount && !seqRuntime.running) {
seqRuntime.running = true;
seqRuntime.currentStep = 0;
seqRuntime.currentRepeat = 0;
seqRuntime.lastStepTime = millis();
seqRuntime.activeSequenceIndex = index;
Serial.printf("✓ 시퀀스 시작: %s\n", sequences[index].name);
}
}
else if (strcmp(cmd, "stopSequence") == 0) {
if (seqRuntime.running) {
seqRuntime.running = false;
Serial.println("✓ 시퀀스 중지");
}
}
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);
}
}
}
// ========================================
// 웹 업데이트 Task (Serial 데이터 전송 추가)
// ========================================
void webUpdateTask(void *parameter) {
const TickType_t xDelay = pdMS_TO_TICKS(100);
Serial.println("✓ Web Update Task 시작");
while (1) {
webSocket.loop();
if (webSocket.connectedClients() > 0) {
DynamicJsonDocument doc(4096); // 3072 → 4096으로 증가 (Serial 메시지 포함)
doc["type"] = "update";
doc["logging"] = loggingEnabled;
doc["serialLogging"] = serialLoggingEnabled;
doc["sdReady"] = sdCardReady;
doc["totalMsg"] = totalMsgCount;
doc["msgPerSec"] = msgPerSecond;
doc["totalTx"] = totalTxCount;
doc["totalSerialRx"] = totalSerialRxCount;
doc["totalSerialTx"] = totalSerialTxCount;
doc["fileSize"] = currentFileSize;
doc["serialFileSize"] = currentSerialFileSize;
doc["queueUsed"] = uxQueueMessagesWaiting(canQueue);
doc["queueSize"] = CAN_QUEUE_SIZE;
doc["serialQueueUsed"] = uxQueueMessagesWaiting(serialQueue);
doc["serialQueueSize"] = SERIAL_QUEUE_SIZE;
doc["timeSync"] = timeSyncStatus.synchronized;
doc["rtcAvail"] = timeSyncStatus.rtcAvailable;
doc["rtcSyncCnt"] = timeSyncStatus.rtcSyncCount;
doc["syncCount"] = timeSyncStatus.syncCount; // 전체 동기화 횟수 (NTP + 수동)
doc["voltage"] = powerStatus.voltage;
doc["minVoltage"] = powerStatus.minVoltage;
doc["lowVoltage"] = powerStatus.lowVoltage;
doc["mcpMode"] = (int)currentMcpMode;
// 현재 로깅 파일명 추가
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 메시지 배열
JsonArray messages = doc.createNestedArray("messages");
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
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]);
}
}
}
// Serial 메시지 배열 (Queue에서 읽기)
SerialMessage serialMsg;
JsonArray serialMessages = doc.createNestedArray("serialMessages");
int serialCount = 0;
// Queue에서 최대 10개의 Serial 메시지 읽기
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 로깅이 활성화되어 있으면 SD 카드에 저장
if (serialLoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serialLogFormatCSV) {
// CSV 형식 로깅 (상대 시간 사용)
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);
// CSV 버퍼에 추가
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 {
// BIN 형식 로깅 (기존 방식)
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);
}
}
}
String json;
serializeJson(doc, json);
webSocket.broadcastTXT(json);
}
vTaskDelay(xDelay);
}
}
// ========================================
// Setup
// ========================================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n========================================");
Serial.println(" Byun CAN Logger + Serial Terminal");
Serial.println(" Version 2.3 - ESP32-S3 Optimized");
Serial.println(" 8MB PSRAM High Performance Edition");
Serial.println("========================================\n");
// PSRAM 확인 (ESP32-S3 N16R8)
if (psramFound()) {
Serial.printf("✓ PSRAM 감지: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
Serial.printf("✓ PSRAM 여유: %d KB\n", ESP.getFreePsram() / 1024);
} else {
Serial.println("✗ PSRAM 없음 - Arduino IDE에서 PSRAM: OPI PSRAM 설정 필요!");
Serial.println("✗ Tools → PSRAM → OPI PSRAM 선택");
while (1) {
delay(1000);
Serial.println("✗ PSRAM 설정 후 재업로드 필요!");
}
}
loadSettings();
analogSetPinAttenuation(MONITORING_VOLT, ADC_11db);
Serial.println("💡 설정 변경: http://[IP]/settings\n");
memset(recentData, 0, sizeof(recentData));
memset(txMessages, 0, sizeof(txMessages));
memset(fileComments, 0, sizeof(fileComments));
pinMode(CAN_INT_PIN, INPUT_PULLUP);
// ADC 설정
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();
pinMode(VSPI_CS, OUTPUT);
digitalWrite(VSPI_CS, HIGH);
delay(100);
vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS);
vspi.setFrequency(40000000);
Serial.println("✓ SPI 초기화 완료");
// Watchdog 완전 비활성화
Serial.println("Watchdog 비활성화...");
esp_task_wdt_deinit();
Serial.println("✓ Watchdog 비활성화 완료");
// MCP2515 초기화
Serial.println("MCP2515 초기화 중...");
mcp2515.reset();
delay(50);
MCP2515::ERROR result = mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
if (result != MCP2515::ERROR_OK) {
Serial.printf("! MCP2515 Bitrate 설정 실패: %d (계속 진행)\n", result);
}
mcp2515.setNormalMode();
Serial.println("✓ MCP2515 초기화 완료");
// Serial 통신 초기화
applySerialSettings();
Serial.println("✓ Serial 통신 초기화 완료 (UART1)");
// Mutex 생성
sdMutex = xSemaphoreCreateMutex();
rtcMutex = xSemaphoreCreateMutex();
serialMutex = xSemaphoreCreateMutex();
if (sdMutex == NULL || rtcMutex == NULL || serialMutex == NULL) {
Serial.println("✗ Mutex 생성 실패!");
while (1) delay(1000);
}
// RTC 초기화
initRTC();
// SD 카드 초기화
if (SD.begin(VSPI_CS, vspi)) {
sdCardReady = true;
Serial.println("✓ SD 카드 초기화 완료");
loadFileComments();
} else {
Serial.println("✗ SD 카드 초기화 실패");
}
// WiFi 설정
if (enableSTAMode && strlen(staSSID) > 0) {
Serial.println("\n📶 WiFi APSTA 모드 시작...");
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
Serial.print("✓ AP SSID: ");
Serial.println(wifiSSID);
Serial.print("✓ AP IP: ");
Serial.println(WiFi.softAPIP());
Serial.printf("📡 WiFi 연결 시도: %s\n", staSSID);
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.println("✓ WiFi 연결 성공!");
Serial.print("✓ STA IP: ");
Serial.println(WiFi.localIP());
// NTP 시간 동기화 시작
initNTP();
// NTP 동기화 대기 (최대 5초)
Serial.print("⏱ NTP 시간 동기화 대기 중");
for (int i = 0; i < 10; i++) {
delay(500);
Serial.print(".");
if (timeSyncStatus.synchronized) {
Serial.println(" 완료!");
break;
}
}
if (!timeSyncStatus.synchronized) {
Serial.println(" 시간 초과 (계속 진행)");
}
} else {
Serial.println("✗ WiFi 연결 실패 (AP 모드는 정상 동작)");
}
} else {
Serial.println("\n📶 WiFi AP 모드 시작...");
WiFi.mode(WIFI_AP);
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
Serial.print("✓ AP SSID: ");
Serial.println(wifiSSID);
Serial.print("✓ AP IP: ");
Serial.println(WiFi.softAPIP());
}
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("/download", HTTP_GET, []() {
if (server.hasArg("file")) {
String filename = "/" + server.arg("file");
if (SD.exists(filename)) {
File file = SD.open(filename, FILE_READ);
if (file) {
String displayName = server.arg("file");
server.sendHeader("Content-Disposition",
"attachment; filename=\"" + displayName + "\"");
server.sendHeader("Content-Type", "application/octet-stream");
server.streamFile(file, "application/octet-stream");
file.close();
} else {
server.send(500, "text/plain", "Failed to open file");
}
} else {
server.send(404, "text/plain", "File not found");
}
} else {
server.send(400, "text/plain", "Bad request");
}
});
server.on("/delete", HTTP_GET, []() {
if (server.hasArg("file")) {
String filename = server.arg("file");
if (loggingEnabled && currentFilename[0] != '\0') {
String currentFileStr = String(currentFilename);
if (currentFileStr.startsWith("/")) currentFileStr = currentFileStr.substring(1);
if (filename == currentFileStr) {
server.send(403, "text/plain", "Cannot delete file currently being logged");
return;
}
}
String fullPath = "/" + filename;
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (SD.exists(fullPath)) {
if (SD.remove(fullPath)) {
xSemaphoreGive(sdMutex);
server.send(200, "text/plain", "File deleted successfully");
Serial.printf("✓ HTTP 파일 삭제: %s\n", filename.c_str());
} else {
xSemaphoreGive(sdMutex);
server.send(500, "text/plain", "Failed to delete file");
}
} else {
xSemaphoreGive(sdMutex);
server.send(404, "text/plain", "File not found");
}
} else {
server.send(503, "text/plain", "SD card busy");
}
} else {
server.send(400, "text/plain", "Bad request");
}
});
server.begin();
// Queue 생성 (Dynamic - 크기 축소)
Serial.println("Queue 생성 중...");
// CAN Queue: 1,000개 × 21 bytes = 21 KB
canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage));
// Serial Queue: 200개 × 75 bytes = 15 KB
serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage));
if (canQueue == NULL || serialQueue == NULL) {
Serial.println("✗ Queue 생성 실패!");
Serial.println("\n✗ 시스템 중지");
Serial.println(" 메모리 부족 - Queue 크기를 더 줄이거나");
Serial.println(" 불필요한 변수를 제거하세요");
while (1) delay(1000);
}
Serial.printf("✓ CAN Queue: %d개 (%.1f KB)\n", CAN_QUEUE_SIZE,
(float)(CAN_QUEUE_SIZE * sizeof(CANMessage)) / 1024.0);
Serial.printf("✓ Serial Queue: %d개 (%.1f KB)\n", SERIAL_QUEUE_SIZE,
(float)(SERIAL_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0);
Serial.printf("✓ 총 Queue 메모리: %.1f KB\n\n",
(float)(CAN_QUEUE_SIZE * sizeof(CANMessage) + SERIAL_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0);
// CAN 인터럽트 활성화
attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING);
// Task 생성 (ESP32-S3 듀얼코어 최적화)
// Core 1 (사용자 전용 - 고성능 작업)
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 6, &canRxTaskHandle, 1); // 최고 우선순위
xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 24576, NULL, 4, &sdWriteTaskHandle, 1); // 높음 (큰 버퍼)
xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4096, NULL, 2, NULL, 1); // 보통
// Core 0 (WiFi/시스템 공유)
xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 5, &serialRxTaskHandle, 0); // 높음
xTaskCreatePinnedToCore(txTask, "TX_TASK", 4096, NULL, 3, NULL, 0); // 보통-높음
xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 10240, NULL, 2, &webTaskHandle, 0); // 보통 (JSON 버퍼)
xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0); // 낮음
// RTC 동기화 Task
if (timeSyncStatus.rtcAvailable) {
xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0); // 최저
Serial.println("✓ RTC 자동 동기화 Task 시작");
}
// 시퀀스 로드
loadSequences();
Serial.println("✓ 모든 태스크 시작 완료");
Serial.println("\n========================================");
Serial.println(" 웹 인터페이스 접속 방법");
Serial.println("========================================");
Serial.printf(" 1. WiFi 연결\n");
Serial.printf(" - SSID : %s\n", wifiSSID);
Serial.printf(" - Password : %s\n", wifiPassword);
Serial.printf(" 2. 웹 브라우저에서 접속\n");
Serial.print(" - URL : http://");
Serial.println(WiFi.softAPIP());
Serial.println(" 3. 페이지 메뉴:");
Serial.println(" - Monitor : /");
Serial.println(" - Transmit : /transmit");
Serial.println(" - Graph : /graph");
Serial.println(" - Settings : /settings");
Serial.println(" - Serial : /serial");
Serial.println("========================================\n");
}
void loop() {
server.handleClient();
vTaskDelay(pdMS_TO_TICKS(10));
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 30000) {
Serial.printf("[상태] CAN큐: %d/%d | Serial큐: %d/%d | CAN로깅: %s | Serial로깅: %s | SD: %s | CAN RX: %lu | CAN TX: %lu | Serial RX: %lu | Serial TX: %lu | 모드: %d\n",
uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE,
uxQueueMessagesWaiting(serialQueue), SERIAL_QUEUE_SIZE,
loggingEnabled ? "ON " : "OFF",
serialLoggingEnabled ? "ON " : "OFF",
sdCardReady ? "OK" : "NO",
totalMsgCount, totalTxCount,
totalSerialRxCount, totalSerialTxCount,
currentMcpMode);
lastPrint = millis();
}
}