823 lines
32 KiB
C++
823 lines
32 KiB
C++
/**
|
|
* 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 <n> ~port <int|usb|ext>
|
|
* ~log on|off ~gpio <pin> <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 <Arduino.h>
|
|
#include <WiFi.h>
|
|
#include <ESPAsyncWebServer.h>
|
|
#include <AsyncTCP.h>
|
|
#include <LittleFS.h>
|
|
#include <ArduinoJson.h>
|
|
#include "LoopbackStream.h"
|
|
#include "meshcore_link.h"
|
|
|
|
#if HAS_BT_CLASSIC
|
|
#include "BluetoothSerial.h"
|
|
#endif
|
|
|
|
#if BOARD_T3S3
|
|
#include <time.h>
|
|
#include <Wire.h>
|
|
#include <SPI.h>
|
|
#include <SD.h>
|
|
#include <Adafruit_GFX.h>
|
|
#include <Adafruit_SSD1306.h>
|
|
#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;i<MAX_TELNET;i++) if (telnetClients[i]&&telnetClients[i].connected()) n++; return n; }(),
|
|
logEnabled ? "on" : "off", ntpSynced ? "yes" : "no",
|
|
(unsigned)ESP.getFreeHeap());
|
|
}
|
|
|
|
// `line` is the text AFTER the CTRL_PREFIX.
|
|
void handleCommand(const char* line, Stream* reply) {
|
|
char verb[16] = {0};
|
|
int n = 0;
|
|
while (line[n] && line[n] != ' ' && n < 15) { verb[n] = line[n]; n++; }
|
|
verb[n] = '\0';
|
|
const char* args = line[n] ? line + n + 1 : line + n;
|
|
|
|
if (!strcmp(verb, "help")) {
|
|
reply->print("[help] ~status ~reset [ms] ~button [ms|on|off] ~baud <n> "
|
|
"~port <int|usb|ext> ~log on|off ~gpio <pin> <0|1>\r\n"
|
|
" ~mesh [on|off] ~psk <base64key> ~chan <name> <base64key> ~msg <text>\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 <n>\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 <pin> <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 <base64 16- or 32-byte key>\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 <name> <base64key>\r\n");
|
|
} else if (!strcmp(verb, "msg")) {
|
|
if (*args) { mc::requestSend(args); meshEchoTx(args); }
|
|
else reply->print("[mesh] usage: ~msg <text>\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<JsonArray>();
|
|
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<JsonObject>();
|
|
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=<basename> (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);
|
|
}
|