From 0baac2bf90b15996275395e0058f474e3afad13a Mon Sep 17 00:00:00 2001 From: byun Date: Wed, 18 Feb 2026 15:49:30 +0000 Subject: [PATCH] =?UTF-8?q?STA=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ESP32_SerialLogger.ino | 247 +++++++++++++++++++++++-------------- README.md | 81 ++++++++++++ config.h | 21 +++- rtc_task.cpp | 272 +++++++++++++++++++++++++++++++++++++++++ rtc_task.h | 46 +++++++ sdcard_task.cpp | 18 +-- serial_task.cpp | 6 +- web_html.h | 126 +++++++++++++++++-- web_task.cpp | 127 ++++++++++++++----- 9 files changed, 794 insertions(+), 150 deletions(-) create mode 100644 README.md create mode 100644 rtc_task.cpp create mode 100644 rtc_task.h diff --git a/ESP32_SerialLogger.ino b/ESP32_SerialLogger.ino index 4ecfa32..cdaa97f 100644 --- a/ESP32_SerialLogger.ino +++ b/ESP32_SerialLogger.ino @@ -1,6 +1,6 @@ // ============================================================ // ESP32 Serial Logger -// Hardware: ESP-WROOM-32D DevKitC V4 +// Hardware: ESP-WROOM-32D DevKitC V4 + DS3231 RTC // // Arduino IDE Settings: // Board: "ESP32 Dev Module" @@ -13,16 +13,19 @@ // PSRAM: Disabled // // Required Libraries: -// 1. WebSocketsServer by Links2004 -// (Install: Library Manager -> "WebSockets" by Markus Sattler) -// 2. ArduinoJson by Benoit Blanchon v6.x -// (Install: Library Manager -> "ArduinoJson") -// 3. SD (built-in) -// 4. SPI (built-in) +// 1. WebSockets by Links2004 +// 2. ArduinoJson v6.x +// 3. SoftWire by Steve Marple (+ AsyncDelay) +// 4. SD, SPI (built-in) // // Pin Assignments: // UART2 TX: GPIO17 | UART2 RX: GPIO16 -// SD CLK: GPIO14 | SD MISO: GPIO26 | SD MOSI: GPIO13 | SD CS: GPIO15 +// SD MISO: GPIO19 | SD MOSI: GPIO23 | SD SCLK: GPIO18 | SD CS: GPIO5 (VSPI) +// RTC SDA: GPIO25 | RTC SCL: GPIO26 (DS3231 @ 0x68, SoftWire) +// +// WiFi: +// Boot → AP mode (always available) +// Settings → STA mode enable (AP+STA dual mode) // ============================================================ #include @@ -33,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -44,78 +48,119 @@ #include "serial_task.h" #include "sdcard_task.h" #include "web_task.h" +#include "rtc_task.h" + +// --- WiFi STA state (controlled from web UI) --- +volatile bool staEnabled = false; +volatile bool staConnected = false; +char staSSID[64] = ""; +char staPW[64] = ""; // ============================================================ -// NTP Time Sync Task -// Only works in STA mode (internet required) -// In AP mode, time is synced from browser via WebSocket +// Enable STA mode (AP+STA dual) - called from web_task // ============================================================ -void ntpSyncTask(void *param) { - Serial.println("[Task] NTP Sync started"); +bool wifiEnableSTA(const char *ssid, const char *password) { + if (!ssid || strlen(ssid) == 0) return false; - // Only attempt NTP if connected to external WiFi (STA mode) - if (WiFi.getMode() == WIFI_AP) { - Serial.println("[NTP] AP mode - skipping NTP, waiting for browser time sync"); - // In AP mode, just keep task alive for potential future use - while (true) { - vTaskDelay(pdMS_TO_TICKS(60000)); - } + strncpy(staSSID, ssid, sizeof(staSSID) - 1); + strncpy(staPW, password, sizeof(staPW) - 1); + + Serial.printf("[WiFi] Enabling STA: SSID='%s'\n", staSSID); + + // Switch to AP+STA dual mode + WiFi.mode(WIFI_AP_STA); + WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASSWORD); + WiFi.begin(staSSID, staPW); + WiFi.setSleep(false); + + staEnabled = true; + + // Wait for connection (non-blocking, with timeout) + unsigned long start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { + delay(500); + Serial.print("."); } + Serial.println(); - // STA mode - try NTP - configTime(NTP_GMT_OFFSET, NTP_DAYLIGHT_OFFSET, NTP_SERVER); + if (WiFi.status() == WL_CONNECTED) { + staConnected = true; + Serial.printf("[WiFi] STA connected! IP: %s (RSSI: %d)\n", + WiFi.localIP().toString().c_str(), WiFi.RSSI()); + Serial.printf("[WiFi] AP still active: %s (%s)\n", + WIFI_AP_SSID, WiFi.softAPIP().toString().c_str()); - struct tm timeinfo; - int retries = 0; - while (!getLocalTime(&timeinfo) && retries < 5) { - Serial.println("[NTP] Waiting for time sync..."); - vTaskDelay(pdMS_TO_TICKS(1000)); - retries++; - } - - if (getLocalTime(&timeinfo)) { - Serial.printf("[NTP] Synced: %04d-%02d-%02d %02d:%02d:%02d\n", - timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); - } else { - Serial.println("[NTP] Failed - will use browser time when client connects"); - } - - // Periodic re-sync every 1 hour - while (true) { - vTaskDelay(pdMS_TO_TICKS(3600000)); + // STA 연결 성공 → NTP 시도 configTime(NTP_GMT_OFFSET, NTP_DAYLIGHT_OFFSET, NTP_SERVER); - Serial.println("[NTP] Time re-synced"); + + return true; + } else { + staConnected = false; + Serial.println("[WiFi] STA connection failed (will keep retrying)"); + return false; } } // ============================================================ -// WiFi Connection (STA + fallback AP mode) +// Disable STA mode - return to AP only // ============================================================ -void wifiConnect() { - Serial.println("[WiFi] Connecting to: " + String(WIFI_SSID)); +void wifiDisableSTA() { + Serial.println("[WiFi] Disabling STA, AP-only mode"); + WiFi.disconnect(true); + delay(100); + WiFi.mode(WIFI_AP); + WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASSWORD); - WiFi.mode(WIFI_STA); - WiFi.begin(WIFI_SSID, WIFI_PASSWORD); - WiFi.setSleep(false); + staEnabled = false; + staConnected = false; + staSSID[0] = '\0'; + staPW[0] = '\0'; - unsigned long startTime = millis(); - while (WiFi.status() != WL_CONNECTED) { - delay(500); - Serial.print("."); - if (millis() - startTime > WIFI_CONNECT_TIMEOUT) { - Serial.println("\n[WiFi] STA failed! Starting AP mode..."); - WiFi.mode(WIFI_AP); - WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASSWORD); - Serial.println("[WiFi] AP SSID: " + String(WIFI_AP_SSID)); - Serial.println("[WiFi] AP IP: " + WiFi.softAPIP().toString()); - return; + Serial.printf("[WiFi] AP mode: %s (%s)\n", + WIFI_AP_SSID, WiFi.softAPIP().toString().c_str()); +} + +// ============================================================ +// NTP + STA Monitor Task +// Periodically checks STA status, retries connection, syncs NTP +// ============================================================ +void ntpSyncTask(void *param) { + Serial.println("[Task] NTP/WiFi monitor started"); + vTaskDelay(pdMS_TO_TICKS(3000)); + + while (true) { + // If STA is enabled, monitor connection + if (staEnabled) { + if (WiFi.status() == WL_CONNECTED) { + if (!staConnected) { + staConnected = true; + Serial.printf("[WiFi] STA reconnected: %s\n", + WiFi.localIP().toString().c_str()); + } + + // Try NTP sync + struct tm timeinfo; + configTime(NTP_GMT_OFFSET, NTP_DAYLIGHT_OFFSET, NTP_SERVER); + if (getLocalTime(&timeinfo, 3000)) { + rtcSyncFromSystem(); + static uint32_t ntpCount = 0; + if (++ntpCount % 60 == 1) { // Log every ~60 cycles + Serial.printf("[NTP] Synced: %04d-%02d-%02d %02d:%02d:%02d\n", + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); + } + } + } else { + staConnected = false; + // Auto-retry connection + if (strlen(staSSID) > 0) { + WiFi.begin(staSSID, staPW); + } + } } - } - Serial.println(); - Serial.println("[WiFi] Connected! IP: " + WiFi.localIP().toString()); - Serial.println("[WiFi] RSSI: " + String(WiFi.RSSI()) + " dBm"); + vTaskDelay(pdMS_TO_TICKS(60000)); // Check every 60s + } } // ============================================================ @@ -126,65 +171,85 @@ void setup() { delay(1000); Serial.println("============================================"); - Serial.println(" ESP32 Serial Logger v1.0"); - Serial.println(" ESP-WROOM-32D DevKitC V4"); + Serial.println(" ESP32 Serial Logger v2.1"); + Serial.println(" ESP-WROOM-32D DevKitC V4 + DS3231"); Serial.println("============================================"); Serial.printf(" Free Heap: %d bytes\n", ESP.getFreeHeap()); Serial.printf(" CPU Freq: %d MHz\n", getCpuFrequencyMhz()); - Serial.println("============================================"); + Serial.println("============================================\n"); - // 1. WiFi - wifiConnect(); + // 1. DS3231 RTC + Serial.println("--- Step 1: RTC ---"); + bool hasRTC = rtcInit(); - // 2. NTP (STA mode only) + Browser time sync (AP mode) - xTaskCreatePinnedToCore(ntpSyncTask, "NTP", TASK_STACK_NTP, - NULL, TASK_PRIORITY_NTP, NULL, 0); - if (WiFi.getMode() != WIFI_AP) { - delay(2000); // Brief wait for NTP only in STA mode + // 2. WiFi - Always start AP mode (immediate access) + Serial.println("\n--- Step 2: WiFi (AP mode) ---"); + WiFi.mode(WIFI_AP); + WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASSWORD); + Serial.printf("[WiFi] AP SSID: %s\n", WIFI_AP_SSID); + Serial.printf("[WiFi] AP IP: %s\n", WiFi.softAPIP().toString().c_str()); + Serial.println("[WiFi] STA: disabled (enable via Settings page)"); + + // 3. Time status + Serial.println("\n--- Step 3: Time ---"); + if (hasRTC && rtcStatus.timeSynced) { + Serial.println("[Time] Using RTC time"); } else { - Serial.println("[Time] AP mode: time will sync from browser on first connect"); + Serial.println("[Time] Waiting for browser sync"); } - // 3. SD Card + // 4. SD Card + Serial.println("\n--- Step 4: SD Card ---"); sdTaskInit(); - // 4. Serial2 UART + // 5. Serial2 UART + Serial.println("\n--- Step 5: Serial2 ---"); serialTaskInit(); - // 5. Web Server + WebSocket + // 6. Web Server + WebSocket + Serial.println("\n--- Step 6: Web Server ---"); webTaskInit(); - Serial.println("============================================"); - Serial.println(" All tasks started!"); - Serial.printf(" Free Heap: %d bytes\n", ESP.getFreeHeap()); + // 7. RTC periodic sync task + rtcTaskInit(); - String ip = (WiFi.getMode() == WIFI_AP) ? - WiFi.softAPIP().toString() : WiFi.localIP().toString(); - Serial.println(" URL: http://" + ip); + // 8. NTP + WiFi monitor task (always running) + xTaskCreatePinnedToCore(ntpSyncTask, "NTP", TASK_STACK_NTP, + NULL, TASK_PRIORITY_NTP, NULL, 0); + + // Status summary + Serial.println("\n============================================"); + Serial.println(" All systems initialized!"); + Serial.printf(" Free Heap: %d bytes\n", ESP.getFreeHeap()); + Serial.printf(" AP URL: http://%s\n", WiFi.softAPIP().toString().c_str()); Serial.println("============================================"); - Serial.println("\n Task Layout:"); Serial.println(" Core 1: SerialRX(5), SerialTX(4)"); - Serial.println(" Core 0: SDLog(3), WebBC(2), NTP(1)"); + Serial.println(" Core 0: SDLog(3), WebBC(2), RTC(1), NTP(1)"); Serial.println(" Loop : server.handleClient()"); - Serial.println(" Time : NTP(STA) or Browser sync(AP)"); + Serial.printf(" RTC: %s | Time: %s\n", + rtcStatus.available ? "OK" : "NO", + rtcStatus.timeSynced ? "Valid" : "Pending"); Serial.println("============================================\n"); } // ============================================================ -// Loop - handles HTTP requests (must be on main loop) +// Loop // ============================================================ void loop() { server.handleClient(); vTaskDelay(pdMS_TO_TICKS(2)); - // Periodic status report (30s) static unsigned long lastReport = 0; if (millis() - lastReport > 30000) { lastReport = millis(); - Serial.printf("[SYS] Heap: %d | MinHeap: %d | SD: %s | WS: %d clients\n", + char timeBuf[24]; + getTimestamp(timeBuf, sizeof(timeBuf)); + Serial.printf("[SYS] %s | Heap:%d | SD:%s | WS:%d | RTC:%s | STA:%s\n", + timeBuf, ESP.getFreeHeap(), - ESP.getMinFreeHeap(), sdCardPresent() ? "OK" : "FAIL", - webSocket.connectedClients()); + webSocket.connectedClients(), + rtcStatus.available ? "OK" : "NO", + staConnected ? "ON" : "OFF"); } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..00cb30b --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# ESP32 Serial Logger v2.0 + +UART2 시리얼 모니터링 + SD카드 로깅 + WiFi 웹 인터페이스 + DS3231 RTC + +## 하드웨어 +- **보드:** ESP-WROOM-32D DevKitC V4 +- **RTC:** DS3231 모듈 (I2C, ±2ppm 정밀도) +- **SD카드:** SPI 방식 MicroSD 모듈 + +## 핀 연결 +``` +ESP32 대상장치 (UART) + GPIO17 (TX) ──────────── RX + GPIO16 (RX) ──────────── TX + GND ──────────────────── GND + +ESP32 SD Card Module (VSPI) + GPIO18 ──────────────── SCLK + GPIO19 ──────────────── MISO + GPIO23 ──────────────── MOSI + GPIO5 ──────────────── CS + 3.3V ──────────────── VCC + GND ──────────────── GND + +ESP32 DS3231 RTC Module (SoftWire I2C) + GPIO25 ──────────────── SDA + GPIO26 ──────────────── SCL + 3.3V ──────────────── VCC + GND ──────────────── GND +``` + +> SoftWire 사용으로 임의 GPIO에 DS3231 연결 가능. GPIO 6~11은 SPI Flash 전용. + +## Arduino IDE 설정 + +| 설정 | 값 | +|---|---| +| Board | ESP32 Dev Module | +| Upload Speed | 921600 | +| CPU Frequency | 240MHz (WiFi/BT) | +| Flash Frequency | 80MHz | +| Flash Mode | QIO | +| Flash Size | 4MB (32Mb) | +| Partition Scheme | Huge APP (3MB No OTA/1MB SPIFFS) | +| PSRAM | Disabled | + +## 필수 라이브러리 + +| 라이브러리 | 설치 | +|---|---| +| WebSockets by Markus Sattler (Links2004) | Library Manager | +| ArduinoJson v6.x | Library Manager | +| SoftWire by Steve Marple | Library Manager → "SoftWire" | +| AsyncDelay by Steve Marple | Library Manager (SoftWire 의존) | +| SD, SPI | 설치 불필요 (내장) | + +## 시간 동기화 전략 (장기 로깅용) + +``` +시간 우선순위: + 1. NTP (STA 모드, 1시간마다 자동 보정) + 2. 브라우저 시간 (WebSocket 접속 시 자동 전송) + 3. DS3231 RTC (부팅 즉시 + 60초마다 드리프트 보정) +``` + +### 동작 흐름 +1. 부팅 → DS3231에서 시간 읽어 시스템 시계 즉시 설정 +2. 핸드폰 접속 → 브라우저 시간 → 시스템 시계 + DS3231 동시 설정 +3. WiFi 끊김/장기 방치 → 60초마다 DS3231 → 시스템 시계 보정 +4. STA 모드 → NTP 성공 시 RTC도 자동 업데이트 + +## FreeRTOS 태스크 구조 +``` +Core 1: SerialRX(Pri:5), SerialTX(Pri:4) +Core 0: SDLog(Pri:3), WebBroadcast(Pri:2), RTC(Pri:1), NTP(Pri:1) +Loop: server.handleClient() +``` + +## 로그 파일 +- 경로: /logs/LOG_YYYYMMDD_HHMMSS.csv +- 형식: "Timestamp","Direction","Data" diff --git a/config.h b/config.h index fc138a5..be75923 100644 --- a/config.h +++ b/config.h @@ -21,22 +21,31 @@ #define DEFAULT_BAUD_RATE 115200 #define DEFAULT_RX_BUFFER 4096 -// --- HSPI SD Card Pin Configuration --- -#define SD_HSPI_CLK 14 -#define SD_HSPI_MISO 26 // Remapped from GPIO12 (strapping pin) -#define SD_HSPI_MOSI 13 -#define SD_HSPI_CS 15 +// --- VSPI SD Card Pin Configuration --- +#define SD_VSPI_MISO 19 +#define SD_VSPI_MOSI 23 +#define SD_VSPI_SCLK 18 +#define SD_VSPI_CS 5 + +// --- DS3231 RTC I2C Configuration (SoftWire) --- +// 소프트웨어 I2C → 임의 GPIO 사용 가능 +#define RTC_SDA_PIN 25 // I2C SDA +#define RTC_SCL_PIN 26 // I2C SCL +#define DS3231_ADDRESS 0x68 // DS3231 I2C Address +#define RTC_SYNC_INTERVAL_MS 60000 // RTC → System clock 보정 주기 (60초) // --- FreeRTOS Task Priorities --- #define TASK_PRIORITY_SERIAL 5 // Highest - must not miss data #define TASK_PRIORITY_SD_LOG 3 // Medium-high - buffered writes #define TASK_PRIORITY_WEB 2 // Medium - user interface -#define TASK_PRIORITY_NTP 1 // Lowest - periodic sync +#define TASK_PRIORITY_RTC 1 // Low - periodic RTC sync +#define TASK_PRIORITY_NTP 1 // Lowest - periodic NTP sync // --- FreeRTOS Task Stack Sizes --- #define TASK_STACK_SERIAL 4096 #define TASK_STACK_SD_LOG 8192 #define TASK_STACK_WEB 8192 +#define TASK_STACK_RTC 3072 #define TASK_STACK_NTP 4096 // --- Queue Configuration --- diff --git a/rtc_task.cpp b/rtc_task.cpp new file mode 100644 index 0000000..96d3e55 --- /dev/null +++ b/rtc_task.cpp @@ -0,0 +1,272 @@ +#include "rtc_task.h" +#include +#include + +RTCStatus rtcStatus = { false, false, 0, 0.0 }; +SemaphoreHandle_t rtcMutex = NULL; + +// SoftWire instance + required buffers +static SoftWire rtcWire(RTC_SDA_PIN, RTC_SCL_PIN); +static char swTxBuffer[16]; +static char swRxBuffer[16]; + +// BCD conversion +static uint8_t bcd2dec(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +static uint8_t dec2bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); } + +// ============================================================ +// Initialize SoftWire I2C and probe DS3231 +// ============================================================ +bool rtcInit() { + rtcMutex = xSemaphoreCreateMutex(); + + rtcWire.setTxBuffer(swTxBuffer, sizeof(swTxBuffer)); + rtcWire.setRxBuffer(swRxBuffer, sizeof(swRxBuffer)); + rtcWire.setDelay_us(5); + rtcWire.begin(); + + // Probe DS3231 at address 0x68 + rtcWire.beginTransmission(DS3231_ADDRESS); + uint8_t err = rtcWire.endTransmission(); + + if (err == 0) { + rtcStatus.available = true; + Serial.printf("[RTC] DS3231 found at 0x%02X (SDA=%d, SCL=%d) [SoftWire]\n", + DS3231_ADDRESS, RTC_SDA_PIN, RTC_SCL_PIN); + + // Read temperature to verify communication + rtcStatus.temperature = rtcReadTemperature(); + Serial.printf("[RTC] Temperature: %.2f C\n", rtcStatus.temperature); + + // Check if RTC has valid time (year >= 2024) + time_t t = rtcReadTime(); + struct tm tm; + localtime_r(&t, &tm); + if (tm.tm_year + 1900 >= 2024) { + rtcStatus.timeSynced = true; + Serial.printf("[RTC] Valid time: %04d-%02d-%02d %02d:%02d:%02d\n", + tm.tm_year+1900, tm.tm_mon+1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + + // Boot with RTC time immediately + rtcSyncToSystem(); + Serial.println("[RTC] System clock set from RTC on boot"); + } else { + Serial.println("[RTC] No valid time stored - waiting for browser sync"); + } + + // Disable DS3231 square wave, enable battery backup + xSemaphoreTake(rtcMutex, portMAX_DELAY); + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x0E); // Control register + rtcWire.write(0x04); // INTCN=1, no SQW + rtcWire.endTransmission(); + xSemaphoreGive(rtcMutex); + + return true; + } else { + rtcStatus.available = false; + Serial.printf("[RTC] DS3231 NOT found (err=%d). Running without RTC.\n", err); + return false; + } +} + +// ============================================================ +// Read time from DS3231 → epoch +// ============================================================ +time_t rtcReadTime() { + if (!rtcStatus.available) return 0; + + xSemaphoreTake(rtcMutex, portMAX_DELAY); + + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x00); + rtcWire.endTransmission(); + + rtcWire.requestFrom((uint8_t)DS3231_ADDRESS, (uint8_t)7); + if (rtcWire.available() < 7) { + xSemaphoreGive(rtcMutex); + return 0; + } + + uint8_t sec = bcd2dec(rtcWire.read() & 0x7F); + uint8_t min = bcd2dec(rtcWire.read()); + uint8_t hour = bcd2dec(rtcWire.read() & 0x3F); + rtcWire.read(); // day of week (skip) + uint8_t date = bcd2dec(rtcWire.read()); + uint8_t raw5 = rtcWire.read(); + uint8_t month = bcd2dec(raw5 & 0x1F); + uint8_t year = bcd2dec(rtcWire.read()); + + xSemaphoreGive(rtcMutex); + + uint16_t fullYear = 2000 + year; + if (raw5 & 0x80) fullYear += 100; + + struct tm tm; + tm.tm_sec = sec; + tm.tm_min = min; + tm.tm_hour = hour; + tm.tm_mday = date; + tm.tm_mon = month - 1; + tm.tm_year = fullYear - 1900; + tm.tm_isdst = 0; + + time_t epoch = mktime(&tm); + return epoch; +} + +// ============================================================ +// Write time to DS3231 from epoch +// ============================================================ +bool rtcWriteTime(time_t epoch) { + if (!rtcStatus.available) return false; + + struct tm tm; + localtime_r(&epoch, &tm); + + xSemaphoreTake(rtcMutex, portMAX_DELAY); + + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x00); + rtcWire.write(dec2bcd(tm.tm_sec)); + rtcWire.write(dec2bcd(tm.tm_min)); + rtcWire.write(dec2bcd(tm.tm_hour)); + rtcWire.write(dec2bcd(tm.tm_wday + 1)); + rtcWire.write(dec2bcd(tm.tm_mday)); + rtcWire.write(dec2bcd(tm.tm_mon + 1)); + rtcWire.write(dec2bcd((tm.tm_year + 1900) % 100)); + uint8_t err = rtcWire.endTransmission(); + + xSemaphoreGive(rtcMutex); + + if (err == 0) { + rtcStatus.timeSynced = true; + Serial.printf("[RTC] Written: %04d-%02d-%02d %02d:%02d:%02d\n", + tm.tm_year+1900, tm.tm_mon+1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + return true; + } + + Serial.printf("[RTC] Write FAILED (err=%d)\n", err); + return false; +} + +// ============================================================ +// Sync: RTC → ESP32 System Clock +// ============================================================ +bool rtcSyncToSystem() { + if (!rtcStatus.available || !rtcStatus.timeSynced) return false; + + time_t rtcEpoch = rtcReadTime(); + if (rtcEpoch == 0) return false; + + struct timeval tv; + tv.tv_sec = rtcEpoch; + tv.tv_usec = 0; + settimeofday(&tv, NULL); + + rtcStatus.syncCount++; + + struct tm tm; + localtime_r(&rtcEpoch, &tm); + Serial.printf("[RTC->SYS] Sync #%u: %04d-%02d-%02d %02d:%02d:%02d\n", + rtcStatus.syncCount, + tm.tm_year+1900, tm.tm_mon+1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + + return true; +} + +// ============================================================ +// Sync: ESP32 System Clock → RTC +// ============================================================ +bool rtcSyncFromSystem() { + struct timeval tv; + gettimeofday(&tv, NULL); + + if (tv.tv_sec < 1700000000) { + Serial.println("[RTC] System time not valid, skipping write to RTC"); + return false; + } + + bool ok = rtcWriteTime(tv.tv_sec); + if (ok) { + Serial.println("[RTC] System time -> RTC written OK"); + } + return ok; +} + +// ============================================================ +// Read DS3231 internal temperature sensor +// ============================================================ +float rtcReadTemperature() { + if (!rtcStatus.available) return -999.0; + + xSemaphoreTake(rtcMutex, portMAX_DELAY); + + rtcWire.beginTransmission(DS3231_ADDRESS); + rtcWire.write(0x11); + rtcWire.endTransmission(); + + rtcWire.requestFrom((uint8_t)DS3231_ADDRESS, (uint8_t)2); + if (rtcWire.available() < 2) { + xSemaphoreGive(rtcMutex); + return -999.0; + } + + int8_t msb = (int8_t)rtcWire.read(); + uint8_t lsb = rtcWire.read(); + + xSemaphoreGive(rtcMutex); + + float temp = (float)msb + ((lsb >> 6) * 0.25f); + return temp; +} + +// ============================================================ +// RTC Periodic Sync Task +// ============================================================ +void rtcSyncTask(void *param) { + Serial.println("[Task] RTC Sync started on core " + String(xPortGetCoreID())); + + vTaskDelay(pdMS_TO_TICKS(5000)); + + while (true) { + vTaskDelay(pdMS_TO_TICKS(RTC_SYNC_INTERVAL_MS)); + + if (!rtcStatus.available || !rtcStatus.timeSynced) continue; + + struct timeval sysTv; + gettimeofday(&sysTv, NULL); + time_t rtcEpoch = rtcReadTime(); + + if (rtcEpoch == 0) continue; + + int32_t drift = (int32_t)(sysTv.tv_sec - rtcEpoch); + + if (abs(drift) > 2) { + Serial.printf("[RTC] Drift detected: %+d sec -> correcting\n", drift); + rtcSyncToSystem(); + } else { + static uint32_t logCounter = 0; + if (++logCounter % 10 == 0) { + rtcStatus.temperature = rtcReadTemperature(); + Serial.printf("[RTC] OK (drift=%+ds, temp=%.1fC, syncs=%u)\n", + drift, rtcStatus.temperature, rtcStatus.syncCount); + } + } + } +} + +// ============================================================ +// Start RTC Task +// ============================================================ +void rtcTaskInit() { + if (rtcStatus.available) { + xTaskCreatePinnedToCore(rtcSyncTask, "RTC", TASK_STACK_RTC, + NULL, TASK_PRIORITY_RTC, NULL, 0); + Serial.println("[RTC] Sync task started (interval: " + + String(RTC_SYNC_INTERVAL_MS / 1000) + "s)"); + } +} diff --git a/rtc_task.h b/rtc_task.h new file mode 100644 index 0000000..d7ccdb3 --- /dev/null +++ b/rtc_task.h @@ -0,0 +1,46 @@ +#ifndef RTC_TASK_H +#define RTC_TASK_H + +#include +#include +#include +#include +#include +#include "config.h" + +// RTC status +struct RTCStatus { + bool available; // DS3231 detected on I2C + bool timeSynced; // RTC has been set with valid time + uint32_t syncCount; // Number of RTC → System syncs + float temperature; // DS3231 internal temperature +}; + +extern RTCStatus rtcStatus; +extern SemaphoreHandle_t rtcMutex; + +// Initialize DS3231 and read time +bool rtcInit(); + +// Read time from DS3231 → returns epoch (0 = fail) +time_t rtcReadTime(); + +// Write time to DS3231 from epoch +bool rtcWriteTime(time_t epoch); + +// Sync: RTC → ESP32 system clock +bool rtcSyncToSystem(); + +// Sync: ESP32 system clock → RTC +bool rtcSyncFromSystem(); + +// Read DS3231 temperature (°C, ±0.25 resolution) +float rtcReadTemperature(); + +// RTC periodic sync task +void rtcSyncTask(void *param); + +// Start RTC task +void rtcTaskInit(); + +#endif // RTC_TASK_H diff --git a/sdcard_task.cpp b/sdcard_task.cpp index d0a07e6..d61b8dc 100644 --- a/sdcard_task.cpp +++ b/sdcard_task.cpp @@ -2,7 +2,7 @@ #include "serial_task.h" #include -static SPIClass hspi(HSPI); +static SPIClass vspi(VSPI); volatile bool sdLoggingActive = false; char currentLogFileName[64] = ""; static File logFile; @@ -12,9 +12,9 @@ static SemaphoreHandle_t sdMutex = NULL; void sdTaskInit() { sdMutex = xSemaphoreCreateMutex(); - hspi.begin(SD_HSPI_CLK, SD_HSPI_MISO, SD_HSPI_MOSI, SD_HSPI_CS); + vspi.begin(SD_VSPI_SCLK, SD_VSPI_MISO, SD_VSPI_MOSI, SD_VSPI_CS); - if (!SD.begin(SD_HSPI_CS, hspi, 4000000)) { + if (!SD.begin(SD_VSPI_CS, vspi, 4000000)) { Serial.println("[SD] Card mount FAILED!"); sdReady = false; } else { @@ -26,14 +26,15 @@ void sdTaskInit() { sdReady = true; uint64_t cardSize = SD.cardSize() / (1024 * 1024); Serial.printf("[SD] Card mounted. Type: %d, Size: %lluMB\n", cardType, cardSize); - hspi.setFrequency(20000000); + vspi.setFrequency(20000000); if (!SD.exists(LOG_DIR)) { SD.mkdir(LOG_DIR); Serial.println("[SD] Created /logs directory"); } - sdCreateNewLogFile(); - sdLoggingActive = true; + // Don't auto-start logging - user starts manually via web UI + sdLoggingActive = false; + Serial.println("[SD] Ready (logging OFF - start via web UI)"); } } @@ -88,11 +89,12 @@ void sdLoggingTask(void *param) { "\"%s\",\"%c\",\"", entry->timestamp, entry->direction == 'T' ? 'T' : 'R'); + // Write data as plain string (no hex encoding) for (int i = 0; i < entry->dataLen && len < (int)sizeof(csvLine) - 4; i++) { char c = entry->data[i]; if (c == '"') { csvLine[len++] = '"'; csvLine[len++] = '"'; } - else if (c >= 0x20 && c < 0x7F) { csvLine[len++] = c; } - else { len += snprintf(csvLine + len, sizeof(csvLine) - len, "\\x%02X", (uint8_t)c); } + else if (c >= 0x20) { csvLine[len++] = c; } + // Skip control chars (0x00~0x1F) silently } len += snprintf(csvLine + len, sizeof(csvLine) - len, "\"\r\n"); logFile.write((uint8_t*)csvLine, len); diff --git a/serial_task.cpp b/serial_task.cpp index a95ffb0..3a7be8f 100644 --- a/serial_task.cpp +++ b/serial_task.cpp @@ -135,7 +135,11 @@ void serialRxTask(void *param) { if (linePos > 0) flushLineToQueues(lineBuf, linePos); continue; } - lineBuf[linePos++] = c; + // Only accept printable ASCII (0x20~0x7E) and TAB (0x09) + // Skip null bytes and noise (0x00, 0xFF, etc.) + if ((c >= 0x20 && c <= 0x7E) || c == '\t') { + lineBuf[linePos++] = c; + } } lastActivity = xTaskGetTickCount(); } else { diff --git a/web_html.h b/web_html.h index 91b5189..8cd9062 100644 --- a/web_html.h +++ b/web_html.h @@ -194,18 +194,47 @@ tr:active{background:rgba(233,69,96,0.1);}

