/** * ESP32 Debug Dongle * * 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). * * 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= * * Boards (platformio.ini): [env:esp32dev] generic ESP32 (+ BT); [env:t3s3] * LilyGo T3-S3 (OLED + SD + LoRa). Pins are build flags -- see platformio.ini. */ #include #include #include #include #include #include #include "LoopbackStream.h" #include "meshcore_link.h" #if HAS_BT_CLASSIC #include "BluetoothSerial.h" #endif #if BOARD_T3S3 #include #include #include #include #include #include #endif // ============================================================================ // Configuration // ============================================================================ #define WIFI_STATION_MODE true #define ST(A) #A #define STR(A) ST(A) #ifndef STA_SSID #define STA_SSID "ExampleSSID" #endif #ifndef STA_PASSWORD #define STA_PASSWORD "ThePassword" #endif const char* AP_SSID = "ESP32-DebugDongle"; const char* AP_PASSWORD = "debug1234"; #define WIFI_CONNECT_TIMEOUT 15000 const char* BT_NAME = "ESP32-Debug"; // 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 #define DEFAULT_BAUD_SERIAL 115200 #define DEFAULT_BAUD_SERIAL1 115200 #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 // ============================================================================ AsyncWebServer server(80); AsyncWebSocket ws("/ws"); #if HAS_BT_CLASSIC BluetoothSerial SerialBT; #endif LoopbackStream internalSerial(INTERNAL_BUFFER_SIZE); Stream* activePort = &internalSerial; 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 // Button line held active (latched) rather than momentary-pulsed. bool buttonLatched = false; // SD logging (T3-S3) bool sdAvailable = false; bool logEnabled = true; // ON by default once we have a date bool ntpSynced = false; #if BOARD_T3S3 // SD on HSPI (SPI3). The MeshCore LoRa radio (env:t3s3_mesh) is pinned to FSPI // (SPI2) in meshcore_link.cpp, so the two SPI peripherals never collide. 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); static const char* portName(SerialPortId p); // ============================================================================ // Serial Port Management // ============================================================================ void setActivePort(SerialPortId port) { currentPort = port; switch (port) { case PORT_INTERNAL: activePort = &internalSerial; break; case PORT_USB: activePort = &Serial; break; case PORT_EXTERNAL: activePort = &Serial1; break; } } 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); } // Hold a control line active (on) or release it (off) -- a latched "press". static void gpioLatch(int pin, bool on, Stream* reply, const char* name) { if (pin < 0) { if (reply) reply->printf("[ctrl] %s pin not configured\r\n", name); return; } int active = GPIO_CTRL_ACTIVE_LOW ? LOW : HIGH; int inactive = GPIO_CTRL_ACTIVE_LOW ? HIGH : LOW; pinMode(pin, OUTPUT); digitalWrite(pin, on ? active : inactive); if (reply) reply->printf("[ctrl] %s latch=%s (pin %d %s)\r\n", name, on ? "on" : "off", pin, on ? "held" : "released"); } // ============================================================================ // 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.printf("# uart: port=%s baud=%lu rx=%d tx=%d\n", portName(currentPort), (unsigned long)baudSerial1, TARGET_RX_PIN, TARGET_TX_PIN); if (mc::enabled()) { logFile.printf("# mesh: %s node=%s channel=%s psk=%s %s\n", mc::up() ? "up" : "down", mc::nodeName(), mc::channelName(), mc::pskB64(), mc::radioConfig()); } 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); } // ============================================================================ // MeshCore comms panel plumbing (mc:: is no-ops when USE_MESHCORE is unset, so // none of this needs #if guards -- the UI/telnet just report "not in build"). // ============================================================================ bool meshEchoTelnet = true; // mirror received/sent mesh lines to telnet+USB static void wsBroadcastJson(JsonDocument& doc) { String s; serializeJson(doc, s); ws.textAll(String((char)0x00) + s); // 0x00-prefixed JSON text frame } // Build one mesh line and fan it out: SD log (always, timestamped per line via // logBytes) + telnet/USB mirror (only when ~mesh echo is on). static void meshEchoLine(const char* dir, const char* channel, const char* who, const char* text, int rssi, float snr) { char line[300]; int n; if (rssi != 0) n = snprintf(line, sizeof(line), "[mesh %s %s] %s: %s (rssi %d snr %.1f)\r\n", dir, channel, who, text, rssi, snr); else n = snprintf(line, sizeof(line), "[mesh %s %s] %s: %s\r\n", dir, channel, who, text); if (n < 0) return; if (n >= (int)sizeof(line)) n = sizeof(line) - 1; logBytes((const uint8_t*)line, n); // -> SD (no-op when off / not T3-S3) if (meshEchoTelnet) { for (int i = 0; i < MAX_TELNET; i++) if (telnetClients[i] && telnetClients[i].connected()) telnetClients[i].print(line); Serial.print(line); } } // Received channel message -- called from mc::loop() in the main task. void onMeshRx(const char* channel, const char* sender, const char* text, int rssi, float snr) { JsonDocument d; d["type"] = "mesh"; d["dir"] = "rx"; d["channel"] = channel; d["sender"] = sender; d["text"] = text; d["rssi"] = rssi; d["snr"] = snr; wsBroadcastJson(d); meshEchoLine("rx", channel, sender, text, rssi, snr); } // Optimistically echo a locally-sent message to the web panel + telnet. static void meshEchoTx(const char* text) { JsonDocument d; d["type"] = "mesh"; d["dir"] = "tx"; d["channel"] = mc::channelName(); d["sender"] = mc::nodeName(); d["text"] = text; wsBroadcastJson(d); meshEchoLine("tx", mc::channelName(), mc::nodeName(), text, 0, 0.0f); } static void meshBuildCfg(JsonDocument& d) { d["type"] = "meshcfg"; d["enabled"] = mc::enabled(); d["up"] = mc::up(); d["channel"] = mc::channelName(); d["psk"] = mc::pskB64(); d["rx"] = mc::rxCount(); d["tx"] = mc::txCount(); } static void meshBroadcastCfg() { JsonDocument d; meshBuildCfg(d); wsBroadcastJson(d); } // Note a (re)programmed channel/PSK in the SD log so the capture is self-describing. static void meshLogCfg() { char cfg[200]; int n = snprintf(cfg, sizeof(cfg), "[mesh cfg] channel=%s psk=%s %s\r\n", mc::channelName(), mc::pskB64(), mc::radioConfig()); if (n < 0) return; if (n >= (int)sizeof(cfg)) n = sizeof(cfg) - 1; logBytes((const uint8_t*)cfg, n); } // ============================================================================ // 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] ~button [ms|on|off] ~baud " "~port ~log on|off ~gpio <0|1>\r\n" " ~mesh [on|off] ~psk ~chan ~msg \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") || !strcmp(verb, "button")) { if (!strncmp(args, "on", 2)) { gpioLatch(GPIO_WAKE_PIN, true, reply, "button"); buttonLatched = true; } else if (!strncmp(args, "off", 3)) { gpioLatch(GPIO_WAKE_PIN, false, reply, "button"); buttonLatched = false; } else { gpioPulse(GPIO_WAKE_PIN, (uint32_t)strtoul(args, nullptr, 10), reply, "button"); buttonLatched = false; } } 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 if (!strcmp(verb, "mesh")) { if (!strncmp(args, "on", 2)) { meshEchoTelnet = true; reply->print("[mesh] echo on\r\n"); } else if (!strncmp(args, "off", 3)) { meshEchoTelnet = false; reply->print("[mesh] echo off\r\n"); } else reply->printf("[mesh] %s up=%s channel=%s psk=%s rx=%lu tx=%lu echo=%s\r\n", mc::enabled() ? "enabled" : "(not in build)", mc::up() ? "yes" : "no", mc::channelName(), mc::pskB64(), (unsigned long)mc::rxCount(), (unsigned long)mc::txCount(), meshEchoTelnet ? "on" : "off"); } else if (!strcmp(verb, "psk")) { if (*args) { mc::requestPsk("", args); reply->printf("[mesh] psk queued: %s\r\n", args); } else reply->print("[mesh] usage: ~psk \r\n"); } else if (!strcmp(verb, "chan")) { char nm[32] = {0}, pk[48] = {0}; if (sscanf(args, "%31s %47s", nm, pk) == 2) { mc::requestPsk(nm, pk); reply->printf("[mesh] channel '%s' queued\r\n", nm); } else reply->print("[mesh] usage: ~chan \r\n"); } else if (!strcmp(verb, "msg")) { if (*args) { mc::requestSend(args); meshEchoTx(args); } else reply->print("[mesh] usage: ~msg \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; } } } } // ============================================================================ // WebSocket Handlers // ============================================================================ void handleWebSocketMessage(AsyncWebSocketClient* client, uint8_t* data, size_t len) { if (len > 0 && data[0] == 0x00) { String cmdStr = String((char*)(data + 1)).substring(0, len - 1); JsonDocument doc; 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; r["buttonLatch"] = buttonLatched; #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 if (!strcmp(cmd, "meshSend")) { const char* t = doc["text"]; if (t && *t) { mc::requestSend(t); meshEchoTx(t); } } else if (!strcmp(cmd, "meshPsk")) { const char* nm = doc["name"]; const char* pk = doc["psk"]; if (pk && *pk) mc::requestPsk(nm ? nm : "", pk); } else if (!strcmp(cmd, "meshGet")) { JsonDocument r; meshBuildCfg(r); String resp; serializeJson(r, resp); client->text(String((char)0x00) + resp); } } else { forwardToTarget(data, len); } } void onWsEvent(AsyncWebSocket*, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) { if (type == WS_EVT_DATA) { AwsFrameInfo* info = (AwsFrameInfo*)arg; if (info->final && info->index == 0 && info->len == len) handleWebSocketMessage(client, data, len); } } // ============================================================================ // 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; doc["buttonLatch"] = buttonLatched; #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() { server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); ws.onEvent(onWsEvent); server.addHandler(&ws); 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"); }); // Momentary pulse (?ms=), or latch the line held (?latch=on|off|1|0). auto doButton = [](AsyncWebServerRequest* req) { if (req->hasParam("latch")) { String v = req->getParam("latch")->value(); bool on = (v == "1" || v.startsWith("on") || v == "true"); gpioLatch(GPIO_WAKE_PIN, on, nullptr, "button"); buttonLatched = on; req->send(200, "application/json", statusJson()); return; } uint32_t ms = req->hasParam("ms") ? req->getParam("ms")->value().toInt() : 0; gpioPulse(GPIO_WAKE_PIN, ms, nullptr, "button"); buttonLatched = false; req->send(200, "application/json", statusJson()); }; server.on("/api/button", HTTP_GET, doButton); server.on("/api/wake", HTTP_GET, doButton); // back-compat alias 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); } 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"); }); #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(); } // ============================================================================ // WiFi // ============================================================================ bool connectToWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(STR(STA_SSID), STR(STA_PASSWORD)); unsigned long t0 = millis(); while (WiFi.status() != WL_CONNECTED) { if (millis() - t0 > WIFI_CONNECT_TIMEOUT) return false; delay(500); Serial.print("."); } 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); Serial.printf("[WiFi] AP %s IP %s\n", AP_SSID, WiFi.softAPIP().toString().c_str()); } void setupWiFi() { WiFi.disconnect(true); delay(100); if (!WIFI_STATION_MODE || !connectToWiFi()) startAccessPoint(); } // ============================================================================ // T3-S3 peripherals: NTP, SD, OLED // ============================================================================ #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, T3S3_SD_FREQ_HZ); Serial.printf("[SD] %s (CS=%d)\n", sdAvailable ? "mounted" : "NOT found -- check T3S3_SD_* pins", T3S3_SD_CS); } void setupNTP() { configTzTime(NTP_TZ, "pool.ntp.org", "time.google.com"); } 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 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 // ============================================================================ // Setup / Loop // ============================================================================ void setup() { Serial.begin(DEFAULT_BAUD_SERIAL); delay(500); Serial.println("\n=== ESP32 Debug Dongle ==="); 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); gpioInit(GPIO_RESET_PIN); gpioInit(GPIO_WAKE_PIN); setActivePort(currentPort); if (!LittleFS.begin(true)) Serial.println("[FS] LittleFS mount failed"); #if BOARD_T3S3 setupOLED(); setupSD(); #endif setupWiFi(); #if BOARD_T3S3 if (WiFi.getMode() == WIFI_STA) setupNTP(); #endif #if HAS_BT_CLASSIC if (SerialBT.begin(BT_NAME)) Serial.printf("[BT] '%s' ready\n", BT_NAME); #endif setupTelnet(); setupWebServer(); mc::begin(onMeshRx); // MeshCore radio (no-op unless USE_MESHCORE) String ip = (WiFi.getMode() == WIFI_STA) ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); Serial.printf("[Ready] http://%s telnet %s 23\n", ip.c_str(), ip.c_str()); } static uint8_t serialBuffer[256]; void loop() { // 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); } #if HAS_BT_CLASSIC while (SerialBT.available()) { int c = SerialBT.read(); if (c >= 0) { uint8_t b = (uint8_t)c; forwardToTarget(&b, 1); } } #endif serviceTelnet(); ws.cleanupClients(); mc::loop(); // pump radio + drain mesh queue if (mc::consumeCfgChanged()) { meshBroadcastCfg(); meshLogCfg(); } #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 delay(1); }