This commit is contained in:
2025-10-12 16:46:31 +00:00
parent 17f1ec6d8c
commit 8e497c515d
5 changed files with 3644 additions and 0 deletions

View File

@@ -0,0 +1,842 @@
/*
* Byun CAN Logger with Web Interface + Time Synchronization
* Version: 1.2
*/
#include <Arduino.h>
#include <SPI.h>
#include <mcp2515.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.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 "index.h"
#include "transmit.h"
#include "graph.h"
#include "graph_viewer.h"
// GPIO 핀 정의
#define CAN_INT_PIN 27
// HSPI 핀 (CAN)
#define HSPI_MISO 12
#define HSPI_MOSI 13
#define HSPI_SCLK 14
#define HSPI_CS 15
// VSPI 핀 (SD Card)
#define VSPI_MISO 19
#define VSPI_MOSI 23
#define VSPI_SCLK 18
#define VSPI_CS 5
// 버퍼 설정
#define CAN_QUEUE_SIZE 1000
#define FILE_BUFFER_SIZE 8192
#define MAX_FILENAME_LEN 64
#define RECENT_MSG_COUNT 100
#define MAX_TX_MESSAGES 20
// CAN 메시지 구조체 - 마이크로초 단위 타임스탬프
struct CANMessage {
uint64_t timestamp_us; // 마이크로초 단위 Unix timestamp
uint32_t id;
uint8_t dlc;
uint8_t data[8];
} __attribute__((packed));
// 실시간 모니터링용 구조체
struct RecentCANData {
CANMessage msg;
uint32_t count;
};
// CAN 송신용 구조체
struct TxMessage {
uint32_t id;
bool extended;
uint8_t dlc;
uint8_t data[8];
uint32_t interval;
uint32_t lastSent;
bool active;
};
// 시간 동기화 상태
struct TimeSyncStatus {
bool synchronized;
uint64_t lastSyncTime;
int32_t offsetUs;
uint32_t syncCount;
} timeSyncStatus = {false, 0, 0, 0};
// WiFi AP 설정
const char* ssid = "Byun_CAN_Logger";
const char* password = "12345678";
// 전역 변수
SPIClass hspi(HSPI);
SPIClass vspi(VSPI);
MCP2515 mcp2515(HSPI_CS, 10000000, &hspi);
WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);
QueueHandle_t canQueue;
SemaphoreHandle_t sdMutex;
TaskHandle_t canRxTaskHandle = NULL;
TaskHandle_t sdWriteTaskHandle = NULL;
TaskHandle_t webTaskHandle = NULL;
volatile bool loggingEnabled = false;
volatile bool sdCardReady = false;
File logFile;
char currentFilename[MAX_FILENAME_LEN];
uint8_t fileBuffer[FILE_BUFFER_SIZE];
uint16_t bufferIndex = 0;
// CAN 속도 설정
CAN_SPEED currentCanSpeed = CAN_1000KBPS;
const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"};
CAN_SPEED canSpeedValues[] = {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS};
// 실시간 모니터링용
RecentCANData recentData[RECENT_MSG_COUNT];
uint32_t totalMsgCount = 0;
uint32_t msgPerSecond = 0;
uint32_t lastMsgCountTime = 0;
uint32_t lastMsgCount = 0;
// CAN 송신용
TxMessage txMessages[MAX_TX_MESSAGES];
uint32_t totalTxCount = 0;
// 정밀한 현재 시간 가져오기 (마이크로초)
uint64_t getMicrosecondTimestamp() {
struct timeval tv;
gettimeofday(&tv, NULL);
return (uint64_t)tv.tv_sec * 1000000ULL + (uint64_t)tv.tv_usec;
}
// 시간 동기화 설정
void setSystemTime(uint64_t timestampMs) {
struct timeval tv;
tv.tv_sec = timestampMs / 1000;
tv.tv_usec = (timestampMs % 1000) * 1000;
settimeofday(&tv, NULL);
timeSyncStatus.synchronized = true;
timeSyncStatus.lastSyncTime = getMicrosecondTimestamp();
timeSyncStatus.syncCount++;
// 현재 시간 출력
time_t now = tv.tv_sec;
struct tm timeinfo;
localtime_r(&now, &timeinfo);
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
Serial.printf("⏰ 시간 동기화 완료: %s.%03d (동기화 횟수: %u)\n",
timeStr, (int)(tv.tv_usec / 1000), timeSyncStatus.syncCount);
}
// 함수 선언
void changeCanSpeed(CAN_SPEED newSpeed);
bool createNewLogFile();
bool flushBuffer();
void startLogging();
void stopLogging();
void canRxTask(void *pvParameters);
void sdWriteTask(void *pvParameters);
void sdMonitorTask(void *pvParameters);
void sendFileList(uint8_t clientNum);
void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length);
void handleCanTransmit(String msg);
void handleStartMessage(String msg);
void handleStopMessage(String msg);
void handleTimeSync(String msg);
void webUpdateTask(void *pvParameters);
// CAN 인터럽트 핸들러
void IRAM_ATTR canISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (canRxTaskHandle != NULL) {
vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
// CAN 속도 변경
void changeCanSpeed(CAN_SPEED newSpeed) {
detachInterrupt(digitalPinToInterrupt(CAN_INT_PIN));
mcp2515.reset();
mcp2515.setBitrate(newSpeed, MCP_8MHZ);
mcp2515.setNormalMode();
currentCanSpeed = newSpeed;
attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING);
Serial.printf("CAN 속도 변경: %s\n", canSpeedNames[newSpeed]);
}
// 새 로그 파일 생성 - 시간 기반 파일명
bool createNewLogFile() {
if (logFile) {
logFile.flush();
logFile.close();
vTaskDelay(pdMS_TO_TICKS(10));
}
// 현재 시간으로 파일명 생성
time_t now;
struct tm timeinfo;
time(&now);
localtime_r(&now, &timeinfo);
char filename[MAX_FILENAME_LEN];
snprintf(filename, MAX_FILENAME_LEN, "/canlog_%04d%02d%02d_%02d%02d%02d.bin",
timeinfo.tm_year + 1900,
timeinfo.tm_mon + 1,
timeinfo.tm_mday,
timeinfo.tm_hour,
timeinfo.tm_min,
timeinfo.tm_sec);
logFile = SD.open(filename, FILE_WRITE);
if (!logFile) {
Serial.printf("파일 생성 실패: %s\n", filename);
return false;
}
strncpy(currentFilename, filename, MAX_FILENAME_LEN);
Serial.printf("새 로그 파일 생성: %s\n", currentFilename);
// 시간 동기화 경고
if (!timeSyncStatus.synchronized) {
Serial.println("⚠️ 경고: 시간이 동기화되지 않았습니다! 웹페이지에서 시간 동기화를 실행하세요.");
}
return true;
}
// 버퍼 플러시
bool flushBuffer() {
if (bufferIndex == 0) return true;
if (xSemaphoreTake(sdMutex, portMAX_DELAY) == pdTRUE) {
if (logFile) {
size_t written = logFile.write(fileBuffer, bufferIndex);
logFile.flush();
xSemaphoreGive(sdMutex);
if (written != bufferIndex) {
Serial.println("SD 쓰기 오류!");
return false;
}
bufferIndex = 0;
return true;
}
xSemaphoreGive(sdMutex);
}
return false;
}
// 로깅 시작
void startLogging() {
if (loggingEnabled) {
Serial.println("이미 로깅 중");
return;
}
if (!sdCardReady) {
Serial.println("SD 카드가 준비되지 않음");
return;
}
Serial.println("로깅 시작");
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (createNewLogFile()) {
loggingEnabled = true;
bufferIndex = 0;
}
xSemaphoreGive(sdMutex);
}
}
// 로깅 중지
void stopLogging() {
if (!loggingEnabled) {
Serial.println("로깅이 실행 중이 아님");
return;
}
Serial.println("로깅 정지");
loggingEnabled = false;
flushBuffer();
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (logFile) {
logFile.close();
}
xSemaphoreGive(sdMutex);
}
}
// CAN 수신 태스크
void canRxTask(void *pvParameters) {
struct can_frame frame;
CANMessage msg;
Serial.println("CAN 수신 태스크 시작");
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {
// 마이크로초 단위 타임스탬프
msg.timestamp_us = getMicrosecondTimestamp();
msg.id = frame.can_id;
msg.dlc = frame.can_dlc;
memcpy(msg.data, frame.data, 8);
if (xQueueSend(canQueue, &msg, 0) != pdTRUE) {
static uint32_t lastWarning = 0;
if (millis() - lastWarning > 1000) {
Serial.println("경고: CAN 큐 오버플로우!");
lastWarning = millis();
}
}
// 최근 메시지 저장 및 카운트 증가
bool found = false;
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].msg.id == msg.id && recentData[i].msg.timestamp_us > 0) {
recentData[i].msg = msg;
recentData[i].count++;
found = true;
break;
}
}
if (!found) {
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].msg.timestamp_us == 0) {
recentData[i].msg = msg;
recentData[i].count = 1;
found = true;
break;
}
}
if (!found) {
static int replaceIndex = 0;
recentData[replaceIndex].msg = msg;
recentData[replaceIndex].count = 1;
replaceIndex = (replaceIndex + 1) % RECENT_MSG_COUNT;
}
}
totalMsgCount++;
}
}
}
// SD 쓰기 태스크
void sdWriteTask(void *pvParameters) {
CANMessage msg;
Serial.println("SD 쓰기 태스크 시작");
while (1) {
if (xQueueReceive(canQueue, &msg, pdMS_TO_TICKS(100)) == pdTRUE) {
if (loggingEnabled && sdCardReady) {
if (bufferIndex + sizeof(CANMessage) > FILE_BUFFER_SIZE) {
if (!flushBuffer()) {
continue;
}
}
memcpy(&fileBuffer[bufferIndex], &msg, sizeof(CANMessage));
bufferIndex += sizeof(CANMessage);
}
} else {
if (loggingEnabled && bufferIndex > 0) {
flushBuffer();
}
}
}
}
// SD 모니터 태스크
void sdMonitorTask(void *pvParameters) {
Serial.println("SD 모니터 태스크 시작");
while (1) {
bool cardPresent = SD.begin(VSPI_CS, vspi);
if (cardPresent != sdCardReady) {
sdCardReady = cardPresent;
if (sdCardReady) {
Serial.println("SD 카드 준비됨");
} else {
Serial.println("SD 카드 없음");
if (loggingEnabled) {
stopLogging();
}
}
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
// 파일 목록 전송
void sendFileList(uint8_t clientNum) {
String fileList = "{\"type\":\"files\",\"files\":[";
if (!sdCardReady) {
fileList += "],\"error\":\"SD card not ready\"}";
webSocket.sendTXT(clientNum, fileList);
return;
}
File root = SD.open("/");
if (!root) {
fileList += "],\"error\":\"Cannot open root directory\"}";
webSocket.sendTXT(clientNum, fileList);
return;
}
File file = root.openNextFile();
bool first = true;
int fileCount = 0;
while (file) {
if (!file.isDirectory()) {
String name = file.name();
if (name.startsWith("/")) name = name.substring(1);
if (name.endsWith(".bin") || name.endsWith(".BIN")) {
if (!first) fileList += ",";
fileList += "{\"name\":\"" + name + "\",\"size\":" + String(file.size()) + "}";
first = false;
fileCount++;
}
}
file.close();
file = root.openNextFile();
}
root.close();
fileList += "]}";
webSocket.sendTXT(clientNum, fileList);
Serial.printf("파일 목록 전송: %d개\n", fileCount);
}
// CAN 메시지 전송 처리
void handleCanTransmit(String msg) {
int idIdx = msg.indexOf("\"id\":\"") + 6;
int idEnd = msg.indexOf("\"", idIdx);
String idStr = msg.substring(idIdx, idEnd);
int typeIdx = msg.indexOf("\"type\":\"") + 8;
String typeStr = msg.substring(typeIdx, typeIdx + 3);
bool extended = (typeStr == "ext");
int dlcIdx = msg.indexOf("\"dlc\":") + 6;
int dlc = msg.substring(dlcIdx, dlcIdx + 1).toInt();
int dataIdx = msg.indexOf("\"data\":\"") + 8;
String dataStr = msg.substring(dataIdx, dataIdx + 16);
uint32_t canId = strtoul(idStr.c_str(), NULL, 16);
uint8_t data[8] = {0};
for (int i = 0; i < dlc && i < 8; i++) {
String byteStr = dataStr.substring(i * 2, i * 2 + 2);
data[i] = strtoul(byteStr.c_str(), NULL, 16);
}
struct can_frame frame;
frame.can_id = canId;
if (extended) frame.can_id |= CAN_EFF_FLAG;
frame.can_dlc = dlc;
memcpy(frame.data, data, 8);
if (mcp2515.sendMessage(&frame) == MCP2515::ERROR_OK) {
totalTxCount++;
Serial.printf("CAN TX: 0x%X [%d]\n", canId, dlc);
}
}
// 주기 전송 시작
void handleStartMessage(String msg) {
int indexIdx = msg.indexOf("\"index\":") + 8;
int index = msg.substring(indexIdx, indexIdx + 2).toInt();
if (index >= 0 && index < MAX_TX_MESSAGES) {
int idIdx = msg.indexOf("\"id\":\"") + 6;
int idEnd = msg.indexOf("\"", idIdx);
String idStr = msg.substring(idIdx, idEnd);
int typeIdx = msg.indexOf("\"type\":\"") + 8;
String typeStr = msg.substring(typeIdx, typeIdx + 3);
int dlcIdx = msg.indexOf("\"dlc\":") + 6;
int dlc = msg.substring(dlcIdx, dlcIdx + 1).toInt();
int dataIdx = msg.indexOf("\"data\":\"") + 8;
String dataStr = msg.substring(dataIdx, dataIdx + 16);
int intervalIdx = msg.indexOf("\"interval\":") + 11;
int interval = msg.substring(intervalIdx, intervalIdx + 5).toInt();
txMessages[index].id = strtoul(idStr.c_str(), NULL, 16);
txMessages[index].extended = (typeStr == "ext");
txMessages[index].dlc = dlc;
for (int i = 0; i < 8; i++) {
String byteStr = dataStr.substring(i * 2, i * 2 + 2);
txMessages[index].data[i] = strtoul(byteStr.c_str(), NULL, 16);
}
txMessages[index].interval = interval;
txMessages[index].lastSent = 0;
txMessages[index].active = true;
Serial.printf("주기 전송 시작 [%d]: 0x%X\n", index, txMessages[index].id);
}
}
// 주기 전송 중지
void handleStopMessage(String msg) {
int indexIdx = msg.indexOf("\"index\":") + 8;
int index = msg.substring(indexIdx, indexIdx + 2).toInt();
if (index >= 0 && index < MAX_TX_MESSAGES) {
txMessages[index].active = false;
Serial.printf("주기 전송 중지 [%d]\n", index);
}
}
// 시간 동기화 처리
void handleTimeSync(String msg) {
int timestampIdx = msg.indexOf("\"timestamp\":") + 12;
String timestampStr = msg.substring(timestampIdx);
timestampStr = timestampStr.substring(0, timestampStr.indexOf("}"));
uint64_t clientTimestamp = strtoull(timestampStr.c_str(), NULL, 10);
if (clientTimestamp > 0) {
setSystemTime(clientTimestamp);
}
}
// 웹소켓 이벤트 핸들러
void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) {
switch(type) {
case WStype_DISCONNECTED:
Serial.printf("WebSocket #%u 연결 해제\n", num);
break;
case WStype_CONNECTED:
{
IPAddress ip = webSocket.remoteIP(num);
Serial.printf("WebSocket #%u 연결: %d.%d.%d.%d\n",
num, ip[0], ip[1], ip[2], ip[3]);
sendFileList(num);
// 시간 동기화 상태 전송
String syncStatus = "{\"type\":\"timeSyncStatus\",\"synchronized\":";
syncStatus += timeSyncStatus.synchronized ? "true" : "false";
syncStatus += ",\"syncCount\":" + String(timeSyncStatus.syncCount) + "}";
webSocket.sendTXT(num, syncStatus);
}
break;
case WStype_TEXT:
{
String msg = String((char*)payload);
if (msg.indexOf("\"cmd\":\"setSpeed\"") >= 0) {
int speedIdx = msg.indexOf("\"speed\":") + 8;
int speed = msg.substring(speedIdx, speedIdx + 1).toInt();
if (speed >= 0 && speed < 4) {
changeCanSpeed(canSpeedValues[speed]);
}
}
else if (msg.indexOf("\"cmd\":\"getFiles\"") >= 0) {
sendFileList(num);
}
else if (msg.indexOf("\"cmd\":\"startLogging\"") >= 0) {
startLogging();
}
else if (msg.indexOf("\"cmd\":\"stopLogging\"") >= 0) {
stopLogging();
}
else if (msg.indexOf("\"cmd\":\"sendCan\"") >= 0) {
handleCanTransmit(msg);
}
else if (msg.indexOf("\"cmd\":\"startMsg\"") >= 0) {
handleStartMessage(msg);
}
else if (msg.indexOf("\"cmd\":\"stopMsg\"") >= 0) {
handleStopMessage(msg);
}
else if (msg.indexOf("\"cmd\":\"stopAll\"") >= 0) {
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
txMessages[i].active = false;
}
}
else if (msg.indexOf("\"cmd\":\"syncTime\"") >= 0) {
handleTimeSync(msg);
}
}
break;
}
}
// 웹 업데이트 태스크
void webUpdateTask(void *pvParameters) {
uint32_t lastStatusUpdate = 0;
uint32_t lastCanUpdate = 0;
uint32_t lastTxStatusUpdate = 0;
const uint32_t CAN_UPDATE_INTERVAL = 500;
Serial.println("웹 업데이트 태스크 시작");
while (1) {
uint32_t now = millis();
webSocket.loop();
// 주기 전송 처리
for (int i = 0; i < MAX_TX_MESSAGES; i++) {
if (txMessages[i].active && (now - txMessages[i].lastSent >= txMessages[i].interval)) {
struct can_frame frame;
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;
}
}
}
// TX 상태 업데이트
if (now - lastTxStatusUpdate >= 1000) {
String txStatus = "{\"type\":\"txStatus\",\"count\":" + String(totalTxCount) + "}";
webSocket.broadcastTXT(txStatus);
lastTxStatusUpdate = now;
}
// 상태 업데이트
if (now - lastStatusUpdate >= 1000) {
if (now - lastMsgCountTime >= 1000) {
msgPerSecond = totalMsgCount - lastMsgCount;
lastMsgCount = totalMsgCount;
lastMsgCountTime = now;
}
String status = "{\"type\":\"status\",";
status += "\"logging\":" + String(loggingEnabled ? "true" : "false") + ",";
status += "\"sdReady\":" + String(sdCardReady ? "true" : "false") + ",";
status += "\"msgCount\":" + String(totalMsgCount) + ",";
status += "\"msgSpeed\":" + String(msgPerSecond) + ",";
status += "\"timeSync\":" + String(timeSyncStatus.synchronized ? "true" : "false") + ",";
status += "\"syncCount\":" + String(timeSyncStatus.syncCount) + ",";
if (loggingEnabled && logFile) {
status += "\"currentFile\":\"" + String(currentFilename) + "\"";
} else {
status += "\"currentFile\":\"\"";
}
status += "}";
webSocket.broadcastTXT(status);
lastStatusUpdate = now;
}
// CAN 메시지 일괄 업데이트
if (now - lastCanUpdate >= CAN_UPDATE_INTERVAL) {
String canBatch = "{\"type\":\"canBatch\",\"messages\":[";
bool first = true;
for (int i = 0; i < RECENT_MSG_COUNT; i++) {
if (recentData[i].msg.timestamp_us > 0) {
CANMessage* msg = &recentData[i].msg;
if (!first) canBatch += ",";
first = false;
canBatch += "{\"id\":\"";
if (msg->id < 0x10) canBatch += "0";
if (msg->id < 0x100) canBatch += "0";
if (msg->id < 0x1000) canBatch += "0";
canBatch += String(msg->id, HEX);
canBatch += "\",\"dlc\":" + String(msg->dlc);
canBatch += ",\"data\":\"";
for (int j = 0; j < msg->dlc; j++) {
if (msg->data[j] < 0x10) canBatch += "0";
canBatch += String(msg->data[j], HEX);
if (j < msg->dlc - 1) canBatch += " ";
}
// 마이크로초 타임스탬프를 밀리초로 변환하여 전송
uint64_t timestamp_ms = msg->timestamp_us / 1000;
canBatch += "\",\"timestamp\":" + String((uint32_t)timestamp_ms);
canBatch += ",\"count\":" + String(recentData[i].count) + "}";
}
}
canBatch += "]}";
if (!first) {
webSocket.broadcastTXT(canBatch);
}
lastCanUpdate = now;
}
vTaskDelay(pdMS_TO_TICKS(50));
}
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n========================================");
Serial.println(" ESP32 CAN Logger with Time Sync ");
Serial.println("========================================");
memset(recentData, 0, sizeof(recentData));
memset(txMessages, 0, sizeof(txMessages));
pinMode(CAN_INT_PIN, INPUT_PULLUP);
hspi.begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_CS);
vspi.begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_CS);
mcp2515.reset();
mcp2515.setBitrate(CAN_1000KBPS, MCP_8MHZ);
mcp2515.setNormalMode();
Serial.println("✓ MCP2515 초기화 완료");
if (SD.begin(VSPI_CS, vspi)) {
sdCardReady = true;
Serial.println("✓ SD 카드 초기화 완료");
} else {
Serial.println("✗ SD 카드 초기화 실패");
}
WiFi.softAP(ssid, password);
Serial.print("✓ AP IP: ");
Serial.println(WiFi.softAPIP());
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("/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();
canQueue = xQueueCreate(CAN_QUEUE_SIZE, sizeof(CANMessage));
sdMutex = xSemaphoreCreateMutex();
if (canQueue == NULL || sdMutex == NULL) {
Serial.println("✗ RTOS 객체 생성 실패!");
while (1) delay(1000);
}
attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING);
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 4096, NULL, 4, &canRxTaskHandle, 1);
xTaskCreatePinnedToCore(sdWriteTask, "SD_WRITE", 12288, NULL, 3, &sdWriteTaskHandle, 1);
xTaskCreatePinnedToCore(sdMonitorTask, "SD_MONITOR", 4096, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 8192, NULL, 2, &webTaskHandle, 0);
Serial.println("✓ 모든 태스크 시작 완료");
Serial.println("\n========================================");
Serial.println(" 웹 인터페이스 접속");
Serial.println("========================================");
Serial.println(" 1. WiFi: Byun_CAN_Logger (12345678)");
Serial.print(" 2. http://");
Serial.println(WiFi.softAPIP());
Serial.println(" 3. Pages:");
Serial.println(" - Monitor: /");
Serial.println(" - Transmit: /transmit");
Serial.println(" - Graph: /graph");
Serial.println("========================================\n");
Serial.println("⚠️ 시간 동기화를 위해 웹페이지에서 '⏰ 시간 동기화' 버튼을 클릭하세요");
}
void loop() {
server.handleClient();
vTaskDelay(pdMS_TO_TICKS(10));
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 10000) {
Serial.printf("[상태] 큐: %d/%d | 로깅: %s | SD: %s | RX: %lu | TX: %lu | 시간동기: %s\n",
uxQueueMessagesWaiting(canQueue), CAN_QUEUE_SIZE,
loggingEnabled ? "ON " : "OFF",
sdCardReady ? "OK" : "NO",
totalMsgCount, totalTxCount,
timeSyncStatus.synchronized ? "OK" : "NO");
lastPrint = millis();
}
}