System Info

-
-
+
+
+
+

WiFi STA (외부 인터넷)

+
+ + + OFF +
+ + +
+
+

DS3231 RTC

+
--
+
--
+
0
+
--
+

SD Logging

-
Active
+
Stopped
- +
@@ -233,8 +262,8 @@ tr:active{background:rgba(233,69,96,0.1);}
- - Logging... + + Log OFF 115200 8N1 RX:0 TX:0 @@ -265,14 +294,14 @@ function wsConn(){ document.getElementById('wsDot').classList.add('on'); document.getElementById('wsSt').textContent='ON'; addSys('[Connected]'); - // Send browser time to ESP32 for clock sync + // Send browser time to ESP32 for clock sync (silent - no terminal message) let now=new Date(); let epoch=Math.floor(now.getTime()/1000); let ms=now.getMilliseconds(); ws.send(JSON.stringify({cmd:'set_time',epoch:epoch,ms:ms})); - addSys('[Time sync: '+now.toLocaleString()+']'); sendC('sysinfo'); sendC('get_serial_config'); + sendC('get_wifi'); }; ws.onmessage=function(e){ try{ @@ -281,7 +310,7 @@ function wsConn(){ else if(m.type==='sysinfo') updSys(m); else if(m.type==='serial_config') updSer(m); else if(m.type==='log_status') updLog(m); - else if(m.type==='time_synced'&&m.ok) addSys('[ESP32 time synced OK]'); + else if(m.type==='wifi_status') updWifi(m); }catch(x){} }; ws.onclose=function(){ @@ -406,11 +435,86 @@ function updSer(m){ // ===== System Info ===== function updSys(m){ - document.getElementById('sIp').value=m.ip||''; - document.getElementById('sRssi').value=(m.rssi||'')+' dBm'; + document.getElementById('sApIp').value=m.apIp||''; + document.getElementById('sApCli').value=m.apClients||0; document.getElementById('sHeap').value=fB(m.heap||0); document.getElementById('sUp').value=fUp(m.uptime||0); document.getElementById('tInfo').textContent=m.time||'--'; + // STA info from sysinfo + if(m.staOn!==undefined) updWifi(m); + // RTC info + let rs=document.getElementById('rtcSt'); + if(m.rtcOk){rs.textContent='OK (DS3231)';rs.style.color='var(--ok)';} + else{rs.textContent='Not found';rs.style.color='var(--btn)';} + let ry=document.getElementById('rtcSynced'); + if(m.rtcSync){ry.textContent='Yes';ry.style.color='var(--ok)';} + else{ry.textContent='No';ry.style.color='var(--warn)';} + document.getElementById('rtcSyncs').textContent=m.rtcSyncs||0; + let t=m.rtcTemp; + document.getElementById('rtcTemp').textContent=(t&&t>-100)?(t.toFixed(1)+' °C'):'--'; +} + +// ===== WiFi STA Control ===== +function updWifi(m){ + let chk=document.getElementById('staChk'); + let slider=document.getElementById('staSlider'); + let label=document.getElementById('staLabel'); + let form=document.getElementById('staForm'); + let info=document.getElementById('staInfo'); + chk.checked=m.staOn; + slider.style.transform=m.staOn?'translateX(22px)':''; + slider.parentElement.previousElementSibling.nextElementSibling.style.background=m.staOn?'var(--ok)':'var(--border)'; + label.textContent=m.staOn?'ON':'OFF'; + label.style.color=m.staOn?'var(--ok)':'#888'; + form.style.display=m.staOn?'block':'none'; + info.style.display=m.staOn?'block':'none'; + if(m.staOn){ + if(m.staSSID) document.getElementById('staSSID').value=m.staSSID; + let ss=document.getElementById('staStatus'); + if(m.staConn){ss.textContent='Connected';ss.style.color='var(--ok)';} + else{ss.textContent='Disconnected';ss.style.color='var(--btn)';} + document.getElementById('staIpDisp').value=m.staIp||'--'; + document.getElementById('staRssiDisp').value=m.staRssi?(m.staRssi+' dBm'):'--'; + document.getElementById('staBtn').textContent='Disconnect'; + document.getElementById('staBtn').style.background='var(--btn)'; + document.getElementById('staBtn').style.color='#fff'; + document.getElementById('staBtn').onclick=disconnectSTA; + }else{ + document.getElementById('staBtn').textContent='Connect'; + document.getElementById('staBtn').style.background='var(--ok)'; + document.getElementById('staBtn').style.color='#000'; + document.getElementById('staBtn').onclick=connectSTA; + } +} + +function toggleSTA(){ + let chk=document.getElementById('staChk'); + let slider=document.getElementById('staSlider'); + let form=document.getElementById('staForm'); + let label=document.getElementById('staLabel'); + slider.style.transform=chk.checked?'translateX(22px)':''; + slider.parentElement.previousElementSibling.nextElementSibling.style.background=chk.checked?'var(--ok)':'var(--border)'; + label.textContent=chk.checked?'ON':'OFF'; + label.style.color=chk.checked?'var(--ok)':'#888'; + form.style.display=chk.checked?'block':'none'; + if(!chk.checked) disconnectSTA(); +} + +function connectSTA(){ + let ssid=document.getElementById('staSSID').value.trim(); + let pw=document.getElementById('staPW').value; + if(!ssid){alert('SSID를 입력하세요');return;} + let ss=document.getElementById('staStatus'); + if(ss) {ss.textContent='Connecting...';ss.style.color='var(--warn)';} + document.getElementById('staInfo').style.display='block'; + if(ws&&ws.readyState===1) ws.send(JSON.stringify({cmd:'wifi_sta_on',ssid:ssid,pw:pw})); +} + +function disconnectSTA(){ + if(ws&&ws.readyState===1) ws.send(JSON.stringify({cmd:'wifi_sta_off'})); + document.getElementById('staInfo').style.display='none'; + document.getElementById('staChk').checked=false; + toggleSTA(); } // ===== Log Status ===== diff --git a/web_task.cpp b/web_task.cpp index 2bae7db..a3a1a9a 100644 --- a/web_task.cpp +++ b/web_task.cpp @@ -2,10 +2,19 @@ #include "web_html.h" #include "serial_task.h" #include "sdcard_task.h" +#include "rtc_task.h" #include #include #include +// WiFi STA control (defined in .ino) +extern volatile bool staEnabled; +extern volatile bool staConnected; +extern char staSSID[]; +extern char staPW[]; +extern bool wifiEnableSTA(const char *ssid, const char *password); +extern void wifiDisableSTA(); + // --- Web Server (port 80) & WebSocket (port 81) --- WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(WS_PORT); @@ -50,28 +59,32 @@ void handleWsMessage(uint8_t num, const char *message) { const char *cmd = doc["cmd"]; if (!cmd) return; - // --- Set Time from browser --- + // --- Set Time from browser → System Clock + RTC --- if (strcmp(cmd, "set_time") == 0) { uint32_t epoch = doc["epoch"] | 0; uint16_t ms = doc["ms"] | 0; if (epoch > 1700000000) { // Sanity check: after 2023 + // 1) Set ESP32 system clock struct timeval tv; tv.tv_sec = (time_t)epoch; tv.tv_usec = (suseconds_t)ms * 1000; settimeofday(&tv, NULL); - // Verify struct tm timeinfo; localtime_r(&tv.tv_sec, &timeinfo); - Serial.printf("[Time] Synced from browser: %04d-%02d-%02d %02d:%02d:%02d\n", + Serial.printf("[Time] Browser sync: %04d-%02d-%02d %02d:%02d:%02d\n", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); - // Broadcast confirmation + // 2) Also write to DS3231 RTC (if available) + bool rtcOk = rtcSyncFromSystem(); + + // 3) Broadcast confirmation StaticJsonDocument<128> resp; resp["type"] = "time_synced"; resp["ok"] = true; + resp["rtc"] = rtcOk; String json; serializeJson(resp, json); webSocket.sendTXT(num, json); @@ -122,7 +135,6 @@ void handleWsMessage(uint8_t num, const char *message) { } else if (strcmp(lineEnd, "lf") == 0) { entry->data[entry->dataLen++] = '\n'; } - // "none" - no line ending } entry->data[entry->dataLen] = '\0'; } @@ -141,7 +153,6 @@ void handleWsMessage(uint8_t num, const char *message) { reconfigureSerial(baud, dataBits, p[0], stopBits); - // Broadcast new config to all clients StaticJsonDocument<256> resp; resp["type"] = "serial_config"; resp["baud"] = baud; @@ -166,17 +177,36 @@ void handleWsMessage(uint8_t num, const char *message) { webSocket.sendTXT(num, json); } - // --- System Info --- + // --- System Info (includes RTC + WiFi status) --- else if (strcmp(cmd, "sysinfo") == 0) { - StaticJsonDocument<512> resp; + StaticJsonDocument<768> resp; resp["type"] = "sysinfo"; - resp["ip"] = WiFi.localIP().toString(); - resp["rssi"] = WiFi.RSSI(); resp["heap"] = ESP.getFreeHeap(); resp["uptime"] = millis() / 1000; char timeBuf[24]; getTimestamp(timeBuf, sizeof(timeBuf)); resp["time"] = timeBuf; + + // WiFi AP info (always active) + resp["apIp"] = WiFi.softAPIP().toString(); + resp["apSSID"] = WIFI_AP_SSID; + resp["apClients"] = WiFi.softAPgetStationNum(); + + // WiFi STA info + resp["staOn"] = (bool)staEnabled; + resp["staConn"] = (bool)staConnected; + if (staEnabled) { + resp["staSSID"] = staSSID; + resp["staIp"] = WiFi.localIP().toString(); + resp["staRssi"] = WiFi.RSSI(); + } + + // RTC info + resp["rtcOk"] = rtcStatus.available; + resp["rtcSync"] = rtcStatus.timeSynced; + resp["rtcSyncs"] = rtcStatus.syncCount; + resp["rtcTemp"] = rtcStatus.temperature; + String json; serializeJson(resp, json); webSocket.sendTXT(num, json); @@ -209,74 +239,114 @@ void handleWsMessage(uint8_t num, const char *message) { serializeJson(resp, json); webSocket.broadcastTXT(json); } + + // --- WiFi STA Enable --- + else if (strcmp(cmd, "wifi_sta_on") == 0) { + const char *ssid = doc["ssid"]; + const char *pw = doc["pw"] | ""; + if (!ssid || strlen(ssid) == 0) return; + + bool ok = wifiEnableSTA(ssid, pw); + + StaticJsonDocument<256> resp; + resp["type"] = "wifi_status"; + resp["staOn"] = (bool)staEnabled; + resp["staConn"] = ok; + resp["staSSID"] = staSSID; + if (ok) { + resp["staIp"] = WiFi.localIP().toString(); + resp["staRssi"] = WiFi.RSSI(); + } + String json; + serializeJson(resp, json); + webSocket.broadcastTXT(json); + } + + // --- WiFi STA Disable --- + else if (strcmp(cmd, "wifi_sta_off") == 0) { + wifiDisableSTA(); + + StaticJsonDocument<128> resp; + resp["type"] = "wifi_status"; + resp["staOn"] = false; + resp["staConn"] = false; + String json; + serializeJson(resp, json); + webSocket.broadcastTXT(json); + } + + // --- Get WiFi Status --- + else if (strcmp(cmd, "get_wifi") == 0) { + StaticJsonDocument<256> resp; + resp["type"] = "wifi_status"; + resp["staOn"] = (bool)staEnabled; + resp["staConn"] = (bool)staConnected; + resp["staSSID"] = staSSID; + resp["apIp"] = WiFi.softAPIP().toString(); + if (staConnected) { + resp["staIp"] = WiFi.localIP().toString(); + resp["staRssi"] = WiFi.RSSI(); + } + String json; + serializeJson(resp, json); + webSocket.sendTXT(num, json); + } } // ============================================================ // Setup Web Server Routes // ============================================================ void setupWebRoutes() { - // Main page server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); }); - // API: File List server.on("/api/files", HTTP_GET, []() { String json = sdGetFileList(); server.send(200, "application/json", json); }); - // API: Delete Files (POST with JSON body) server.on("/api/delete", HTTP_POST, []() { if (!server.hasArg("plain")) { server.send(400, "application/json", "{\"error\":\"no body\"}"); return; } - StaticJsonDocument<1024> doc; DeserializationError err = deserializeJson(doc, server.arg("plain")); if (err) { server.send(400, "application/json", "{\"error\":\"invalid json\"}"); return; } - JsonArray files = doc["files"]; int deleted = 0; for (JsonVariant f : files) { const char *fname = f.as(); if (fname && sdDeleteFile(fname)) deleted++; } - String resp = "{\"deleted\":" + String(deleted) + "}"; server.send(200, "application/json", resp); }); - // API: Download File server.on("/download", HTTP_GET, []() { if (!server.hasArg("file")) { server.send(400, "text/plain", "Missing file parameter"); return; } String filename = server.arg("file"); - - // Security: prevent path traversal if (filename.indexOf("..") >= 0) { server.send(403, "text/plain", "Forbidden"); return; } - String path = String(LOG_DIR) + "/" + filename; if (!SD.exists(path)) { server.send(404, "text/plain", "File not found"); return; } - File file = SD.open(path, FILE_READ); if (!file) { server.send(500, "text/plain", "Cannot open file"); return; } - size_t fileSize = file.size(); server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); server.sendHeader("Content-Length", String(fileSize)); @@ -286,29 +356,23 @@ void setupWebRoutes() { } // ============================================================ -// Web Broadcast Task - Sends serial data to WebSocket clients -// Runs webSocket.loop() and broadcasts queued data +// Web Broadcast Task // ============================================================ void webBroadcastTask(void *param) { Serial.println("[Task] WebBroadcast started on core " + String(xPortGetCoreID())); - - vTaskDelay(pdMS_TO_TICKS(500)); // Initial stabilization + vTaskDelay(pdMS_TO_TICKS(500)); while (true) { - // Process WebSocket events (REQUIRED for Links2004 library) webSocket.loop(); - // Process web queue entries and broadcast if (webSocket.connectedClients() > 0) { LogEntry *entry; - // Process up to 10 entries per cycle for responsiveness int processed = 0; while (processed < 10 && xQueueReceive(queueWeb, &entry, 0) == pdTRUE) { StaticJsonDocument<768> doc; doc["type"] = (entry->direction == 'T') ? "tx" : "rx"; doc["ts"] = entry->timestamp; doc["data"] = entry->data; - String json; serializeJson(doc, json); webSocket.broadcastTXT(json); @@ -316,13 +380,11 @@ void webBroadcastTask(void *param) { processed++; } } else { - // No clients - drain web queue to prevent memory buildup LogEntry *entry; while (xQueueReceive(queueWeb, &entry, 0) == pdTRUE) { vPortFree(entry); } } - vTaskDelay(pdMS_TO_TICKS(10)); } } @@ -340,7 +402,6 @@ void webTaskInit() { server.begin(); Serial.println("[Web] HTTP server started on port 80"); - // Create broadcast task on core 0 xTaskCreatePinnedToCore(webBroadcastTask, "WebBC", TASK_STACK_WEB, NULL, TASK_PRIORITY_WEB, NULL, 0); }