diff --git a/ESP32_CAN_Logger.ino b/ESP32_CAN_Logger.ino
index 1a78bec..dc39490 100644
--- a/ESP32_CAN_Logger.ino
+++ b/ESP32_CAN_Logger.ino
@@ -85,6 +85,33 @@ struct TxMessage {
bool active;
};
+// CAN 시퀀스 스텝 구조체
+struct SequenceStep {
+ uint32_t canId;
+ bool extended;
+ uint8_t dlc;
+ uint8_t data[8];
+ uint32_t delayMs; // 이 스텝 실행 후 대기 시간 (ms)
+};
+
+// CAN 시퀀스 구조체
+struct CANSequence {
+ char name[32];
+ SequenceStep steps[20]; // 최대 20개 스텝
+ uint8_t stepCount;
+ uint8_t repeatMode; // 0=한번, 1=특정횟수, 2=무한
+ uint32_t repeatCount; // repeatMode=1일 때 반복 횟수
+};
+
+// 시퀀스 실행 상태
+struct SequenceRuntime {
+ bool running;
+ uint8_t currentStep;
+ uint32_t currentRepeat;
+ uint32_t lastStepTime;
+ int8_t activeSequenceIndex; // 실행 중인 시퀀스 인덱스
+};
+
// 파일 커멘트 구조체
struct FileComment {
char filename[MAX_FILENAME_LEN];
@@ -110,11 +137,21 @@ struct PowerStatus {
uint32_t lastMinReset; // 최소값 리셋 시간
} powerStatus = {0.0, 999.9, false, 0, 0};
+// MCP2515 레지스터 주소 정의 (라이브러리에 없는 경우)
+#ifndef MCP_CANCTRL
+#define MCP_CANCTRL 0x0F
+#endif
+
+#ifndef MCP_CANSTAT
+#define MCP_CANSTAT 0x0E
+#endif
+
// MCP2515 모드 정의
enum MCP2515Mode {
MCP_MODE_NORMAL = 0,
MCP_MODE_LISTEN_ONLY = 1,
- MCP_MODE_LOOPBACK = 2
+ MCP_MODE_LOOPBACK = 2,
+ MCP_MODE_TRANSMIT = 3 // 송신만 (ACK 없음)
};
// WiFi AP 기본 설정
@@ -174,6 +211,12 @@ uint32_t lastMsgCount = 0;
TxMessage txMessages[MAX_TX_MESSAGES];
uint32_t totalTxCount = 0;
+// CAN 시퀀스 (최대 10개 저장 가능)
+#define MAX_SEQUENCES 10
+CANSequence sequences[MAX_SEQUENCES];
+uint8_t sequenceCount = 0;
+SequenceRuntime seqRuntime = {false, 0, 0, 0, -1};
+
// 파일 커멘트 저장 (최대 50개)
#define MAX_FILE_COMMENTS 50
FileComment fileComments[MAX_FILE_COMMENTS];
@@ -216,6 +259,92 @@ void saveSettings() {
Serial.println("⚠️ 재부팅 후 WiFi 설정이 적용됩니다.");
}
+// ========================================
+// 시퀀스 관리 함수
+// ========================================
+
+void loadSequences() {
+ if (!sdCardReady) return;
+
+ if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
+ File seqFile = SD.open("/sequences.bin", FILE_READ);
+ if (seqFile) {
+ sequenceCount = 0;
+ while (seqFile.available() && sequenceCount < MAX_SEQUENCES) {
+ seqFile.read((uint8_t*)&sequences[sequenceCount], sizeof(CANSequence));
+ sequenceCount++;
+ }
+ seqFile.close();
+ Serial.printf("✓ 시퀀스 로드: %d개\n", sequenceCount);
+ }
+ xSemaphoreGive(sdMutex);
+ }
+}
+
+void saveSequences() {
+ if (!sdCardReady) return;
+
+ if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
+ // 기존 파일 삭제
+ if (SD.exists("/sequences.bin")) {
+ SD.remove("/sequences.bin");
+ }
+
+ File seqFile = SD.open("/sequences.bin", FILE_WRITE);
+ if (seqFile) {
+ for (int i = 0; i < sequenceCount; i++) {
+ seqFile.write((uint8_t*)&sequences[i], sizeof(CANSequence));
+ }
+ seqFile.close();
+ Serial.println("✓ 시퀀스 저장 완료");
+ }
+ xSemaphoreGive(sdMutex);
+ }
+}
+
+int addSequence(const CANSequence& seq) {
+ if (sequenceCount >= MAX_SEQUENCES) {
+ return -1; // 가득 참
+ }
+
+ sequences[sequenceCount] = seq;
+ sequenceCount++;
+ saveSequences();
+ return sequenceCount - 1;
+}
+
+bool deleteSequence(uint8_t index) {
+ if (index >= sequenceCount) return false;
+
+ // 배열 왼쪽으로 시프트
+ for (int i = index; i < sequenceCount - 1; i++) {
+ sequences[i] = sequences[i + 1];
+ }
+ sequenceCount--;
+ saveSequences();
+ return true;
+}
+
+void stopSequence() {
+ seqRuntime.running = false;
+ seqRuntime.currentStep = 0;
+ seqRuntime.currentRepeat = 0;
+ seqRuntime.activeSequenceIndex = -1;
+ Serial.println("✓ 시퀀스 실행 중지");
+}
+
+void startSequence(uint8_t index) {
+ if (index >= sequenceCount) return;
+
+ seqRuntime.running = true;
+ seqRuntime.currentStep = 0;
+ seqRuntime.currentRepeat = 0;
+ seqRuntime.lastStepTime = millis();
+ seqRuntime.activeSequenceIndex = index;
+
+ Serial.printf("✓ 시퀀스 실행 시작: %s\n", sequences[index].name);
+}
+
// ========================================
// 파일 커멘트 관리 함수
// ========================================
@@ -510,6 +639,19 @@ bool setMCP2515Mode(MCP2515Mode mode) {
return true;
}
break;
+
+ case MCP_MODE_TRANSMIT:
+ // Transmit mode: Normal 모드와 동일하게 동작
+ // (라이브러리 제약으로 인해 레지스터 직접 접근 불가)
+ // 송신 위주로 사용하되, 수신도 가능한 모드
+ result = mcp2515.setNormalMode();
+ if (result == MCP2515::ERROR_OK) {
+ currentMcpMode = MCP_MODE_TRANSMIT;
+ Serial.println("✓ MCP2515 모드: TRANSMIT (Normal mode base)");
+ Serial.println(" ※ 송신 위주 사용, 수신 데이터는 로깅하지 않음");
+ return true;
+ }
+ break;
}
Serial.println("✗ MCP2515 모드 변경 실패");
@@ -535,6 +677,15 @@ void canRxTask(void* parameter) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+ // Transmit 모드일 때는 수신 데이터를 처리하지 않음
+ if (currentMcpMode == MCP_MODE_TRANSMIT) {
+ // MCP2515 버퍼만 비우고 처리는 건너뜀
+ while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {
+ // 버퍼만 비움
+ }
+ continue;
+ }
+
// 한 번에 여러 메시지를 읽어서 처리 속도 향상
int readCount = 0;
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK && readCount < 20) {
@@ -547,10 +698,12 @@ void canRxTask(void* parameter) {
canMsg.dlc = frame.can_dlc;
memcpy(canMsg.data, frame.data, 8);
- // 큐에 추가 (블로킹 없이)
- xQueueSend(canQueue, &canMsg, 0);
+ // 로깅 중일 때만 큐에 추가
+ if (loggingEnabled) {
+ xQueueSend(canQueue, &canMsg, 0);
+ }
- // 실시간 데이터 업데이트
+ // 실시간 데이터 업데이트 (웹 표시용)
bool found = false;
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].count > 0 && recentData[i].msg.id == canMsg.id) {
@@ -685,9 +838,16 @@ void stopLogging() {
bufferIndex = 0;
+ // 큐 비우기 (로깅 중이 아닐 때는 큐가 불필요)
+ CANMessage dummyMsg;
+ while (xQueueReceive(canQueue, &dummyMsg, 0) == pdTRUE) {
+ // 큐의 모든 메시지 제거
+ }
+
Serial.print("✓ 로깅 종료: ");
Serial.println(currentFilename);
Serial.printf(" 파일 크기: %u bytes\n", currentFileSize);
+ Serial.println("✓ 큐 비움 완료");
// 현재 파일명 초기화
currentFilename[0] = '\0';
@@ -725,6 +885,7 @@ void txTask(void* parameter) {
for (;;) {
uint32_t now = millis();
+ // 주기적 송신 (기존)
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
if (txMessages[i].active && txMessages[i].interval > 0) {
if (now - txMessages[i].lastSent >= txMessages[i].interval) {
@@ -747,6 +908,81 @@ void txTask(void* parameter) {
}
}
+// ========================================
+// 시퀀스 실행 태스크
+// ========================================
+
+void sequenceTask(void* parameter) {
+ struct can_frame frame;
+
+ for (;;) {
+ if (seqRuntime.running && seqRuntime.activeSequenceIndex >= 0) {
+ CANSequence* seq = &sequences[seqRuntime.activeSequenceIndex];
+ uint32_t now = millis();
+
+ if (seqRuntime.currentStep < seq->stepCount) {
+ SequenceStep* step = &seq->steps[seqRuntime.currentStep];
+
+ // 첫 번째 스텝이거나 딜레이 시간이 지났으면 실행
+ if (seqRuntime.currentStep == 0 || (now - seqRuntime.lastStepTime >= step->delayMs)) {
+ // CAN 메시지 전송
+ 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);
+
+ MCP2515::ERROR result = mcp2515.sendMessage(&frame);
+ if (result == MCP2515::ERROR_OK) {
+ totalTxCount++;
+ Serial.printf(" [Seq] Step %d/%d: ID=0x%X, DLC=%d, Delay=%dms - OK\n",
+ seqRuntime.currentStep + 1, seq->stepCount,
+ step->canId, step->dlc, step->delayMs);
+ } else {
+ Serial.printf(" [Seq] Step %d/%d: ID=0x%X - FAIL (Error %d)\n",
+ seqRuntime.currentStep + 1, seq->stepCount, step->canId, result);
+ }
+
+ seqRuntime.currentStep++;
+ seqRuntime.lastStepTime = now;
+ }
+ } else {
+ // 모든 스텝 완료
+ seqRuntime.currentRepeat++;
+
+ // 반복 체크
+ bool shouldContinue = false;
+
+ if (seq->repeatMode == 0) {
+ // 한 번만
+ shouldContinue = false;
+ } else if (seq->repeatMode == 1) {
+ // 특정 횟수
+ if (seqRuntime.currentRepeat < seq->repeatCount) {
+ shouldContinue = true;
+ }
+ } else if (seq->repeatMode == 2) {
+ // 무한 반복
+ shouldContinue = true;
+ }
+
+ if (shouldContinue) {
+ seqRuntime.currentStep = 0;
+ seqRuntime.lastStepTime = now;
+ Serial.printf(" [Seq] 반복 %d회 시작\n", seqRuntime.currentRepeat + 1);
+ } else {
+ Serial.printf("✓ 시퀀스 실행 완료: %s (총 %d회 반복)\n",
+ seq->name, seqRuntime.currentRepeat);
+ stopSequence();
+ }
+ }
+ }
+
+ vTaskDelay(pdMS_TO_TICKS(10));
+ }
+}
+
// ========================================
// 파일 리스트 전송 함수
// ========================================
@@ -974,6 +1210,179 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length)
saveSettings();
webSocket.sendTXT(num, "{\"type\":\"settingsSaved\",\"success\":true}");
+
+ } else if (message.indexOf("\"cmd\":\"getSequences\"") >= 0) {
+ // 시퀀스 리스트 전송
+ String seqList = "{\"type\":\"sequences\",\"sequences\":[";
+ for (int i = 0; i < sequenceCount; i++) {
+ if (i > 0) seqList += ",";
+ seqList += "{\"index\":" + String(i);
+ seqList += ",\"name\":\"" + String(sequences[i].name) + "\"";
+ seqList += ",\"steps\":" + String(sequences[i].stepCount);
+ seqList += ",\"mode\":" + String(sequences[i].repeatMode);
+ seqList += ",\"count\":" + String(sequences[i].repeatCount) + "}";
+ }
+ seqList += "]}";
+ webSocket.sendTXT(num, seqList);
+
+ } else if (message.indexOf("\"cmd\":\"getSequence\"") >= 0) {
+ // 특정 시퀀스 상세 정보 전송
+ int indexStart = message.indexOf("\"index\":") + 8;
+ int indexEnd = message.indexOf(",", indexStart);
+ if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart);
+ int index = message.substring(indexStart, indexEnd).toInt();
+
+ if (index >= 0 && index < sequenceCount) {
+ String seqData = "{\"type\":\"sequenceDetail\",\"sequence\":{";
+ seqData += "\"name\":\"" + String(sequences[index].name) + "\",";
+ seqData += "\"mode\":" + String(sequences[index].repeatMode) + ",";
+ seqData += "\"count\":" + String(sequences[index].repeatCount) + ",";
+ seqData += "\"steps\":[";
+
+ for (int i = 0; i < sequences[index].stepCount; i++) {
+ if (i > 0) seqData += ",";
+ SequenceStep* step = &sequences[index].steps[i];
+ seqData += "{\"id\":" + String(step->canId);
+ seqData += ",\"ext\":" + String(step->extended ? "true" : "false");
+ seqData += ",\"dlc\":" + String(step->dlc);
+ seqData += ",\"data\":\"";
+ for (int j = 0; j < 8; j++) {
+ if (step->data[j] < 0x10) seqData += "0";
+ seqData += String(step->data[j], HEX);
+ if (j < 7) seqData += " ";
+ }
+ seqData += "\",\"delay\":" + String(step->delayMs) + "}";
+ }
+ seqData += "]}}";
+ webSocket.sendTXT(num, seqData);
+ }
+
+ } else if (message.indexOf("\"cmd\":\"saveSequence\"") >= 0) {
+ // 새 시퀀스 저장 (JSON 파싱)
+ CANSequence newSeq;
+ memset(&newSeq, 0, sizeof(CANSequence));
+
+ // 이름 추출
+ int nameStart = message.indexOf("\"name\":\"") + 8;
+ int nameEnd = message.indexOf("\"", nameStart);
+ String name = message.substring(nameStart, nameEnd);
+ strncpy(newSeq.name, name.c_str(), sizeof(newSeq.name) - 1);
+
+ // 모드 추출
+ int modeStart = message.indexOf("\"mode\":") + 7;
+ int modeEnd = message.indexOf(",", modeStart);
+ newSeq.repeatMode = message.substring(modeStart, modeEnd).toInt();
+
+ // 반복 횟수 추출
+ int countStart = message.indexOf("\"repeatCount\":") + 14;
+ int countEnd = message.indexOf(",", countStart);
+ if (countEnd < 0) countEnd = message.indexOf("}", countStart);
+ newSeq.repeatCount = message.substring(countStart, countEnd).toInt();
+
+ // 스텝 배열 파싱
+ int stepsStart = message.indexOf("\"steps\":[");
+ if (stepsStart >= 0) {
+ stepsStart += 9; // "steps":[ 길이
+ int stepsEnd = message.indexOf("]}", stepsStart);
+ String stepsJson = message.substring(stepsStart, stepsEnd);
+
+ // 각 스텝 파싱
+ newSeq.stepCount = 0;
+ int pos = 0;
+
+ while (pos < stepsJson.length() && newSeq.stepCount < 20) {
+ int stepStart = stepsJson.indexOf("{", pos);
+ if (stepStart < 0) break;
+
+ int stepEnd = stepsJson.indexOf("}", stepStart);
+ if (stepEnd < 0) break;
+
+ String stepJson = stepsJson.substring(stepStart, stepEnd + 1);
+
+ // canId 추출
+ int idStart = stepJson.indexOf("\"canId\":") + 8;
+ int idEnd = stepJson.indexOf(",", idStart);
+ if (idEnd < 0) idEnd = stepJson.indexOf("}", idStart);
+ newSeq.steps[newSeq.stepCount].canId = stepJson.substring(idStart, idEnd).toInt();
+
+ // extended 추출
+ int extStart = stepJson.indexOf("\"extended\":") + 11;
+ String extStr = stepJson.substring(extStart, extStart + 5);
+ newSeq.steps[newSeq.stepCount].extended = (extStr.indexOf("true") >= 0);
+
+ // dlc 추출
+ int dlcStart = stepJson.indexOf("\"dlc\":") + 6;
+ int dlcEnd = stepJson.indexOf(",", dlcStart);
+ newSeq.steps[newSeq.stepCount].dlc = stepJson.substring(dlcStart, dlcEnd).toInt();
+
+ // data 배열 추출
+ int dataStart = stepJson.indexOf("\"data\":[") + 8;
+ int dataEnd = stepJson.indexOf("]", dataStart);
+ String dataStr = stepJson.substring(dataStart, dataEnd);
+
+ // data 바이트 파싱
+ int bytePos = 0;
+ int byteIdx = 0;
+ while (bytePos < dataStr.length() && byteIdx < 8) {
+ int commaPos = dataStr.indexOf(",", bytePos);
+ if (commaPos < 0) commaPos = dataStr.length();
+
+ String byteStr = dataStr.substring(bytePos, commaPos);
+ byteStr.trim();
+ newSeq.steps[newSeq.stepCount].data[byteIdx] = byteStr.toInt();
+
+ byteIdx++;
+ bytePos = commaPos + 1;
+ }
+
+ // delay 추출
+ int delayStart = stepJson.indexOf("\"delayMs\":") + 10;
+ int delayEnd = stepJson.indexOf(",", delayStart);
+ if (delayEnd < 0) delayEnd = stepJson.indexOf("}", delayStart);
+ newSeq.steps[newSeq.stepCount].delayMs = stepJson.substring(delayStart, delayEnd).toInt();
+
+ newSeq.stepCount++;
+ pos = stepEnd + 1;
+ }
+ }
+
+ Serial.printf("📝 시퀀스 저장: %s (%d 스텝)\n", newSeq.name, newSeq.stepCount);
+ for (int i = 0; i < newSeq.stepCount; i++) {
+ Serial.printf(" Step %d: ID=0x%X, DLC=%d, Delay=%dms\n",
+ i, newSeq.steps[i].canId, newSeq.steps[i].dlc, newSeq.steps[i].delayMs);
+ }
+
+ int result = addSequence(newSeq);
+ if (result >= 0) {
+ webSocket.sendTXT(num, "{\"type\":\"sequenceSaved\",\"success\":true,\"index\":" + String(result) + "}");
+ } else {
+ webSocket.sendTXT(num, "{\"type\":\"sequenceSaved\",\"success\":false}");
+ }
+
+ } else if (message.indexOf("\"cmd\":\"deleteSequence\"") >= 0) {
+ // 시퀀스 삭제
+ int indexStart = message.indexOf("\"index\":") + 8;
+ int indexEnd = message.indexOf(",", indexStart);
+ if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart);
+ int index = message.substring(indexStart, indexEnd).toInt();
+
+ bool success = deleteSequence(index);
+ webSocket.sendTXT(num, "{\"type\":\"sequenceDeleted\",\"success\":" + String(success ? "true" : "false") + "}");
+
+ } else if (message.indexOf("\"cmd\":\"startSequence\"") >= 0) {
+ // 시퀀스 실행
+ int indexStart = message.indexOf("\"index\":") + 8;
+ int indexEnd = message.indexOf(",", indexStart);
+ if (indexEnd < 0) indexEnd = message.indexOf("}", indexStart);
+ int index = message.substring(indexStart, indexEnd).toInt();
+
+ startSequence(index);
+ webSocket.sendTXT(num, "{\"type\":\"sequenceStarted\",\"success\":true}");
+
+ } else if (message.indexOf("\"cmd\":\"stopSequence\"") >= 0) {
+ // 시퀀스 중지
+ stopSequence();
+ webSocket.sendTXT(num, "{\"type\":\"sequenceStopped\",\"success\":true}");
}
}
}
@@ -1247,6 +1656,7 @@ void setup() {
xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0);
xTaskCreatePinnedToCore(txTask, "TX_TASK", 4096, NULL, 2, NULL, 1);
+ xTaskCreatePinnedToCore(sequenceTask, "SEQ_TASK", 4096, NULL, 2, NULL, 1); // 시퀀스 Task 추가
// RTC 동기화 Task
if (timeSyncStatus.rtcAvailable) {
@@ -1254,6 +1664,9 @@ void setup() {
Serial.println("✓ RTC 자동 동기화 Task 시작");
}
+ // 시퀀스 로드
+ loadSequences();
+
Serial.println("✓ 모든 태스크 시작 완료");
Serial.println("\n========================================");
Serial.println(" 웹 인터페이스 접속 방법");
diff --git a/index.h b/index.h
index 7fb43df..5b23885 100644
--- a/index.h
+++ b/index.h
@@ -694,6 +694,7 @@ const char index_html[] PROGMEM = R"rawliteral(
+
@@ -751,7 +752,7 @@ const char index_html[] PROGMEM = R"rawliteral(
let messageOrder = [];
let lastMessageData = {};
const speedNames = {0: '125K', 1: '250K', 2: '500K', 3: '1M'};
- const modeNames = {0: 'NORMAL', 1: 'LISTEN-ONLY', 2: 'LOOPBACK'};
+ const modeNames = {0: 'NORMAL', 1: 'LISTEN-ONLY', 2: 'LOOPBACK', 3: 'TRANSMIT'};
let currentLoggingFile = '';
let commentingFile = '';
let hasInitialSync = false; // 초기 동기화 완료 여부
diff --git a/transmit.h b/transmit.h
index d5474de..4ef3168 100644
--- a/transmit.h
+++ b/transmit.h
@@ -6,13 +6,13 @@ const char transmit_html[] PROGMEM = R"rawliteral(
-
- CAN Transmitter
+
+ CAN Transmit - Sequence Editor
@@ -419,216 +233,97 @@ const char transmit_html[] PROGMEM = R"rawliteral(
-
- Disconnected
- Sent: 0
-
-
-
-
-
-
-
-
-
-
-
Message List Presets
-
-
-
-
-
-
-
+
+
+
Create New Sequence
-
Add CAN Message
-