Files
2025-12-03 08:58:44 +00:00

1449 lines
44 KiB
C++

/*
* ============================================================================
* Byun CAN Logger v2.5 PSRAM Edition - Final Release
* ============================================================================
*
* ESP32-S3 기반 고성능 CAN Bus & Serial 데이터 로거
*
* 주요 기능:
* - 1Mbps CAN Bus 통신 (MCP2515)
* - RS232/UART Serial 통신
* - PSRAM 활용 대용량 큐 (CAN: 10,000개, Serial: 2,000개)
* - 실시간 웹 모니터링 (WebSocket)
* - SD 카드 로깅 (CSV/BIN 형식)
* - CAN 시퀀스 전송
* - DBC 기반 실시간 그래프
* - WiFi AP/APSTA 모드
* - NTP 시간 동기화
*
* 하드웨어:
* - ESP32-S3 (PSRAM 8MB)
* - MCP2515 CAN Controller
* - SD Card Module
* - RS232 Transceiver (Optional)
*
* 작성자: Byun
* 버전: 2.5 Final
* 날짜: 2024
* ============================================================================
*/
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <WebSocketsServer.h>
#include <SPI.h>
#include <SD.h>
#include <mcp2515.h>
#include <ArduinoJson.h>
#include <time.h>
#include <Preferences.h>
// ============================================================================
// HTML 페이지 포함
// ============================================================================
#include "index.h"
#include "graph.h"
#include "graph_viewer.h"
#include "transmit.h"
#include "serial_terminal.h"
#include "settings.h"
// ============================================================================
// 핀 정의
// ============================================================================
#define CAN_CS_PIN 5 // MCP2515 CS
#define SD_CS_PIN 15 // SD Card CS
#define VOLTAGE_PIN 4 // 전압 측정 (ADC)
// Serial2 핀 정의 (ESP32-S3)
#define SERIAL2_RX 16
#define SERIAL2_TX 17
// ============================================================================
// 상수 정의
// ============================================================================
#define RECENT_MSG_COUNT 100 // 최근 메시지 추적 개수
#define MAX_SERIAL_LINE_LEN 256 // Serial 라인 최대 길이
#define SEQUENCE_MAX_STEPS 50 // 시퀀스 최대 스텝
#define MAX_SEQUENCES 10 // 최대 시퀀스 개수
// PSRAM 기반 대용량 큐
#define CAN_QUEUE_SIZE 10000 // CAN 메시지 큐 (PSRAM)
#define SERIAL_QUEUE_SIZE 2000 // Serial 메시지 큐 (PSRAM)
// 전압 관련
#define LOW_VOLTAGE_THRESHOLD 11.0 // 저전압 경고 (12V 기준)
#define VOLTAGE_SAMPLES 10 // 전압 측정 샘플 수
// WebSocket 업데이트 주기
#define WEB_UPDATE_INTERVAL 500 // 500ms
// ============================================================================
// 구조체 정의
// ============================================================================
// CAN 메시지 구조체
struct CANMessage {
uint32_t id;
uint8_t dlc;
uint8_t data[8];
uint32_t timestamp_ms;
bool extended;
};
// CAN 데이터 추적 구조체
struct CANData {
struct can_frame msg;
uint32_t count;
uint32_t lastUpdate;
};
// Serial 메시지 구조체
struct SerialMessage {
uint64_t timestamp_us;
char data[MAX_SERIAL_LINE_LEN];
uint16_t length;
bool isTx;
};
// CAN 시퀀스 스텝
struct SequenceStep {
uint32_t canId;
bool extended;
uint8_t dlc;
uint8_t data[8];
uint32_t delayMs;
};
// CAN 시퀀스
struct CANSequence {
char name[32];
uint8_t repeatMode; // 0=Once, 1=Count, 2=Infinite
uint16_t repeatCount;
uint8_t stepCount;
SequenceStep steps[SEQUENCE_MAX_STEPS];
};
// 시퀀스 실행 상태
struct SequenceRuntime {
bool running;
int8_t activeSequenceIndex;
uint8_t currentStep;
uint16_t currentRepeat;
uint32_t lastStepTime;
};
// WiFi 설정
struct WiFiSettings {
char ssid[32];
char password[64];
bool staEnable;
char staSSID[32];
char staPassword[64];
};
// Serial 설정
struct SerialConfig {
uint32_t baudRate;
uint8_t dataBits;
uint8_t parity; // 0=None, 1=Even, 2=Odd
uint8_t stopBits;
};
// ============================================================================
// 전역 변수
// ============================================================================
// 하드웨어 객체
MCP2515 mcp2515(CAN_CS_PIN);
AsyncWebServer server(80);
WebSocketsServer webSocket(81);
Preferences preferences;
// FreeRTOS 큐 (PSRAM 할당)
QueueHandle_t canQueue = nullptr;
QueueHandle_t serialQueue = nullptr;
// FreeRTOS 태스크 핸들
TaskHandle_t canRxTaskHandle = nullptr;
TaskHandle_t canTxTaskHandle = nullptr;
TaskHandle_t serialRxTaskHandle = nullptr;
TaskHandle_t loggingTaskHandle = nullptr;
TaskHandle_t webUpdateTaskHandle = nullptr;
// CAN 데이터
CANData recentData[RECENT_MSG_COUNT];
// CAN 통계
uint32_t totalCanRxCount = 0;
uint32_t totalCanTxCount = 0;
uint32_t canMessagesPerSecond = 0;
uint32_t lastCanCountTime = 0;
uint32_t lastCanCount = 0;
// Serial 통계
uint32_t totalSerialRxCount = 0;
uint32_t totalSerialTxCount = 0;
// 로깅 상태
volatile bool canLoggingEnabled = false;
volatile bool serialLoggingEnabled = false;
File canLogFile;
File serialLogFile;
char currentCanFileName64 = {0};
char currentSerialFileName[64] = {0};
uint32_t currentCanFileSize = 0;
uint32_t currentSerialFileSize = 0;
String canLogFormat = "bin"; // "bin" or "csv"
String serialLogFormat = "csv"; // "csv" or "bin"
// SD 카드 상태
bool sdCardReady = false;
// 시간 동기화
bool timeSynced = false;
uint32_t rtcSyncCount = 0;
struct tm timeinfo;
// 전압 모니터링
float currentVoltage = 0.0;
float minVoltage1s = 99.0;
bool lowVoltageDetected = false;
uint32_t lastVoltageResetTime = 0;
// CAN 시퀀스
CANSequence sequences[MAX_SEQUENCES];
uint8_t sequenceCount = 0;
SequenceRuntime seqRuntime = {false, -1, 0, 0, 0};
// WiFi 설정
WiFiSettings wifiSettings;
// Serial 설정
SerialConfig serialConfig = {115200, 8, 0, 1};
// MCP2515 모드
uint8_t currentMcpMode = 0; // 0=Normal, 1=Listen-Only, 2=Loopback, 3=TX-Only
// WebSocket 클라이언트 수
uint8_t wsClientCount = 0;
// ============================================================================
// 함수 선언
// ============================================================================
// 초기화
void initSPIBus();
void initSDCard();
void initMCP2515();
void initSerial2();
void loadSettings();
void saveSettings();
// FreeRTOS 태스크
void canRxTask(void* parameter);
void canTxTask(void* parameter);
void serialRxTask(void* parameter);
void loggingTask(void* parameter);
void webUpdateTask(void* parameter);
// CAN 기능
void setCANSpeed(uint8_t speed);
void setMCPMode(uint8_t mode);
bool sendCANMessage(uint32_t id, bool ext, uint8_t dlc, const uint8_t* data);
// 로깅
void startCANLogging(const String& format);
void stopCANLogging();
void startSerialLogging(const String& format);
void stopSerialLogging();
void writeCANMessageToFile(const CANMessage& msg);
void writeSerialMessageToFile(const SerialMessage& msg);
// 시퀀스
void loadSequences();
void saveSequences();
void startSequence(uint8_t index);
void stopSequence();
void processSequence();
// 시간
void syncTimeFromPhone(int year, int month, int day, int hour, int minute, int second);
String getTimestamp();
// 전압
void updateVoltage();
// WebSocket
void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
void sendWebUpdate();
void handleWebSocketCommand(uint8_t num, const char* payload);
// 파일 관리
void sendFileList(uint8_t clientNum);
void deleteFile(const String& filename, uint8_t clientNum);
void addFileComment(const String& filename, const String& comment);
String getFileComment(const String& filename);
// 유틸리티
String formatBytes(uint32_t bytes);
void printSystemInfo();
// ============================================================================
// setup()
// ============================================================================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n\n");
Serial.println("============================================================================");
Serial.println(" Byun CAN Logger v2.5 PSRAM Edition - Final Release");
Serial.println("============================================================================");
// PSRAM 확인
if (psramFound()) {
Serial.printf("✓ PSRAM detected: %d KB\n", ESP.getPsramSize() / 1024);
Serial.printf(" Free PSRAM: %d KB\n", ESP.getFreePsram() / 1024);
} else {
Serial.println("✗ WARNING: PSRAM not found!");
}
// 설정 로드
preferences.begin("can-logger", false);
loadSettings();
// SPI 초기화
initSPIBus();
// SD 카드 초기화
initSDCard();
// MCP2515 초기화
initMCP2515();
// Serial2 초기화
initSerial2();
// 시퀀스 로드
loadSequences();
// PSRAM 기반 큐 생성
Serial.println("\n[Queue Creation]");
canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage));
if (canQueue == nullptr) {
Serial.println("✗ Failed to create CAN queue!");
} else {
Serial.printf("✓ CAN Queue created: %d slots\n", CAN_QUEUE_SIZE);
}
serialQueue = xQueueCreate(SERIAL_QUEUE_SIZE, sizeof(SerialMessage));
if (serialQueue == nullptr) {
Serial.println("✗ Failed to create Serial queue!");
} else {
Serial.printf("✓ Serial Queue created: %d slots\n", SERIAL_QUEUE_SIZE);
}
// FreeRTOS 태스크 생성
Serial.println("\n[Task Creation]");
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 5, &canRxTaskHandle, 0);
Serial.println("✓ CAN RX Task created (Core 0)");
xTaskCreatePinnedToCore(canTxTask, "CAN_TX", 4096, NULL, 4, &canTxTaskHandle, 0);
Serial.println("✓ CAN TX Task created (Core 0)");
xTaskCreatePinnedToCore(serialRxTask, "Serial_RX", 4096, NULL, 3, &serialRxTaskHandle, 1);
Serial.println("✓ Serial RX Task created (Core 1)");
xTaskCreatePinnedToCore(loggingTask, "Logging", 8192, NULL, 2, &loggingTaskHandle, 1);
Serial.println("✓ Logging Task created (Core 1)");
xTaskCreatePinnedToCore(webUpdateTask, "Web_Update", 8192, NULL, 1, &webUpdateTaskHandle, 1);
Serial.println("✓ Web Update Task created (Core 1)");
// WiFi 시작
Serial.println("\n[WiFi Configuration]");
// AP 모드 시작
WiFi.mode(WIFI_AP_STA); // 항상 APSTA 모드로 시작
WiFi.softAP(wifiSettings.ssid, wifiSettings.password);
Serial.printf("✓ AP Mode started\n");
Serial.printf(" SSID: %s\n", wifiSettings.ssid);
Serial.printf(" IP: %s\n", WiFi.softAPIP().toString().c_str());
// Station 모드 (옵션)
if (wifiSettings.staEnable && strlen(wifiSettings.staSSID) > 0) {
Serial.printf(" Connecting to WiFi: %s\n", wifiSettings.staSSID);
WiFi.begin(wifiSettings.staSSID, wifiSettings.staPassword);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n✓ Station connected\n");
Serial.printf(" IP: %s\n", WiFi.localIP().toString().c_str());
// NTP 시간 동기화
configTime(9 * 3600, 0, "pool.ntp.org", "time.nist.gov");
if (getLocalTime(&timeinfo)) {
timeSynced = true;
Serial.println("✓ NTP time synchronized");
}
} else {
Serial.println("\n✗ Station connection failed (AP mode still active)");
}
}
// 웹 서버 라우트 설정
Serial.println("\n[Web Server Setup]");
server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", index_html);
});
server.on("/graph", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", graph_html);
});
server.on("/graph-view", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", graph_viewer_html);
});
server.on("/transmit", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", transmit_html);
});
server.on("/serial", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", serial_terminal_html);
});
server.on("/settings", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", settings_html);
});
// 파일 다운로드
server.on("/download", HTTP_GET, [](AsyncWebServerRequest* request) {
if (request->hasParam("file")) {
String filename = "/" + request->getParam("file")->value();
if (SD.exists(filename)) {
request->send(SD, filename, "application/octet-stream");
} else {
request->send(404, "text/plain", "File not found");
}
} else {
request->send(400, "text/plain", "Missing file parameter");
}
});
server.begin();
Serial.println("✓ HTTP Server started on port 80");
// WebSocket 시작
webSocket.begin();
webSocket.onEvent(webSocketEvent);
Serial.println("✓ WebSocket Server started on port 81");
// 시스템 정보 출력
printSystemInfo();
Serial.println("\n============================================================================");
Serial.println(" System Ready!");
Serial.println(" Connect to WiFi and access: http://192.168.4.1");
Serial.println("============================================================================\n");
}
// ============================================================================
// loop()
// ============================================================================
void loop() {
webSocket.loop();
// 전압 업데이트 (1초마다)
static uint32_t lastVoltageUpdate = 0;
if (millis() - lastVoltageUpdate > 1000) {
updateVoltage();
lastVoltageUpdate = millis();
}
// 시퀀스 처리
if (seqRuntime.running) {
processSequence();
}
// CAN 메시지 속도 계산 (1초마다)
if (millis() - lastCanCountTime >= 1000) {
canMessagesPerSecond = totalCanRxCount - lastCanCount;
lastCanCount = totalCanRxCount;
lastCanCountTime = millis();
// 1초마다 최소 전압 리셋
if (millis() - lastVoltageResetTime >= 1000) {
minVoltage1s = currentVoltage;
lastVoltageResetTime = millis();
}
}
delay(10);
}
// ============================================================================
// 초기화 함수들
// ============================================================================
void initSPIBus() {
Serial.println("\n[SPI Bus Initialization]");
SPI.begin();
Serial.println("✓ SPI Bus initialized");
}
void initSDCard() {
Serial.println("\n[SD Card Initialization]");
if (!SD.begin(SD_CS_PIN)) {
Serial.println("✗ SD Card initialization failed!");
sdCardReady = false;
return;
}
uint64_t cardSize = SD.cardSize() / (1024 * 1024);
Serial.printf("✓ SD Card detected: %llu MB\n", cardSize);
uint64_t usedBytes = SD.usedBytes() / (1024 * 1024);
uint64_t totalBytes = SD.totalBytes() / (1024 * 1024);
Serial.printf(" Used: %llu MB / %llu MB\n", usedBytes, totalBytes);
sdCardReady = true;
}
void initMCP2515() {
Serial.println("\n[MCP2515 Initialization]");
mcp2515.reset();
mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ);
mcp2515.setNormalMode();
Serial.println("✓ MCP2515 initialized");
Serial.println(" Speed: 1 Mbps");
Serial.println(" Mode: Normal");
currentMcpMode = 0;
}
void initSerial2() {
Serial.println("\n[Serial2 Initialization]");
Serial2.begin(serialConfig.baudRate, SERIAL_8N1, SERIAL2_RX, SERIAL2_TX);
Serial.printf("✓ Serial2 initialized\n");
Serial.printf(" Baud: %d\n", serialConfig.baudRate);
Serial.printf(" Config: 8N1\n");
Serial.printf(" RX: GPIO%d, TX: GPIO%d\n", SERIAL2_RX, SERIAL2_TX);
}
void loadSettings() {
Serial.println("\n[Loading Settings]");
// WiFi 설정
preferences.getString("ssid", wifiSettings.ssid, sizeof(wifiSettings.ssid));
preferences.getString("password", wifiSettings.password, sizeof(wifiSettings.password));
wifiSettings.staEnable = preferences.getBool("staEnable", false);
preferences.getString("staSSID", wifiSettings.staSSID, sizeof(wifiSettings.staSSID));
preferences.getString("staPassword", wifiSettings.staPassword, sizeof(wifiSettings.staPassword));
// 기본값 설정
if (strlen(wifiSettings.ssid) == 0) {
strcpy(wifiSettings.ssid, "Byun_CAN_Logger");
strcpy(wifiSettings.password, "12345678");
}
Serial.printf("✓ WiFi AP: %s\n", wifiSettings.ssid);
if (wifiSettings.staEnable) {
Serial.printf("✓ WiFi STA enabled: %s\n", wifiSettings.staSSID);
}
// Serial 설정
serialConfig.baudRate = preferences.getUInt("serialBaud", 115200);
serialConfig.dataBits = preferences.getUChar("serialData", 8);
serialConfig.parity = preferences.getUChar("serialParity", 0);
serialConfig.stopBits = preferences.getUChar("serialStop", 1);
Serial.printf("✓ Serial: %d 8N1\n", serialConfig.baudRate);
}
void saveSettings() {
Serial.println("\n[Saving Settings]");
preferences.putString("ssid", wifiSettings.ssid);
preferences.putString("password", wifiSettings.password);
preferences.putBool("staEnable", wifiSettings.staEnable);
preferences.putString("staSSID", wifiSettings.staSSID);
preferences.putString("staPassword", wifiSettings.staPassword);
preferences.putUInt("serialBaud", serialConfig.baudRate);
preferences.putUChar("serialData", serialConfig.dataBits);
preferences.putUChar("serialParity", serialConfig.parity);
preferences.putUChar("serialStop", serialConfig.stopBits);
Serial.println("✓ Settings saved to NVS");
}
// ============================================================================
// FreeRTOS 태스크들
// ============================================================================
void canRxTask(void* parameter) {
struct can_frame frame;
CANMessage msg;
Serial.println("[CAN RX Task] Started");
while (true) {
if (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {
// CAN 메시지를 큐에 추가
msg.id = frame.can_id & 0x1FFFFFFF; // Extended ID 비트 제거
msg.extended = (frame.can_id & CAN_EFF_FLAG) ? true : false;
msg.dlc = frame.can_dlc;
memcpy(msg.data, frame.data, 8);
msg.timestamp_ms = millis();
if (xQueueSend(canQueue, &msg, 0) != pdTRUE) {
// 큐가 가득 찬 경우 (드롭)
}
// 최근 메시지 업데이트
bool found = false;
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].msg.can_id == frame.can_id) {
memcpy(&recentData[i].msg, &frame, sizeof(struct can_frame));
recentData[i].count++;
recentData[i].lastUpdate = millis();
found = true;
break;
}
}
if (!found) {
// 빈 슬롯 찾기
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].count == 0) {
memcpy(&recentData[i].msg, &frame, sizeof(struct can_frame));
recentData[i].count = 1;
recentData[i].lastUpdate = millis();
break;
}
}
}
totalCanRxCount++;
}
vTaskDelay(1 / portTICK_PERIOD_MS); // 1ms 대기
}
}
void canTxTask(void* parameter) {
Serial.println("[CAN TX Task] Started");
while (true) {
// 현재는 시퀀스에서만 TX 사용
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void serialRxTask(void* parameter) {
static char lineBuffer[MAX_SERIAL_LINE_LEN];
static uint16_t bufferIndex = 0;
SerialMessage msg;
Serial.println("[Serial RX Task] Started");
while (true) {
while (Serial2.available()) {
char c = Serial2.read();
if (c == '\n' || c == '\r') {
if (bufferIndex > 0) {
// 라인 완성
msg.timestamp_us = esp_timer_get_time();
msg.isTx = false;
msg.length = bufferIndex;
memcpy(msg.data, lineBuffer, bufferIndex);
msg.data[bufferIndex] = '\0';
xQueueSend(serialQueue, &msg, 0);
totalSerialRxCount++;
bufferIndex = 0;
}
} else {
if (bufferIndex < MAX_SERIAL_LINE_LEN - 1) {
lineBuffer[bufferIndex++] = c;
}
}
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void loggingTask(void* parameter) {
CANMessage canMsg;
SerialMessage serialMsg;
Serial.println("[Logging Task] Started");
while (true) {
// CAN 로깅
if (canLoggingEnabled && canLogFile) {
while (xQueueReceive(canQueue, &canMsg, 0) == pdTRUE) {
writeCANMessageToFile(canMsg);
}
}
// Serial 로깅
if (serialLoggingEnabled && serialLogFile) {
while (xQueueReceive(serialQueue, &serialMsg, 0) == pdTRUE) {
writeSerialMessageToFile(serialMsg);
}
}
vTaskDelay(100 / portTICK_PERIOD_MS);
}
}
void webUpdateTask(void* parameter) {
Serial.println("[Web Update Task] Started");
while (true) {
if (wsClientCount > 0) {
sendWebUpdate();
}
vTaskDelay(WEB_UPDATE_INTERVAL / portTICK_PERIOD_MS);
}
}
// ============================================================================
// CAN 기능
// ============================================================================
void setCANSpeed(uint8_t speed) {
mcp2515.reset();
switch (speed) {
case 0: mcp2515.setBitrate(CAN_125KBPS, MCP_8MHZ); break;
case 1: mcp2515.setBitrate(CAN_250KBPS, MCP_8MHZ); break;
case 2: mcp2515.setBitrate(CAN_500KBPS, MCP_8MHZ); break;
case 3: mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); break;
default: mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ); break;
}
// 모드 복원
setMCPMode(currentMcpMode);
Serial.printf("CAN speed changed to: %d\n", speed);
}
void setMCPMode(uint8_t mode) {
currentMcpMode = mode;
switch (mode) {
case 0: mcp2515.setNormalMode(); break;
case 1: mcp2515.setListenOnlyMode(); break;
case 2: mcp2515.setLoopbackMode(); break;
case 3: mcp2515.setNormalMode(); break; // TX-Only는 Normal 사용
default: mcp2515.setNormalMode(); break;
}
Serial.printf("MCP2515 mode changed to: %d\n", mode);
}
bool sendCANMessage(uint32_t id, bool ext, uint8_t dlc, const uint8_t* data) {
struct can_frame frame;
frame.can_id = id;
if (ext) {
frame.can_id |= CAN_EFF_FLAG;
}
frame.can_dlc = dlc;
memcpy(frame.data, data, dlc);
if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) {
totalCanTxCount++;
return true;
}
return false;
}
// ============================================================================
// 로깅 기능
// ============================================================================
void startCANLogging(const String& format) {
if (canLoggingEnabled) {
stopCANLogging();
}
if (!sdCardReady) {
Serial.println("Cannot start CAN logging: SD card not ready");
return;
}
canLogFormat = format;
// 파일명 생성
String timestamp = getTimestamp();
String extension = (format == "csv") ? ".csv" : ".bin";
snprintf(currentCanFileName, sizeof(currentCanFileName), "/CAN_%s%s",
timestamp.c_str(), extension.c_str());
canLogFile = SD.open(currentCanFileName, FILE_WRITE);
if (!canLogFile) {
Serial.println("Failed to create CAN log file");
return;
}
// CSV 헤더
if (format == "csv") {
canLogFile.println("Timestamp_ms,ID,Extended,DLC,Data");
}
canLoggingEnabled = true;
currentCanFileSize = 0;
Serial.printf("CAN logging started: %s (format: %s)\n", currentCanFileName, format.c_str());
}
void stopCANLogging() {
if (!canLoggingEnabled) return;
canLoggingEnabled = false;
if (canLogFile) {
canLogFile.flush();
canLogFile.close();
}
Serial.printf("CAN logging stopped: %s (%d bytes)\n", currentCanFileName, currentCanFileSize);
currentCanFileName[0] = '\0';
currentCanFileSize = 0;
}
void startSerialLogging(const String& format) {
if (serialLoggingEnabled) {
stopSerialLogging();
}
if (!sdCardReady) {
Serial.println("Cannot start Serial logging: SD card not ready");
return;
}
serialLogFormat = format;
String timestamp = getTimestamp();
String extension = (format == "csv") ? ".csv" : ".bin";
snprintf(currentSerialFileName, sizeof(currentSerialFileName), "/SER_%s%s",
timestamp.c_str(), extension.c_str());
serialLogFile = SD.open(currentSerialFileName, FILE_WRITE);
if (!serialLogFile) {
Serial.println("Failed to create Serial log file");
return;
}
if (format == "csv") {
serialLogFile.println("Timestamp_us,Direction,Data");
}
serialLoggingEnabled = true;
currentSerialFileSize = 0;
Serial.printf("Serial logging started: %s (format: %s)\n", currentSerialFileName, format.c_str());
}
void stopSerialLogging() {
if (!serialLoggingEnabled) return;
serialLoggingEnabled = false;
if (serialLogFile) {
serialLogFile.flush();
serialLogFile.close();
}
Serial.printf("Serial logging stopped: %s (%d bytes)\n", currentSerialFileName, currentSerialFileSize);
currentSerialFileName[0] = '\0';
currentSerialFileSize = 0;
}
void writeCANMessageToFile(const CANMessage& msg) {
if (!canLogFile) return;
if (canLogFormat == "csv") {
// CSV 형식
char line[128];
char dataStr[32];
for (int i = 0; i < msg.dlc; i++) {
sprintf(dataStr + i * 3, "%02X ", msg.data[i]);
}
sprintf(line, "%u,0x%X,%d,%d,%s\n",
msg.timestamp_ms, msg.id, msg.extended ? 1 : 0, msg.dlc, dataStr);
canLogFile.print(line);
currentCanFileSize += strlen(line);
} else {
// Binary 형식
canLogFile.write((uint8_t*)&msg, sizeof(CANMessage));
currentCanFileSize += sizeof(CANMessage);
}
// 주기적으로 flush (100개마다)
static uint32_t writeCount = 0;
if (++writeCount % 100 == 0) {
canLogFile.flush();
}
}
void writeSerialMessageToFile(const SerialMessage& msg) {
if (!serialLogFile) return;
if (serialLogFormat == "csv") {
char line[MAX_SERIAL_LINE_LEN + 64];
sprintf(line, "%llu,%s,%s\n",
msg.timestamp_us, msg.isTx ? "TX" : "RX", msg.data);
serialLogFile.print(line);
currentSerialFileSize += strlen(line);
} else {
serialLogFile.write((uint8_t*)&msg, sizeof(SerialMessage));
currentSerialFileSize += sizeof(SerialMessage);
}
static uint32_t writeCount = 0;
if (++writeCount % 50 == 0) {
serialLogFile.flush();
}
}
// ============================================================================
// 시퀀스 기능
// ============================================================================
void loadSequences() {
// Preferences에서 시퀀스 로드 (간단한 구현)
sequenceCount = preferences.getUChar("seqCount", 0);
Serial.printf("Loaded %d sequences from NVS\n", sequenceCount);
}
void saveSequences() {
preferences.putUChar("seqCount", sequenceCount);
Serial.printf("Saved %d sequences to NVS\n", sequenceCount);
}
void startSequence(uint8_t index) {
if (index >= sequenceCount) return;
seqRuntime.running = true;
seqRuntime.activeSequenceIndex = index;
seqRuntime.currentStep = 0;
seqRuntime.currentRepeat = 0;
seqRuntime.lastStepTime = millis();
Serial.printf("Sequence started: %s\n", sequences[index].name);
}
void stopSequence() {
if (!seqRuntime.running) return;
Serial.printf("Sequence stopped: %s\n", sequences[seqRuntime.activeSequenceIndex].name);
seqRuntime.running = false;
seqRuntime.activeSequenceIndex = -1;
}
void processSequence() {
if (!seqRuntime.running || seqRuntime.activeSequenceIndex < 0) return;
CANSequence& seq = sequences[seqRuntime.activeSequenceIndex];
if (seqRuntime.currentStep >= seq.stepCount) {
// 반복 처리
seqRuntime.currentRepeat++;
if (seq.repeatMode == 0) {
// Once
stopSequence();
return;
} else if (seq.repeatMode == 1) {
// Count
if (seqRuntime.currentRepeat >= seq.repeatCount) {
stopSequence();
return;
}
}
// Infinite: 계속 실행
seqRuntime.currentStep = 0;
}
SequenceStep& step = seq.steps[seqRuntime.currentStep];
if (millis() - seqRuntime.lastStepTime >= step.delayMs) {
// CAN 메시지 전송
sendCANMessage(step.canId, step.extended, step.dlc, step.data);
seqRuntime.currentStep++;
seqRuntime.lastStepTime = millis();
}
}
// ============================================================================
// 시간 함수
// ============================================================================
void syncTimeFromPhone(int year, int month, int day, int hour, int minute, int second) {
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 now = { .tv_sec = t };
settimeofday(&now, NULL);
timeSynced = true;
rtcSyncCount++;
Serial.printf("Time synced from phone: %04d-%02d-%02d %02d:%02d:%02d\n",
year, month, day, hour, minute, second);
}
String getTimestamp() {
if (timeSynced) {
time_t now;
time(&now);
struct tm* tm_now = localtime(&now);
char buf[32];
sprintf(buf, "%04d%02d%02d_%02d%02d%02d",
tm_now->tm_year + 1900, tm_now->tm_mon + 1, tm_now->tm_mday,
tm_now->tm_hour, tm_now->tm_min, tm_now->tm_sec);
return String(buf);
} else {
char buf[32];
sprintf(buf, "%010lu", millis());
return String(buf);
}
}
// ============================================================================
// 전압 모니터링
// ============================================================================
void updateVoltage() {
float sum = 0;
for (int i = 0; i < VOLTAGE_SAMPLES; i++) {
int rawValue = analogRead(VOLTAGE_PIN);
float voltage = (rawValue / 4095.0) * 3.3 * (12.0 / 3.3); // 분압비 가정
sum += voltage;
delay(1);
}
currentVoltage = sum / VOLTAGE_SAMPLES;
if (currentVoltage < minVoltage1s) {
minVoltage1s = currentVoltage;
}
lowVoltageDetected = (currentVoltage < LOW_VOLTAGE_THRESHOLD);
}
// ============================================================================
// WebSocket 함수
// ============================================================================
void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
switch (type) {
case WStype_DISCONNECTED:
Serial.printf("[WS] Client #%u disconnected\n", num);
if (wsClientCount > 0) wsClientCount--;
break;
case WStype_CONNECTED:
{
IPAddress ip = webSocket.remoteIP(num);
Serial.printf("[WS] Client #%u connected from %s\n", num, ip.toString().c_str());
wsClientCount++;
}
break;
case WStype_TEXT:
Serial.printf("[WS] Client #%u: %s\n", num, payload);
handleWebSocketCommand(num, (char*)payload);
break;
default:
break;
}
}
void handleWebSocketCommand(uint8_t num, const char* payload) {
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.printf("JSON parse error: %s\n", error.c_str());
return;
}
const char* cmd = doc["cmd"];
if (strcmp(cmd, "setSpeed") == 0) {
uint8_t speed = doc["speed"];
setCANSpeed(speed);
}
else if (strcmp(cmd, "setMcpMode") == 0) {
uint8_t mode = doc["mode"];
setMCPMode(mode);
}
else if (strcmp(cmd, "startLogging") == 0) {
String format = doc["format"] | "bin";
startCANLogging(format);
}
else if (strcmp(cmd, "stopLogging") == 0) {
stopCANLogging();
}
else if (strcmp(cmd, "startSerialLogging") == 0) {
String format = doc["format"] | "csv";
startSerialLogging(format);
}
else if (strcmp(cmd, "stopSerialLogging") == 0) {
stopSerialLogging();
}
else if (strcmp(cmd, "getFiles") == 0) {
sendFileList(num);
}
else if (strcmp(cmd, "deleteFile") == 0) {
String filename = "/" + String((const char*)doc["filename"]);
deleteFile(filename, num);
}
else if (strcmp(cmd, "syncTimeFromPhone") == 0) {
int year = doc["year"];
int month = doc["month"];
int day = doc["day"];
int hour = doc["hour"];
int minute = doc["minute"];
int second = doc["second"];
syncTimeFromPhone(year, month, day, hour, minute, second);
}
else if (strcmp(cmd, "getSettings") == 0) {
DynamicJsonDocument response(1024);
response["type"] = "settings";
response["ssid"] = wifiSettings.ssid;
response["password"] = wifiSettings.password;
response["staEnable"] = wifiSettings.staEnable;
response["staSSID"] = wifiSettings.staSSID;
response["staPassword"] = wifiSettings.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) {
strcpy(wifiSettings.ssid, doc["ssid"]);
strcpy(wifiSettings.password, doc["password"]);
wifiSettings.staEnable = doc["staEnable"];
strcpy(wifiSettings.staSSID, doc["staSSID"]);
strcpy(wifiSettings.staPassword, doc["staPassword"]);
saveSettings();
DynamicJsonDocument response(256);
response["type"] = "settingsSaved";
response["success"] = true;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "reboot") == 0) {
Serial.println("Rebooting by user request...");
delay(500);
ESP.restart();
}
else if (strcmp(cmd, "getSequences") == 0) {
DynamicJsonDocument response(4096);
response["type"] = "sequences";
JsonArray list = response.createNestedArray("list");
for (int i = 0; i < sequenceCount; i++) {
JsonObject seq = list.createNestedObject();
seq["name"] = sequences[i].name;
seq["index"] = i;
seq["mode"] = sequences[i].repeatMode;
seq["count"] = sequences[i].repeatCount;
seq["stepCount"] = sequences[i].stepCount;
seq["running"] = (seqRuntime.running && seqRuntime.activeSequenceIndex == i);
}
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
else if (strcmp(cmd, "startSequence") == 0) {
uint8_t index = doc["index"];
startSequence(index);
}
else if (strcmp(cmd, "stopSequence") == 0) {
stopSequence();
}
else if (strcmp(cmd, "addSequence") == 0) {
if (sequenceCount < MAX_SEQUENCES) {
CANSequence& seq = sequences[sequenceCount];
strcpy(seq.name, doc["name"]);
seq.repeatMode = doc["repeatMode"];
seq.repeatCount = doc["repeatCount"];
JsonArray steps = doc["steps"];
seq.stepCount = 0;
for (JsonObject step : steps) {
if (seq.stepCount >= SEQUENCE_MAX_STEPS) break;
SequenceStep& s = seq.steps[seq.stepCount];
String idStr = step["id"];
s.canId = strtoul(idStr.c_str(), NULL, 16);
s.extended = step["ext"];
s.dlc = step["dlc"];
JsonArray data = step["data"];
for (int i = 0; i < 8; i++) {
s.data[i] = data[i];
}
s.delayMs = step["delay"];
seq.stepCount++;
}
sequenceCount++;
saveSequences();
DynamicJsonDocument response(256);
response["type"] = "sequenceSaved";
response["success"] = true;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
else if (strcmp(cmd, "removeSequence") == 0) {
uint8_t index = doc["index"];
if (index < sequenceCount) {
// 시퀀스 삭제 (배열 이동)
for (int i = index; i < sequenceCount - 1; i++) {
sequences[i] = sequences[i + 1];
}
sequenceCount--;
saveSequences();
DynamicJsonDocument response(256);
response["type"] = "sequenceDeleted";
response["success"] = true;
String json;
serializeJson(response, json);
webSocket.sendTXT(num, json);
}
}
}
void sendWebUpdate() {
DynamicJsonDocument doc(8192);
doc["type"] = "update";
doc["logging"] = canLoggingEnabled;
doc["serialLogging"] = serialLoggingEnabled;
doc["sdReady"] = sdCardReady;
doc["totalMsg"] = totalCanRxCount;
doc["msgPerSec"] = canMessagesPerSecond;
doc["totalTx"] = totalCanTxCount;
doc["timeSync"] = timeSynced;
doc["syncCount"] = rtcSyncCount;
doc["mcpMode"] = currentMcpMode;
doc["currentFile"] = String(currentCanFileName);
doc["fileSize"] = currentCanFileSize;
doc["voltage"] = currentVoltage;
doc["minVoltage"] = minVoltage1s;
doc["lowVoltage"] = lowVoltageDetected;
// 큐 상태
doc["queueUsed"] = uxQueueMessagesWaiting(canQueue);
doc["queueSize"] = CAN_QUEUE_SIZE;
doc["serialQueueUsed"] = uxQueueMessagesWaiting(serialQueue);
doc["serialQueueSize"] = SERIAL_QUEUE_SIZE;
// PSRAM 상태
doc["psramFree"] = ESP.getFreePsram();
// Serial 통계
doc["totalSerialRx"] = totalSerialRxCount;
doc["totalSerialTx"] = totalSerialTxCount;
doc["serialFileSize"] = currentSerialFileSize;
doc["currentSerialFile"] = String(currentSerialFileName);
// 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.can_id & 0x1FFFFFFF;
msgObj["dlc"] = recentData[i].msg.can_dlc;
msgObj["count"] = recentData[i].count;
JsonArray dataArray = msgObj.createNestedArray("data");
for (int j = 0; j < recentData[i].msg.can_dlc; j++) {
dataArray.add(recentData[i].msg.data[j]);
}
}
}
String json;
serializeJson(doc, json);
webSocket.broadcastTXT(json);
}
// ============================================================================
// 파일 관리
// ============================================================================
void sendFileList(uint8_t clientNum) {
if (!sdCardReady) return;
DynamicJsonDocument doc(8192);
doc["type"] = "files";
JsonArray list = doc.createNestedArray("list");
File root = SD.open("/");
File file = root.openNextFile();
while (file) {
if (!file.isDirectory()) {
String filename = file.name();
// '/' 제거
if (filename.startsWith("/")) {
filename = filename.substring(1);
}
JsonObject fileObj = list.createNestedObject();
fileObj["name"] = filename;
fileObj["size"] = file.size();
String comment = getFileComment(filename);
if (comment.length() > 0) {
fileObj["comment"] = comment;
}
}
file = root.openNextFile();
}
String json;
serializeJson(doc, json);
webSocket.sendTXT(clientNum, json);
}
void deleteFile(const String& filename, uint8_t clientNum) {
bool success = false;
String message = "Unknown error";
if (sdCardReady) {
if (SD.exists(filename)) {
if (SD.remove(filename)) {
success = true;
message = "File deleted";
Serial.printf("File deleted: %s\n", filename.c_str());
} else {
message = "Failed to delete file";
}
} else {
message = "File not found";
}
} else {
message = "SD card not ready";
}
DynamicJsonDocument response(256);
response["type"] = "deleteResult";
response["success"] = success;
response["message"] = message;
String json;
serializeJson(response, json);
webSocket.sendTXT(clientNum, json);
}
void addFileComment(const String& filename, const String& comment) {
String key = "cmt_" + filename;
key.replace("/", "_");
key.replace(".", "_");
preferences.putString(key.c_str(), comment);
Serial.printf("Comment added: %s = %s\n", filename.c_str(), comment.c_str());
}
String getFileComment(const String& filename) {
String key = "cmt_" + filename;
key.replace("/", "_");
key.replace(".", "_");
return preferences.getString(key.c_str(), "");
}
// ============================================================================
// 유틸리티
// ============================================================================
String formatBytes(uint32_t bytes) {
if (bytes < 1024) return String(bytes) + " B";
if (bytes < 1024 * 1024) return String(bytes / 1024.0, 2) + " KB";
return String(bytes / 1024.0 / 1024.0, 2) + " MB";
}
void printSystemInfo() {
Serial.println("\n[System Information]");
Serial.printf("Chip Model: %s\n", ESP.getChipModel());
Serial.printf("Chip Revision: %d\n", ESP.getChipRevision());
Serial.printf("CPU Cores: %d\n", ESP.getChipCores());
Serial.printf("CPU Frequency: %d MHz\n", ESP.getCpuFreqMHz());
Serial.printf("Flash Size: %d MB\n", ESP.getFlashChipSize() / (1024 * 1024));
Serial.printf("Free Heap: %d KB\n", ESP.getFreeHeap() / 1024);
if (psramFound()) {
Serial.printf("PSRAM Size: %d KB\n", ESP.getPsramSize() / 1024);
Serial.printf("Free PSRAM: %d KB\n", ESP.getFreePsram() / 1024);
}
Serial.printf("SDK Version: %s\n", ESP.getSdkVersion());
}