762
test_i2c_reset/graph.h Normal file
View File

@@ -0,0 +1,762 @@
#ifndef GRAPH_H
#define GRAPH_H
const char graph_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CAN Signal Graph</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
min-height: 100vh;
padding: 10px;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 { font-size: 1.8em; margin-bottom: 5px; }
.nav {
background: #2c3e50;
padding: 10px;
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 15px;
border-radius: 5px;
transition: all 0.3s;
font-size: 0.9em;
}
.nav a:hover { background: #34495e; }
.nav a.active { background: #3498db; }
.content { padding: 15px; }
.dbc-upload {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin-bottom: 15px;
}
.upload-area {
border: 3px dashed #43cea2;
border-radius: 10px;
padding: 30px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover { background: #f0f9ff; border-color: #185a9d; }
.upload-area input { display: none; }
.signal-selector {
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
margin-bottom: 15px;
}
.signal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-top: 15px;
}
.signal-item {
background: white;
padding: 12px;
border-radius: 8px;
border: 2px solid #ddd;
cursor: pointer;
transition: all 0.3s;
}
.signal-item:hover { border-color: #43cea2; transform: translateY(-2px); }
.signal-item.selected { border-color: #185a9d; background: #e3f2fd; }
.signal-name { font-weight: 600; color: #333; margin-bottom: 5px; font-size: 0.9em; }
.signal-info { font-size: 0.8em; color: #666; }
.graph-container {
background: white;
padding: 15px;
border-radius: 10px;
margin-bottom: 15px;
border: 2px solid #e0e0e0;
}
.graph-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 2px solid #43cea2;
}
.graph-title {
font-size: 1em;
font-weight: 600;
color: #333;
}
.graph-value {
font-size: 1.2em;
font-weight: 700;
color: #185a9d;
font-family: 'Courier New', monospace;
}
canvas {
width: 100%;
height: 250px;
border: 1px solid #ddd;
border-radius: 5px;
background: #fafafa;
}
.controls {
display: flex;
gap: 8px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
font-size: 0.9em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary { background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%); color: white; }
.btn-success { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); color: white; }
.btn-danger { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); color: white; }
.btn:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.3); }
h2 {
color: #333;
margin: 20px 0 15px 0;
padding-bottom: 8px;
border-bottom: 3px solid #43cea2;
font-size: 1.3em;
}
.status { padding: 12px; background: #fff3cd; border-radius: 5px; margin-bottom: 15px; font-size: 0.9em; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
@media (max-width: 768px) {
body { padding: 5px; }
.header h1 { font-size: 1.5em; }
.content { padding: 10px; }
.signal-grid { grid-template-columns: 1fr; gap: 8px; }
canvas { height: 200px; }
h2 { font-size: 1.1em; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>CAN Signal Graph</h1>
<p>Real-time Signal Visualization (Offline Mode)</p>
</div>
<div class="nav">
<a href="/">Monitor</a>
<a href="/transmit">Transmit</a>
<a href="/graph" class="active">Graph</a>
</div>
<div class="content">
<div id="status" class="status" style="display:none;"></div>
<h2>Upload DBC File</h2>
<div class="dbc-upload">
<div class="upload-area" onclick="document.getElementById('dbc-file').click()">
<input type="file" id="dbc-file" accept=".dbc" onchange="loadDBCFile(event)">
<p style="font-size: 1.1em; margin-bottom: 8px;">Click to upload DBC</p>
<p style="color: #666; font-size: 0.85em;" id="dbc-status">No file loaded</p>
</div>
</div>
<div id="signal-section" style="display:none;">
<h2>Select Signals (Max 6)</h2>
<div class="controls">
<button class="btn btn-success" onclick="startGraphing()">Start</button>
<button class="btn btn-danger" onclick="stopGraphing()">Stop</button>
<button class="btn btn-primary" onclick="clearSelection()">Clear</button>
</div>
<div class="signal-selector">
<div id="signal-list" class="signal-grid"></div>
</div>
<div style="background: #e3f2fd; padding: 15px; border-radius: 8px; margin-top: 15px; border-left: 4px solid #185a9d;">
<p style="color: #333; font-size: 0.9em; margin: 0;">
<strong> Info:</strong> Click "Start" to open a new window with real-time graphs.
Your selected signals will be saved automatically.
</p>
</div>
</div>
</div>
</div>
<script>
let ws;
let dbcData = {};
let selectedSignals = [];
let charts = {};
let graphing = false;
let startTime = 0;
const MAX_SIGNALS = 6;
const MAX_DATA_POINTS = 60;
const COLORS = [
{line: '#FF6384', fill: 'rgba(255, 99, 132, 0.1)'},
{line: '#36A2EB', fill: 'rgba(54, 162, 235, 0.1)'},
{line: '#FFCE56', fill: 'rgba(255, 206, 86, 0.1)'},
{line: '#4BC0C0', fill: 'rgba(75, 192, 192, 0.1)'},
{line: '#9966FF', fill: 'rgba(153, 102, 255, 0.1)'},
{line: '#FF9F40', fill: 'rgba(255, 159, 64, 0.1)'}
];
// 커스텀 차트 클래스
class SimpleChart {
constructor(canvas, signal, colorIndex) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.signal = signal;
this.data = [];
this.labels = [];
this.colors = COLORS[colorIndex % COLORS.length];
this.currentValue = 0;
// Canvas 크기 설정
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * window.devicePixelRatio;
this.canvas.height = rect.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
this.width = rect.width;
this.height = rect.height;
this.draw();
}
addData(value, label) {
this.data.push(value);
this.labels.push(label);
this.currentValue = value;
if (this.data.length > MAX_DATA_POINTS) {
this.data.shift();
this.labels.shift();
}
this.draw();
}
draw() {
if (this.data.length === 0) return;
const ctx = this.ctx;
const padding = 40;
const graphWidth = this.width - padding * 2;
const graphHeight = this.height - padding * 2;
// 배경 클리어
ctx.clearRect(0, 0, this.width, this.height);
// 데이터 범위 계산
const minValue = Math.min(...this.data);
const maxValue = Math.max(...this.data);
const range = maxValue - minValue || 1;
// 축 그리기
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, this.height - padding);
ctx.lineTo(this.width - padding, this.height - padding);
ctx.stroke();
// Y축 라벨
ctx.fillStyle = '#666';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(maxValue.toFixed(2), padding - 5, padding + 5);
ctx.fillText(minValue.toFixed(2), padding - 5, this.height - padding);
// X축 라벨 (시간 - 초 단위)
ctx.textAlign = 'center';
ctx.fillStyle = '#666';
ctx.font = '10px Arial';
if (this.labels.length > 0) {
// 첫 번째와 마지막 시간 표시
ctx.fillText(this.labels[0] + 's', padding, this.height - padding + 15);
if (this.labels.length > 1) {
const lastIdx = this.labels.length - 1;
ctx.fillText(this.labels[lastIdx] + 's',
padding + (graphWidth / (MAX_DATA_POINTS - 1)) * lastIdx,
this.height - padding + 15);
}
}
// X축 타이틀
ctx.fillText('Time (sec)', this.width / 2, this.height - 5);
// 그리드 라인
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
for (let i = 1; i < 5; i++) {
const y = padding + (graphHeight / 5) * i;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(this.width - padding, y);
ctx.stroke();
}
if (this.data.length < 2) return;
// 영역 채우기
ctx.fillStyle = this.colors.fill;
ctx.beginPath();
ctx.moveTo(padding, this.height - padding);
for (let i = 0; i < this.data.length; i++) {
const x = padding + (graphWidth / (MAX_DATA_POINTS - 1)) * i;
const y = this.height - padding - ((this.data[i] - minValue) / range) * graphHeight;
if (i === 0) {
ctx.lineTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.lineTo(padding + (graphWidth / (MAX_DATA_POINTS - 1)) * (this.data.length - 1), this.height - padding);
ctx.closePath();
ctx.fill();
// 선 그리기
ctx.strokeStyle = this.colors.line;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < this.data.length; i++) {
const x = padding + (graphWidth / (MAX_DATA_POINTS - 1)) * i;
const y = this.height - padding - ((this.data[i] - minValue) / range) * graphHeight;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// 데이터 포인트
ctx.fillStyle = this.colors.line;
for (let i = 0; i < this.data.length; i++) {
const x = padding + (graphWidth / (MAX_DATA_POINTS - 1)) * i;
const y = this.height - padding - ((this.data[i] - minValue) / range) * graphHeight;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
}
}
function initWebSocket() {
ws = new WebSocket('ws://' + window.location.hostname + ':81');
ws.onopen = function() {
console.log('WebSocket connected');
showStatus('Connected', 'success');
};
ws.onclose = function() {
console.log('WebSocket disconnected');
showStatus('Disconnected - Reconnecting...', 'error');
setTimeout(initWebSocket, 3000);
};
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'canBatch' && graphing) {
processCANData(data.messages);
}
} catch(e) {
console.error('Error parsing WebSocket data:', e);
}
};
}
function loadDBCFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const content = e.target.result;
parseDBCContent(content);
document.getElementById('dbc-status').textContent = file.name;
// DBC 파일 저장
saveDBCToLocalStorage(content, file.name);
};
reader.readAsText(file);
}
// DBC 파일을 localStorage에 저장
function saveDBCToLocalStorage(content, filename) {
try {
localStorage.setItem('dbc_content', content);
localStorage.setItem('dbc_filename', filename);
console.log('DBC saved to localStorage:', filename);
} catch(e) {
console.error('Failed to save DBC to localStorage:', e);
}
}
// localStorage에서 DBC 파일 복원
function loadDBCFromLocalStorage() {
try {
const content = localStorage.getItem('dbc_content');
const filename = localStorage.getItem('dbc_filename');
if (content && filename) {
parseDBCContent(content);
document.getElementById('dbc-status').textContent = filename + ' (restored)';
showStatus('DBC file restored: ' + filename, 'success');
console.log('DBC restored from localStorage:', filename);
return true;
}
} catch(e) {
console.error('Failed to load DBC from localStorage:', e);
}
return false;
}
function parseDBCContent(content) {
dbcData = {messages: {}};
const lines = content.split('\n');
let currentMessage = null;
for (let line of lines) {
line = line.trim();
if (line.startsWith('BO_ ')) {
const match = line.match(/BO_\s+(\d+)\s+(\w+)\s*:/);
if (match) {
const id = parseInt(match[1]);
const name = match[2];
currentMessage = {id: id, name: name, signals: []};
dbcData.messages[id] = currentMessage;
}
}
else if (line.startsWith('SG_ ') && currentMessage) {
const match = line.match(/SG_\s+(\w+)\s*:\s*(\d+)\|(\d+)@([01])([+-])\s*\(([^,]+),([^)]+)\)\s*\[([^\]]+)\]\s*"([^"]*)"/);
if (match) {
const signal = {
name: match[1],
startBit: parseInt(match[2]),
bitLength: parseInt(match[3]),
byteOrder: match[4] === '0' ? 'motorola' : 'intel',
signed: match[5] === '-',
factor: parseFloat(match[6]),
offset: parseFloat(match[7]),
unit: match[9],
messageId: currentMessage.id,
messageName: currentMessage.name
};
currentMessage.signals.push(signal);
}
}
}
displaySignals();
showStatus('DBC loaded: ' + Object.keys(dbcData.messages).length + ' messages', 'success');
}
function displaySignals() {
const signalList = document.getElementById('signal-list');
signalList.innerHTML = '';
for (let msgId in dbcData.messages) {
const msg = dbcData.messages[msgId];
msg.signals.forEach(signal => {
const item = document.createElement('div');
item.className = 'signal-item';
item.onclick = () => toggleSignal(signal, item);
item.innerHTML =
'<div class="signal-name">' + signal.name + '</div>' +
'<div class="signal-info">' +
'ID: 0x' + signal.messageId.toString(16).toUpperCase() + ' | ' +
signal.bitLength + 'bit' +
(signal.unit ? ' | ' + signal.unit : '') +
'</div>';
signalList.appendChild(item);
});
}
document.getElementById('signal-section').style.display = 'block';
// 저장된 선택 복원
setTimeout(() => loadSelectedSignals(), 100);
}
function toggleSignal(signal, element) {
const index = selectedSignals.findIndex(s =>
s.messageId === signal.messageId && s.name === signal.name);
if (index >= 0) {
selectedSignals.splice(index, 1);
element.classList.remove('selected');
} else {
if (selectedSignals.length >= MAX_SIGNALS) {
showStatus('Max ' + MAX_SIGNALS + ' signals!', 'error');
return;
}
selectedSignals.push(signal);
element.classList.add('selected');
}
// 선택한 신호 저장
saveSelectedSignals();
}
function clearSelection() {
selectedSignals = [];
document.querySelectorAll('.signal-item').forEach(item => {
item.classList.remove('selected');
});
// 저장된 선택 삭제
saveSelectedSignals();
}
// 선택한 신호를 localStorage에 저장
function saveSelectedSignals() {
try {
localStorage.setItem('selected_signals', JSON.stringify(selectedSignals));
console.log('Saved', selectedSignals.length, 'signals');
} catch(e) {
console.error('Failed to save selected signals:', e);
}
}
// localStorage에서 선택한 신호 복원
function loadSelectedSignals() {
try {
const saved = localStorage.getItem('selected_signals');
if (saved) {
const signals = JSON.parse(saved);
// 신호 목록이 표시된 후에 선택 상태 복원
signals.forEach(savedSignal => {
// DBC에 해당 신호가 있는지 확인
let found = false;
for (let msgId in dbcData.messages) {
const msg = dbcData.messages[msgId];
const signal = msg.signals.find(s =>
s.messageId === savedSignal.messageId && s.name === savedSignal.name);
if (signal) {
selectedSignals.push(signal);
found = true;
break;
}
}
});
// UI 업데이트
document.querySelectorAll('.signal-item').forEach(item => {
const signalName = item.querySelector('.signal-name').textContent;
const signalInfo = item.querySelector('.signal-info').textContent;
const idMatch = signalInfo.match(/ID: 0x([0-9A-F]+)/);
if (idMatch) {
const msgId = parseInt(idMatch[1], 16);
const isSelected = selectedSignals.some(s =>
s.messageId === msgId && s.name === signalName);
if (isSelected) {
item.classList.add('selected');
}
}
});
if (selectedSignals.length > 0) {
showStatus('Restored ' + selectedSignals.length + ' selected signals', 'success');
}
}
} catch(e) {
console.error('Failed to load selected signals:', e);
}
}
function startGraphing() {
if (selectedSignals.length === 0) {
showStatus('Select at least one signal!', 'error');
return;
}
// 선택한 신호 저장 (새 창에서 사용)
saveSelectedSignals();
// 새 창 열기
const width = 1200;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
'/graph-view',
'CAN_Graph_Viewer',
'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',resizable=yes,scrollbars=yes'
);
showStatus('Graph viewer opened in new window', 'success');
}
function stopGraphing() {
showStatus('Use the stop button in the graph viewer window', 'error');
}
function createGraphs() {
// 이 함수는 새 창에서 사용됨
}
function processCANData(messages) {
// 이 함수는 새 창에서 사용됨
}
function decodeSignal(signal, hexData) {
const bytes = [];
if (typeof hexData === 'string') {
const cleanHex = hexData.replace(/\s/g, '').toUpperCase();
for (let i = 0; i < cleanHex.length && i < 16; i += 2) {
bytes.push(parseInt(cleanHex.substring(i, i + 2), 16));
}
}
while (bytes.length < 8) {
bytes.push(0);
}
let rawValue = 0;
if (signal.byteOrder === 'intel') {
for (let i = 0; i < signal.bitLength; i++) {
const bitPos = signal.startBit + i;
const byteIdx = Math.floor(bitPos / 8);
const bitIdx = bitPos % 8;
if (byteIdx < bytes.length) {
const bit = (bytes[byteIdx] >> bitIdx) & 1;
rawValue |= (bit << i);
}
}
} else {
for (let i = 0; i < signal.bitLength; i++) {
const bitPos = signal.startBit - i;
const byteIdx = Math.floor(bitPos / 8);
const bitIdx = 7 - (bitPos % 8);
if (byteIdx < bytes.length && byteIdx >= 0) {
const bit = (bytes[byteIdx] >> bitIdx) & 1;
rawValue |= (bit << (signal.bitLength - 1 - i));
}
}
}
if (signal.signed && (rawValue & (1 << (signal.bitLength - 1)))) {
rawValue -= (1 << signal.bitLength);
}
const physicalValue = rawValue * signal.factor + signal.offset;
return physicalValue;
}
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'status ' + type;
status.style.display = 'block';
setTimeout(() => { status.style.display = 'none'; }, 5000);
}
const uploadArea = document.querySelector('.upload-area');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
});
});
['dragenter', 'dragover'].forEach(eventName => {
uploadArea.addEventListener(eventName, () => {
uploadArea.style.borderColor = '#185a9d';
uploadArea.style.background = '#f0f9ff';
});
});
['dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, () => {
uploadArea.style.borderColor = '#43cea2';
uploadArea.style.background = '';
});
});
uploadArea.addEventListener('drop', function(e) {
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.dbc')) {
const reader = new FileReader();
reader.onload = function(ev) {
parseDBCContent(ev.target.result);
document.getElementById('dbc-status').textContent = file.name;
// DBC 파일 저장
saveDBCToLocalStorage(ev.target.result, file.name);
};
reader.readAsText(file);
}
});
// 페이지 로드 시 localStorage에서 DBC 복원
window.addEventListener('load', function() {
loadDBCFromLocalStorage();
});
initWebSocket();
</script>
</body>
</html>
)rawliteral";
#endif

