From a8a09850dd60b7cb5ea6aa7ccc6d0a8341c57589 Mon Sep 17 00:00:00 2001 From: Scott Penrose Date: Tue, 16 Jun 2026 13:11:48 +1000 Subject: [PATCH] Working on better version --- PKA_SUMMARY.md | 24 ++ PLAN.md | 69 +++++ data/index.html | 57 ++++ platformio.ini | 55 ++++ src/main.cpp | 794 +++++++++++++++++++++++++++++++----------------- 5 files changed, 715 insertions(+), 284 deletions(-) create mode 100644 PKA_SUMMARY.md create mode 100644 PLAN.md diff --git a/PKA_SUMMARY.md b/PKA_SUMMARY.md new file mode 100644 index 0000000..afd9306 --- /dev/null +++ b/PKA_SUMMARY.md @@ -0,0 +1,24 @@ +--- +title: "esp32-debug-dongle" +source: pka-assess +--- + +# esp32-debug-dongle + +## Purpose + +ESP32 dev-kit firmware turning the chip into a WiFi + Bluetooth serial-terminal dongle. Browser-based terminal uses xterm.js served from the ESP's LittleFS; also exposes a classic Bluetooth SPP port for desktop/mobile terminal apps. Multi-port (internal debug / USB / external Serial1) with on-the-fly baud-rate switching. + +## Type + +ESP32 PlatformIO firmware. `#embedded`, `#firmware`, `#iot`, `#service`. + +## Dependencies + +- **External:** PlatformIO `espressif32` + Arduino framework, xterm.js (bundled in LittleFS), Bluetooth SPP stack (classic BT — needs a WROOM-style ESP32, NOT S2/S3/C3). +- **Internal:** complementary tool to ESP32 Sh3d projects — [[Sh3dNb]], [[Sh3dController]], [[Sh3dStick]], [[Doxy]] — when serial access is awkward. + +## Notable + +- Uses a `huge_app.csv` partition (3 MB app, 1 MB FS, no OTA) — swap to `min_spiffs.csv` if OTA is needed. +- Classic Bluetooth requirement is a hard constraint on target hardware. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..68b1b02 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,69 @@ +# esp32-debug-dongle → LilyGo T3-S3 debug bridge + +## Why +USB serial to the trough/solartrack sensors drops on every deep sleep, so we +can't watch boots/crashes/wakes. This dongle becomes an always-connected box on +the LAN that bridges a sensor's UART to telnet + a web terminal, controls GPIO +to reset/wake the target, logs to SD, and (Phase 2) sniffs the MeshCore LoRa +mesh — so we can debug remotely and stop flying blind. + +## Decisions (confirmed with user) +1. **Board: LilyGo T3-S3** (ESP32-S3 + SX127x LoRa + SSD1306 128×64 OLED + SD). + Chosen over TTGO for onboard SD and LoRa (mesh monitoring). +2. **Extend `esp32-debug-dongle` in place** (not a new Sh3dNb app). Keep the + existing `esp32dev` env working; add a `t3s3` env. +3. **Both telnet (:23) and the WebSocket terminal.** Telnet is the primary + remote control path (nc/minicom + an agent driving it); GPIO reset/wake and + port/baud are exposed as **both** telnet `~commands` and REST endpoints. +4. **SD logging in Phase 1, ON by default** once NTP (built-in `configTzTime`) + gives today's date — log file named/stamped from real time. +5. **Power monitoring: out of scope.** +6. **Phase 2: MeshCore monitor** over the onboard LoRa (passive RX of mesh + packets, surfaced on telnet/web/SD). + +## Board facts that shape the build +- ESP32-S3 has **no Bluetooth Classic** → `BluetoothSerial`/SerialBT is guarded + behind `HAS_BT_CLASSIC` (esp32dev only). On T3-S3, telnet + WebSocket replace it. +- USB CDC on boot (`-DARDUINO_USB_CDC_ON_BOOT=1`) → UART0 (GPIO43/44) is free for + the target bridge. +- Pins (from `Sh3dNb/apps_oglas/lilygot3s3_basic`): OLED SDA18/SCL17/RST21@0x3C; + LoRa SCK5/MISO3/MOSI6/CS7/RST8/DIO0:9; BTN0. + +## Phase 1 (this build) — extend src/main.cpp + platformio.ini +- **platformio.ini**: keep `[env:esp32dev]` (+ `-DHAS_BT_CLASSIC=1`); add + `[env:t3s3]` (esp32-s3-devkitc-1, USB CDC on boot, huge_app, littlefs, + Adafruit SSD1306+GFX). All pins via build flags (target UART, GPIO reset/wake, + SD, OLED) so they're config, not hardcoded. +- **Target UART bridge**: Serial1 on `TARGET_RX/TX` pins ↔ fan-out to WebSocket + (existing), **telnet clients**, and **SD log**. RX/TX byte+line counters. +- **Telnet server :23** with line protocol: non-`~` lines forward to the target + UART; `~` lines are commands: + `~help ~status ~reset [ms] ~wake [ms] ~baud ~port ~log on|off + ~gpio <0|1>`. Lets an operator (or agent via `nc`) drive a remote board. +- **REST** (mirror of the commands): `/api/status /api/reset /api/wake + /api/baud?baud= /api/log?on= /api/gpio?pin=&val= /api/send?data=`. +- **GPIO control**: configurable `reset`/`wake` pins (active-low option), pulse. +- **NTP + SD logging**: `configTzTime()`; once time syncs, open + `/logs/YYYYMMDD-HHMMSS.log` and append timestamped serial. Logging ON by + default; `~log off` / REST toggles. Graceful no-op if SD absent/pins wrong. +- **OLED**: IP/SSID/RSSI, target pins+baud, RX/TX counts, telnet client count, + logging on/off, NTP synced y/n. + +## Phase 2 — MeshCore monitor (later) +- Bring up the T3-S3 LoRa radio (confirm chip: SX1276 vs SX1262 variant) with + the mesh's PHY (freq/SF/BW/CR matching the sensors), RX-only. +- Decode/forward MeshCore channel frames to telnet/web/SD so we see beacons + (e.g. count trough beacons to confirm the sleep-crash fix) without a sensor. + +## Pins to CONFIRM against the actual board (set as build flags) +- **SD**: SCK/MISO/MOSI/CS for the T3-S3 SD slot (may share the LoRa SPI bus + + own CS — verify). Logging silently disables if wrong. +- **Target UART bridge** RX/TX (default UART0 43/44, free under USB CDC). +- **GPIO reset/wake** pins to whatever lines you wire to the sensor's RST/control. +- **LoRa chip variant** (Phase 2). + +## Verify +- `pio run -e t3s3` compiles; `pio run -e esp32dev` still compiles (BT intact). +- Flash T3-S3: OLED shows IP; `nc 23` streams a wired sensor's UART; + `~reset` reboots it (seen on the same stream); a dated file appears under + `/logs` on the SD and grows. diff --git a/data/index.html b/data/index.html index 39746de..d00c656 100644 --- a/data/index.html +++ b/data/index.html @@ -167,6 +167,8 @@ + +
@@ -184,6 +186,14 @@
+ +
@@ -315,6 +325,7 @@ document.getElementById('baudSelect').value = msg.baudSerial1; updateStatus('btStatus', msg.btConnected); document.getElementById('heap').textContent = `Heap: ${msg.freeHeap}`; + updateLogUi(msg); // Show WiFi info const wifiInfo = document.getElementById('wifiInfo'); @@ -406,6 +417,52 @@ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1024 / 1024).toFixed(1) + ' MB'; } + + // ---- SD logging controls ---- + let logOn = false; + function toggleLog() { + fetch('/api/log?on=' + (logOn ? 0 : 1)) + .then(r => r.json()).then(updateLogUi) + .catch(() => term.writeln('\r\n\x1b[31m[log toggle failed]\x1b[0m')); + } + function updateLogUi(s) { + if (typeof s.log === 'undefined') return; + logOn = !!s.log; + const b = document.getElementById('logBtn'); + b.textContent = 'Log: ' + (logOn ? 'ON' : 'off'); + b.classList.toggle('danger', logOn); + const st = document.getElementById('logState'); + if (st) { + st.textContent = !s.sd ? 'no SD card' + : s.logfile ? ('file: ' + String(s.logfile).split('/').pop()) + : (s.ntp ? 'idle' : 'waiting for NTP date'); + } + } + function toggleFiles() { + const p = document.getElementById('filesPanel'); + const show = p.style.display === 'none'; + p.style.display = show ? 'block' : 'none'; + if (show) refreshLogs(); + } + function refreshLogs() { + fetch('/api/logs').then(r => r.json()).then(list => { + const t = document.getElementById('filesTable'); + if (!list.length) { t.innerHTML = '(no log files yet)'; return; } + t.innerHTML = list.map(f => + ` + ${f.name}${f.active ? ' (active)' : ''} + ${formatBytes(f.size)} + download + ${f.active ? '' : `delete`} + `).join(''); + }).catch(() => { + document.getElementById('filesTable').innerHTML = '(failed -- SD only on T3-S3 build)'; + }); + } + function deleteLog(name) { + if (!confirm('Delete ' + name + '?')) return; + fetch('/api/logdelete?name=' + encodeURIComponent(name)).then(() => refreshLogs()); + } // Initial connection term.writeln('\x1b[1;36m╔═══════════════════════════════════════╗\x1b[0m'); diff --git a/platformio.ini b/platformio.ini index c9b7e96..e064fb4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -28,6 +28,7 @@ build_flags = -DCORE_DEBUG_LEVEL=3 -DCONFIG_BT_ENABLED=1 -DCONFIG_BLUEDROID_ENABLED=1 + -DHAS_BT_CLASSIC=1 ; classic SPP exists on the original ESP32 -DSTA_SSID="${sysenv.STA_SSID}" -DSTA_PASSWORD="${sysenv.STA_PASSWORD}" @@ -47,3 +48,57 @@ lib_deps = ; Extra scripts for LittleFS extra_scripts = pre:scripts/download_xterm.py + +; ============================================================================ +; LilyGo T3-S3 -- ESP32-S3 + SX127x LoRa + SSD1306 128x64 OLED + microSD. +; The remote debug bridge: UART <-> telnet(:23)/WebSocket, GPIO reset/wake, +; SD logging (NTP-dated), OLED status. NOTE: ESP32-S3 has NO Bluetooth Classic, +; so HAS_BT_CLASSIC is NOT set here (telnet + WebSocket replace SerialBT). +; Pins marked CONFIRM must be checked against your actual board wiring. +; ============================================================================ +[env:t3s3] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder +board_build.mcu = esp32s3 +board_build.flash_size = 4MB +board_upload.flash_size = 4MB +board_build.filesystem = littlefs +board_build.partitions = huge_app.csv +build_flags = + -DCORE_DEBUG_LEVEL=3 + -DARDUINO_USB_CDC_ON_BOOT=1 ; console on USB CDC -> frees UART0 (43/44) + ;-DSTA_SSID="${sysenv.STA_SSID}" + ;-DSTA_PASSWORD="${sysenv.STA_PASSWORD}" + -DSTA_SSID="MeridenRainbow5G" + -DSTA_PASSWORD="4z8bcw5vfrs3n7dm" + -DBOARD_T3S3=1 + ; --- OLED (SSD1306 128x64) --- + -DT3S3_OLED_SDA=18 + -DT3S3_OLED_SCL=17 + -DT3S3_OLED_RST=21 + -DT3S3_OLED_ADDR=0x3C + ; --- target UART bridge (wire to the sensor's debug UART) --- + -DTARGET_RX_PIN=44 ; CONFIRM: dongle RX <- sensor TX + -DTARGET_TX_PIN=43 ; CONFIRM: dongle TX -> sensor RX + ; --- GPIO control lines to the target (reset / wake) --- + -DGPIO_RESET_PIN=2 ; CONFIRM: -> sensor RST + -DGPIO_WAKE_PIN=1 ; CONFIRM: -> sensor control/wake pin + -DGPIO_CTRL_ACTIVE_LOW=1 + ; --- microSD (CONFIRM: may share the LoRa SPI bus + own CS) --- + -DT3S3_SD_SCK=5 + -DT3S3_SD_MISO=3 + -DT3S3_SD_MOSI=6 + -DT3S3_SD_CS=13 + ; --- timezone for NTP-dated logs (Australia/Victoria) --- + -DNTP_TZ='"AEST-10AEDT,M10.1.0,M4.1.0/3"' +lib_deps = + https://github.com/ESP32Async/ESPAsyncWebServer + https://github.com/ESP32Async/AsyncTCP + ArduinoJson + adafruit/Adafruit SSD1306 + adafruit/Adafruit GFX Library +extra_scripts = + pre:scripts/download_xterm.py diff --git a/src/main.cpp b/src/main.cpp index 8d186bb..aeb58c1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,18 +1,23 @@ /** * ESP32 Debug Dongle * - * Web-based and Bluetooth serial terminal with multi-port support. + * Remote serial debug bridge for the trough/solartrack sensors (and anything + * with a UART). Bridges a target device's UART to: + * - Telnet (port 23) -- primary remote path; nc/minicom or an agent + * - Web terminal -- xterm.js over WebSocket + * - Bluetooth SPP -- original ESP32 only (no Classic BT on ESP32-S3) + * Plus: GPIO control to reset/wake the target, NTP-dated SD logging, and an + * OLED status page (LilyGo T3-S3). * - * Features: - * - Web terminal using xterm.js over WebSocket - * - Bluetooth Classic SPP serial bridge - * - Switch between internal (virtual), Serial, and Serial1 - * - Configurable baud rates + * Telnet protocol: a line is forwarded verbatim to the target UART UNLESS it + * starts with '~', in which case it's a dongle command: + * ~help ~status ~reset [ms] ~wake [ms] ~baud ~port + * ~log on|off ~gpio <0|1> + * REST mirrors these: /api/status /api/reset /api/wake /api/baud?baud= + * /api/port?port= /api/log?on= /api/gpio?pin=&val= /api/send?data= * - * Hardware connections for Serial1 (external device): - * GPIO16 = RX1 (connect to external TX) - * GPIO17 = TX1 (connect to external RX) - * GND = Common ground + * Boards (platformio.ini): [env:esp32dev] generic ESP32 (+ BT); [env:t3s3] + * LilyGo T3-S3 (OLED + SD + LoRa). Pins are build flags -- see platformio.ini. */ #include @@ -21,20 +26,29 @@ #include #include #include -#include "BluetoothSerial.h" #include "LoopbackStream.h" +#if HAS_BT_CLASSIC +#include "BluetoothSerial.h" +#endif + +#if BOARD_T3S3 +#include +#include +#include +#include +#include +#include +#endif + // ============================================================================ // Configuration // ============================================================================ -// WiFi Mode: Set to true to connect to existing network, false for AP mode #define WIFI_STATION_MODE true - #define ST(A) #A #define STR(A) ST(A) -// WiFi Station mode settings (connect to existing network) #ifndef STA_SSID #define STA_SSID "ExampleSSID" #endif @@ -42,60 +56,94 @@ #define STA_PASSWORD "ThePassword" #endif - -// WiFi Access Point fallback settings (if station fails or AP mode selected) const char* AP_SSID = "ESP32-DebugDongle"; -const char* AP_PASSWORD = "debug1234"; // Min 8 characters - -// Station mode connection timeout (milliseconds) +const char* AP_PASSWORD = "debug1234"; #define WIFI_CONNECT_TIMEOUT 15000 -// Bluetooth device name const char* BT_NAME = "ESP32-Debug"; -// Serial1 pins (external device connection) -#define SERIAL1_RX_PIN 16 -#define SERIAL1_TX_PIN 17 +// Target UART pins (override per board in platformio.ini). +#ifndef TARGET_RX_PIN +#define TARGET_RX_PIN 16 +#endif +#ifndef TARGET_TX_PIN +#define TARGET_TX_PIN 17 +#endif + +// GPIO control lines to the target. -1 disables. Active-low by default on T3-S3. +#ifndef GPIO_RESET_PIN +#define GPIO_RESET_PIN -1 +#endif +#ifndef GPIO_WAKE_PIN +#define GPIO_WAKE_PIN -1 +#endif +#ifndef GPIO_CTRL_ACTIVE_LOW +#define GPIO_CTRL_ACTIVE_LOW 0 +#endif -// Default baud rates #define DEFAULT_BAUD_SERIAL 115200 #define DEFAULT_BAUD_SERIAL1 115200 - -// Internal serial buffer size #define INTERNAL_BUFFER_SIZE 2048 +#define TELNET_PORT 23 +#define MAX_TELNET 4 +#define TELNET_LINE_MAX 256 +#define CTRL_PREFIX '~' + +#if BOARD_T3S3 +#ifndef NTP_TZ +#define NTP_TZ "UTC0" +#endif +#endif + // ============================================================================ // Global Objects // ============================================================================ -// Web server on port 80 AsyncWebServer server(80); AsyncWebSocket ws("/ws"); -// Bluetooth Serial +#if HAS_BT_CLASSIC BluetoothSerial SerialBT; +#endif -// Hardware serial for external device -// HardwareSerial Serial1(1); - -// Virtual/loopback serial for internal debug output LoopbackStream internalSerial(INTERNAL_BUFFER_SIZE); - -// Active serial port pointer Stream* activePort = &internalSerial; -// Port identifiers -enum SerialPortId { - PORT_INTERNAL = 0, // Virtual loopback (ESP32 debug output) - PORT_USB = 1, // Serial (USB) - shares with programming - PORT_EXTERNAL = 2 // Serial1 (GPIO pins) - external device -}; - -SerialPortId currentPort = PORT_INTERNAL; - -// Baud rates (can be changed at runtime) +enum SerialPortId { PORT_INTERNAL = 0, PORT_USB = 1, PORT_EXTERNAL = 2 }; +SerialPortId currentPort = PORT_EXTERNAL; // default: watch the wired target uint32_t baudSerial1 = DEFAULT_BAUD_SERIAL1; +// Telnet +WiFiServer telnetServer(TELNET_PORT); +WiFiClient telnetClients[MAX_TELNET]; +char telnetLine[MAX_TELNET][TELNET_LINE_MAX]; +size_t telnetLineLen[MAX_TELNET] = {0}; + +// Counters +volatile uint32_t rxBytes = 0; // from target +volatile uint32_t txBytes = 0; // to target + +// SD logging (T3-S3) +bool sdAvailable = false; +bool logEnabled = true; // ON by default once we have a date +bool ntpSynced = false; +#if BOARD_T3S3 +SPIClass sdSPI(HSPI); +File logFile; +bool logAtLineStart = true; +char logPath[40] = {0}; +Adafruit_SSD1306 oled(128, 64, &Wire, T3S3_OLED_RST); +bool oledOk = false; +#endif + +// ============================================================================ +// Forward decls +// ============================================================================ +void broadcastFromTarget(const uint8_t* data, size_t len); +void forwardToTarget(const uint8_t* data, size_t len); +void handleCommand(const char* line, Stream* reply); + // ============================================================================ // Serial Port Management // ============================================================================ @@ -103,27 +151,216 @@ uint32_t baudSerial1 = DEFAULT_BAUD_SERIAL1; void setActivePort(SerialPortId port) { currentPort = port; switch (port) { - case PORT_INTERNAL: - activePort = &internalSerial; - Serial.println("[System] Switched to Internal serial"); - break; - case PORT_USB: - activePort = &Serial; - Serial.println("[System] Switched to USB serial"); - break; - case PORT_EXTERNAL: - activePort = &Serial1; - Serial.println("[System] Switched to External serial (Serial1)"); - break; + case PORT_INTERNAL: activePort = &internalSerial; break; + case PORT_USB: activePort = &Serial; break; + case PORT_EXTERNAL: activePort = &Serial1; break; } } -void setBaudRate(SerialPortId port, uint32_t baud) { - if (port == PORT_EXTERNAL) { - baudSerial1 = baud; - Serial1.end(); - Serial1.begin(baud, SERIAL_8N1, SERIAL1_RX_PIN, SERIAL1_TX_PIN); - Serial.printf("[System] Serial1 baud rate set to %lu\n", baud); +void setBaudRate(uint32_t baud) { + baudSerial1 = baud; + Serial1.end(); + Serial1.begin(baud, SERIAL_8N1, TARGET_RX_PIN, TARGET_TX_PIN); +} + +// ============================================================================ +// GPIO control (reset / wake) +// ============================================================================ + +static void gpioInit(int pin) { + if (pin < 0) return; + // Idle = inactive level. + pinMode(pin, OUTPUT); + digitalWrite(pin, GPIO_CTRL_ACTIVE_LOW ? HIGH : LOW); +} + +static void gpioPulse(int pin, uint32_t ms, Stream* reply, const char* name) { + if (pin < 0) { if (reply) reply->printf("[ctrl] %s pin not configured\r\n", name); return; } + if (ms == 0) ms = 200; + int active = GPIO_CTRL_ACTIVE_LOW ? LOW : HIGH; + int inactive = GPIO_CTRL_ACTIVE_LOW ? HIGH : LOW; + pinMode(pin, OUTPUT); + digitalWrite(pin, active); + delay(ms); + digitalWrite(pin, inactive); + if (reply) reply->printf("[ctrl] %s pulsed pin %d for %lu ms\r\n", name, pin, (unsigned long)ms); +} + +// ============================================================================ +// SD logging (T3-S3) +// ============================================================================ + +#if BOARD_T3S3 +static void openLogIfReady() { + if (!sdAvailable || logFile) return; + time_t now = time(nullptr); + if (now < 1700000000) return; // wait for NTP (well past 2023) + struct tm tmv; localtime_r(&now, &tmv); + SD.mkdir("/logs"); + strftime(logPath, sizeof(logPath), "/logs/%Y%m%d-%H%M%S.log", &tmv); + logFile = SD.open(logPath, FILE_WRITE); + if (logFile) { + logFile.printf("# debug-dongle log opened %s\n", logPath); + logFile.flush(); + logAtLineStart = true; + } +} + +static void logBytes(const uint8_t* data, size_t len) { + if (!logEnabled || !logFile) return; + for (size_t i = 0; i < len; i++) { + if (logAtLineStart) { + time_t now = time(nullptr); + struct tm tmv; localtime_r(&now, &tmv); + char ts[16]; + strftime(ts, sizeof(ts), "[%H:%M:%S] ", &tmv); + logFile.print(ts); + logAtLineStart = false; + } + logFile.write(data[i]); + if (data[i] == '\n') logAtLineStart = true; + } + logFile.flush(); // power-cut safe (it's a debug log) +} +#else +static void openLogIfReady() {} +static void logBytes(const uint8_t*, size_t) {} +#endif + +// ============================================================================ +// Fan-out +// ============================================================================ + +// Target UART -> every sink (web, telnet, BT, SD). +void broadcastFromTarget(const uint8_t* data, size_t len) { + rxBytes += len; + ws.binaryAll((uint8_t*)data, len); + for (int i = 0; i < MAX_TELNET; i++) + if (telnetClients[i] && telnetClients[i].connected()) + telnetClients[i].write(data, len); +#if HAS_BT_CLASSIC + if (SerialBT.connected()) SerialBT.write(data, len); +#endif + logBytes(data, len); +} + +// Anything (web/telnet/BT) -> target UART. +void forwardToTarget(const uint8_t* data, size_t len) { + txBytes += len; + activePort->write(data, len); +} + +// ============================================================================ +// Command handling (telnet ~cmds and REST share this) +// ============================================================================ + +static const char* portName(SerialPortId p) { + return p == PORT_INTERNAL ? "internal" : p == PORT_USB ? "usb" : "external"; +} + +void printStatus(Stream* o) { + o->printf("[status] port=%s baud=%lu rx=%lu tx=%lu telnet=%d log=%s ntp=%s heap=%u\r\n", + portName(currentPort), (unsigned long)baudSerial1, + (unsigned long)rxBytes, (unsigned long)txBytes, + []{ int n=0; for (int i=0;iprint("[help] ~status ~reset [ms] ~wake [ms] ~baud " + "~port ~log on|off ~gpio <0|1>\r\n" + " (any non-~ line is sent to the target UART)\r\n"); + } else if (!strcmp(verb, "status")) { + printStatus(reply); + } else if (!strcmp(verb, "reset")) { + gpioPulse(GPIO_RESET_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "reset"); + } else if (!strcmp(verb, "wake")) { + gpioPulse(GPIO_WAKE_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "wake"); + } else if (!strcmp(verb, "baud")) { + uint32_t b = strtoul(args, nullptr, 10); + if (b) { setBaudRate(b); reply->printf("[ctrl] baud=%lu\r\n", (unsigned long)b); } + else reply->print("[ctrl] usage: ~baud \r\n"); + } else if (!strcmp(verb, "port")) { + if (!strncmp(args, "int", 3)) setActivePort(PORT_INTERNAL); + else if (!strncmp(args, "usb", 3)) setActivePort(PORT_USB); + else if (!strncmp(args, "ext", 3)) setActivePort(PORT_EXTERNAL); + reply->printf("[ctrl] port=%s\r\n", portName(currentPort)); + } else if (!strcmp(verb, "log")) { +#if BOARD_T3S3 + if (!strncmp(args, "on", 2)) logEnabled = true; + else if (!strncmp(args, "off", 3)) logEnabled = false; + if (logEnabled) openLogIfReady(); + reply->printf("[ctrl] log=%s file=%s\r\n", logEnabled ? "on" : "off", + logFile ? logPath : "(none)"); +#else + reply->print("[ctrl] no SD on this build\r\n"); +#endif + } else if (!strcmp(verb, "gpio")) { + int pin = -1, val = 0; + if (sscanf(args, "%d %d", &pin, &val) == 2 && pin >= 0) { + pinMode(pin, OUTPUT); + digitalWrite(pin, val ? HIGH : LOW); + reply->printf("[ctrl] gpio %d = %d\r\n", pin, val ? 1 : 0); + } else reply->print("[ctrl] usage: ~gpio <0|1>\r\n"); + } else { + reply->printf("[ctrl] unknown '~%s' (try ~help)\r\n", verb); + } +} + +// ============================================================================ +// Telnet +// ============================================================================ + +void setupTelnet() { telnetServer.begin(); telnetServer.setNoDelay(true); } + +void serviceTelnet() { + // Accept new clients into a free slot. + if (telnetServer.hasClient()) { + int slot = -1; + for (int i = 0; i < MAX_TELNET; i++) + if (!telnetClients[i] || !telnetClients[i].connected()) { slot = i; break; } + WiFiClient c = telnetServer.available(); + if (slot >= 0) { + telnetClients[slot] = c; + telnetLineLen[slot] = 0; + telnetClients[slot].printf("[dongle] connected -- ~help for commands, " + "other lines go to the target UART\r\n"); + } else { + c.println("[dongle] too many clients"); + c.stop(); + } + } + // Read each client line-by-line. + for (int i = 0; i < MAX_TELNET; i++) { + WiFiClient& cl = telnetClients[i]; + if (!cl || !cl.connected()) continue; + while (cl.available()) { + char ch = (char)cl.read(); + if (ch == '\r') continue; + if (ch == '\n') { + telnetLine[i][telnetLineLen[i]] = '\0'; + if (telnetLineLen[i] > 0 && telnetLine[i][0] == CTRL_PREFIX) { + handleCommand(telnetLine[i] + 1, &cl); + } else { + // Forward the line (with newline) to the target. + forwardToTarget((const uint8_t*)telnetLine[i], telnetLineLen[i]); + const uint8_t nl = '\n'; + forwardToTarget(&nl, 1); + } + telnetLineLen[i] = 0; + } else if (telnetLineLen[i] < TELNET_LINE_MAX - 1) { + telnetLine[i][telnetLineLen[i]++] = ch; + } + } } } @@ -132,310 +369,299 @@ void setBaudRate(SerialPortId port, uint32_t baud) { // ============================================================================ void handleWebSocketMessage(AsyncWebSocketClient* client, uint8_t* data, size_t len) { - // Check for command prefix (starts with 0x00) if (len > 0 && data[0] == 0x00) { - // Command message - parse JSON String cmdStr = String((char*)(data + 1)).substring(0, len - 1); JsonDocument doc; - DeserializationError err = deserializeJson(doc, cmdStr); - - if (!err) { - const char* cmd = doc["cmd"]; - - if (strcmp(cmd, "setPort") == 0) { - int port = doc["port"]; - setActivePort((SerialPortId)port); - - // Send confirmation - String response; - JsonDocument respDoc; - respDoc["type"] = "portChanged"; - respDoc["port"] = port; - serializeJson(respDoc, response); - client->text(String((char)0x00) + response); - } - else if (strcmp(cmd, "setBaud") == 0) { - int port = doc["port"]; - uint32_t baud = doc["baud"]; - setBaudRate((SerialPortId)port, baud); - } - else if (strcmp(cmd, "getStatus") == 0) { - String response; - JsonDocument respDoc; - respDoc["type"] = "status"; - respDoc["currentPort"] = currentPort; - respDoc["baudSerial1"] = baudSerial1; - respDoc["btConnected"] = SerialBT.connected(); - respDoc["freeHeap"] = ESP.getFreeHeap(); - respDoc["wifiMode"] = (WiFi.getMode() == WIFI_STA) ? "station" : "ap"; - respDoc["ip"] = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); - if (WiFi.getMode() == WIFI_STA) { - respDoc["rssi"] = WiFi.RSSI(); - } - serializeJson(respDoc, response); - client->text(String((char)0x00) + response); - } + if (deserializeJson(doc, cmdStr)) return; + const char* cmd = doc["cmd"]; + if (!cmd) return; + if (!strcmp(cmd, "setPort")) { + setActivePort((SerialPortId)(int)doc["port"]); + } else if (!strcmp(cmd, "setBaud")) { + setBaudRate((uint32_t)doc["baud"]); + } else if (!strcmp(cmd, "getStatus")) { + String response; JsonDocument r; + r["type"] = "status"; + r["currentPort"] = currentPort; + r["baudSerial1"] = baudSerial1; + r["rx"] = rxBytes; r["tx"] = txBytes; + r["log"] = logEnabled; r["ntp"] = ntpSynced; r["sd"] = sdAvailable; +#if BOARD_T3S3 + r["logfile"] = logFile ? logPath : ""; +#endif + r["freeHeap"] = ESP.getFreeHeap(); + r["wifiMode"] = (WiFi.getMode() == WIFI_STA) ? "station" : "ap"; + r["ip"] = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); + serializeJson(r, response); + client->text(String((char)0x00) + response); } } else { - // Regular serial data - send to active port - activePort->write(data, len); + forwardToTarget(data, len); } } -void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, +void onWsEvent(AsyncWebSocket*, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) { - switch (type) { - case WS_EVT_CONNECT: - Serial.printf("[WS] Client #%u connected from %s\n", - client->id(), client->remoteIP().toString().c_str()); - break; - - case WS_EVT_DISCONNECT: - Serial.printf("[WS] Client #%u disconnected\n", client->id()); - break; - - case WS_EVT_DATA: { - AwsFrameInfo* info = (AwsFrameInfo*)arg; - if (info->final && info->index == 0 && info->len == len) { - handleWebSocketMessage(client, data, len); - } - break; - } - - case WS_EVT_PONG: - case WS_EVT_ERROR: - break; + if (type == WS_EVT_DATA) { + AwsFrameInfo* info = (AwsFrameInfo*)arg; + if (info->final && info->index == 0 && info->len == len) + handleWebSocketMessage(client, data, len); } } // ============================================================================ -// Web Server Setup +// REST API // ============================================================================ +static String statusJson() { + JsonDocument doc; + doc["currentPort"] = currentPort; + doc["baudSerial1"] = baudSerial1; + doc["rx"] = rxBytes; doc["tx"] = txBytes; + doc["log"] = logEnabled; doc["ntp"] = ntpSynced; doc["sd"] = sdAvailable; +#if BOARD_T3S3 + doc["logfile"] = logFile ? logPath : ""; +#endif + doc["freeHeap"] = ESP.getFreeHeap(); + doc["wifiMode"] = (WiFi.getMode() == WIFI_STA) ? "station" : "ap"; + doc["ip"] = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); + doc["ssid"] = (WiFi.getMode() == WIFI_STA) ? WiFi.SSID() : String(AP_SSID); + if (WiFi.getMode() == WIFI_STA) doc["rssi"] = WiFi.RSSI(); + String s; serializeJson(doc, s); return s; +} + void setupWebServer() { - // Serve static files from LittleFS server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); - - // WebSocket handler ws.onEvent(onWsEvent); server.addHandler(&ws); - // API endpoint for status (can be used without WebSocket) - server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest* request) { - String response; - JsonDocument doc; - doc["currentPort"] = currentPort; - doc["baudSerial1"] = baudSerial1; - doc["btConnected"] = SerialBT.connected(); - doc["freeHeap"] = ESP.getFreeHeap(); - doc["wifiMode"] = (WiFi.getMode() == WIFI_STA) ? "station" : "ap"; - doc["ip"] = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); - doc["ssid"] = (WiFi.getMode() == WIFI_STA) ? WiFi.SSID() : AP_SSID; - if (WiFi.getMode() == WIFI_STA) { - doc["rssi"] = WiFi.RSSI(); + server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send(200, "application/json", statusJson()); + }); + server.on("/api/reset", HTTP_GET, [](AsyncWebServerRequest* req) { + uint32_t ms = req->hasParam("ms") ? req->getParam("ms")->value().toInt() : 0; + gpioPulse(GPIO_RESET_PIN, ms, nullptr, "reset"); + req->send(200, "text/plain", "reset pulsed\n"); + }); + server.on("/api/wake", HTTP_GET, [](AsyncWebServerRequest* req) { + uint32_t ms = req->hasParam("ms") ? req->getParam("ms")->value().toInt() : 0; + gpioPulse(GPIO_WAKE_PIN, ms, nullptr, "wake"); + req->send(200, "text/plain", "wake pulsed\n"); + }); + server.on("/api/baud", HTTP_GET, [](AsyncWebServerRequest* req) { + if (req->hasParam("baud")) setBaudRate(req->getParam("baud")->value().toInt()); + req->send(200, "application/json", statusJson()); + }); + server.on("/api/port", HTTP_GET, [](AsyncWebServerRequest* req) { + if (req->hasParam("port")) { + String p = req->getParam("port")->value(); + if (p.startsWith("int")) setActivePort(PORT_INTERNAL); + else if (p.startsWith("usb")) setActivePort(PORT_USB); + else if (p.startsWith("ext")) setActivePort(PORT_EXTERNAL); } - serializeJson(doc, response); - request->send(200, "application/json", response); + req->send(200, "application/json", statusJson()); + }); + server.on("/api/log", HTTP_GET, [](AsyncWebServerRequest* req) { + if (req->hasParam("on")) { + logEnabled = req->getParam("on")->value().toInt() != 0; + if (logEnabled) openLogIfReady(); + } + req->send(200, "application/json", statusJson()); + }); + server.on("/api/gpio", HTTP_GET, [](AsyncWebServerRequest* req) { + if (req->hasParam("pin") && req->hasParam("val")) { + int pin = req->getParam("pin")->value().toInt(); + int val = req->getParam("val")->value().toInt(); + if (pin >= 0) { pinMode(pin, OUTPUT); digitalWrite(pin, val ? HIGH : LOW); } + } + req->send(200, "text/plain", "ok\n"); + }); + server.on("/api/send", HTTP_GET, [](AsyncWebServerRequest* req) { + if (req->hasParam("data")) { + String d = req->getParam("data")->value(); + forwardToTarget((const uint8_t*)d.c_str(), d.length()); + const uint8_t nl = '\n'; forwardToTarget(&nl, 1); + } + req->send(200, "text/plain", "sent\n"); }); - // 404 handler - server.onNotFound([](AsyncWebServerRequest* request) { - request->send(404, "text/plain", "Not found"); +#if BOARD_T3S3 + // List SD log files: JSON [{name,size,active}]. + server.on("/api/logs", HTTP_GET, [](AsyncWebServerRequest* req) { + JsonDocument doc; JsonArray arr = doc.to(); + File dir = SD.open("/logs"); + if (dir && dir.isDirectory()) { + for (File f = dir.openNextFile(); f; f = dir.openNextFile()) { + if (f.isDirectory()) continue; + const char* n = f.name(); + const char* base = strrchr(n, '/'); base = base ? base + 1 : n; + JsonObject o = arr.add(); + o["name"] = base; + o["size"] = (uint32_t)f.size(); + o["active"] = (logFile && strcmp(logPath, n) == 0); + } + } + String s; serializeJson(doc, s); + req->send(200, "application/json", s); }); + // Download a log file. ?name= (no slashes). + server.on("/api/logfile", HTTP_GET, [](AsyncWebServerRequest* req) { + if (!req->hasParam("name")) { req->send(400, "text/plain", "name?\n"); return; } + String name = req->getParam("name")->value(); + if (name.indexOf('/') >= 0 || name.indexOf("..") >= 0) { req->send(400, "text/plain", "bad name\n"); return; } + String p = "/logs/" + name; + if (!SD.exists(p)) { req->send(404, "text/plain", "not found\n"); return; } + if (logFile) logFile.flush(); // flush in-flight bytes first + req->send(SD, p, "text/plain", true); // true => download + }); + // Delete a log file (refuses the currently-open one). + server.on("/api/logdelete", HTTP_GET, [](AsyncWebServerRequest* req) { + if (!req->hasParam("name")) { req->send(400, "text/plain", "name?\n"); return; } + String name = req->getParam("name")->value(); + if (name.indexOf('/') >= 0 || name.indexOf("..") >= 0) { req->send(400, "text/plain", "bad name\n"); return; } + String p = "/logs/" + name; + if (logFile && strcmp(logPath, p.c_str()) == 0) { req->send(409, "text/plain", "active log -- ~log off first\n"); return; } + bool ok = SD.remove(p); + req->send(ok ? 200 : 404, "text/plain", ok ? "deleted\n" : "not found\n"); + }); +#endif + server.onNotFound([](AsyncWebServerRequest* req) { req->send(404, "text/plain", "Not found"); }); server.begin(); - Serial.println("[Web] Server started"); } // ============================================================================ -// WiFi Setup +// WiFi // ============================================================================ bool connectToWiFi() { - Serial.printf("[WiFi] Connecting to %s", STR(STA_SSID)); - WiFi.mode(WIFI_STA); WiFi.begin(STR(STA_SSID), STR(STA_PASSWORD)); - - unsigned long startTime = millis(); + unsigned long t0 = millis(); while (WiFi.status() != WL_CONNECTED) { - if (millis() - startTime > WIFI_CONNECT_TIMEOUT) { - Serial.println(" TIMEOUT"); - return false; - } - delay(500); - Serial.print("."); + if (millis() - t0 > WIFI_CONNECT_TIMEOUT) return false; + delay(500); Serial.print("."); } - - Serial.println(" OK"); - Serial.printf("[WiFi] Connected to: %s\n", STR(STA_SSID)); - Serial.printf("[WiFi] IP Address: %s\n", WiFi.localIP().toString().c_str()); - Serial.printf("[WiFi] Signal strength: %d dBm\n", WiFi.RSSI()); + Serial.printf("\n[WiFi] %s IP %s %d dBm\n", STR(STA_SSID), + WiFi.localIP().toString().c_str(), WiFi.RSSI()); return true; } void startAccessPoint() { WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID, AP_PASSWORD); - - IPAddress ip = WiFi.softAPIP(); - Serial.println("[WiFi] Access Point started"); - Serial.printf("[WiFi] SSID: %s\n", AP_SSID); - Serial.printf("[WiFi] Password: %s\n", AP_PASSWORD); - Serial.printf("[WiFi] IP Address: %s\n", ip.toString().c_str()); + Serial.printf("[WiFi] AP %s IP %s\n", AP_SSID, WiFi.softAPIP().toString().c_str()); } void setupWiFi() { - WiFi.disconnect(true); // Clear any previous connection - delay(100); - - if (WIFI_STATION_MODE) { - // Try to connect to existing network - if (!connectToWiFi()) { - Serial.println("[WiFi] Station mode failed, falling back to AP mode"); - startAccessPoint(); - } - } else { - // Start as Access Point directly - startAccessPoint(); - } + WiFi.disconnect(true); delay(100); + if (!WIFI_STATION_MODE || !connectToWiFi()) startAccessPoint(); } // ============================================================================ -// Bluetooth Setup +// T3-S3 peripherals: NTP, SD, OLED // ============================================================================ -void setupBluetooth() { - if (!SerialBT.begin(BT_NAME)) { - Serial.println("[BT] Failed to initialize Bluetooth"); - return; - } - Serial.printf("[BT] Bluetooth started as '%s'\n", BT_NAME); - Serial.println("[BT] Ready for pairing"); +#if BOARD_T3S3 +void setupSD() { + sdSPI.begin(T3S3_SD_SCK, T3S3_SD_MISO, T3S3_SD_MOSI, T3S3_SD_CS); + sdAvailable = SD.begin(T3S3_SD_CS, sdSPI); + Serial.printf("[SD] %s (CS=%d)\n", sdAvailable ? "mounted" : "NOT found -- check T3S3_SD_* pins", + T3S3_SD_CS); } -// ============================================================================ -// Debug Output Helper -// ============================================================================ +void setupNTP() { configTzTime(NTP_TZ, "pool.ntp.org", "time.google.com"); } -// Use this function in your code to write to the internal virtual serial -// These messages will be visible when PORT_INTERNAL is selected -void debugPrint(const char* format, ...) { - char buffer[256]; - va_list args; - va_start(args, format); - vsnprintf(buffer, sizeof(buffer), format, args); - va_end(args); - - internalSerial.print(buffer); +void setupOLED() { + Wire.begin(T3S3_OLED_SDA, T3S3_OLED_SCL); + oledOk = oled.begin(SSD1306_SWITCHCAPVCC, T3S3_OLED_ADDR); + if (oledOk) { oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); oled.setTextSize(1); oled.display(); } + Serial.printf("[OLED] %s\n", oledOk ? "ok" : "NOT found"); } -void debugPrintln(const char* format, ...) { - char buffer[256]; - va_list args; - va_start(args, format); - vsnprintf(buffer, sizeof(buffer), format, args); - va_end(args); - - internalSerial.println(buffer); +void renderOLED() { + if (!oledOk) return; + int clients = 0; + for (int i = 0; i < MAX_TELNET; i++) if (telnetClients[i] && telnetClients[i].connected()) clients++; + String ip = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); + oled.clearDisplay(); + oled.setCursor(0, 0); oled.println(ip); + oled.setCursor(0, 10); oled.printf("%s %ddBm\n", + (WiFi.getMode()==WIFI_STA)?WiFi.SSID().c_str():AP_SSID, + (WiFi.getMode()==WIFI_STA)?WiFi.RSSI():0); + oled.setCursor(0, 20); oled.printf("uart %d/%d %lu\n", TARGET_RX_PIN, TARGET_TX_PIN, (unsigned long)baudSerial1); + oled.setCursor(0, 30); oled.printf("rx %lu tx %lu\n", (unsigned long)rxBytes, (unsigned long)txBytes); + oled.setCursor(0, 40); oled.printf("telnet %d log %s\n", clients, (logEnabled && logFile) ? "ON" : "off"); + oled.setCursor(0, 50); oled.printf("ntp %s sd %s\n", ntpSynced ? "ok" : "--", sdAvailable ? "ok" : "--"); + oled.display(); } +#endif // ============================================================================ -// Main Setup +// Setup / Loop // ============================================================================ void setup() { - // Initialize USB serial (for local debugging) Serial.begin(DEFAULT_BAUD_SERIAL); - delay(1000); + delay(500); + Serial.println("\n=== ESP32 Debug Dongle ==="); - Serial.println("\n\n========================================"); - Serial.println(" ESP32 Debug Dongle"); - Serial.println("========================================\n"); + Serial1.begin(baudSerial1, SERIAL_8N1, TARGET_RX_PIN, TARGET_TX_PIN); + Serial.printf("[Serial1] %lu baud RX=%d TX=%d\n", baudSerial1, TARGET_RX_PIN, TARGET_TX_PIN); - // Initialize Serial1 for external device - Serial1.begin(baudSerial1, SERIAL_8N1, SERIAL1_RX_PIN, SERIAL1_TX_PIN); - Serial.printf("[Serial1] Initialized at %lu baud (RX=%d, TX=%d)\n", - baudSerial1, SERIAL1_RX_PIN, SERIAL1_TX_PIN); + gpioInit(GPIO_RESET_PIN); + gpioInit(GPIO_WAKE_PIN); + setActivePort(currentPort); - // Initialize LittleFS - if (!LittleFS.begin(true)) { - Serial.println("[FS] LittleFS mount failed!"); - } else { - Serial.println("[FS] LittleFS mounted"); - } + if (!LittleFS.begin(true)) Serial.println("[FS] LittleFS mount failed"); + +#if BOARD_T3S3 + setupOLED(); + setupSD(); +#endif - // Setup WiFi Access Point setupWiFi(); - // Setup Bluetooth - setupBluetooth(); +#if BOARD_T3S3 + if (WiFi.getMode() == WIFI_STA) setupNTP(); +#endif - // Setup Web Server +#if HAS_BT_CLASSIC + if (SerialBT.begin(BT_NAME)) Serial.printf("[BT] '%s' ready\n", BT_NAME); +#endif + + setupTelnet(); setupWebServer(); - Serial.println("\n[System] Ready!"); String ip = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); - Serial.printf("[System] Open http://%s in browser\n", ip.c_str()); - Serial.println("[System] Or connect via Bluetooth\n"); - - // Send initial message to internal serial - debugPrintln("ESP32 Debug Dongle initialized"); - debugPrintln("Free heap: %d bytes", ESP.getFreeHeap()); + Serial.printf("[Ready] http://%s telnet %s 23\n", ip.c_str(), ip.c_str()); } -// ============================================================================ -// Main Loop -// ============================================================================ - -// Buffer for efficient serial reading static uint8_t serialBuffer[256]; void loop() { - // Read from active serial port and send to WebSocket + Bluetooth - size_t available = activePort->available(); - if (available > 0) { - size_t toRead = min(available, sizeof(serialBuffer)); - size_t bytesRead = 0; - - // Read bytes into buffer - for (size_t i = 0; i < toRead; i++) { - int c = activePort->read(); - if (c >= 0) { - serialBuffer[bytesRead++] = (uint8_t)c; - } - } - - if (bytesRead > 0) { - // Send to all WebSocket clients - ws.binaryAll(serialBuffer, bytesRead); - - // Send to Bluetooth if connected - if (SerialBT.connected()) { - SerialBT.write(serialBuffer, bytesRead); - } - } + // Target UART -> sinks + size_t avail = activePort->available(); + if (avail > 0) { + size_t toRead = min(avail, sizeof(serialBuffer)); + size_t got = 0; + for (size_t i = 0; i < toRead; i++) { int c = activePort->read(); if (c >= 0) serialBuffer[got++] = (uint8_t)c; } + if (got > 0) broadcastFromTarget(serialBuffer, got); } - // Read from Bluetooth and send to active serial port - while (SerialBT.available()) { - int c = SerialBT.read(); - if (c >= 0) { - activePort->write((uint8_t)c); - } - } +#if HAS_BT_CLASSIC + while (SerialBT.available()) { int c = SerialBT.read(); if (c >= 0) { uint8_t b = (uint8_t)c; forwardToTarget(&b, 1); } } +#endif - // Cleanup disconnected WebSocket clients + serviceTelnet(); ws.cleanupClients(); - // Periodic internal debug messages (example) - static unsigned long lastDebug = 0; - if (millis() - lastDebug > 30000) { // Every 30 seconds - lastDebug = millis(); - debugPrintln("[%lu] Heartbeat - Heap: %d, WS clients: %d", - millis() / 1000, ESP.getFreeHeap(), ws.count()); - } +#if BOARD_T3S3 + // Once NTP lands, mark synced + open today's log. + if (!ntpSynced && time(nullptr) > 1700000000) { ntpSynced = true; if (logEnabled) openLogIfReady(); } + static unsigned long lastOled = 0; + if (millis() - lastOled > 500) { lastOled = millis(); renderOLED(); } +#endif - // Small delay to prevent WDT issues delay(1); }