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(
-

CAN Transmitter

-

Send CAN Messages - Periodic & Sequence Mode

+

🚀 CAN Sequence Transmitter

+

Create and Execute CAN Message Sequences

-
- Disconnected - Sent: 0 -
- - -
- - -
- - -
-

Message List Presets

-
-
- - -
-
-

No saved presets

-
-
+ +
+

Create New Sequence

-

Add CAN Message

-
-
-
- - -
-
- - -
-
- - -
-
- - -
+
+
+ +
- -
- - - - Delay between messages when using "Send All Once" button - -
- -
- -
- - - - - - - - -
-
- -
- - -
-
- -

Message List

-
- - - - -
- -
-

No messages added yet

-
-
- - -
-

Sequence Builder

- -
-
- - + + +
- - - - -
- -
-

Add Step to Sequence

-
-
- - -
-
- -
-
-
- - -
-
- - -
-
- - -
-
-
- -
- - - - - - - - -
-
-
- - - -
- +
+ +
-

Sequence Steps

-
- - - +

Add Step

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-

No steps added yet

+
+ + +
+ +
+ + + +
+ +

Steps (0)

+
+

No steps added yet

+
+
+ + +
+

Saved Sequences

+
+

Loading...

@@ -636,663 +331,283 @@ const char transmit_html[] PROGMEM = R"rawliteral(