View File

@@ -0,0 +1,559 @@
#ifndef GRAPH_VIEWER_H
#define GRAPH_VIEWER_H
const char graph_viewer_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CAN Signal Graph Viewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
color: white;
overflow-x: hidden;
}
.header {
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
padding: 15px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.header h1 { font-size: 1.5em; }
.controls {
background: #2a2a2a;
padding: 10px 15px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.control-group {
display: flex;
gap: 10px;
align-items: center;
}
.control-label {
font-size: 0.85em;
color: #aaa;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 5px;
font-size: 0.9em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-success { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); color: white; }
.btn-danger { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); color: white; }
.btn-info { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.btn-warning { background: linear-gradient(135deg, #f2994a 0%, #f2c94c 100%); color: white; }
.btn:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.3); }
.btn.active {
box-shadow: inset 0 3px 5px rgba(0,0,0,0.3);
transform: translateY(0);
}
.graphs {
padding: 15px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 15px;
}
.graph-container {
background: #2a2a2a;
padding: 15px;
border-radius: 10px;
border: 2px solid #3a3a3a;
}
.graph-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 2px solid #43cea2;
}
.graph-title {
font-size: 1em;
font-weight: 600;
color: #43cea2;
}
.graph-value {
font-size: 1.2em;
font-weight: 700;
color: #38ef7d;
font-family: 'Courier New', monospace;
}
canvas {
width: 100%;
height: 250px;
border: 1px solid #3a3a3a;
border-radius: 5px;
background: #1a1a1a;
}
.status {
position: fixed;
top: 10px;
right: 10px;
padding: 10px 15px;
border-radius: 5px;
background: #2a2a2a;
border: 2px solid #43cea2;
font-size: 0.9em;
z-index: 1000;
}
.status.disconnected {
border-color: #eb3349;
background: #3a2a2a;
}
@media (max-width: 768px) {
.graphs { grid-template-columns: 1fr; }
canvas { height: 200px; }
}
</style>
</head>
<body>
<div class="header">
<h1>Real-time CAN Signal Graphs (Scatter Mode)</h1>
</div>
<div class="controls">
<div class="control-group">
<button class="btn btn-success" onclick="startGraphing()">Start</button>
<button class="btn btn-danger" onclick="stopGraphing()">Stop</button>
<button class="btn btn-danger" onclick="window.close()">Close</button>
</div>
<div class="control-group">
<span class="control-label">X-Axis Scale:</span>
<button class="btn btn-info active" id="btn-index-mode" onclick="setScaleMode('index')">Index</button>
<button class="btn btn-info" id="btn-time-mode" onclick="setScaleMode('time')">Time-Based</button>
</div>
<div class="control-group">
<span class="control-label">X-Axis Range:</span>
<button class="btn btn-warning active" id="btn-range-10s" onclick="setRangeMode('10s')">10s Window</button>
<button class="btn btn-warning" id="btn-range-all" onclick="setRangeMode('all')">All Time</button>
</div>
</div>
<div class="status" id="status">Connecting...</div>
<div class="graphs" id="graphs"></div>
<script>
let ws;
let charts = {};
let graphing = false;
let startTime = 0;
let selectedSignals = [];
let dbcData = {};
let lastTimestamps = {};
const MAX_DATA_POINTS = 60;
let scaleMode = 'index'; // 'index' or 'time'
let rangeMode = '10s'; // '10s' or 'all'
const COLORS = [
{line: '#FF6384', fill: 'rgba(255, 99, 132, 0.2)'},
{line: '#36A2EB', fill: 'rgba(54, 162, 235, 0.2)'},
{line: '#FFCE56', fill: 'rgba(255, 206, 86, 0.2)'},
{line: '#4BC0C0', fill: 'rgba(75, 192, 192, 0.2)'},
{line: '#9966FF', fill: 'rgba(153, 102, 255, 0.2)'},
{line: '#FF9F40', fill: 'rgba(255, 159, 64, 0.2)'}
];
class SimpleChart {
constructor(canvas, signal, colorIndex) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.signal = signal;
this.data = [];
this.times = []; // 실제 시간(초) 저장
this.labels = [];
this.colors = COLORS[colorIndex % COLORS.length];
this.currentValue = 0;
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * window.devicePixelRatio;
this.canvas.height = rect.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
this.width = rect.width;
this.height = rect.height;
this.draw();
}
addData(value, time) {
this.data.push(value);
this.times.push(parseFloat(time));
this.labels.push(time);
this.currentValue = value;
// 인덱스 모드에서만 개수 제한
if (scaleMode === 'index' && this.data.length > MAX_DATA_POINTS) {
this.data.shift();
this.times.shift();
this.labels.shift();
}
this.draw();
}
draw() {
if (this.data.length === 0) return;
const ctx = this.ctx;
const padding = 40;
const graphWidth = this.width - padding * 2;
const graphHeight = this.height - padding * 2;
ctx.clearRect(0, 0, this.width, this.height);
// 표시할 데이터 필터링
let displayData = [];
let displayTimes = [];
let displayLabels = [];
if (rangeMode === '10s') {
// 최근 10초 데이터만
const currentTime = this.times[this.times.length - 1];
for (let i = 0; i < this.times.length; i++) {
if (currentTime - this.times[i] <= 10) {
displayData.push(this.data[i]);
displayTimes.push(this.times[i]);
displayLabels.push(this.labels[i]);
}
}
} else {
// 전체 데이터
displayData = [...this.data];
displayTimes = [...this.times];
displayLabels = [...this.labels];
}
if (displayData.length === 0) return;
const minValue = Math.min(...displayData);
const maxValue = Math.max(...displayData);
const range = maxValue - minValue || 1;
// X축 범위 계산
let minTime, maxTime, timeRange;
if (scaleMode === 'time') {
minTime = displayTimes[0];
maxTime = displayTimes[displayTimes.length - 1];
timeRange = maxTime - minTime || 1;
}
// 축
ctx.strokeStyle = '#444';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, this.height - padding);
ctx.lineTo(this.width - padding, this.height - padding);
ctx.stroke();
// Y축 레이블
ctx.fillStyle = '#aaa';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(maxValue.toFixed(2), padding - 5, padding + 5);
ctx.fillText(minValue.toFixed(2), padding - 5, this.height - padding);
// X축 레이블
ctx.textAlign = 'center';
ctx.fillStyle = '#aaa';
ctx.font = '10px Arial';
if (displayLabels.length > 0) {
ctx.fillText(displayLabels[0] + 's', padding, this.height - padding + 15);
if (displayLabels.length > 1) {
const lastIdx = displayLabels.length - 1;
let xPos;
if (scaleMode === 'time') {
xPos = this.width - padding;
} else {
xPos = padding + (graphWidth / (MAX_DATA_POINTS - 1)) * Math.min(lastIdx, MAX_DATA_POINTS - 1);
}
ctx.fillText(displayLabels[lastIdx] + 's', xPos, this.height - padding + 15);
}
}
ctx.fillText('Time (sec)', this.width / 2, this.height - 5);
// 그리드
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
for (let i = 1; i < 5; i++) {
const y = padding + (graphHeight / 5) * i;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(this.width - padding, y);
ctx.stroke();
}
if (displayData.length < 1) return;
// 점 그리기
ctx.fillStyle = this.colors.line;
for (let i = 0; i < displayData.length; i++) {
let x;
if (scaleMode === 'time') {
// 시간 기반: 실제 시간 간격을 X축에 반영
const timePos = (displayTimes[i] - minTime) / timeRange;
x = padding + graphWidth * timePos;
} else {
// 인덱스 기반: 균등 간격
x = padding + (graphWidth / (MAX_DATA_POINTS - 1)) * i;
}
const y = this.height - padding - ((displayData[i] - minValue) / range) * graphHeight;
// 점 그리기
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
// 점 테두리
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
function initWebSocket() {
const hostname = window.location.hostname;
ws = new WebSocket('ws://' + hostname + ':81');
ws.onopen = function() {
console.log('WebSocket connected');
updateStatus('Connected', false);
};
ws.onclose = function() {
console.log('WebSocket disconnected');
updateStatus('Disconnected', true);
setTimeout(initWebSocket, 3000);
};
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'canBatch' && graphing) {
processCANData(data.messages);
}
} catch(e) {
console.error('Error:', e);
}
};
}
function updateStatus(text, isError) {
const status = document.getElementById('status');
status.textContent = text;
status.className = 'status' + (isError ? ' disconnected' : '');
}
function loadData() {
try {
const signals = localStorage.getItem('selected_signals');
const dbc = localStorage.getItem('dbc_content');
if (!signals || !dbc) {
alert('No signals selected. Please select signals first.');
window.close();
return false;
}
selectedSignals = JSON.parse(signals);
// DBC 파싱 (간단 버전 - 필요한 정보만)
dbcData = {messages: {}};
selectedSignals.forEach(sig => {
if (!dbcData.messages[sig.messageId]) {
dbcData.messages[sig.messageId] = {
id: sig.messageId,
signals: []
};
}
dbcData.messages[sig.messageId].signals.push(sig);
});
return true;
} catch(e) {
console.error('Failed to load data:', e);
alert('Failed to load signal data');
window.close();
return false;
}
}
function createGraphs() {
const graphsDiv = document.getElementById('graphs');
graphsDiv.innerHTML = '';
charts = {};
selectedSignals.forEach((signal, index) => {
const container = document.createElement('div');
container.className = 'graph-container';
const canvas = document.createElement('canvas');
canvas.id = 'chart-' + index;
container.innerHTML =
'<div class="graph-header">' +
'<div class="graph-title">' + signal.name + ' (0x' + signal.messageId.toString(16).toUpperCase() + ')' +
(signal.unit ? ' [' + signal.unit + ']' : '') + '</div>' +
'<div class="graph-value" id="value-' + index + '">-</div>' +
'</div>';
container.appendChild(canvas);
graphsDiv.appendChild(container);
charts[index] = new SimpleChart(canvas, signal, index);
});
}
function startGraphing() {
if (!graphing) {
graphing = true;
startTime = Date.now();
lastTimestamps = {}; // 타임스탬프 초기화
updateStatus('Graphing...', false);
}
}
function stopGraphing() {
graphing = false;
updateStatus('Stopped', false);
}
function setScaleMode(mode) {
scaleMode = mode;
// 버튼 활성화 상태 업데이트
document.getElementById('btn-index-mode').classList.toggle('active', mode === 'index');
document.getElementById('btn-time-mode').classList.toggle('active', mode === 'time');
// 모든 차트 다시 그리기
Object.values(charts).forEach(chart => chart.draw());
console.log('Scale mode changed to:', mode);
}
function setRangeMode(mode) {
rangeMode = mode;
// 버튼 활성화 상태 업데이트
document.getElementById('btn-range-10s').classList.toggle('active', mode === '10s');
document.getElementById('btn-range-all').classList.toggle('active', mode === 'all');
// 모든 차트 다시 그리기
Object.values(charts).forEach(chart => chart.draw());
console.log('Range mode changed to:', mode);
}
function processCANData(messages) {
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1);
messages.forEach(canMsg => {
const idStr = canMsg.id.replace(/\s/g, '').toUpperCase();
const msgId = parseInt(idStr, 16);
const timestamp = canMsg.timestamp;
selectedSignals.forEach((signal, index) => {
if (signal.messageId === msgId && charts[index]) {
try {
// 신호별 고유 키 생성
const signalKey = msgId + '_' + signal.name;
// 이전 타임스탬프와 비교 - 새로운 메시지일 때만 점 추가
if (!lastTimestamps[signalKey] || lastTimestamps[signalKey] !== timestamp) {
const value = decodeSignal(signal, canMsg.data);
charts[index].addData(value, elapsedTime);
// 타임스탬프 업데이트
lastTimestamps[signalKey] = timestamp;
const valueDiv = document.getElementById('value-' + index);
if (valueDiv) {
valueDiv.textContent = value.toFixed(2) + (signal.unit ? ' ' + signal.unit : '');
}
}
} catch(e) {
console.error('Error decoding signal:', e);
}
}
});
});
}
function decodeSignal(signal, hexData) {
const bytes = [];
if (typeof hexData === 'string') {
const cleanHex = hexData.replace(/\s/g, '').toUpperCase();
for (let i = 0; i < cleanHex.length && i < 16; i += 2) {
bytes.push(parseInt(cleanHex.substring(i, i + 2), 16));
}
}
while (bytes.length < 8) bytes.push(0);
let rawValue = 0;
if (signal.byteOrder === 'intel') {
for (let i = 0; i < signal.bitLength; i++) {
const bitPos = signal.startBit + i;
const byteIdx = Math.floor(bitPos / 8);
const bitIdx = bitPos % 8;
if (byteIdx < bytes.length) {
const bit = (bytes[byteIdx] >> bitIdx) & 1;
rawValue |= (bit << i);
}
}
} else {
for (let i = 0; i < signal.bitLength; i++) {
const bitPos = signal.startBit - i;
const byteIdx = Math.floor(bitPos / 8);
const bitIdx = 7 - (bitPos % 8);
if (byteIdx < bytes.length && byteIdx >= 0) {
const bit = (bytes[byteIdx] >> bitIdx) & 1;
rawValue |= (bit << (signal.bitLength - 1 - i));
}
}
}
if (signal.signed && (rawValue & (1 << (signal.bitLength - 1)))) {
rawValue -= (1 << signal.bitLength);
}
return rawValue * signal.factor + signal.offset;
}
// 초기화
if (loadData()) {
createGraphs();
initWebSocket();
startGraphing();
}
</script>
</body>
</html>
)rawliteral";
#endif

