Files
esp32s3_canlogger_mcp2515/ESP32_CAN_Logger-a.ino

2258 lines
86 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/*
* Byun CAN Logger with Web Interface + Serial Terminal
* Version: 2.3 - PSRAM Optimized (Complete Version)
*
* PSRAM 최적화 완전판:
* - 원본 기능 100% 유지
* - 대용량 버퍼/Queue를 PSRAM에 할당
* - 웹서버, WebSocket, 모든 Task 포함
*
* Arduino IDE 설정:
* - Board: ESP32S3 Dev Module
* - PSRAM: OPI PSRAM ⭐ 필수!
* - Flash Size: 16MB (128Mb)
* - Partition: 16MB Flash (3MB APP/9.9MB FATFS)
*/
#include <Arduino.h>
#include <SPI.h>
#include <mcp2515.h>
#include <SoftWire.h>
#include <SD.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"
#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
// VSPI 핀 (SD Card)
#define VSPI_MISO 41
#define VSPI_MOSI 40
#define VSPI_SCLK 39
#define VSPI_CS 42
// 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);
MCP2515 mcp2515(HSPI_CS, 20000000, &hspi);
HardwareSerial SerialComm(1); // UART1
HardwareSerial Serial2Comm(2); // ⭐ UART2 추가
WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);
Preferences preferences;
// FreeRTOS 핸들
QueueHandle_t canQueue = NULL;
QueueHandle_t serialQueue = NULL;
QueueHandle_t serial2Queue = NULL; // ⭐ Serial2 추가
SemaphoreHandle_t sdMutex = NULL;
SemaphoreHandle_t rtcMutex = NULL;
SemaphoreHandle_t serialMutex = NULL;
SemaphoreHandle_t serial2Mutex = NULL; // ⭐ Serial2 추가
TaskHandle_t canRxTaskHandle = NULL;
TaskHandle_t sdWriteTaskHandle = NULL;
TaskHandle_t webTaskHandle = NULL;
TaskHandle_t rtcTaskHandle = NULL;
TaskHandle_t serialRxTaskHandle = NULL;
TaskHandle_t serial2RxTaskHandle = NULL; // ⭐ Serial2 추가
// 로깅 변수
volatile bool loggingEnabled = false;
volatile bool serialLoggingEnabled = false;
volatile bool serial2LoggingEnabled = false; // ⭐ Serial2 추가
volatile bool sdCardReady = false;
File logFile;
File serialLogFile;
File serial2LogFile; // ⭐ Serial2 추가
char currentFilename[MAX_FILENAME_LEN];
char currentSerialFilename[MAX_FILENAME_LEN];
char currentSerial2Filename[MAX_FILENAME_LEN]; // ⭐ Serial2 추가
uint32_t bufferIndex = 0; // ⭐ uint16_t → uint32_t (오버플로우 방지)
uint32_t serialCsvIndex = 0; // ⭐ uint16_t → uint32_t
uint32_t serial2CsvIndex = 0; // ⭐ uint16_t → uint32_t // ⭐ Serial2 추가
volatile uint32_t currentFileSize = 0;
volatile uint32_t currentSerialFileSize = 0;
volatile uint32_t currentSerial2FileSize = 0; // ⭐ Serial2 추가
volatile bool canLogFormatCSV = false;
volatile bool serialLogFormatCSV = true;
volatile bool serial2LogFormatCSV = true; // ⭐ Serial2 추가
volatile uint64_t canLogStartTime = 0;
volatile uint64_t serialLogStartTime = 0;
volatile uint64_t serial2LogStartTime = 0; // ⭐ Serial2 추가
// 기타 전역 변수
MCP2515Mode currentMcpMode = MCP_MODE_NORMAL;
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);
// ========================================
// PSRAM 초기화 함수
// ========================================
bool initPSRAM() {
Serial.println("\n========================================");
Serial.println(" PSRAM 메모리 할당");
Serial.println("========================================");
if (!psramFound()) {
Serial.println("✗ PSRAM을 찾을 수 없습니다!");
Serial.println("✗ Arduino IDE 설정:");
Serial.println(" Tools → PSRAM → OPI PSRAM");
return false;
}
Serial.printf("✓ PSRAM 총 용량: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
Serial.printf("✓ PSRAM 여유: %d KB\n\n", ESP.getFreePsram() / 1024);
// 버퍼 할당
fileBuffer = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
if (!fileBuffer) {
Serial.println("✗ fileBuffer 할당 실패");
return false;
}
Serial.printf("✓ fileBuffer: %d KB\n", FILE_BUFFER_SIZE / 1024);
serialCsvBuffer = (char*)ps_malloc(SERIAL_CSV_BUFFER_SIZE);
if (!serialCsvBuffer) {
Serial.println("✗ serialCsvBuffer 할당 실패");
return false;
}
Serial.printf("✓ serialCsvBuffer: %d KB\n", SERIAL_CSV_BUFFER_SIZE / 1024);
// ⭐ Serial2 CSV Buffer
serial2CsvBuffer = (char*)ps_malloc(SERIAL2_CSV_BUFFER_SIZE);
if (!serial2CsvBuffer) {
Serial.println("✗ serial2CsvBuffer 할당 실패");
return false;
}
Serial.printf("✓ serial2CsvBuffer: %d KB\n", SERIAL2_CSV_BUFFER_SIZE / 1024);
recentData = (RecentCANData*)ps_calloc(RECENT_MSG_COUNT, sizeof(RecentCANData));
if (!recentData) {
Serial.println("✗ recentData 할당 실패");
return false;
}
Serial.printf("✓ recentData: %.2f KB\n", (float)(RECENT_MSG_COUNT * sizeof(RecentCANData)) / 1024.0);
txMessages = (TxMessage*)ps_calloc(MAX_TX_MESSAGES, sizeof(TxMessage));
if (!txMessages) {
Serial.println("✗ txMessages 할당 실패");
return false;
}
Serial.printf("✓ txMessages: %.2f KB\n", (float)(MAX_TX_MESSAGES * sizeof(TxMessage)) / 1024.0);
sequences = (CANSequence*)ps_calloc(MAX_SEQUENCES, sizeof(CANSequence));
if (!sequences) {
Serial.println("✗ sequences 할당 실패");
return false;
}
Serial.printf("✓ sequences: %.2f KB\n", (float)(MAX_SEQUENCES * sizeof(CANSequence)) / 1024.0);
fileComments = (FileComment*)ps_calloc(MAX_FILE_COMMENTS, sizeof(FileComment));
if (!fileComments) {
Serial.println("✗ fileComments 할당 실패");
return false;
}
Serial.printf("✓ fileComments: %.2f KB\n", (float)(MAX_FILE_COMMENTS * sizeof(FileComment)) / 1024.0);
// Queue 저장소 할당
Serial.println("\n📦 Queue 저장소 할당...");
canQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t));
canQueueStorage = (uint8_t*)ps_malloc(CAN_QUEUE_SIZE * sizeof(CANMessage));
if (!canQueueBuffer || !canQueueStorage) {
Serial.println("✗ CAN Queue 저장소 할당 실패");
return false;
}
Serial.printf("✓ CAN Queue: %d 개 × %d bytes = %.2f KB\n",
CAN_QUEUE_SIZE, sizeof(CANMessage),
(float)(CAN_QUEUE_SIZE * sizeof(CANMessage)) / 1024.0);
serialQueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t));
serialQueueStorage = (uint8_t*)ps_malloc(SERIAL_QUEUE_SIZE * sizeof(SerialMessage));
if (!serialQueueBuffer || !serialQueueStorage) {
Serial.println("✗ Serial Queue 저장소 할당 실패");
return false;
}
Serial.printf("✓ Serial Queue: %d 개 × %d bytes = %.2f KB\n",
SERIAL_QUEUE_SIZE, sizeof(SerialMessage),
(float)(SERIAL_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0);
// ⭐ Serial2 Queue
serial2QueueBuffer = (StaticQueue_t*)ps_malloc(sizeof(StaticQueue_t));
serial2QueueStorage = (uint8_t*)ps_malloc(SERIAL2_QUEUE_SIZE * sizeof(SerialMessage));
if (!serial2QueueBuffer || !serial2QueueStorage) {
Serial.println("✗ Serial2 Queue 저장소 할당 실패");
return false;
}
Serial.printf("✓ Serial2 Queue: %d 개 × %d bytes = %.2f KB\n",
SERIAL2_QUEUE_SIZE, sizeof(SerialMessage),
(float)(SERIAL2_QUEUE_SIZE * sizeof(SerialMessage)) / 1024.0);
Serial.println("========================================");
Serial.printf("✓ PSRAM 남은 용량: %.2f KB\n", (float)ESP.getFreePsram() / 1024.0);
Serial.println("========================================\n");
return true;
}
bool createQueues() {
Serial.println("Queue 생성 (PSRAM 사용)...");
canQueue = xQueueCreateStatic(
CAN_QUEUE_SIZE,
sizeof(CANMessage),
canQueueStorage,
canQueueBuffer
);
if (canQueue == NULL) {
Serial.println("✗ CAN Queue 생성 실패");
return false;
}
Serial.printf("✓ CAN Queue: %d 개\n", CAN_QUEUE_SIZE);
serialQueue = xQueueCreateStatic(
SERIAL_QUEUE_SIZE,
sizeof(SerialMessage),
serialQueueStorage,
serialQueueBuffer
);
if (serialQueue == NULL) {
Serial.println("✗ Serial Queue 생성 실패");
return false;
}
Serial.printf("✓ Serial Queue: %d 개\n", SERIAL_QUEUE_SIZE);
// ⭐ Serial2 Queue 생성 (중요!)
serial2Queue = xQueueCreateStatic(
SERIAL2_QUEUE_SIZE,
sizeof(SerialMessage),
serial2QueueStorage,
serial2QueueBuffer
);
if (serial2Queue == NULL) {
Serial.println("✗ Serial2 Queue 생성 실패");
return false;
}
Serial.printf("✓ Serial2 Queue: %d 개\n\n", SERIAL2_QUEUE_SIZE);
return true;
}
// ========================================
// 설정 저장/로드 함수
// ========================================
void loadSerialSettings() {
serialSettings.baudRate = preferences.getUInt("ser_baud", 115200);
serialSettings.dataBits = preferences.getUChar("ser_data", 8);
serialSettings.parity = preferences.getUChar("ser_parity", 0);
serialSettings.stopBits = preferences.getUChar("ser_stop", 1);
// ⭐ Serial2
serial2Settings.baudRate = preferences.getUInt("ser2_baud", 115200);
serial2Settings.dataBits = preferences.getUChar("ser2_data", 8);
serial2Settings.parity = preferences.getUChar("ser2_parity", 0);
serial2Settings.stopBits = preferences.getUChar("ser2_stop", 1);
}
void saveSerialSettings() {
preferences.putUInt("ser_baud", serialSettings.baudRate);
preferences.putUChar("ser_data", serialSettings.dataBits);
preferences.putUChar("ser_parity", serialSettings.parity);
preferences.putUChar("ser_stop", serialSettings.stopBits);
// ⭐ Serial2
preferences.putUInt("ser2_baud", serial2Settings.baudRate);
preferences.putUChar("ser2_data", serial2Settings.dataBits);
preferences.putUChar("ser2_parity", serial2Settings.parity);
preferences.putUChar("ser2_stop", serial2Settings.stopBits);
}
void applySerialSettings() {
uint32_t config = SERIAL_8N1;
if (serialSettings.dataBits == 5) {
if (serialSettings.parity == 0) config = SERIAL_5N1;
else if (serialSettings.parity == 1) config = SERIAL_5E1;
else if (serialSettings.parity == 2) config = SERIAL_5O1;
} else if (serialSettings.dataBits == 6) {
if (serialSettings.parity == 0) config = SERIAL_6N1;
else if (serialSettings.parity == 1) config = SERIAL_6E1;
else if (serialSettings.parity == 2) config = SERIAL_6O1;
} else if (serialSettings.dataBits == 7) {
if (serialSettings.parity == 0) config = SERIAL_7N1;
else if (serialSettings.parity == 1) config = SERIAL_7E1;
else if (serialSettings.parity == 2) config = SERIAL_7O1;
} else {
if (serialSettings.parity == 0) config = SERIAL_8N1;
else if (serialSettings.parity == 1) config = SERIAL_8E1;
else if (serialSettings.parity == 2) config = SERIAL_8O1;
}
if (serialSettings.stopBits == 2) {
config |= 0x3000;
}
SerialComm.begin(serialSettings.baudRate, config, SERIAL_RX_PIN, SERIAL_TX_PIN);
SerialComm.setRxBufferSize(2048);
// ⭐ Serial2 설정
uint32_t config2 = SERIAL_8N1;
if (serial2Settings.dataBits == 5) {
if (serial2Settings.parity == 0) config2 = SERIAL_5N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_5E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_5O1;
} else if (serial2Settings.dataBits == 6) {
if (serial2Settings.parity == 0) config2 = SERIAL_6N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_6E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_6O1;
} else if (serial2Settings.dataBits == 7) {
if (serial2Settings.parity == 0) config2 = SERIAL_7N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_7E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_7O1;
} else {
if (serial2Settings.parity == 0) config2 = SERIAL_8N1;
else if (serial2Settings.parity == 1) config2 = SERIAL_8E1;
else if (serial2Settings.parity == 2) config2 = SERIAL_8O1;
}
if (serial2Settings.stopBits == 2) config2 |= 0x3000;
Serial2Comm.begin(serial2Settings.baudRate, config2, SERIAL2_RX_PIN, SERIAL2_TX_PIN);
Serial2Comm.setRxBufferSize(2048);
}
void loadSettings() {
preferences.begin("can-logger", false);
preferences.getString("wifi_ssid", wifiSSID, sizeof(wifiSSID));
preferences.getString("wifi_pass", wifiPassword, sizeof(wifiPassword));
enableSTAMode = preferences.getBool("sta_enable", false);
preferences.getString("sta_ssid", staSSID, sizeof(staSSID));
preferences.getString("sta_pass", staPassword, sizeof(staPassword));
if (strlen(wifiSSID) == 0) strcpy(wifiSSID, "Byun_CAN_Logger");
if (strlen(wifiPassword) == 0) strcpy(wifiPassword, "12345678");
int speedIndex = preferences.getInt("can_speed", 3);
if (speedIndex >= 0 && speedIndex < 4) {
currentCanSpeed = canSpeedValues[speedIndex];
}
int savedMode = preferences.getInt("mcp_mode", 0);
if (savedMode >= 0 && savedMode <= 3) {
currentMcpMode = (MCP2515Mode)savedMode;
}
loadSerialSettings();
preferences.end();
}
void saveSettings() {
preferences.begin("can-logger", false);
preferences.putString("wifi_ssid", wifiSSID);
preferences.putString("wifi_pass", wifiPassword);
preferences.putBool("sta_enable", enableSTAMode);
preferences.putString("sta_ssid", staSSID);
preferences.putString("sta_pass", staPassword);
for (int i = 0; i < 4; i++) {
if (canSpeedValues[i] == currentCanSpeed) {
preferences.putInt("can_speed", i);
break;
}
}
preferences.putInt("mcp_mode", (int)currentMcpMode);
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) {
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;
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, 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, pdMS_TO_TICKS(10)) == pdTRUE) {
totalMsgCount++;
}
}
}
}
// ========================================
// SD Write Task
// ========================================
void sdWriteTask(void *parameter) {
CANMessage canMsg;
SerialMessage serialMsg;
while (1) {
bool hasWork = false;
// CAN 메시지 처리
if (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) {
hasWork = true;
// 실시간 모니터링 업데이트
bool found = false;
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].msg.id == canMsg.id) {
recentData[i].msg = canMsg;
recentData[i].count++;
found = true;
break;
}
}
if (!found) {
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].count == 0) {
recentData[i].msg = canMsg;
recentData[i].count = 1;
break;
}
}
}
// CAN 로깅
if (loggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
if (canLogFormatCSV) {
char csvLine[128];
uint64_t relativeTime = canMsg.timestamp_us - canLogStartTime;
char dataStr[32];
int dataLen = 0;
for (int i = 0; i < canMsg.dlc; i++) {
dataLen += sprintf(&dataStr[dataLen], "%02X", canMsg.data[i]);
if (i < canMsg.dlc - 1) dataStr[dataLen++] = ' ';
}
dataStr[dataLen] = '\0';
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,0x%X,%d,%s\n",
relativeTime, canMsg.id, canMsg.dlc, dataStr);
if (logFile) {
logFile.write((uint8_t*)csvLine, lineLen);
currentFileSize += lineLen;
static int csvFlushCounter = 0;
if (++csvFlushCounter >= 20) { // 50 → 20으로 더 자주 플러시
logFile.flush();
csvFlushCounter = 0;
}
}
} else {
// BIN 형식
// ⭐⭐⭐ 1단계: 버퍼 가득 찼으면 먼저 플러시
if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) {
if (logFile) {
size_t written = logFile.write(fileBuffer, bufferIndex);
logFile.flush();
Serial.printf("✓ BIN 버퍼 플러시: %d bytes written\n", written);
bufferIndex = 0;
}
}
// ⭐⭐⭐ 2단계: 이제 공간 확보됨, 데이터 추가
memcpy(&fileBuffer[bufferIndex], &canMsg, sizeof(CANMessage));
bufferIndex += 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.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.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();
}
}
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.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.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();
}
}
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) {
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.open(currentFilename, FILE_WRITE);
if (logFile) {
if (canLogFormatCSV) {
logFile.println("Time_us,CAN_ID,DLC,Data");
logFile.flush();
}
logFile.close(); // ⭐ 헤더 쓰고 닫기
// ⭐⭐⭐ APPEND 모드로 다시 열기
logFile = SD.open(currentFilename, FILE_APPEND);
if (logFile) {
loggingEnabled = true;
bufferIndex = 0;
currentFileSize = logFile.size();
Serial.printf("✓ 로깅 파일 열림 (APPEND): %s\n", currentFilename);
} else {
Serial.println("✗ APPEND 모드로 파일 열기 실패");
}
} 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;
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;
}
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) {
if (serialLogFormatCSV) {
serialLogFile.println("Time_us,Direction,Data");
}
serialLoggingEnabled = true;
serialCsvIndex = 0;
currentSerialFileSize = serialLogFile.size();
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopSerialLogging") == 0) {
if (serialLoggingEnabled) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (serialCsvIndex > 0 && serialLogFile) {
serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex);
serialCsvIndex = 0;
}
if (serialLogFile) {
serialLogFile.close();
}
serialLoggingEnabled = false;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
SerialComm.println(data);
SerialMessage serialMsg;
struct timeval tv;
gettimeofday(&tv, NULL);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = strlen(data) + 2;
if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) {
serialMsg.length = MAX_SERIAL_LINE_LEN - 1;
}
snprintf((char*)serialMsg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data);
serialMsg.isTx = true;
if (xQueueSend(serialQueue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) {
totalSerialTxCount++;
}
}
}
else if (strcmp(cmd, "setSerialConfig") == 0) {
uint32_t baud = doc["baudRate"] | 115200;
uint8_t data = doc["dataBits"] | 8;
uint8_t parity = doc["parity"] | 0;
uint8_t stop = doc["stopBits"] | 1;
serialSettings.baudRate = baud;
serialSettings.dataBits = data;
serialSettings.parity = parity;
serialSettings.stopBits = stop;
saveSerialSettings();
applySerialSettings();
}
else if (strcmp(cmd, "getSerialConfig") == 0) {
DynamicJsonDocument response(512);
response["type"] = "serialConfig";
response["baudRate"] = serialSettings.baudRate;
response["dataBits"] = serialSettings.dataBits;
response["parity"] = serialSettings.parity;
response["stopBits"] = serialSettings.stopBits;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "setSpeed") == 0) {
int speedIndex = doc["speed"];
if (speedIndex >= 0 && speedIndex < 4) {
currentCanSpeed = canSpeedValues[speedIndex];
// ⭐⭐⭐ MCP2515 리셋 제거! (CAN 수신 중단 방지)
// 속도 변경은 로깅 중지 후 수동으로만 가능하도록 변경
// mcp2515.reset();
// mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
// setMCP2515Mode(currentMcpMode);
saveSettings();
// 사용자에게 안내 메시지
StaticJsonDocument<256> response;
response["type"] = "info";
response["message"] = "CAN speed saved. Stop logging and restart to apply.";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
else if (strcmp(cmd, "startSerial2Logging") == 0) {
if (!serial2LoggingEnabled && sdCardReady) {
const char* format = doc["format"];
if (format && strcmp(format, "bin") == 0) {
serial2LogFormatCSV = false;
} else {
serial2LogFormatCSV = true;
}
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
struct tm timeinfo;
time_t now;
time(&now);
localtime_r(&now, &timeinfo);
struct timeval tv;
gettimeofday(&tv, NULL);
serial2LogStartTime = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
const char* ext = serial2LogFormatCSV ? "csv" : "bin";
snprintf(currentSerial2Filename, sizeof(currentSerial2Filename),
"/SER2_%04d%02d%02d_%02d%02d%02d.%s",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, ext);
serial2LogFile = SD.open(currentSerial2Filename, FILE_WRITE);
if (serial2LogFile) {
if (serial2LogFormatCSV) {
serial2LogFile.println("Time_us,Direction,Data");
}
serial2LoggingEnabled = true;
serial2CsvIndex = 0;
currentSerial2FileSize = serial2LogFile.size();
}
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "stopSerial2Logging") == 0) {
if (serial2LoggingEnabled) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (serial2CsvIndex > 0 && serial2LogFile) {
serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex);
serial2CsvIndex = 0;
}
if (serial2LogFile) {
serial2LogFile.close();
}
serial2LoggingEnabled = false;
xSemaphoreGive(sdMutex);
}
}
}
else if (strcmp(cmd, "sendSerial2") == 0) {
const char* data = doc["data"];
if (data && strlen(data) > 0) {
Serial2Comm.println(data);
SerialMessage serialMsg;
struct timeval tv;
gettimeofday(&tv, NULL);
serialMsg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
serialMsg.length = strlen(data) + 2;
if (serialMsg.length > MAX_SERIAL_LINE_LEN - 1) {
serialMsg.length = MAX_SERIAL_LINE_LEN - 1;
}
snprintf((char*)serialMsg.data, MAX_SERIAL_LINE_LEN, "%s\r\n", data);
serialMsg.isTx = true;
if (serial2Queue && xQueueSend(serial2Queue, &serialMsg, pdMS_TO_TICKS(10)) == pdTRUE) { // ⭐ NULL 체크
totalSerial2TxCount++;
}
}
}
else if (strcmp(cmd, "setSerial2Config") == 0) {
uint32_t baud = doc["baudRate"] | 115200;
uint8_t data = doc["dataBits"] | 8;
uint8_t parity = doc["parity"] | 0;
uint8_t stop = doc["stopBits"] | 1;
serial2Settings.baudRate = baud;
serial2Settings.dataBits = data;
serial2Settings.parity = parity;
serial2Settings.stopBits = stop;
saveSerialSettings();
applySerialSettings();
}
else if (strcmp(cmd, "getSerial2Config") == 0) {
DynamicJsonDocument response(512);
response["type"] = "serial2Config";
response["baudRate"] = serial2Settings.baudRate;
response["dataBits"] = serial2Settings.dataBits;
response["parity"] = serial2Settings.parity;
response["stopBits"] = serial2Settings.stopBits;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "setSpeed") == 0) {
int speedIndex = doc["speed"];
if (speedIndex >= 0 && speedIndex < 4) {
currentCanSpeed = canSpeedValues[speedIndex];
// ⭐⭐⭐ MCP2515 리셋 제거! (CAN 수신 중단 방지)
// 속도 변경은 로깅 중지 후 수동으로만 가능하도록 변경
// mcp2515.reset();
// mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
// setMCP2515Mode(currentMcpMode);
saveSettings();
// 사용자에게 안내 메시지
StaticJsonDocument<256> response;
response["type"] = "info";
response["message"] = "CAN speed saved. Stop logging and restart to apply.";
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
else if (strcmp(cmd, "setMcpMode") == 0) {
int mode = doc["mode"];
if (mode >= 0 && mode <= 3) {
setMCP2515Mode((MCP2515Mode)mode);
saveSettings();
}
}
else if (strcmp(cmd, "syncTimeFromPhone") == 0) {
int year = doc["year"] | 2024;
int month = doc["month"] | 1;
int day = doc["day"] | 1;
int hour = doc["hour"] | 0;
int minute = doc["minute"] | 0;
int second = doc["second"] | 0;
struct tm timeinfo;
timeinfo.tm_year = year - 1900;
timeinfo.tm_mon = month - 1;
timeinfo.tm_mday = day;
timeinfo.tm_hour = hour;
timeinfo.tm_min = minute;
timeinfo.tm_sec = second;
time_t t = mktime(&timeinfo);
struct timeval tv = {t, 0};
settimeofday(&tv, NULL);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = (uint64_t)t * 1000000ULL;
timeSyncStatus.syncCount++;
if (timeSyncStatus.rtcAvailable) {
writeRTC(&timeinfo);
}
}
else if (strcmp(cmd, "getFiles") == 0) {
if (sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
DynamicJsonDocument response(6144);
response["type"] = "files";
JsonArray files = response.createNestedArray("files");
File root = SD.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.exists(fullPath)) {
if (SD.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);
}
}
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, "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);
}
}
}
// ========================================
// Web Update Task
// ========================================
void webUpdateTask(void *parameter) {
const TickType_t xDelay = pdMS_TO_TICKS(200); // ⭐ 100ms → 200ms (WiFi 안정성 향상)
while (1) {
webSocket.loop();
if (webSocket.connectedClients() > 0) {
DynamicJsonDocument doc(16384); // ⭐ 4096 → 8192로 증가
doc["type"] = "update";
doc["logging"] = loggingEnabled;
doc["serialLogging"] = serialLoggingEnabled;
doc["serial2Logging"] = serial2LoggingEnabled;
doc["totalSerial2Rx"] = totalSerial2RxCount;
doc["totalSerial2Tx"] = totalSerial2TxCount;
doc["serial2QueueUsed"] = serial2Queue ? uxQueueMessagesWaiting(serial2Queue) : 0; // ⭐ NULL 체크
doc["serial2QueueSize"] = SERIAL2_QUEUE_SIZE;
doc["serial2FileSize"] = currentSerial2FileSize;
if (serial2LoggingEnabled && currentSerial2Filename[0] != '\0') {
doc["currentSerial2File"] = String(currentSerial2Filename);
} else {
doc["currentSerial2File"] = "";
}
doc["sdReady"] = sdCardReady;
doc["totalMsg"] = totalMsgCount;
doc["msgPerSec"] = msgPerSecond;
doc["totalTx"] = totalTxCount;
doc["totalSerialRx"] = totalSerialRxCount;
doc["totalSerialTx"] = totalSerialTxCount;
doc["fileSize"] = currentFileSize;
doc["serialFileSize"] = currentSerialFileSize;
doc["queueUsed"] = 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;
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 메시지 배열 (최근 20개만 전송)
JsonArray messages = doc.createNestedArray("messages");
int msgCount = 0;
for (int i = 0; i < RECENT_MSG_COUNT && msgCount < 20; i++) { // ⭐ 최대 20개
if (recentData[i].count > 0) {
JsonObject msgObj = messages.createNestedObject();
msgObj["id"] = recentData[i].msg.id;
msgObj["dlc"] = recentData[i].msg.dlc;
msgObj["count"] = recentData[i].count;
JsonArray dataArray = msgObj.createNestedArray("data");
for (int j = 0; j < recentData[i].msg.dlc; j++) {
dataArray.add(recentData[i].msg.data[j]);
}
msgCount++;
}
}
// Serial 메시지 배열
SerialMessage serialMsg;
JsonArray serialMessages = doc.createNestedArray("serialMessages");
int serialCount = 0;
while (serialCount < 10 && xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) {
JsonObject serMsgObj = serialMessages.createNestedObject();
serMsgObj["timestamp"] = serialMsg.timestamp_us;
serMsgObj["isTx"] = serialMsg.isTx;
char dataStr[MAX_SERIAL_LINE_LEN + 1];
memcpy(dataStr, serialMsg.data, serialMsg.length);
dataStr[serialMsg.length] = '\0';
serMsgObj["data"] = dataStr;
serialCount++;
// Serial 로깅
if (serialLoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serialLogFormatCSV) {
uint64_t relativeTime = serialMsg.timestamp_us - serialLogStartTime;
char csvLine[256];
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,%s,\"%s\"\n",
relativeTime,
serialMsg.isTx ? "TX" : "RX",
dataStr);
if (serialCsvIndex + lineLen <= SERIAL_CSV_BUFFER_SIZE) { // < → <=
memcpy(&serialCsvBuffer[serialCsvIndex], csvLine, lineLen);
serialCsvIndex += lineLen;
currentSerialFileSize += lineLen;
}
if (serialCsvIndex >= SERIAL_CSV_BUFFER_SIZE - 256) {
if (serialLogFile) {
serialLogFile.write((uint8_t*)serialCsvBuffer, serialCsvIndex);
serialLogFile.flush();
serialCsvIndex = 0;
}
}
} else {
if (serialLogFile) {
serialLogFile.write((uint8_t*)&serialMsg, sizeof(SerialMessage));
currentSerialFileSize += sizeof(SerialMessage);
static int binFlushCounter = 0;
if (++binFlushCounter >= 50) {
serialLogFile.flush();
binFlushCounter = 0;
}
}
}
xSemaphoreGive(sdMutex);
}
}
}
// ⭐ Serial2 메시지 배열 처리
SerialMessage serial2Msg;
JsonArray serial2Messages = doc.createNestedArray("serial2Messages");
int serial2Count = 0;
while (serial2Queue && serial2Count < 10 && xQueueReceive(serial2Queue, &serial2Msg, 0) == pdTRUE) { // ⭐ NULL 체크
JsonObject serMsgObj = serial2Messages.createNestedObject();
serMsgObj["timestamp"] = serial2Msg.timestamp_us;
serMsgObj["isTx"] = serial2Msg.isTx;
char dataStr[MAX_SERIAL_LINE_LEN + 1];
memcpy(dataStr, serial2Msg.data, serial2Msg.length);
dataStr[serial2Msg.length] = '\0';
serMsgObj["data"] = dataStr;
serial2Count++;
// Serial2 로깅
if (serial2LoggingEnabled && sdCardReady) {
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
if (serial2LogFormatCSV) {
uint64_t relativeTime = serial2Msg.timestamp_us - serial2LogStartTime;
char csvLine[256];
int lineLen = snprintf(csvLine, sizeof(csvLine),
"%llu,%s,\"%s\"\n",
relativeTime,
serial2Msg.isTx ? "TX" : "RX",
dataStr);
if (serial2CsvIndex + lineLen <= SERIAL2_CSV_BUFFER_SIZE) { // < → <=
memcpy(&serial2CsvBuffer[serial2CsvIndex], csvLine, lineLen);
serial2CsvIndex += lineLen;
currentSerial2FileSize += lineLen;
}
if (serial2CsvIndex >= SERIAL2_CSV_BUFFER_SIZE - 256) {
if (serial2LogFile) {
serial2LogFile.write((uint8_t*)serial2CsvBuffer, serial2CsvIndex);
serial2LogFile.flush();
serial2CsvIndex = 0;
}
}
} else {
// BIN 형식
if (serial2LogFile) {
serial2LogFile.write((uint8_t*)&serial2Msg, sizeof(SerialMessage));
currentSerial2FileSize += sizeof(SerialMessage);
static int binFlushCounter2 = 0;
if (++binFlushCounter2 >= 50) {
serial2LogFile.flush();
binFlushCounter2 = 0;
}
}
}
xSemaphoreGive(sdMutex);
}
}
}
String json;
size_t jsonSize = serializeJson(doc, json);
// JSON 크기 확인 (8KB 이하만 전송)
if (jsonSize > 0 && jsonSize < 8192) {
webSocket.broadcastTXT(json);
} else {
Serial.printf("! JSON 크기 초과: %d bytes\n", jsonSize);
}
}
vTaskDelay(xDelay);
}
}
// ========================================
// Setup
// ========================================
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.setSleep(false);
Serial.println("\n========================================");
Serial.println(" Byun CAN Logger + Serial Terminal");
Serial.println(" Version 2.3 - PSRAM Optimized");
Serial.println(" ESP32-S3 Complete Edition");
Serial.println("========================================\n");
// ★★★ PSRAM 초기화 (가장 먼저!) ★★★
if (!initPSRAM()) {
Serial.println("\n✗ PSRAM 초기화 실패!");
Serial.println("✗ Arduino IDE 설정:");
Serial.println(" Tools → PSRAM → OPI PSRAM");
while (1) {
delay(1000);
Serial.println("✗ 설정 후 재업로드 필요!");
}
}
loadSettings();
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();
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 비활성화
esp_task_wdt_deinit();
// MCP2515 초기화
Serial.println("MCP2515 초기화...");
// ⭐⭐⭐ 하드 리셋
digitalWrite(HSPI_CS, LOW);
delay(10);
digitalWrite(HSPI_CS, HIGH);
delay(50);
mcp2515.reset();
delay(100);
mcp2515.setBitrate(currentCanSpeed, MCP_8MHZ);
delay(10);
mcp2515.setNormalMode();
delay(50);
// 버퍼 클리어
Serial.println("CAN 버퍼 클리어...");
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);
}
mcp2515.clearRXnOVRFlags();
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 카드 초기화
if (SD.begin(VSPI_CS, vspi)) {
sdCardReady = true;
Serial.println("✓ SD 카드 초기화 완료");
loadFileComments();
loadSequences();
} else {
Serial.println("✗ 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")) {
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.begin();
Serial.println("✓ 웹 서버 시작 완료");
// ★★★ Queue 생성 (PSRAM 사용) ★★★
if (!createQueues()) {
Serial.println("✗ Queue 생성 실패!");
while (1) delay(1000);
}
// CAN 인터럽트 활성화
attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING);
// Task 생성
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8192, NULL, configMAX_PRIORITIES - 1, &canRxTaskHandle, 1); // Core 0, Pri 24
xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 12576, NULL, 6, &sdWriteTaskHandle, 0);
xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(serialRxTask, "SERIAL_RX", 6144, NULL, 5, &serialRxTaskHandle, 0);
xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 5, &serial2RxTaskHandle, 0); // ⭐ Serial2
xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1);
xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 16384, NULL, 5, &webTaskHandle, 0); // ⭐ 10240 → 16384
xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0);
if (timeSyncStatus.rtcAvailable) {
xTaskCreatePinnedToCore(rtcSyncTask, "RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0);
}
Serial.println("✓ 모든 Task 시작 완료");
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));
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();
}
}