1248 lines
53 KiB
C++
1248 lines
53 KiB
C++
/*
|
||
* Byun CANFD Logger with Web Interface
|
||
* Version: 1.0
|
||
* Hardware: ESP32-S3 + MCP2518FD (ACAN2517FD 라이브러리)
|
||
*
|
||
* 라이브러리: ACAN2517FD by Pierre Molinaro (라이브러리 매니저에서 설치)
|
||
*
|
||
* 핀 구성:
|
||
* CAN SPI : SCK=12, MOSI=11, MISO=13, CS=10, INT=9(폴링255)
|
||
* SD SDIO : CLK=39, CMD=38, D0=40, D1=41, D2=42, D3=2
|
||
* RTC I2C : SDA=8, SCL=18 (DS3231)
|
||
*
|
||
* Arduino IDE 설정:
|
||
* Board: ESP32S3 Dev Module
|
||
* PSRAM: OPI PSRAM (필수!)
|
||
* Flash: 16MB / Partition: 16MB(3MB APP/9.9MB FATFS)
|
||
*/
|
||
|
||
#include <Arduino.h>
|
||
#include <SPI.h>
|
||
#include <ACAN2517FD.h>
|
||
#include <SoftWire.h>
|
||
#include <SD_MMC.h>
|
||
#include <WiFi.h>
|
||
#include <esp_wifi.h>
|
||
#include <esp_task_wdt.h>
|
||
#include <esp_sntp.h>
|
||
#include <WebServer.h>
|
||
#include <WebSocketsServer.h>
|
||
#include <ArduinoJson.h>
|
||
#include <freertos/FreeRTOS.h>
|
||
#include <freertos/task.h>
|
||
#include <freertos/semphr.h>
|
||
#include <sys/time.h>
|
||
#include <time.h>
|
||
#include <Preferences.h>
|
||
|
||
#include "index.h"
|
||
#include "transmit.h"
|
||
#include "graph.h"
|
||
#include "graph_viewer.h"
|
||
#include "settings.h"
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 핀 정의
|
||
// ─────────────────────────────────────────────
|
||
#define PIN_CAN_SCK 12
|
||
#define PIN_CAN_MOSI 11
|
||
#define PIN_CAN_MISO 13
|
||
#define PIN_CAN_CS 10
|
||
#define PIN_CAN_INT 255 // 255 = 폴링모드 (INT핀 미사용)
|
||
|
||
#define PIN_SD_CLK 39
|
||
#define PIN_SD_CMD 38
|
||
#define PIN_SD_D0 40
|
||
#define PIN_SD_D1 41
|
||
#define PIN_SD_D2 42
|
||
#define PIN_SD_D3 2
|
||
|
||
#define PIN_I2C_SDA 8
|
||
#define PIN_I2C_SCL 18
|
||
#define DS3231_ADDR 0x68
|
||
|
||
// ─────────────────────────────────────────────
|
||
// CAN FD 설정
|
||
// ─────────────────────────────────────────────
|
||
// OSC: 20MHz (알리 보드 확인)
|
||
// 변경 필요시: OSC_40MHz, OSC_4MHz10xPLL 등
|
||
#define CANFD_OSC ACAN2517FDSettings::OSC_20MHz
|
||
|
||
// 최대 데이터 길이 (CAN FD = 64, Classic CAN = 8)
|
||
#define CANFD_MAX_DATA 64
|
||
|
||
// DLC → 실제 바이트 수 변환 테이블
|
||
static const uint8_t dlcToLen[16] = {
|
||
0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64
|
||
};
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 버퍼 크기 (2의 거듭제곱 필수)
|
||
// ─────────────────────────────────────────────
|
||
#define CAN_RING_SIZE 4096 // CAN FD 메시지 링버퍼 슬롯 수
|
||
#define FILE_BUFFER_SIZE 65536 // 64KB 더블버퍼 (SD 쓰기)
|
||
|
||
#define MAX_FILENAME_LEN 64
|
||
#define RECENT_MSG_COUNT 128
|
||
#define MAX_TX_MESSAGES 16
|
||
#define MAX_COMMENT_LEN 128
|
||
#define MAX_FILE_COMMENTS 50
|
||
#define RTC_SYNC_INTERVAL 60000
|
||
|
||
// ─────────────────────────────────────────────
|
||
// CAN FD 속도 프리셋
|
||
// ─────────────────────────────────────────────
|
||
struct CANFDPreset {
|
||
const char* name;
|
||
uint32_t arbBPS;
|
||
DataBitRateFactor factor;
|
||
bool isFD;
|
||
};
|
||
|
||
static const CANFDPreset speedPresets[] = {
|
||
{"Classic 125K", 125000UL, DataBitRateFactor::x1, false},
|
||
{"Classic 250K", 250000UL, DataBitRateFactor::x1, false},
|
||
{"Classic 500K", 500000UL, DataBitRateFactor::x1, false},
|
||
{"Classic 1M", 1000000UL, DataBitRateFactor::x1, false},
|
||
{"FD 500K/2M", 500000UL, DataBitRateFactor::x4, true},
|
||
{"FD 500K/4M", 500000UL, DataBitRateFactor::x8, true},
|
||
{"FD 1M/4M", 1000000UL, DataBitRateFactor::x4, true},
|
||
{"FD 1M/8M", 1000000UL, DataBitRateFactor::x8, true},
|
||
};
|
||
#define NUM_SPEED_PRESETS (sizeof(speedPresets)/sizeof(speedPresets[0]))
|
||
|
||
// ─────────────────────────────────────────────
|
||
// PCAP 구조체 (Wireshark, LINKTYPE_CAN_SOCKETCAN=227)
|
||
// ─────────────────────────────────────────────
|
||
struct PcapGlobalHeader {
|
||
uint32_t magic; // 0xa1b2c3d4
|
||
uint16_t ver_major; // 2
|
||
uint16_t ver_minor; // 4
|
||
int32_t thiszone;
|
||
uint32_t sigfigs;
|
||
uint32_t snaplen;
|
||
uint32_t network; // 227 = LINKTYPE_CAN_SOCKETCAN
|
||
} __attribute__((packed));
|
||
|
||
struct PcapPktHdr {
|
||
uint32_t ts_sec;
|
||
uint32_t ts_usec;
|
||
uint32_t incl_len;
|
||
uint32_t orig_len;
|
||
} __attribute__((packed));
|
||
|
||
// Classic CAN SocketCAN frame (16 bytes)
|
||
struct SCFrame {
|
||
uint32_t can_id;
|
||
uint8_t can_dlc;
|
||
uint8_t pad, res0, res1;
|
||
uint8_t data[8];
|
||
} __attribute__((packed));
|
||
|
||
// CAN FD SocketCAN frame (72 bytes)
|
||
struct SCFDFrame {
|
||
uint32_t can_id;
|
||
uint8_t len;
|
||
uint8_t flags; // CANFD_BRS=1, CANFD_ESI=2, CANFD_FDF=4
|
||
uint8_t res0, res1;
|
||
uint8_t data[64];
|
||
} __attribute__((packed));
|
||
|
||
static_assert(sizeof(SCFrame) == 16, "SCFrame 16 bytes");
|
||
static_assert(sizeof(SCFDFrame) == 72, "SCFDFrame 72 bytes");
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 로깅용 메시지 구조체 (PSRAM 링버퍼 원소)
|
||
// ─────────────────────────────────────────────
|
||
struct CANFDLog {
|
||
uint64_t timestamp_us;
|
||
uint32_t id;
|
||
uint8_t dlc; // DLC 코드 (0~15)
|
||
uint8_t len; // 실제 바이트 수 (0~64)
|
||
uint8_t flags; // bit7=EFF, bit6=RTR, bit5=FDF, bit4=BRS, bit3=ESI
|
||
uint8_t data[CANFD_MAX_DATA];
|
||
} __attribute__((packed)); // 8+4+1+1+1+64 = 79 bytes
|
||
|
||
// ─────────────────────────────────────────────
|
||
// SPSC 링버퍼
|
||
// ─────────────────────────────────────────────
|
||
struct CANRingBuffer {
|
||
volatile uint32_t head;
|
||
volatile uint32_t tail;
|
||
uint32_t size;
|
||
uint32_t mask;
|
||
CANFDLog* buf;
|
||
volatile uint32_t dropped;
|
||
};
|
||
|
||
CANRingBuffer canRing;
|
||
|
||
static inline bool IRAM_ATTR ring_push(CANRingBuffer* rb, const CANFDLog* msg) {
|
||
uint32_t h = rb->head;
|
||
if ((h - rb->tail) >= rb->size) { rb->dropped++; return false; }
|
||
memcpy(&rb->buf[h & rb->mask], msg, sizeof(CANFDLog));
|
||
__sync_synchronize();
|
||
rb->head = h + 1;
|
||
return true;
|
||
}
|
||
static inline bool ring_pop(CANRingBuffer* rb, CANFDLog* msg) {
|
||
uint32_t t = rb->tail;
|
||
if (rb->head == t) return false;
|
||
memcpy(msg, &rb->buf[t & rb->mask], sizeof(CANFDLog));
|
||
__sync_synchronize();
|
||
rb->tail = t + 1;
|
||
return true;
|
||
}
|
||
static inline uint32_t ring_count(const CANRingBuffer* rb) {
|
||
return rb->head - rb->tail;
|
||
}
|
||
static inline void ring_clear(CANRingBuffer* rb) {
|
||
rb->tail = rb->head; rb->dropped = 0;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 최근 메시지 (웹 모니터링 테이블)
|
||
// ─────────────────────────────────────────────
|
||
struct RecentMsg {
|
||
CANFDLog msg;
|
||
uint32_t count;
|
||
};
|
||
|
||
// ─────────────────────────────────────────────
|
||
// TX 메시지
|
||
// ─────────────────────────────────────────────
|
||
struct TxMessage {
|
||
uint32_t id;
|
||
bool extended;
|
||
bool isFD;
|
||
bool brs;
|
||
uint8_t dlc;
|
||
uint8_t data[CANFD_MAX_DATA];
|
||
uint32_t intervalMs;
|
||
uint32_t lastSent;
|
||
bool active;
|
||
};
|
||
|
||
struct FileComment {
|
||
char filename[MAX_FILENAME_LEN];
|
||
char comment[MAX_COMMENT_LEN];
|
||
};
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 시간 동기화 상태
|
||
// ─────────────────────────────────────────────
|
||
struct TimeSyncStatus {
|
||
bool synced;
|
||
uint64_t lastSyncUs;
|
||
uint32_t syncCount;
|
||
bool rtcAvail;
|
||
uint32_t rtcSyncCount;
|
||
} timeSyncSt = {};
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 전역 객체
|
||
// ─────────────────────────────────────────────
|
||
SPIClass gSPI(HSPI);
|
||
ACAN2517FD gCAN(PIN_CAN_CS, gSPI, PIN_CAN_INT);
|
||
|
||
SoftWire rtcWire(PIN_I2C_SDA, PIN_I2C_SCL);
|
||
|
||
WebServer server(80);
|
||
WebSocketsServer webSocket(81);
|
||
Preferences prefs;
|
||
|
||
// PSRAM 버퍼
|
||
RecentMsg* recentData = nullptr;
|
||
TxMessage* txMessages = nullptr;
|
||
FileComment* fileComments = nullptr;
|
||
uint8_t* fileBuf1 = nullptr;
|
||
uint8_t* fileBuf2 = nullptr;
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 상태 변수
|
||
// ─────────────────────────────────────────────
|
||
char wifiSSID[32] = "CANFD_Logger";
|
||
char wifiPassword[64] = "12345678";
|
||
bool staMode = false;
|
||
char staSSID[32] = "";
|
||
char staPassword[64] = "";
|
||
|
||
volatile bool loggingEnabled = false;
|
||
volatile bool sdCardReady = false;
|
||
volatile uint32_t totalMsgCount = 0;
|
||
volatile uint32_t msgPerSecond = 0;
|
||
uint32_t lastMsgCountMs = 0;
|
||
uint32_t lastMsgCount = 0;
|
||
uint32_t totalTxCount = 0;
|
||
uint32_t lastBroadcast = 0;
|
||
|
||
// 현재 CAN 설정
|
||
int speedPresetIdx = 2; // 기본: Classic 500K (안전한 기본값)
|
||
bool listenOnly = false;
|
||
volatile bool canInitOK = false; // CAN 초기화 성공 여부
|
||
|
||
// 더블 버퍼
|
||
uint8_t* curWriteBuf = nullptr;
|
||
uint8_t* curFlushBuf = nullptr;
|
||
uint32_t writeBufIdx = 0;
|
||
uint32_t flushBufSize = 0;
|
||
volatile bool flushInProg = false;
|
||
|
||
// 로그 파일
|
||
File logFile;
|
||
char curFilename[MAX_FILENAME_LEN];
|
||
volatile uint32_t curFileSize = 0;
|
||
volatile bool canLogCSV = false;
|
||
volatile bool canLogPCAP = false;
|
||
volatile uint64_t logStartUs = 0;
|
||
|
||
// 파일 코멘트
|
||
int commentCount = 0;
|
||
|
||
// ─────────────────────────────────────────────
|
||
// FreeRTOS 핸들
|
||
// ─────────────────────────────────────────────
|
||
SemaphoreHandle_t sdMutex = NULL;
|
||
SemaphoreHandle_t rtcMutex = NULL;
|
||
TaskHandle_t canRxTaskH = NULL;
|
||
TaskHandle_t sdWriteTaskH = NULL;
|
||
TaskHandle_t sdFlushTaskH = NULL;
|
||
TaskHandle_t webTaskH = NULL;
|
||
TaskHandle_t rtcTaskH = NULL;
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 유틸리티
|
||
// ─────────────────────────────────────────────
|
||
uint8_t bcdToDec(uint8_t v) { return (v >> 4) * 10 + (v & 0x0F); }
|
||
uint8_t decToBcd(uint8_t v) { return ((v / 10) << 4) | (v % 10); }
|
||
|
||
// CANFDMessage → 내부 플래그 변환
|
||
static inline uint8_t makeFlags(const CANFDMessage& m) {
|
||
uint8_t f = 0;
|
||
if (m.ext) f |= 0x80;
|
||
if (m.type == CANFDMessage::CAN_REMOTE) f |= 0x40;
|
||
if (m.type == CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH ||
|
||
m.type == CANFDMessage::CANFD_NO_BIT_RATE_SWITCH) f |= 0x20; // FDF
|
||
if (m.type == CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH) f |= 0x10; // BRS
|
||
return f;
|
||
}
|
||
|
||
bool writePcapGlobalHeader(File& f) {
|
||
PcapGlobalHeader h;
|
||
h.magic = 0xa1b2c3d4; h.ver_major = 2; h.ver_minor = 4;
|
||
h.thiszone = 0; h.sigfigs = 0; h.snaplen = 65535; h.network = 227;
|
||
return f.write((uint8_t*)&h, sizeof(h)) == sizeof(h);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// PSRAM 초기화
|
||
// ─────────────────────────────────────────────
|
||
bool initPSRAM() {
|
||
Serial.println("\n=== PSRAM 초기화 (CAN FD Logger) ===");
|
||
if (!psramFound()) { Serial.println("✗ PSRAM 없음!"); return false; }
|
||
Serial.printf("✓ PSRAM: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
|
||
|
||
// CAN 링버퍼 (4096 × 79 bytes ≈ 316 KB)
|
||
canRing.buf = (CANFDLog*)ps_malloc(CAN_RING_SIZE * sizeof(CANFDLog));
|
||
if (!canRing.buf) { Serial.println("✗ CAN RingBuffer 실패"); return false; }
|
||
canRing.size = CAN_RING_SIZE; canRing.mask = CAN_RING_SIZE - 1;
|
||
canRing.head = canRing.tail = canRing.dropped = 0;
|
||
Serial.printf("✓ CAN RingBuffer: %d슬롯 × %d bytes = %.1f KB\n",
|
||
CAN_RING_SIZE, sizeof(CANFDLog),
|
||
(float)(CAN_RING_SIZE * sizeof(CANFDLog)) / 1024.0f);
|
||
|
||
// 더블 버퍼 (2 × 64KB = 128 KB)
|
||
fileBuf1 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
|
||
fileBuf2 = (uint8_t*)ps_malloc(FILE_BUFFER_SIZE);
|
||
if (!fileBuf1 || !fileBuf2) { Serial.println("✗ 더블버퍼 실패"); return false; }
|
||
curWriteBuf = fileBuf1; curFlushBuf = fileBuf2;
|
||
Serial.printf("✓ Double Buffer: 2 × %d KB\n", FILE_BUFFER_SIZE / 1024);
|
||
|
||
// 기타 데이터
|
||
recentData = (RecentMsg*) ps_calloc(RECENT_MSG_COUNT, sizeof(RecentMsg));
|
||
txMessages = (TxMessage*) ps_calloc(MAX_TX_MESSAGES, sizeof(TxMessage));
|
||
fileComments = (FileComment*) ps_calloc(MAX_FILE_COMMENTS,sizeof(FileComment));
|
||
if (!recentData || !txMessages || !fileComments) {
|
||
Serial.println("✗ 데이터 버퍼 실패"); return false;
|
||
}
|
||
Serial.printf("✓ PSRAM 남은 용량: %.1f KB\n\n", (float)ESP.getFreePsram() / 1024.0f);
|
||
return true;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 설정 저장/로드
|
||
// ─────────────────────────────────────────────
|
||
void loadSettings() {
|
||
prefs.begin("canfd-logger", true);
|
||
prefs.getString("ssid", wifiSSID, sizeof(wifiSSID));
|
||
prefs.getString("pass", wifiPassword, sizeof(wifiPassword));
|
||
staMode = prefs.getBool("sta_en", false);
|
||
prefs.getString("sta_ssid", staSSID, sizeof(staSSID));
|
||
prefs.getString("sta_pass", staPassword, sizeof(staPassword));
|
||
if (!strlen(wifiSSID)) strcpy(wifiSSID, "CANFD_Logger");
|
||
if (!strlen(wifiPassword)) strcpy(wifiPassword,"12345678");
|
||
speedPresetIdx = prefs.getInt("speed_idx", 2);
|
||
if (speedPresetIdx < 0 || speedPresetIdx >= (int)NUM_SPEED_PRESETS) speedPresetIdx = 2;
|
||
listenOnly = prefs.getBool("listen_only", false);
|
||
prefs.end();
|
||
}
|
||
|
||
void saveSettings() {
|
||
prefs.begin("canfd-logger", false);
|
||
prefs.putString("ssid", wifiSSID);
|
||
prefs.putString("pass", wifiPassword);
|
||
prefs.putBool ("sta_en", staMode);
|
||
prefs.putString("sta_ssid", staSSID);
|
||
prefs.putString("sta_pass", staPassword);
|
||
prefs.putInt ("speed_idx",speedPresetIdx);
|
||
prefs.putBool ("listen_only", listenOnly);
|
||
prefs.end();
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// CAN FD 초기화 (ACAN2517FD)
|
||
// ─────────────────────────────────────────────
|
||
bool initCANFD() {
|
||
const CANFDPreset& p = speedPresets[speedPresetIdx];
|
||
Serial.printf("CAN FD 초기화: %s (Listen:%s)\n",
|
||
p.name, listenOnly ? "ON" : "OFF");
|
||
|
||
ACAN2517FDSettings settings(CANFD_OSC, p.arbBPS, p.factor);
|
||
|
||
if (listenOnly) {
|
||
settings.mRequestedMode = ACAN2517FDSettings::ListenOnly;
|
||
} else if (!p.isFD) {
|
||
settings.mRequestedMode = ACAN2517FDSettings::Normal20B;
|
||
} else {
|
||
settings.mRequestedMode = ACAN2517FDSettings::NormalFD;
|
||
}
|
||
|
||
// ★ TX FIFO 크기 미설정 (기본값 유지) → 0x1000(kTXQSizeTooLarge) 에러 방지
|
||
// ★ RX FIFO 16 = 동작 확인된 값
|
||
settings.mControllerReceiveFIFOSize = 16;
|
||
|
||
Serial.printf(" Arb : %lu bps (실제 %lu bps)\n",
|
||
p.arbBPS, settings.actualArbitrationBitRate());
|
||
if (p.isFD) {
|
||
Serial.printf(" Data : %lu bps (실제 %lu bps)\n",
|
||
p.arbBPS * (uint32_t)p.factor, settings.actualDataBitRate());
|
||
}
|
||
|
||
// ★ CS 핀 명시적으로 HIGH 보장 (이전 작업 잔류 상태 초기화)
|
||
pinMode(PIN_CAN_CS, OUTPUT);
|
||
digitalWrite(PIN_CAN_CS, HIGH);
|
||
delay(10);
|
||
|
||
// ★ 최대 3회 재시도 (0x2=readback오류는 타이밍 문제로 재시도하면 해결됨)
|
||
uint32_t err = 0xFFFFFFFF;
|
||
for (int attempt = 0; attempt < 3; attempt++) {
|
||
if (attempt > 0) {
|
||
Serial.printf(" 재시도 %d/3 (SPI 재초기화)...\n", attempt + 1);
|
||
gSPI.end();
|
||
delay(50);
|
||
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, PIN_CAN_CS);
|
||
digitalWrite(PIN_CAN_CS, HIGH);
|
||
delay(100);
|
||
}
|
||
err = gCAN.begin(settings, NULL); // NULL = 폴링모드
|
||
if (err == 0) break;
|
||
Serial.printf(" 시도 %d 실패: 0x%08X\n", attempt + 1, err);
|
||
delay(200);
|
||
}
|
||
|
||
if (err == 0) {
|
||
Serial.printf("✓ CAN FD 초기화 성공: %s\n", p.name);
|
||
canInitOK = true;
|
||
return true;
|
||
} else {
|
||
canInitOK = false;
|
||
Serial.printf("✗ CAN FD 초기화 최종 실패: 0x%08X\n", err);
|
||
if (err & 0x00000001) Serial.println(" 0x1 → Configuration모드 타임아웃 (SPI배선 확인)");
|
||
if (err & 0x00000002) Serial.println(" 0x2 → 레지스터 readback 오류 (SPI노이즈/속도)");
|
||
if (err & 0x00001000) Serial.println(" 0x1000 → FIFO크기 초과 (TX FIFO 설정 확인)");
|
||
if (err & 0x00010000) Serial.println(" 0x10000 → 모드전환 타임아웃 (CS/INT 확인)");
|
||
|
||
// ★ FD 설정 실패 시 Classic 500K(index=2)로 자동 폴백 (1회)
|
||
if (speedPresets[speedPresetIdx].isFD && speedPresetIdx != 2) {
|
||
Serial.println(" → Classic 500K으로 자동 폴백 시도...");
|
||
speedPresetIdx = 2;
|
||
saveSettings();
|
||
return initCANFD(); // 1회 재귀 (Classic은 isFD=false → 재귀 없음)
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// RTC (DS3231)
|
||
// ─────────────────────────────────────────────
|
||
void initRTC() {
|
||
rtcWire.begin();
|
||
rtcWire.setClock(100000);
|
||
rtcWire.beginTransmission(DS3231_ADDR);
|
||
timeSyncSt.rtcAvail = (rtcWire.endTransmission() == 0);
|
||
Serial.printf("%s RTC(DS3231) %s\n",
|
||
timeSyncSt.rtcAvail ? "✓" : "!", timeSyncSt.rtcAvail ? "감지됨" : "없음");
|
||
}
|
||
|
||
bool readRTC(struct tm* t) {
|
||
if (!timeSyncSt.rtcAvail) return false;
|
||
if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false;
|
||
rtcWire.beginTransmission(DS3231_ADDR); rtcWire.write(0x00);
|
||
if (rtcWire.endTransmission() != 0) { xSemaphoreGive(rtcMutex); return false; }
|
||
if (rtcWire.requestFrom(DS3231_ADDR, 7) != 7) { xSemaphoreGive(rtcMutex); return false; }
|
||
uint8_t b[7]; for (int i=0;i<7;i++) b[i]=rtcWire.read();
|
||
xSemaphoreGive(rtcMutex);
|
||
t->tm_sec = bcdToDec(b[0]&0x7F); t->tm_min = bcdToDec(b[1]&0x7F);
|
||
t->tm_hour = bcdToDec(b[2]&0x3F); t->tm_mday = bcdToDec(b[4]&0x3F);
|
||
t->tm_mon = bcdToDec(b[5]&0x1F)-1; t->tm_year = bcdToDec(b[6])+100;
|
||
return true;
|
||
}
|
||
|
||
bool writeRTC(const struct tm* t) {
|
||
if (!timeSyncSt.rtcAvail) return false;
|
||
if (xSemaphoreTake(rtcMutex, pdMS_TO_TICKS(100)) != pdTRUE) return false;
|
||
rtcWire.beginTransmission(DS3231_ADDR); rtcWire.write(0x00);
|
||
rtcWire.write(decToBcd(t->tm_sec)); rtcWire.write(decToBcd(t->tm_min));
|
||
rtcWire.write(decToBcd(t->tm_hour)); rtcWire.write(decToBcd(t->tm_wday+1));
|
||
rtcWire.write(decToBcd(t->tm_mday)); rtcWire.write(decToBcd(t->tm_mon+1));
|
||
rtcWire.write(decToBcd(t->tm_year-100));
|
||
bool ok = (rtcWire.endTransmission() == 0);
|
||
xSemaphoreGive(rtcMutex);
|
||
return ok;
|
||
}
|
||
|
||
void ntpCallback(struct timeval* tv) {
|
||
timeSyncSt.synced = true;
|
||
timeSyncSt.lastSyncUs= (uint64_t)tv->tv_sec*1000000ULL + tv->tv_usec;
|
||
timeSyncSt.syncCount++;
|
||
struct tm t; time_t now = tv->tv_sec; localtime_r(&now, &t);
|
||
if (timeSyncSt.rtcAvail && writeRTC(&t)) timeSyncSt.rtcSyncCount++;
|
||
}
|
||
|
||
void rtcSyncTask(void* pv) {
|
||
while (1) {
|
||
if (timeSyncSt.rtcAvail) {
|
||
struct tm t;
|
||
if (readRTC(&t)) {
|
||
time_t now = mktime(&t);
|
||
struct timeval tv = {now, 0}; settimeofday(&tv, NULL);
|
||
timeSyncSt.synced = true;
|
||
timeSyncSt.lastSyncUs = (uint64_t)now * 1000000ULL;
|
||
timeSyncSt.rtcSyncCount++;
|
||
}
|
||
}
|
||
vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL));
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 파일 코멘트
|
||
// ─────────────────────────────────────────────
|
||
void saveFileComments() {
|
||
if (!sdCardReady) return;
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||
File f = SD_MMC.open("/comments.dat", FILE_WRITE);
|
||
if (f) {
|
||
f.write((uint8_t*)&commentCount, sizeof(commentCount));
|
||
f.write((uint8_t*)fileComments, sizeof(FileComment)*commentCount);
|
||
f.close();
|
||
}
|
||
xSemaphoreGive(sdMutex);
|
||
}
|
||
}
|
||
|
||
void loadFileComments() {
|
||
if (!sdCardReady) return;
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||
if (SD_MMC.exists("/comments.dat")) {
|
||
File f = SD_MMC.open("/comments.dat", FILE_READ);
|
||
if (f) {
|
||
f.read((uint8_t*)&commentCount, sizeof(commentCount));
|
||
if (commentCount > MAX_FILE_COMMENTS) commentCount = MAX_FILE_COMMENTS;
|
||
f.read((uint8_t*)fileComments, sizeof(FileComment)*commentCount);
|
||
f.close();
|
||
}
|
||
}
|
||
xSemaphoreGive(sdMutex);
|
||
}
|
||
}
|
||
|
||
const char* getComment(const char* fn) {
|
||
for (int i=0;i<commentCount;i++)
|
||
if (strcmp(fileComments[i].filename, fn)==0) return fileComments[i].comment;
|
||
return "";
|
||
}
|
||
|
||
void addComment(const char* fn, const char* cm) {
|
||
for (int i=0;i<commentCount;i++) {
|
||
if (strcmp(fileComments[i].filename,fn)==0) {
|
||
strncpy(fileComments[i].comment,cm,MAX_COMMENT_LEN-1); saveFileComments(); return;
|
||
}
|
||
}
|
||
if (commentCount < MAX_FILE_COMMENTS) {
|
||
strncpy(fileComments[commentCount].filename,fn,MAX_FILENAME_LEN-1);
|
||
strncpy(fileComments[commentCount].comment,cm,MAX_COMMENT_LEN-1);
|
||
commentCount++; saveFileComments();
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// CAN RX Task (ACAN2517FD 폴링)
|
||
// ─────────────────────────────────────────────
|
||
void canRxTask(void* pv) {
|
||
Serial.println("✓ CAN RX Task 시작 (Core 1, Pri 24)");
|
||
vTaskDelay(pdMS_TO_TICKS(500));
|
||
|
||
while (1) {
|
||
// ★ CAN 미초기화 시 sleep (CPU 독점 방지)
|
||
if (!canInitOK) {
|
||
vTaskDelay(pdMS_TO_TICKS(50));
|
||
continue;
|
||
}
|
||
|
||
// ★ 폴링 모드: isr() 호출 후 receive
|
||
gCAN.isr();
|
||
|
||
bool gotMsg = false;
|
||
CANFDMessage rxMsg;
|
||
while (gCAN.receive(rxMsg)) {
|
||
gotMsg = true;
|
||
CANFDLog log;
|
||
struct timeval tv; gettimeofday(&tv, NULL);
|
||
log.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec;
|
||
log.id = rxMsg.id;
|
||
log.flags = makeFlags(rxMsg);
|
||
log.dlc = rxMsg.len; // ACAN2517FD: len 필드에 DLC가 들어옴
|
||
log.len = rxMsg.len <= 15 ? dlcToLen[rxMsg.len] : rxMsg.len;
|
||
if (log.len > CANFD_MAX_DATA) log.len = CANFD_MAX_DATA;
|
||
memcpy(log.data, rxMsg.data, log.len);
|
||
|
||
if (ring_push(&canRing, &log)) totalMsgCount++;
|
||
}
|
||
|
||
// ★ 메시지 없을 때 5ms 대기 → Core1 idle task가 WDT 리셋 가능
|
||
// ★ 메시지 있을 때 2ms → 연속 수신 처리 유지
|
||
vTaskDelay(pdMS_TO_TICKS(gotMsg ? 2 : 5));
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// SD Flush Task (더블 버퍼 비동기 flush)
|
||
// ─────────────────────────────────────────────
|
||
void sdFlushTask(void* pv) {
|
||
Serial.println("✓ SD Flush Task 시작 (Core 0, Pri 9)");
|
||
uint32_t flushCnt = 0;
|
||
while (1) {
|
||
uint32_t flushSize;
|
||
if (xTaskNotifyWait(0, 0xFFFFFFFF, &flushSize, portMAX_DELAY) == pdTRUE) {
|
||
if (flushSize > 0 && flushSize <= FILE_BUFFER_SIZE) {
|
||
flushInProg = true;
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(5000)) == pdTRUE) {
|
||
if (logFile && sdCardReady && loggingEnabled) {
|
||
uint32_t t0 = millis();
|
||
logFile.write(curFlushBuf, flushSize);
|
||
if (++flushCnt % 4 == 0) logFile.flush();
|
||
uint32_t el = millis()-t0;
|
||
if (el > 200) Serial.printf("⚠ SD Flush 지연: %dms (%d bytes)\n",el,(int)flushSize);
|
||
}
|
||
xSemaphoreGive(sdMutex);
|
||
}
|
||
flushInProg = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// SD Write Task (링버퍼 Consumer)
|
||
// ─────────────────────────────────────────────
|
||
void sdWriteTask(void* pv) {
|
||
Serial.println("✓ SD Write Task 시작 (Core 0, Pri 8)");
|
||
CANFDLog msg;
|
||
|
||
while (1) {
|
||
bool worked = false;
|
||
|
||
if (ring_pop(&canRing, &msg)) {
|
||
worked = true;
|
||
|
||
// 모니터링 테이블 업데이트
|
||
bool found = false;
|
||
for (int i=0;i<RECENT_MSG_COUNT;i++) {
|
||
if (recentData[i].msg.id == msg.id) {
|
||
recentData[i].msg = msg; recentData[i].count++; found = true; break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
for (int i=0;i<RECENT_MSG_COUNT;i++) {
|
||
if (recentData[i].count == 0) {
|
||
recentData[i].msg = msg; recentData[i].count = 1; break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// SD 로깅
|
||
if (loggingEnabled && sdCardReady) {
|
||
if (canLogCSV) {
|
||
// CSV 직접 쓰기
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1)) == pdTRUE) {
|
||
char line[256];
|
||
uint64_t rel = msg.timestamp_us - logStartUs;
|
||
char dat[CANFD_MAX_DATA*3+1]; int di=0;
|
||
for (int i=0;i<msg.len;i++) {
|
||
di += sprintf(&dat[di], "%02X", msg.data[i]);
|
||
if (i<msg.len-1) dat[di++]=' ';
|
||
}
|
||
dat[di]='\0';
|
||
bool isFD = (msg.flags & 0x20) != 0;
|
||
bool isBRS = (msg.flags & 0x10) != 0;
|
||
int ll = snprintf(line, sizeof(line),
|
||
"%llu,0x%08X,%d,%s,%s,%d,%s\n",
|
||
rel, msg.id, msg.dlc,
|
||
isFD?"FD":"CL", isBRS?"BRS":"-",
|
||
msg.len, dat);
|
||
if (logFile) { logFile.write((uint8_t*)line, ll); curFileSize += ll;
|
||
static int cc=0; if(++cc>=1000){logFile.flush();cc=0;} }
|
||
xSemaphoreGive(sdMutex);
|
||
}
|
||
} else if (canLogPCAP) {
|
||
// PCAP 더블 버퍼
|
||
bool isFD = (msg.flags & 0x20) != 0;
|
||
size_t recSize = sizeof(PcapPktHdr) + (isFD ? sizeof(SCFDFrame) : sizeof(SCFrame));
|
||
if (writeBufIdx + recSize > FILE_BUFFER_SIZE) {
|
||
while (flushInProg) vTaskDelay(1);
|
||
uint8_t* tmp=curWriteBuf; curWriteBuf=curFlushBuf; curFlushBuf=tmp;
|
||
flushBufSize=writeBufIdx; writeBufIdx=0;
|
||
if (sdFlushTaskH) xTaskNotify(sdFlushTaskH, flushBufSize, eSetValueWithOverwrite);
|
||
}
|
||
PcapPktHdr ph;
|
||
ph.ts_sec = (uint32_t)(msg.timestamp_us/1000000ULL);
|
||
ph.ts_usec = (uint32_t)(msg.timestamp_us%1000000ULL);
|
||
ph.incl_len= ph.orig_len = isFD ? sizeof(SCFDFrame) : sizeof(SCFrame);
|
||
memcpy(&curWriteBuf[writeBufIdx], &ph, sizeof(ph)); writeBufIdx+=sizeof(ph);
|
||
if (isFD) {
|
||
SCFDFrame fr; memset(&fr,0,sizeof(fr));
|
||
fr.can_id = msg.id;
|
||
if (msg.flags & 0x80) fr.can_id |= 0x80000000U;
|
||
fr.len = msg.len;
|
||
fr.flags = 0x04; // CANFD_FDF
|
||
if (msg.flags & 0x10) fr.flags |= 0x01; // BRS
|
||
memcpy(fr.data, msg.data, msg.len);
|
||
memcpy(&curWriteBuf[writeBufIdx], &fr, sizeof(fr)); writeBufIdx+=sizeof(fr);
|
||
} else {
|
||
SCFrame fr; memset(&fr,0,sizeof(fr));
|
||
fr.can_id = msg.id;
|
||
if (msg.flags & 0x80) fr.can_id |= 0x80000000U;
|
||
if (msg.flags & 0x40) fr.can_id |= 0x40000000U;
|
||
fr.can_dlc = msg.dlc;
|
||
memcpy(fr.data, msg.data, msg.len < 8 ? msg.len : 8);
|
||
memcpy(&curWriteBuf[writeBufIdx], &fr, sizeof(fr)); writeBufIdx+=sizeof(fr);
|
||
}
|
||
curFileSize += recSize;
|
||
} else {
|
||
// BIN 더블 버퍼 (가장 빠름, 손실 없음)
|
||
if (writeBufIdx + sizeof(CANFDLog) > FILE_BUFFER_SIZE) {
|
||
while (flushInProg) vTaskDelay(1);
|
||
uint8_t* tmp=curWriteBuf; curWriteBuf=curFlushBuf; curFlushBuf=tmp;
|
||
flushBufSize=writeBufIdx; writeBufIdx=0;
|
||
if (sdFlushTaskH) xTaskNotify(sdFlushTaskH, flushBufSize, eSetValueWithOverwrite);
|
||
}
|
||
memcpy(&curWriteBuf[writeBufIdx], &msg, sizeof(CANFDLog));
|
||
writeBufIdx += sizeof(CANFDLog);
|
||
curFileSize += sizeof(CANFDLog);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!worked) vTaskDelay(1);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// TX Task
|
||
// ─────────────────────────────────────────────
|
||
void txTask(void* pv) {
|
||
while (1) {
|
||
uint32_t now = millis();
|
||
bool any = false;
|
||
for (int i=0;i<MAX_TX_MESSAGES;i++) {
|
||
TxMessage& tx = txMessages[i];
|
||
if (!tx.active || tx.intervalMs == 0) continue;
|
||
any = true;
|
||
if (now - tx.lastSent >= tx.intervalMs) {
|
||
CANFDMessage m;
|
||
m.id = tx.id;
|
||
m.ext = tx.extended;
|
||
m.len = tx.dlc < 16 ? dlcToLen[tx.dlc] : tx.dlc;
|
||
if (tx.isFD && tx.brs) m.type = CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH;
|
||
else if (tx.isFD) m.type = CANFDMessage::CANFD_NO_BIT_RATE_SWITCH;
|
||
else m.type = CANFDMessage::CAN_DATA;
|
||
memcpy(m.data, tx.data, m.len);
|
||
if (gCAN.tryToSend(m)) { totalTxCount++; tx.lastSent = now; }
|
||
}
|
||
}
|
||
vTaskDelay(any ? pdMS_TO_TICKS(1) : pdMS_TO_TICKS(10));
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// WebSocket 이벤트
|
||
// ─────────────────────────────────────────────
|
||
void wsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||
if (type == WStype_CONNECTED) {
|
||
lastBroadcast = 0; return;
|
||
}
|
||
if (type != WStype_TEXT) return;
|
||
|
||
DynamicJsonDocument doc(4096);
|
||
if (deserializeJson(doc, payload)) return;
|
||
const char* cmd = doc["cmd"];
|
||
if (!cmd) return;
|
||
|
||
// ── 설정 ────────────────────────────────
|
||
if (strcmp(cmd, "getSettings") == 0) {
|
||
DynamicJsonDocument r(1024);
|
||
r["type"]="settings"; r["ssid"]=wifiSSID; r["password"]=wifiPassword;
|
||
r["staEnable"]=staMode; r["staSSID"]=staSSID;
|
||
r["speedIdx"]=speedPresetIdx; r["listenOnly"]=listenOnly;
|
||
r["staConnected"]=(WiFi.status()==WL_CONNECTED);
|
||
r["staIP"]=WiFi.localIP().toString();
|
||
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
|
||
}
|
||
else if (strcmp(cmd, "saveSettings") == 0) {
|
||
const char* s=doc["ssid"]; if(s&&strlen(s)) strncpy(wifiSSID,s,31);
|
||
const char* p=doc["password"]; if(p) strncpy(wifiPassword,p,63);
|
||
staMode=doc["staEnable"]|false;
|
||
const char* ss=doc["staSSID"]; if(ss) strncpy(staSSID,ss,31);
|
||
const char* sp=doc["staPassword"]; if(sp) strncpy(staPassword,sp,63);
|
||
int si=doc["speedIdx"]|-1;
|
||
if(si>=0&&si<(int)NUM_SPEED_PRESETS) speedPresetIdx=si;
|
||
listenOnly=doc["listenOnly"]|false;
|
||
saveSettings();
|
||
webSocket.sendTXT(num,"{\"type\":\"settingsSaved\",\"success\":true}");
|
||
}
|
||
// ── CAN FD 재초기화 ─────────────────────
|
||
else if (strcmp(cmd, "reinitCAN") == 0) {
|
||
int si = doc["speedIdx"]|-1;
|
||
if (si>=0&&si<(int)NUM_SPEED_PRESETS) speedPresetIdx=si;
|
||
listenOnly = doc["listenOnly"]|false;
|
||
saveSettings();
|
||
bool ok = initCANFD();
|
||
DynamicJsonDocument r(256);
|
||
r["type"]="reinitResult";
|
||
r["success"]=ok;
|
||
r["preset"]=speedPresets[speedPresetIdx].name;
|
||
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
|
||
}
|
||
// ── 로깅 ────────────────────────────────
|
||
else if (strcmp(cmd, "startLogging") == 0) {
|
||
if (!loggingEnabled && sdCardReady) {
|
||
const char* fmt=doc["format"];
|
||
canLogCSV = (fmt && strcmp(fmt,"csv") ==0);
|
||
canLogPCAP = (fmt && strcmp(fmt,"pcap")==0);
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||
time_t now2; struct tm ti; time(&now2); localtime_r(&now2,&ti);
|
||
struct timeval tv; gettimeofday(&tv,NULL);
|
||
logStartUs = (uint64_t)tv.tv_sec*1000000ULL+tv.tv_usec;
|
||
const char* ext = canLogPCAP?"pcap":(canLogCSV?"csv":"bin");
|
||
snprintf(curFilename,sizeof(curFilename),
|
||
"/CANFD_%04d%02d%02d_%02d%02d%02d.%s",
|
||
ti.tm_year+1900,ti.tm_mon+1,ti.tm_mday,
|
||
ti.tm_hour,ti.tm_min,ti.tm_sec,ext);
|
||
logFile = SD_MMC.open(curFilename, FILE_WRITE);
|
||
if (logFile) {
|
||
if (canLogCSV) {
|
||
logFile.println("Time_us,CAN_ID,DLC,Type,BRS,DataLen,Data");
|
||
logFile.flush();
|
||
} else if (canLogPCAP) {
|
||
writePcapGlobalHeader(logFile); logFile.flush();
|
||
}
|
||
logFile.close();
|
||
logFile = SD_MMC.open(curFilename, FILE_APPEND);
|
||
if (logFile) {
|
||
loggingEnabled=true; writeBufIdx=0; flushBufSize=0;
|
||
flushInProg=false; curFileSize=logFile.size();
|
||
Serial.printf("✓ 로깅 시작 [%s]: %s\n",
|
||
canLogPCAP?"PCAP":(canLogCSV?"CSV":"BIN"), curFilename);
|
||
}
|
||
}
|
||
xSemaphoreGive(sdMutex);
|
||
}
|
||
}
|
||
}
|
||
else if (strcmp(cmd, "stopLogging") == 0) {
|
||
if (loggingEnabled) {
|
||
loggingEnabled = false;
|
||
int wc=0; while(flushInProg&&wc++<500) vTaskDelay(10);
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(2000)) == pdTRUE) {
|
||
if (writeBufIdx>0&&logFile) { logFile.write(curWriteBuf,writeBufIdx); writeBufIdx=0; }
|
||
if (logFile) { logFile.flush(); logFile.close(); }
|
||
curFilename[0]='\0'; flushBufSize=0;
|
||
xSemaphoreGive(sdMutex);
|
||
}
|
||
Serial.println("✓ 로깅 중지");
|
||
}
|
||
}
|
||
// ── 파일 목록 ────────────────────────────
|
||
else if (strcmp(cmd, "getFiles") == 0) {
|
||
if (sdCardReady && xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||
DynamicJsonDocument r(8192);
|
||
r["type"]="files";
|
||
JsonArray fa = r.createNestedArray("files");
|
||
File root = SD_MMC.open("/");
|
||
if (root) {
|
||
File f = root.openNextFile(); int cnt=0;
|
||
while (f && cnt<60) {
|
||
if (!f.isDirectory()) {
|
||
const char* fn=f.name(); if(fn[0]=='/') fn++;
|
||
if(fn[0]!='.'&&strlen(fn)>0) {
|
||
JsonObject fo=fa.createNestedObject();
|
||
fo["name"]=fn; fo["size"]=f.size();
|
||
const char* cm=getComment(fn);
|
||
if(strlen(cm)>0) fo["comment"]=cm;
|
||
cnt++;
|
||
}
|
||
}
|
||
f.close(); f=root.openNextFile();
|
||
}
|
||
root.close();
|
||
}
|
||
xSemaphoreGive(sdMutex);
|
||
String j; serializeJson(r,j); webSocket.sendTXT(num,j);
|
||
}
|
||
}
|
||
else if (strcmp(cmd, "deleteFile") == 0) {
|
||
const char* fn=doc["filename"];
|
||
if (fn&&strlen(fn)>0) {
|
||
String fp="/"+String(fn);
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
|
||
bool ok=SD_MMC.exists(fp)&&SD_MMC.remove(fp);
|
||
xSemaphoreGive(sdMutex);
|
||
char r[64]; snprintf(r,sizeof(r),"{\"type\":\"deleteResult\",\"success\":%s}",ok?"true":"false");
|
||
webSocket.sendTXT(num,r);
|
||
}
|
||
}
|
||
}
|
||
else if (strcmp(cmd, "addComment") == 0) {
|
||
const char* fn=doc["filename"], *cm=doc["comment"];
|
||
if(fn&&cm) addComment(fn,cm);
|
||
}
|
||
// ── TX ───────────────────────────────────
|
||
else if (strcmp(cmd, "sendOnce") == 0) {
|
||
CANFDMessage m;
|
||
m.id = strtoul(doc["id"]|"0x0", NULL, 16);
|
||
m.ext = doc["ext"]|false;
|
||
bool isFD=doc["fd"]|false, brs=doc["brs"]|false;
|
||
if(isFD&&brs) m.type=CANFDMessage::CANFD_WITH_BIT_RATE_SWITCH;
|
||
else if(isFD) m.type=CANFDMessage::CANFD_NO_BIT_RATE_SWITCH;
|
||
else m.type=CANFDMessage::CAN_DATA;
|
||
uint8_t dlc=doc["dlc"]|8;
|
||
m.len = dlc<16?dlcToLen[dlc]:dlc;
|
||
JsonArray da=doc["data"];
|
||
for(int i=0;i<(int)m.len&&i<64;i++) m.data[i]=da[i]|0;
|
||
if(gCAN.tryToSend(m)) totalTxCount++;
|
||
}
|
||
else if (strcmp(cmd, "setTxMsg") == 0) {
|
||
int idx=doc["idx"]|-1;
|
||
if(idx>=0&&idx<MAX_TX_MESSAGES) {
|
||
TxMessage& tx=txMessages[idx];
|
||
const char* ids=doc["id"]; tx.id=ids?strtoul(ids,NULL,16):0;
|
||
tx.extended=doc["ext"]|false;
|
||
tx.isFD=doc["fd"]|false; tx.brs=doc["brs"]|false;
|
||
uint8_t dlc=doc["dlc"]|8; tx.dlc=dlc;
|
||
JsonArray da=doc["data"];
|
||
for(int i=0;i<64;i++) tx.data[i]=(i<(int)da.size())?((uint8_t)da[i]):0;
|
||
tx.intervalMs=doc["interval"]|0;
|
||
tx.active=doc["active"]|false;
|
||
tx.lastSent=0;
|
||
}
|
||
}
|
||
// ── 시간 동기화 ──────────────────────────
|
||
else if (strcmp(cmd, "syncTime") == 0) {
|
||
struct tm ti;
|
||
ti.tm_year=((int)doc["year"])-1900; ti.tm_mon=((int)doc["month"])-1;
|
||
ti.tm_mday=doc["day"]|1; ti.tm_hour=doc["hour"]|0;
|
||
ti.tm_min=doc["minute"]|0; ti.tm_sec=doc["second"]|0;
|
||
time_t t=mktime(&ti);
|
||
struct timeval tv={t,0}; settimeofday(&tv,NULL);
|
||
timeSyncSt.synced=true; timeSyncSt.lastSyncUs=(uint64_t)t*1000000ULL;
|
||
timeSyncSt.syncCount++;
|
||
if(timeSyncSt.rtcAvail) writeRTC(&ti);
|
||
}
|
||
else if (strcmp(cmd, "hwReset") == 0) {
|
||
webSocket.sendTXT(num,"{\"type\":\"hwReset\"}");
|
||
delay(200); ESP.restart();
|
||
}
|
||
else if (strcmp(cmd, "clearStats") == 0) {
|
||
ring_clear(&canRing);
|
||
totalMsgCount=0; totalTxCount=0; msgPerSecond=0; lastMsgCount=0;
|
||
for(int i=0;i<RECENT_MSG_COUNT;i++) recentData[i].count=0;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// Web Update Task
|
||
// ─────────────────────────────────────────────
|
||
void webUpdateTask(void* pv) {
|
||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||
while (1) {
|
||
server.handleClient();
|
||
webSocket.loop();
|
||
|
||
// 초당 메시지 수 계산
|
||
uint32_t now2 = millis();
|
||
if (now2 - lastMsgCountMs >= 1000) {
|
||
uint32_t cur=totalMsgCount;
|
||
msgPerSecond = cur - lastMsgCount;
|
||
lastMsgCount = cur;
|
||
lastMsgCountMs= now2;
|
||
}
|
||
|
||
if (now2 - lastBroadcast < 500) { vTaskDelay(10); continue; }
|
||
lastBroadcast = now2;
|
||
|
||
if (webSocket.connectedClients() == 0) { vTaskDelay(10); continue; }
|
||
|
||
DynamicJsonDocument doc(12288);
|
||
doc["type"] = "update";
|
||
doc["logging"] = loggingEnabled;
|
||
doc["sdReady"] = sdCardReady;
|
||
doc["totalMsg"] = totalMsgCount;
|
||
doc["msgPerSec"] = msgPerSecond;
|
||
doc["totalTx"] = totalTxCount;
|
||
doc["fileSize"] = curFileSize;
|
||
doc["ringUsed"] = ring_count(&canRing);
|
||
doc["ringSize"] = CAN_RING_SIZE;
|
||
doc["dropped"] = canRing.dropped;
|
||
doc["timeSync"] = timeSyncSt.synced;
|
||
doc["rtcAvail"] = timeSyncSt.rtcAvail;
|
||
doc["rtcSyncCnt"]= timeSyncSt.rtcSyncCount;
|
||
doc["syncCount"] = timeSyncSt.syncCount;
|
||
doc["speedIdx"] = speedPresetIdx;
|
||
doc["apIP"] = WiFi.softAPIP().toString();
|
||
doc["staIP"] = WiFi.localIP().toString();
|
||
doc["staConnected"]= (WiFi.status()==WL_CONNECTED);
|
||
doc["speedName"] = speedPresets[speedPresetIdx].name;
|
||
doc["listenOnly"]= listenOnly;
|
||
doc["currentFile"]= (loggingEnabled&&curFilename[0]) ? String(curFilename) : "";
|
||
doc["logFormat"] = canLogPCAP?"pcap":(canLogCSV?"csv":"bin");
|
||
|
||
// 버스 부하율 추정 (FD는 가변이므로 근사)
|
||
const CANFDPreset& pr = speedPresets[speedPresetIdx];
|
||
uint32_t maxMPS = pr.arbBPS / 100; // 대략적 추정
|
||
float busLoad = maxMPS>0?(msgPerSecond*100.0f)/maxMPS:0;
|
||
if(busLoad>100.0f) busLoad=100.0f;
|
||
doc["busLoad"] = (int)busLoad;
|
||
|
||
time_t ts; time(&ts); doc["timestamp"]=(uint64_t)ts;
|
||
|
||
// 최근 메시지 (최대 30개)
|
||
JsonArray msgs = doc.createNestedArray("messages");
|
||
int cnt=0;
|
||
for(int i=0;i<RECENT_MSG_COUNT&&cnt<30;i++) {
|
||
if(recentData[i].count==0) continue;
|
||
const CANFDLog& m=recentData[i].msg;
|
||
JsonObject mo=msgs.createNestedObject();
|
||
mo["id"]=m.id; mo["dlc"]=m.dlc; mo["len"]=m.len;
|
||
mo["count"]=recentData[i].count;
|
||
bool isFD=(m.flags&0x20)!=0, isBRS=(m.flags&0x10)!=0, isExt=(m.flags&0x80)!=0;
|
||
mo["fd"]=isFD; mo["brs"]=isBRS; mo["ext"]=isExt;
|
||
JsonArray da=mo.createNestedArray("data");
|
||
for(int j=0;j<m.len&&j<16;j++) da.add(m.data[j]);
|
||
cnt++;
|
||
}
|
||
|
||
String json; size_t sz=serializeJson(doc,json);
|
||
if(sz>0&&sz<8192) webSocket.broadcastTXT(json);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// Download Handler
|
||
// ─────────────────────────────────────────────
|
||
void handleDownload() {
|
||
if (!server.hasArg("file")) { server.send(400,"text/plain","Bad request"); return; }
|
||
String fname = "/" + server.arg("file");
|
||
bool wasLog = loggingEnabled;
|
||
if (wasLog) {
|
||
loggingEnabled=false;
|
||
int w=0; while(flushInProg&&w++<200) delay(10);
|
||
delay(50);
|
||
}
|
||
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(3000)) != pdTRUE) {
|
||
if(wasLog) loggingEnabled=true;
|
||
server.send(503,"text/plain","SD busy"); return;
|
||
}
|
||
if (!SD_MMC.exists(fname)) {
|
||
xSemaphoreGive(sdMutex); if(wasLog) loggingEnabled=true;
|
||
server.send(404,"text/plain","Not found"); return;
|
||
}
|
||
// 1-bit 모드로 전환 (WiFi 간섭 방지)
|
||
SD_MMC.end(); delay(50);
|
||
SD_MMC.setPins(PIN_SD_CLK, PIN_SD_CMD, PIN_SD_D0);
|
||
if (!SD_MMC.begin("/sdcard", true, false, 10000000)) {
|
||
xSemaphoreGive(sdMutex); if(wasLog) loggingEnabled=true;
|
||
server.send(500,"text/plain","SD 1-bit init fail"); return;
|
||
}
|
||
File file = SD_MMC.open(fname, FILE_READ);
|
||
if (!file) {
|
||
xSemaphoreGive(sdMutex); if(wasLog) loggingEnabled=true;
|
||
server.send(500,"text/plain","Open fail"); return;
|
||
}
|
||
size_t fsz = file.size();
|
||
server.setContentLength(fsz);
|
||
server.sendHeader("Content-Disposition","attachment; filename=\""+server.arg("file")+"\"");
|
||
server.sendHeader("Content-Type","application/octet-stream");
|
||
server.sendHeader("Connection","close");
|
||
server.send(200,"application/octet-stream","");
|
||
const size_t CHUNK=4096;
|
||
uint8_t* buf=(uint8_t*)malloc(CHUNK);
|
||
if(buf) {
|
||
size_t tot=0; WiFiClient cl=server.client(); cl.setNoDelay(true);
|
||
while(tot<fsz&&cl.connected()) {
|
||
size_t r=file.read(buf,min((size_t)(fsz-tot),CHUNK));
|
||
if(r==0) break;
|
||
size_t w=0;
|
||
while(w<r) { size_t wr=cl.write(buf+w,r-w); if(wr>0) w+=wr; else { yield();delay(1); } }
|
||
tot+=r; yield();
|
||
}
|
||
free(buf);
|
||
}
|
||
file.close();
|
||
// 4-bit 모드 복원
|
||
SD_MMC.end(); delay(50);
|
||
SD_MMC.setPins(PIN_SD_CLK,PIN_SD_CMD,PIN_SD_D0,PIN_SD_D1,PIN_SD_D2,PIN_SD_D3);
|
||
if(SD_MMC.begin("/sdcard",false,false,10000000)) sdCardReady=true;
|
||
xSemaphoreGive(sdMutex);
|
||
if(wasLog) loggingEnabled=true;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// Setup
|
||
// ─────────────────────────────────────────────
|
||
void setup() {
|
||
Serial.begin(115200); delay(1000);
|
||
Serial.println("\n========================================");
|
||
Serial.println(" Byun CANFD Logger v1.0");
|
||
Serial.println(" MCP2518FD + ACAN2517FD 라이브러리");
|
||
Serial.println("========================================\n");
|
||
|
||
if (!initPSRAM()) {
|
||
Serial.println("✗ PSRAM 실패! Tools→PSRAM→OPI PSRAM 설정 확인");
|
||
while(1) { delay(1000); Serial.println("재업로드 필요"); }
|
||
}
|
||
|
||
loadSettings();
|
||
|
||
// SPI + CAN FD 초기화
|
||
// ★ CS는 라이브러리가 직접 관리 → SPI.begin에 CS 미전달 (HW SS 충돌 방지)
|
||
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, -1);
|
||
pinMode(PIN_CAN_CS, OUTPUT);
|
||
digitalWrite(PIN_CAN_CS, HIGH);
|
||
delay(50); // SPI 버스 안정화 대기
|
||
|
||
if (!initCANFD()) {
|
||
Serial.println("⚠ CAN FD 초기화 실패 → 설정 페이지에서 속도 변경 후 재초기화 하세요");
|
||
}
|
||
|
||
// Mutex
|
||
sdMutex = xSemaphoreCreateMutex();
|
||
rtcMutex = xSemaphoreCreateMutex();
|
||
|
||
initRTC();
|
||
esp_task_wdt_deinit();
|
||
|
||
// SD 카드 (SDIO 4-bit)
|
||
Serial.println("SD 카드 초기화...");
|
||
if (SD_MMC.setPins(PIN_SD_CLK,PIN_SD_CMD,PIN_SD_D0,PIN_SD_D1,PIN_SD_D2,PIN_SD_D3) &&
|
||
SD_MMC.begin("/sdcard",false,false,10000000)) {
|
||
sdCardReady=true;
|
||
Serial.printf("✓ SD: %llu MB\n", SD_MMC.cardSize()/1024/1024);
|
||
loadFileComments();
|
||
} else {
|
||
Serial.println("✗ SD 초기화 실패");
|
||
}
|
||
|
||
// WiFi: AP는 항상 켜짐 (STA 성공 여부 무관)
|
||
WiFi.setSleep(false);
|
||
if (staMode && strlen(staSSID)>0) {
|
||
WiFi.mode(WIFI_AP_STA);
|
||
} else {
|
||
WiFi.mode(WIFI_AP);
|
||
}
|
||
// ★ AP 먼저 기동 (STA 결과 무관하게 항상 접속 가능)
|
||
WiFi.softAP(wifiSSID, wifiPassword, 1, 0, 4);
|
||
delay(200);
|
||
Serial.println();
|
||
Serial.println("╔══════════════════════════════════╗");
|
||
Serial.printf ("║ AP SSID : %-22s║\n", wifiSSID);
|
||
Serial.printf ("║ AP IP : %-22s║\n", WiFi.softAPIP().toString().c_str());
|
||
Serial.printf ("║ AP PW : %-22s║\n", wifiPassword);
|
||
Serial.println("╚══════════════════════════════════╝");
|
||
Serial.println(" ★ 위 AP로 항상 접속 가능합니다.");
|
||
|
||
if (staMode && strlen(staSSID)>0) {
|
||
Serial.printf(" STA 연결 시도: %s\n", staSSID);
|
||
WiFi.begin(staSSID, staPassword);
|
||
int at=0;
|
||
while(WiFi.status()!=WL_CONNECTED && at++<20) {
|
||
delay(500); Serial.print(".");
|
||
}
|
||
Serial.println();
|
||
if(WiFi.status()==WL_CONNECTED){
|
||
Serial.printf(" ✓ STA 연결 성공 IP: %s\n", WiFi.localIP().toString().c_str());
|
||
configTime(9*3600,0,"pool.ntp.org");
|
||
sntp_set_time_sync_notification_cb(ntpCallback);
|
||
} else {
|
||
Serial.println(" ✗ STA 연결 실패 → AP로만 접속하세요");
|
||
}
|
||
}
|
||
esp_wifi_set_max_tx_power(84);
|
||
|
||
webSocket.begin();
|
||
webSocket.onEvent(wsEvent);
|
||
|
||
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("/settings", HTTP_GET, [](){ server.send_P(200,"text/html",settings_html); });
|
||
server.on("/download", HTTP_GET, handleDownload);
|
||
server.begin();
|
||
Serial.println("✓ 웹 서버 시작");
|
||
|
||
// ── Task 생성 ────────────────────────────
|
||
xTaskCreatePinnedToCore(webUpdateTask,"WEB", 12288, NULL, 8, &webTaskH, 0);
|
||
xTaskCreatePinnedToCore(sdFlushTask, "FLUSH", 4096, NULL, 9, &sdFlushTaskH,0);
|
||
xTaskCreatePinnedToCore(sdWriteTask, "WRITE",12288, NULL, 8, &sdWriteTaskH,0);
|
||
xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1);
|
||
if(timeSyncSt.rtcAvail)
|
||
xTaskCreatePinnedToCore(rtcSyncTask,"RTC",3072, NULL, 0, &rtcTaskH, 0);
|
||
// canRxTask는 마지막에 생성 (Core 1 최고 우선순위)
|
||
xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 8192, NULL, 24, &canRxTaskH, 1);
|
||
|
||
Serial.println("\n========================================");
|
||
Serial.println(" Task 구성 완료");
|
||
Serial.println(" Core1: CAN_RX(24) TX(3)");
|
||
Serial.println(" Core0: FLUSH(9) WRITE(8) WEB(8)");
|
||
Serial.println("========================================\n");
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// Loop (Core 1, 저우선순위 - 상태 출력만)
|
||
// ─────────────────────────────────────────────
|
||
void loop() {
|
||
vTaskDelay(pdMS_TO_TICKS(30));
|
||
|
||
static uint32_t lastPrint = 0;
|
||
if (millis() - lastPrint > 30000) {
|
||
lastPrint = millis();
|
||
Serial.printf("[상태] CAN:%s Ring:%u/%u drop:%u Msg/s:%u PSRAM:%dKB\n",
|
||
canInitOK?"OK":"FAIL",
|
||
ring_count(&canRing), CAN_RING_SIZE, canRing.dropped,
|
||
msgPerSecond, ESP.getFreePsram()/1024);
|
||
Serial.printf(" AP접속: SSID=%s IP=%s\n",
|
||
wifiSSID, WiFi.softAPIP().toString().c_str());
|
||
}
|
||
} |