709
test_i2c_reset/index.h Normal file
View File

@@ -0,0 +1,709 @@
#ifndef INDEX_H
#define INDEX_H
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Byun CAN Logger</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 10px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 { font-size: 1.8em; margin-bottom: 5px; }
.header p { opacity: 0.9; font-size: 0.9em; }
.nav {
background: #2c3e50;
padding: 10px;
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 15px;
border-radius: 5px;
transition: all 0.3s;
font-size: 0.9em;
white-space: nowrap;
}
.nav a:hover { background: #34495e; }
.nav a.active { background: #3498db; }
.content { padding: 15px; }
.time-sync-banner {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
box-shadow: 0 4px 15px rgba(240, 147, 251, 0.4);
}
.time-sync-info {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.time-info-item {
display: flex;
flex-direction: column;
gap: 3px;
}
.time-label {
font-size: 0.75em;
opacity: 0.9;
font-weight: 600;
}
.time-value {
font-family: 'Courier New', monospace;
font-size: 1.1em;
font-weight: 700;
}
.btn-time-sync {
background: white;
color: #f5576c;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1em;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
}
.btn-time-sync:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
}
.btn-time-sync:active {
transform: translateY(-1px);
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.status-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px;
border-radius: 10px;
text-align: center;
}
.status-card h3 { font-size: 0.75em; opacity: 0.9; margin-bottom: 8px; }
.status-card .value { font-size: 1.5em; font-weight: bold; word-break: break-all; }
.status-on { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%) !important; }
.status-off { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%) !important; }
.control-panel {
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
}
.control-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.control-row:last-child { margin-bottom: 0; }
label { font-weight: 600; color: #333; font-size: 0.9em; }
select, button {
padding: 8px 15px;
border: none;
border-radius: 5px;
font-size: 0.9em;
cursor: pointer;
transition: all 0.3s;
}
select {
background: white;
border: 2px solid #667eea;
color: #333;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
}
button:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.3); }
button:active { transform: translateY(0); }
.can-table-container {
background: #f8f9fa;
border-radius: 10px;
padding: 10px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
min-width: 500px;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
}
th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 8px;
text-align: left;
font-weight: 600;
font-size: 0.85em;
}
td {
padding: 8px;
border-bottom: 1px solid #e9ecef;
font-family: 'Courier New', monospace;
font-size: 0.8em;
}
tr:hover { background: #f8f9fa; }
.flash-row {
animation: flashAnimation 0.3s ease-in-out;
}
@keyframes flashAnimation {
0%, 100% { background-color: transparent; }
50% { background-color: #fff3cd; }
}
.mono { font-family: 'Courier New', monospace; }
h2 {
color: #333;
margin: 20px 0 15px 0;
padding-bottom: 8px;
border-bottom: 3px solid #667eea;
font-size: 1.3em;
}
.file-list {
background: #f8f9fa;
border-radius: 10px;
padding: 15px;
}
.file-item {
background: white;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
flex-wrap: wrap;
gap: 10px;
}
.file-item:hover { transform: translateX(5px); box-shadow: 0 3px 10px rgba(0,0,0,0.1); }
.file-name { font-weight: 600; color: #333; font-size: 0.9em; }
.file-size { color: #666; margin-left: 10px; font-size: 0.85em; }
.download-btn {
padding: 6px 12px;
font-size: 0.85em;
}
@media (max-width: 768px) {
body { padding: 5px; }
.header h1 { font-size: 1.5em; }
.header p { font-size: 0.85em; }
.content { padding: 10px; }
.status-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.status-card { padding: 10px; }
.status-card h3 { font-size: 0.7em; }
.status-card .value { font-size: 1.2em; }
h2 { font-size: 1.1em; }
.nav a { padding: 8px 12px; font-size: 0.85em; }
table { min-width: 400px; }
th, td { padding: 6px 4px; font-size: 0.75em; }
.time-sync-banner {
flex-direction: column;
align-items: stretch;
padding: 12px 15px;
}
.time-sync-info {
gap: 10px;
}
.time-value {
font-size: 1em;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Byun CAN Logger</h1>
<p>Real-time CAN Bus Monitor & Data Logger with Time Sync</p>
</div>
<div class="nav">
<a href="/" class="active">Monitor</a>
<a href="/transmit">Transmit</a>
<a href="/graph">Graph</a>
</div>
<div class="content">
<div class="time-sync-banner">
<div class="time-sync-info">
<div class="time-info-item">
<span class="time-label"> </span>
<span class="time-value" id="sync-status"> ...</span>
</div>
<div class="time-info-item">
<span class="time-label">🕐 </span>
<span class="time-value" id="current-time">--:--:--</span>
</div>
</div>
<button class="btn-time-sync" onclick="syncTime()"> </button>
</div>
<div class="status-grid">
<div class="status-card" id="logging-status">
<h3>LOGGING</h3>
<div class="value">OFF</div>
</div>
<div class="status-card" id="sd-status">
<h3>SD CARD</h3>
<div class="value">NOT READY</div>
</div>
<div class="status-card">
<h3>MESSAGES</h3>
<div class="value" id="msg-count">0</div>
</div>
<div class="status-card">
<h3>SPEED</h3>
<div class="value" id="msg-speed">0/s</div>
</div>
<div class="status-card" id="time-sync-card">
<h3>TIME SYNC</h3>
<div class="value" id="sync-count">0</div>
</div>
<div class="status-card" id="file-status">
<h3>CURRENT FILE</h3>
<div class="value" id="current-file" style="font-size: 0.85em;">-</div>
</div>
</div>
<div class="control-panel">
<h2>Control Panel</h2>
<div class="control-row">
<label for="can-speed">CAN Speed:</label>
<select id="can-speed">
<option value="0">125 Kbps</option>
<option value="1">250 Kbps</option>
<option value="2">500 Kbps</option>
<option value="3" selected>1 Mbps</option>
</select>
<button onclick="setCanSpeed()">Apply</button>
<span id="speed-status" style="color: #11998e; font-size: 0.85em; font-weight: 600;"></span>
</div>
<div class="control-row">
<button onclick="startLogging()" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">Start Logging</button>
<button onclick="stopLogging()" style="background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);">Stop Logging</button>
<button onclick="refreshFiles()">Refresh Files</button>
<button onclick="clearMessages()">Clear Display</button>
</div>
</div>
<h2>CAN Messages (by ID)</h2>
<div class="can-table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>DLC</th>
<th>Data</th>
<th>Count</th>
<th>Time(ms)</th>
</tr>
</thead>
<tbody id="can-messages"></tbody>
</table>
</div>
<h2>Log Files</h2>
<div class="file-list" id="file-list">
<p style="text-align: center; color: #666; font-size: 0.9em;">Loading...</p>
</div>
</div>
</div>
<script>
let ws;
let reconnectInterval;
let canMessages = {};
let messageOrder = [];
let lastMessageData = {};
const speedNames = ['125 Kbps', '250 Kbps', '500 Kbps', '1 Mbps'];
// 현재 시간 업데이트
function updateCurrentTime() {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
document.getElementById('current-time').textContent = timeStr;
}
setInterval(updateCurrentTime, 1000);
updateCurrentTime();
// 시간 동기화 함수
function syncTime() {
if (ws && ws.readyState === WebSocket.OPEN) {
const timestamp = Date.now();
ws.send(JSON.stringify({
cmd: 'syncTime',
timestamp: timestamp
}));
document.getElementById('sync-status').textContent = ' ...';
setTimeout(() => {
const now = new Date();
const dateStr = now.getFullYear() + '-' +
(now.getMonth() + 1).toString().padStart(2, '0') + '-' +
now.getDate().toString().padStart(2, '0') + ' ' +
now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
document.getElementById('sync-status').textContent = ' ' + dateStr;
}, 200);
console.log(' :', new Date(timestamp).toLocaleString());
} else {
alert('WebSocket이 !');
}
}
function saveCanSpeed() {
const speed = document.getElementById('can-speed').value;
try {
window.localStorage.setItem('canSpeed', speed);
console.log('Saved CAN speed:', speedNames[speed]);
} catch(e) {
console.error('Failed to save CAN speed:', e);
}
}
function loadCanSpeed() {
try {
const savedSpeed = window.localStorage.getItem('canSpeed');
if (savedSpeed !== null) {
document.getElementById('can-speed').value = savedSpeed;
console.log('Restored CAN speed:', speedNames[savedSpeed]);
const statusSpan = document.getElementById('speed-status');
if (statusSpan) {
statusSpan.textContent = '(Restored: ' + speedNames[savedSpeed] + ')';
setTimeout(() => {
statusSpan.textContent = '';
}, 3000);
}
}
} catch(e) {
console.error('Failed to load CAN speed:', e);
}
}
function initWebSocket() {
ws = new WebSocket('ws://' + window.location.hostname + ':81');
ws.onopen = function() {
console.log('WebSocket connected');
clearInterval(reconnectInterval);
setTimeout(() => { refreshFiles(); }, 500);
// 연결 직후 자동 시간 동기화
setTimeout(() => {
syncTime();
console.log(' ');
}, 1000);
};
ws.onclose = function() {
console.log('WebSocket disconnected');
reconnectInterval = setInterval(initWebSocket, 3000);
document.getElementById('sync-status').textContent = ' ';
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'status') {
updateStatus(data);
} else if (data.type === 'can') {
addCanMessage(data);
} else if (data.type === 'canBatch') {
updateCanBatch(data.messages);
} else if (data.type === 'files') {
if (data.error) {
document.getElementById('file-list').innerHTML =
'<p style="text-align: center; color: #e74c3c; font-size: 0.9em;">Error: ' + data.error + '</p>';
} else {
updateFileList(data.files);
}
} else if (data.type === 'timeSyncStatus') {
if (data.synchronized) {
document.getElementById('sync-count').textContent = data.syncCount;
const card = document.getElementById('time-sync-card');
card.classList.add('status-on');
card.classList.remove('status-off');
}
}
};
}
function updateStatus(data) {
const loggingCard = document.getElementById('logging-status');
const sdCard = document.getElementById('sd-status');
const fileCard = document.getElementById('file-status');
const timeSyncCard = document.getElementById('time-sync-card');
if (data.logging) {
loggingCard.classList.add('status-on');
loggingCard.classList.remove('status-off');
loggingCard.querySelector('.value').textContent = 'ON';
} else {
loggingCard.classList.add('status-off');
loggingCard.classList.remove('status-on');
loggingCard.querySelector('.value').textContent = 'OFF';
}
if (data.sdReady) {
sdCard.classList.add('status-on');
sdCard.classList.remove('status-off');
sdCard.querySelector('.value').textContent = 'READY';
} else {
sdCard.classList.add('status-off');
sdCard.classList.remove('status-on');
sdCard.querySelector('.value').textContent = 'NOT READY';
}
if (data.timeSync) {
timeSyncCard.classList.add('status-on');
timeSyncCard.classList.remove('status-off');
} else {
timeSyncCard.classList.add('status-off');
timeSyncCard.classList.remove('status-on');
}
if (data.syncCount !== undefined) {
document.getElementById('sync-count').textContent = data.syncCount;
}
if (data.currentFile && data.currentFile !== '') {
fileCard.classList.add('status-on');
fileCard.classList.remove('status-off');
document.getElementById('current-file').textContent = data.currentFile;
} else {
fileCard.classList.remove('status-on', 'status-off');
document.getElementById('current-file').textContent = '-';
}
document.getElementById('msg-count').textContent = data.msgCount.toLocaleString();
document.getElementById('msg-speed').textContent = data.msgSpeed + '/s';
}
function addCanMessage(data) {
const canId = data.id;
if (!canMessages[canId]) {
messageOrder.push(canId);
}
canMessages[canId] = {
timestamp: data.timestamp,
dlc: data.dlc,
data: data.data,
updateCount: data.count
};
}
function updateCanBatch(messages) {
messages.forEach(msg => {
const canId = msg.id;
if (!canMessages[canId]) {
messageOrder.push(canId);
}
canMessages[canId] = {
timestamp: msg.timestamp,
dlc: msg.dlc,
data: msg.data,
updateCount: msg.count
};
});
updateCanTable();
}
function updateCanTable() {
const tbody = document.getElementById('can-messages');
const existingRows = new Map();
Array.from(tbody.rows).forEach(row => {
existingRows.set(row.dataset.canId, row);
});
messageOrder.forEach(canId => {
const msg = canMessages[canId];
let row = existingRows.get(canId);
const prevData = lastMessageData[canId];
const hasChanged = !prevData ||
prevData.data !== msg.data ||
prevData.dlc !== msg.dlc ||
prevData.timestamp !== msg.timestamp;
if (row) {
row.cells[1].textContent = msg.dlc;
row.cells[2].textContent = msg.data;
row.cells[3].textContent = msg.updateCount;
row.cells[4].textContent = msg.timestamp;
if (hasChanged) {
row.classList.add('flash-row');
setTimeout(() => row.classList.remove('flash-row'), 300);
}
} else {
row = tbody.insertRow();
row.dataset.canId = canId;
row.innerHTML =
'<td class="mono">0x' + canId + '</td>' +
'<td>' + msg.dlc + '</td>' +
'<td class="mono">' + msg.data + '</td>' +
'<td>' + msg.updateCount + '</td>' +
'<td>' + msg.timestamp + '</td>';
row.classList.add('flash-row');
setTimeout(() => row.classList.remove('flash-row'), 300);
}
lastMessageData[canId] = {
data: msg.data,
dlc: msg.dlc,
timestamp: msg.timestamp,
updateCount: msg.updateCount
};
});
}
function updateFileList(files) {
const fileList = document.getElementById('file-list');
if (!files || files.length === 0) {
fileList.innerHTML = '<p style="text-align: center; color: #666; font-size: 0.9em;">No log files</p>';
return;
}
files.sort((a, b) => {
return b.name.localeCompare(a.name);
});
fileList.innerHTML = '';
files.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.innerHTML =
'<div style="flex: 1; min-width: 0;">' +
'<span class="file-name">' + file.name + '</span>' +
'<span class="file-size">(' + formatBytes(file.size) + ')</span>' +
'</div>' +
'<button class="download-btn" onclick="downloadFile(\'' + file.name + '\')">Download</button>';
fileList.appendChild(fileItem);
});
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function setCanSpeed() {
const speed = document.getElementById('can-speed').value;
const speedName = speedNames[speed];
ws.send(JSON.stringify({cmd: 'setSpeed', speed: parseInt(speed)}));
saveCanSpeed();
const statusSpan = document.getElementById('speed-status');
if (statusSpan) {
statusSpan.textContent = ' Applied: ' + speedName;
statusSpan.style.color = '#11998e';
setTimeout(() => {
statusSpan.textContent = '';
}, 3000);
}
console.log('CAN speed set to:', speedName);
}
function startLogging() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({cmd: 'startLogging'}));
console.log('Start logging command sent');
}
}
function stopLogging() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({cmd: 'stopLogging'}));
console.log('Stop logging command sent');
}
}
function refreshFiles() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({cmd: 'getFiles'}));
}
}
function clearMessages() {
canMessages = {};
messageOrder = [];
lastMessageData = {};
document.getElementById('can-messages').innerHTML = '';
}
function downloadFile(filename) {
window.location.href = '/download?file=' + encodeURIComponent(filename);
}
window.addEventListener('load', function() {
loadCanSpeed();
});
initWebSocket();
setTimeout(() => { refreshFiles(); }, 2000);
</script>
</body>
</html>
)rawliteral";
#endif

