test
This commit is contained in:
842
test_i2c_reset/ESP32_CAN_Logger.ino
Normal file
842
test_i2c_reset/ESP32_CAN_Logger.ino
Normal 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
762
test_i2c_reset/graph.h
Normal 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
|
||||
559
test_i2c_reset/graph_viewer.h
Normal file
559
test_i2c_reset/graph_viewer.h
Normal 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
709
test_i2c_reset/index.h
Normal 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
772
test_i2c_reset/transmit.h
Normal 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
|
||||
Reference in New Issue
Block a user