772
test_i2c_reset/transmit.h Normal file
View File

@@ -0,0 +1,772 @@
#ifndef TRANSMIT_H
#define TRANSMIT_H
const char transmit_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CAN Transmitter</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
min-height: 100vh;
padding: 10px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 { font-size: 1.8em; margin-bottom: 5px; }
.header p { opacity: 0.9; font-size: 0.9em; }
.nav {
background: #2c3e50;
padding: 10px;
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 15px;
border-radius: 5px;
transition: all 0.3s;
font-size: 0.9em;
}
.nav a:hover { background: #34495e; }
.nav a.active { background: #3498db; }
.content { padding: 15px; }
.message-form {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: 600;
margin-bottom: 5px;
color: #333;
font-size: 0.9em;
}
.form-group input, .form-group select {
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 0.95em;
font-family: 'Courier New', monospace;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #f093fb;
}
.data-bytes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(45px, 1fr));
gap: 8px;
max-width: 100%;
}
.data-bytes input {
text-align: center;
text-transform: uppercase;
width: 100%;
min-width: 45px;
padding: 10px 5px;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 5px;
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.btn-success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
}
.btn-danger {
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
color: white;
}
.btn-warning {
background: linear-gradient(135deg, #f2994a 0%, #f2c94c 100%);
color: white;
}
.btn-info {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.3); }
.message-list {
background: #f8f9fa;
border-radius: 10px;
padding: 15px;
}
.message-item {
background: white;
padding: 12px;
margin-bottom: 10px;
border-radius: 8px;
display: grid;
grid-template-columns: 90px 70px 1fr auto;
gap: 10px;
align-items: center;
border-left: 4px solid #f093fb;
}
.message-item.active {
border-left-color: #38ef7d;
background: #f0fff4;
}
.message-id {
font-weight: 700;
color: #f5576c;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.message-type {
padding: 4px 8px;
border-radius: 5px;
font-size: 0.75em;
font-weight: 600;
text-align: center;
}
.type-std { background: #3498db; color: white; }
.type-ext { background: #9b59b6; color: white; }
.message-data {
font-family: 'Courier New', monospace;
color: #666;
font-size: 0.85em;
word-break: break-all;
}
.message-controls {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.btn-small {
padding: 6px 12px;
font-size: 0.8em;
}
h2 {
color: #333;
margin: 20px 0 15px 0;
padding-bottom: 8px;
border-bottom: 3px solid #f093fb;
font-size: 1.3em;
}
.status-bar {
background: #2c3e50;
color: white;
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9em;
}
.status-bar span { font-weight: 600; }
.preset-manager {
background: #fff3cd;
padding: 15px;
border-radius: 10px;
margin-bottom: 15px;
border-left: 4px solid #f2994a;
}
.preset-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.preset-controls input {
flex: 1;
min-width: 200px;
padding: 8px;
border: 2px solid #f2994a;
border-radius: 5px;
font-size: 0.9em;
}
.preset-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
margin-top: 10px;
}
.preset-item {
background: white;
padding: 10px;
border-radius: 5px;
border: 2px solid #f2c94c;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
}
.preset-item:hover {
transform: translateX(5px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.preset-name {
font-weight: 600;
color: #333;
font-size: 0.9em;
}
.preset-info {
font-size: 0.75em;
color: #666;
margin-top: 3px;
}
.preset-buttons {
display: flex;
gap: 5px;
}
@media (max-width: 768px) {
body { padding: 5px; }
.header h1 { font-size: 1.5em; }
.content { padding: 10px; }
.form-row { grid-template-columns: 1fr; }
.data-bytes {
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.data-bytes input {
min-width: 40px;
font-size: 0.9em;
padding: 8px 4px;
}
.message-item {
grid-template-columns: 1fr;
gap: 8px;
}
.message-controls {
justify-content: flex-start;
}
.nav a {
padding: 8px 10px;
font-size: 0.85em;
}
h2 {
font-size: 1.1em;
}
.status-bar {
flex-direction: column;
gap: 8px;
text-align: center;
}
.preset-list {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>CAN Transmitter</h1>
<p>Send CAN Messages</p>
</div>
<div class="nav">
<a href="/">Monitor</a>
<a href="/transmit" class="active">Transmit</a>
<a href="/graph">Graph</a>
</div>
<div class="content">
<div class="status-bar">
<span id="connection-status">Disconnected</span>
<span id="tx-count">Sent: 0</span>
</div>
<h2>Message List Presets</h2>
<div class="preset-manager">
<div class="preset-controls">
<input type="text" id="preset-name" placeholder="Enter preset name...">
<button class="btn btn-warning" onclick="savePreset()">Save Current List</button>
</div>
<div class="preset-list" id="preset-list">
<p style="text-align: center; color: #666; font-size: 0.9em;">No saved presets</p>
</div>
</div>
<h2>Add CAN Message</h2>
<div class="message-form">
<div class="form-row">
<div class="form-group">
<label>CAN ID (Hex)</label>
<input type="text" id="can-id" placeholder="123" maxlength="8">
</div>
<div class="form-group">
<label>Type</label>
<select id="msg-type">
<option value="std">Standard</option>
<option value="ext">Extended</option>
</select>
</div>
<div class="form-group">
<label>DLC</label>
<select id="dlc">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8" selected>8</option>
</select>
</div>
<div class="form-group">
<label>Interval (ms)</label>
<input type="number" id="interval" value="100" min="10" max="10000">
</div>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Send All Once - Waiting Time (ms)</label>
<input type="number" id="send-all-delay" value="10" min="0" max="1000"
style="max-width: 200px; padding: 10px; border: 2px solid #ddd; border-radius: 5px;">
<small style="color: #666; margin-top: 5px; display: block;">
Delay between messages when using "Send All Once" button
</small>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Data Bytes (Hex)</label>
<div class="data-bytes">
<input type="text" id="d0" placeholder="00" maxlength="2" value="00">
<input type="text" id="d1" placeholder="00" maxlength="2" value="00">
<input type="text" id="d2" placeholder="00" maxlength="2" value="00">
<input type="text" id="d3" placeholder="00" maxlength="2" value="00">
<input type="text" id="d4" placeholder="00" maxlength="2" value="00">
<input type="text" id="d5" placeholder="00" maxlength="2" value="00">
<input type="text" id="d6" placeholder="00" maxlength="2" value="00">
<input type="text" id="d7" placeholder="00" maxlength="2" value="00">
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="addMessage()">Add to List</button>
<button class="btn btn-success" onclick="sendOnce()">Send Once</button>
</div>
</div>
<h2>Message List</h2>
<div style="margin-bottom: 15px; display: flex; gap: 10px; flex-wrap: wrap;">
<button class="btn btn-success" onclick="startAll()">Start All</button>
<button class="btn btn-danger" onclick="stopAll()">Stop All</button>
<button class="btn btn-info" onclick="sendAllOnce()">📤 Send All Once</button>
<button class="btn btn-danger" onclick="clearAll()">Clear All</button>
</div>
<div class="message-list" id="message-list">
<p style="text-align: center; color: #666; font-size: 0.9em;">No messages added yet</p>
</div>
</div>
</div>
<script>
let ws;
let messages = [];
let txCount = 0;
function initWebSocket() {
ws = new WebSocket('ws://' + window.location.hostname + ':81');
ws.onopen = function() {
console.log('WebSocket connected');
document.getElementById('connection-status').innerHTML = 'Connected';
};
ws.onclose = function() {
console.log('WebSocket disconnected');
document.getElementById('connection-status').innerHTML = 'Disconnected';
setTimeout(initWebSocket, 3000);
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'txStatus') {
txCount = data.count;
document.getElementById('tx-count').textContent = 'Sent: ' + txCount;
}
};
}
// 프리셋 관리
function savePreset() {
const presetName = document.getElementById('preset-name').value.trim();
if (!presetName) {
alert('Please enter a preset name!');
return;
}
if (messages.length === 0) {
alert('No messages to save!');
return;
}
try {
let presets = JSON.parse(localStorage.getItem('tx_presets') || '{}');
if (presets[presetName] && !confirm('Preset "' + presetName + '" already exists. Overwrite?')) {
return;
}
presets[presetName] = {
messages: JSON.parse(JSON.stringify(messages)),
savedAt: new Date().toISOString(),
count: messages.length
};
localStorage.setItem('tx_presets', JSON.stringify(presets));
document.getElementById('preset-name').value = '';
loadPresetList();
alert('Preset "' + presetName + '" saved successfully!');
} catch(e) {
console.error('Failed to save preset:', e);
alert('Failed to save preset!');
}
}
function loadPreset(presetName) {
try {
const presets = JSON.parse(localStorage.getItem('tx_presets') || '{}');
const preset = presets[presetName];
if (!preset) {
alert('Preset not found!');
return;
}
if (messages.length > 0 && !confirm('Current message list will be replaced. Continue?')) {
return;
}
stopAll();
messages = JSON.parse(JSON.stringify(preset.messages));
messages.forEach(msg => msg.active = false);
updateMessageList();
alert('Loaded preset "' + presetName + '" with ' + preset.count + ' messages');
} catch(e) {
console.error('Failed to load preset:', e);
alert('Failed to load preset!');
}
}
function deletePreset(presetName) {
if (!confirm('Delete preset "' + presetName + '"?')) {
return;
}
try {
let presets = JSON.parse(localStorage.getItem('tx_presets') || '{}');
delete presets[presetName];
localStorage.setItem('tx_presets', JSON.stringify(presets));
loadPresetList();
} catch(e) {
console.error('Failed to delete preset:', e);
alert('Failed to delete preset!');
}
}
function loadPresetList() {
const presetListDiv = document.getElementById('preset-list');
try {
const presets = JSON.parse(localStorage.getItem('tx_presets') || '{}');
const presetNames = Object.keys(presets);
if (presetNames.length === 0) {
presetListDiv.innerHTML = '<p style="text-align: center; color: #666; font-size: 0.9em;">No saved presets</p>';
return;
}
presetListDiv.innerHTML = '';
presetNames.sort().forEach(name => {
const preset = presets[name];
const item = document.createElement('div');
item.className = 'preset-item';
const savedDate = new Date(preset.savedAt);
const dateStr = savedDate.toLocaleDateString() + ' ' + savedDate.toLocaleTimeString();
item.innerHTML =
'<div>' +
'<div class="preset-name">' + name + '</div>' +
'<div class="preset-info">' + preset.count + ' messages | ' + dateStr + '</div>' +
'</div>' +
'<div class="preset-buttons">' +
'<button class="btn btn-success btn-small" onclick="loadPreset(\'' + name + '\')">Load</button>' +
'<button class="btn btn-danger btn-small" onclick="deletePreset(\'' + name + '\')">Delete</button>' +
'</div>';
presetListDiv.appendChild(item);
});
} catch(e) {
console.error('Failed to load preset list:', e);
presetListDiv.innerHTML = '<p style="text-align: center; color: #e74c3c; font-size: 0.9em;">Error loading presets</p>';
}
}
function addMessage() {
const id = document.getElementById('can-id').value.toUpperCase();
const type = document.getElementById('msg-type').value;
const dlc = parseInt(document.getElementById('dlc').value);
const interval = parseInt(document.getElementById('interval').value);
if (!id || !/^[0-9A-F]+$/.test(id)) {
alert('Invalid CAN ID!');
return;
}
const data = [];
for (let i = 0; i < 8; i++) {
const val = document.getElementById('d' + i).value.toUpperCase();
if (!/^[0-9A-F]{0,2}$/.test(val)) {
alert('Invalid data byte D' + i + '!');
return;
}
data.push(val.padStart(2, '0'));
}
const msg = {
id: id,
type: type,
dlc: dlc,
data: data,
interval: interval,
active: false
};
messages.push(msg);
updateMessageList();
}
function sendOnce() {
const id = document.getElementById('can-id').value.toUpperCase();
const type = document.getElementById('msg-type').value;
const dlc = parseInt(document.getElementById('dlc').value);
const data = [];
for (let i = 0; i < 8; i++) {
data.push(document.getElementById('d' + i).value.toUpperCase().padStart(2, '0'));
}
sendCanMessage(id, type, dlc, data);
}
function sendCanMessage(id, type, dlc, data) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('WebSocket not connected!');
return;
}
const cmd = {
cmd: 'sendCan',
id: id,
type: type,
dlc: dlc,
data: data.join('')
};
ws.send(JSON.stringify(cmd));
console.log('Sent CAN message: ID=' + id + ', DLC=' + dlc + ', Data=' + data.join(''));
}
function updateMessageList() {
const list = document.getElementById('message-list');
if (messages.length === 0) {
list.innerHTML = '<p style="text-align: center; color: #666; font-size: 0.9em;">No messages</p>';
return;
}
list.innerHTML = '';
messages.forEach((msg, index) => {
const item = document.createElement('div');
item.className = 'message-item' + (msg.active ? ' active' : '');
item.innerHTML =
'<div class="message-id">0x' + msg.id + '</div>' +
'<div class="message-type type-' + msg.type + '">' + msg.type.toUpperCase() + '</div>' +
'<div class="message-data">' + msg.data.slice(0, msg.dlc).join(' ') + '</div>' +
'<div class="message-controls">' +
'<button class="btn ' + (msg.active ? 'btn-danger' : 'btn-success') + ' btn-small" onclick="toggleMessage(' + index + ')">' +
(msg.active ? 'Stop' : 'Start') +
'</button>' +
'<button class="btn btn-danger btn-small" onclick="deleteMessage(' + index + ')">Delete</button>' +
'</div>';
list.appendChild(item);
});
}
function toggleMessage(index) {
messages[index].active = !messages[index].active;
const cmd = {
cmd: messages[index].active ? 'startMsg' : 'stopMsg',
index: index,
id: messages[index].id,
type: messages[index].type,
dlc: messages[index].dlc,
data: messages[index].data.join(''),
interval: messages[index].interval
};
ws.send(JSON.stringify(cmd));
updateMessageList();
}
function deleteMessage(index) {
if (messages[index].active) {
ws.send(JSON.stringify({cmd: 'stopMsg', index: index}));
}
messages.splice(index, 1);
updateMessageList();
}
function startAll() {
messages.forEach((msg, index) => {
if (!msg.active) {
toggleMessage(index);
}
});
}
function stopAll() {
ws.send(JSON.stringify({cmd: 'stopAll'}));
messages.forEach(msg => msg.active = false);
updateMessageList();
}
function sendAllOnce() {
if (messages.length === 0) {
alert('No messages in the list!');
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('WebSocket not connected!');
return;
}
const delayMs = parseInt(document.getElementById('send-all-delay').value) || 10;
// 버튼 비활성화 및 시각적 피드백
const btn = event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ' Sending...';
let sentCount = 0;
// 순차적으로 메시지 전송 (딜레이 포함)
function sendNext(index) {
if (index >= messages.length) {
// 모든 메시지 전송 완료
console.log('Sent all messages once: ' + sentCount + ' messages');
btn.innerHTML = ' Sent ' + sentCount + ' msgs';
btn.style.background = 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.background = '';
btn.disabled = false;
}, 2000);
return;
}
const msg = messages[index];
sendCanMessage(msg.id, msg.type, msg.dlc, msg.data);
sentCount++;
// 진행 상황 표시
btn.innerHTML = ' Sending ' + (index + 1) + '/' + messages.length;
// 다음 메시지 전송 (딜레이 후)
setTimeout(() => {
sendNext(index + 1);
}, delayMs);
}
// 첫 번째 메시지부터 시작
sendNext(0);
}
function clearAll() {
if (confirm('Clear all messages?')) {
stopAll();
messages = [];
updateMessageList();
}
}
document.querySelectorAll('input[id^="d"]').forEach(input => {
input.addEventListener('input', function() {
this.value = this.value.toUpperCase().replace(/[^0-9A-F]/g, '');
});
});
document.getElementById('can-id').addEventListener('input', function() {
this.value = this.value.toUpperCase().replace(/[^0-9A-F]/g, '');
});
// 페이지 로드 시 프리셋 목록 불러오기
window.addEventListener('load', function() {
loadPresetList();
});
initWebSocket();
</script>
</body>
</html>
)rawliteral";
#endif