Codex added unified library, all work
This commit is contained in:
parent
18a1d1558c
commit
8370e546ff
25 changed files with 2935 additions and 0 deletions
13
lib/tbeam_web/library.json
Normal file
13
lib/tbeam_web/library.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "tbeam_web",
|
||||
"version": "0.1.0",
|
||||
"description": "Reusable WiFi AP web file service for LilyGO T-Beam Supreme SD logs.",
|
||||
"frameworks": "arduino",
|
||||
"platforms": "espressif32",
|
||||
"dependencies": [
|
||||
{
|
||||
"name": "tbeam_storage",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
358
lib/tbeam_web/src/TBeamWeb.cpp
Normal file
358
lib/tbeam_web/src/TBeamWeb.cpp
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
#include "TBeamWeb.h"
|
||||
|
||||
#include <SD.h>
|
||||
#include <stdarg.h>
|
||||
|
||||
namespace tbeam {
|
||||
|
||||
TBeamWeb* TBeamWeb::active_ = nullptr;
|
||||
|
||||
TBeamWeb::TBeamWeb(Print& diagnostic) : diagnostic_(diagnostic), server_(80) {}
|
||||
|
||||
bool TBeamWeb::begin(TBeamStorage& storage, const WebConfig& config) {
|
||||
storage_ = &storage;
|
||||
config_ = config;
|
||||
clearError();
|
||||
|
||||
snprintf(ssid_, sizeof(ssid_), "%s-%s", config_.ssidPrefix ? config_.ssidPrefix : "TBEAM", config_.boardId ? config_.boardId : "NODE");
|
||||
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.setSleep(false);
|
||||
|
||||
ip_ = IPAddress(192, 168, config_.ipOctet, 1);
|
||||
const IPAddress gateway(192, 168, config_.ipOctet, 1);
|
||||
const IPAddress netmask(255, 255, 255, 0);
|
||||
if (!WiFi.softAPConfig(ip_, gateway, netmask)) {
|
||||
setError("WiFi softAPConfig failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool apOk = false;
|
||||
if (config_.password && strlen(config_.password) >= 8) {
|
||||
apOk = WiFi.softAP(ssid_, config_.password);
|
||||
} else {
|
||||
apOk = WiFi.softAP(ssid_);
|
||||
}
|
||||
if (!apOk) {
|
||||
setError("WiFi softAP failed");
|
||||
return false;
|
||||
}
|
||||
ip_ = WiFi.softAPIP();
|
||||
|
||||
active_ = this;
|
||||
server_.on("/", HTTP_GET, handleRootThunk);
|
||||
server_.on("/status", HTTP_GET, handleStatusThunk);
|
||||
server_.on("/files", HTTP_GET, handleFilesThunk);
|
||||
server_.on("/download", HTTP_GET, handleDownloadThunk);
|
||||
server_.on("/delete", HTTP_POST, handleDeleteThunk);
|
||||
server_.onNotFound(handleNotFoundThunk);
|
||||
server_.begin(config_.port);
|
||||
|
||||
ready_ = true;
|
||||
logf("web: AP started ssid=%s ip=%s port=%u", ssid_, ip_.toString().c_str(), (unsigned)config_.port);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TBeamWeb::update() {
|
||||
if (ready_) {
|
||||
server_.handleClient();
|
||||
}
|
||||
}
|
||||
|
||||
void TBeamWeb::stop() {
|
||||
if (ready_) {
|
||||
server_.stop();
|
||||
WiFi.softAPdisconnect(true);
|
||||
}
|
||||
ready_ = false;
|
||||
}
|
||||
|
||||
uint8_t TBeamWeb::stationCount() const {
|
||||
return (uint8_t)WiFi.softAPgetStationNum();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleRootThunk() {
|
||||
if (active_) active_->handleRoot();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleStatusThunk() {
|
||||
if (active_) active_->handleStatus();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleFilesThunk() {
|
||||
if (active_) active_->handleFiles();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleDownloadThunk() {
|
||||
if (active_) active_->handleDownload();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleDeleteThunk() {
|
||||
if (active_) active_->handleDelete();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleNotFoundThunk() {
|
||||
if (active_) active_->handleNotFound();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleRoot() {
|
||||
String body;
|
||||
body.reserve(2048);
|
||||
body += F("<!doctype html><html><head><meta charset='utf-8'>");
|
||||
body += F("<meta name='viewport' content='width=device-width,initial-scale=1'>");
|
||||
body += F("<title>T-Beam Files</title></head><body>");
|
||||
body += F("<h1>T-Beam Files</h1>");
|
||||
body += F("<p>SSID: ");
|
||||
body += htmlEscape(ssid_);
|
||||
body += F("</p><p>IP: ");
|
||||
body += htmlEscape(ip_.toString());
|
||||
body += F("</p><p>SD: ");
|
||||
body += (storage_ && storage_->ready()) ? F("mounted") : F("not mounted");
|
||||
body += F("</p><p>Stations: ");
|
||||
body += String(stationCount());
|
||||
body += F("</p><p><a href='/files?path=/logs'>Files</a> ");
|
||||
body += F("<a href='/status'>Status</a></p>");
|
||||
body += F("</body></html>");
|
||||
server_.send(200, "text/html", body);
|
||||
}
|
||||
|
||||
void TBeamWeb::handleStatus() {
|
||||
String body;
|
||||
body.reserve(512);
|
||||
body += F("{\"ready\":");
|
||||
body += ready_ ? F("true") : F("false");
|
||||
body += F(",\"ssid\":\"");
|
||||
body += htmlEscape(ssid_);
|
||||
body += F("\",\"ip\":\"");
|
||||
body += ip_.toString();
|
||||
body += F("\",\"stations\":");
|
||||
body += String(stationCount());
|
||||
body += F(",\"sd_ready\":");
|
||||
body += (storage_ && storage_->ready()) ? F("true") : F("false");
|
||||
body += F("}");
|
||||
server_.send(200, "application/json", body);
|
||||
}
|
||||
|
||||
void TBeamWeb::handleFiles() {
|
||||
if (!storage_ || !storage_->ready()) {
|
||||
server_.send(503, "text/plain", "SD not mounted\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char path[128];
|
||||
const String requested = server_.hasArg("path") ? server_.arg("path") : String("/logs");
|
||||
if (!normalizePath(requested, path, sizeof(path))) {
|
||||
server_.send(400, "text/plain", "invalid path\n");
|
||||
return;
|
||||
}
|
||||
|
||||
String body;
|
||||
body.reserve(8192);
|
||||
body += F("<!doctype html><html><head><meta charset='utf-8'>");
|
||||
body += F("<meta name='viewport' content='width=device-width,initial-scale=1'>");
|
||||
body += F("<title>SD Files</title></head><body>");
|
||||
body += F("<h1>SD Files</h1><p>Path: ");
|
||||
body += htmlEscape(path);
|
||||
body += F("</p><p><a href='/'>Home</a> <a href='/files?path=/'>Root</a> <a href='/files?path=/logs'>Logs</a></p><ul>");
|
||||
listDirectoryHtml(body, path, 4);
|
||||
body += F("</ul></body></html>");
|
||||
server_.send(200, "text/html", body);
|
||||
}
|
||||
|
||||
void TBeamWeb::handleDownload() {
|
||||
if (!storage_ || !storage_->ready()) {
|
||||
server_.send(503, "text/plain", "SD not mounted\n");
|
||||
return;
|
||||
}
|
||||
if (!server_.hasArg("path")) {
|
||||
server_.send(400, "text/plain", "missing path\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char path[128];
|
||||
if (!normalizePath(server_.arg("path"), path, sizeof(path))) {
|
||||
server_.send(400, "text/plain", "invalid path\n");
|
||||
return;
|
||||
}
|
||||
|
||||
File file = SD.open(path, FILE_READ);
|
||||
if (!file || file.isDirectory()) {
|
||||
file.close();
|
||||
server_.send(404, "text/plain", "file not found\n");
|
||||
return;
|
||||
}
|
||||
|
||||
String filename(path);
|
||||
const int slash = filename.lastIndexOf('/');
|
||||
if (slash >= 0) {
|
||||
filename.remove(0, slash + 1);
|
||||
}
|
||||
server_.sendHeader("Content-Disposition", String("attachment; filename=\"") + filename + "\"");
|
||||
server_.streamFile(file, contentTypeFor(path));
|
||||
file.close();
|
||||
}
|
||||
|
||||
void TBeamWeb::handleDelete() {
|
||||
if (!config_.enableDelete) {
|
||||
server_.send(403, "text/plain", "delete disabled\n");
|
||||
return;
|
||||
}
|
||||
if (!storage_ || !storage_->ready()) {
|
||||
server_.send(503, "text/plain", "SD not mounted\n");
|
||||
return;
|
||||
}
|
||||
if (!server_.hasArg("path")) {
|
||||
server_.send(400, "text/plain", "missing path\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char path[128];
|
||||
if (!normalizePath(server_.arg("path"), path, sizeof(path))) {
|
||||
server_.send(400, "text/plain", "invalid path\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storage_->removeFile(path)) {
|
||||
server_.send(500, "text/plain", String("delete failed: ") + storage_->lastError() + "\n");
|
||||
return;
|
||||
}
|
||||
server_.sendHeader("Location", "/files?path=/logs");
|
||||
server_.send(303, "text/plain", "deleted\n");
|
||||
}
|
||||
|
||||
void TBeamWeb::handleNotFound() {
|
||||
server_.send(404, "text/plain", "not found\n");
|
||||
}
|
||||
|
||||
void TBeamWeb::listDirectoryHtml(String& body, const char* path, uint8_t depth) {
|
||||
File dir = SD.open(path, FILE_READ);
|
||||
if (!dir) {
|
||||
body += F("<li>open failed</li>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dir.isDirectory()) {
|
||||
body += F("<li>");
|
||||
body += htmlEscape(path);
|
||||
body += F(" ");
|
||||
body += String((unsigned long)dir.size());
|
||||
body += F(" bytes <a href='/download?path=");
|
||||
body += urlEncode(path);
|
||||
body += F("'>download</a></li>");
|
||||
dir.close();
|
||||
return;
|
||||
}
|
||||
|
||||
File entry = dir.openNextFile();
|
||||
while (entry) {
|
||||
const String name = entry.name();
|
||||
const int slash = name.lastIndexOf('/');
|
||||
const String displayName = slash >= 0 ? name.substring(slash + 1) : (name.length() ? name : String("(unnamed)"));
|
||||
String childPath;
|
||||
if (name.startsWith("/")) {
|
||||
childPath = name;
|
||||
} else if (strcmp(path, "/") == 0) {
|
||||
childPath = String("/") + name;
|
||||
} else {
|
||||
childPath = String(path) + "/" + name;
|
||||
}
|
||||
body += F("<li>");
|
||||
if (entry.isDirectory()) {
|
||||
body += F("<a href='/files?path=");
|
||||
body += urlEncode(childPath);
|
||||
body += F("'>");
|
||||
body += htmlEscape(displayName);
|
||||
body += F("/</a>");
|
||||
if (depth > 0) {
|
||||
body += F("<ul>");
|
||||
listDirectoryHtml(body, childPath.c_str(), depth - 1);
|
||||
body += F("</ul>");
|
||||
}
|
||||
} else {
|
||||
body += htmlEscape(displayName);
|
||||
body += F(" ");
|
||||
body += String((unsigned long)entry.size());
|
||||
body += F(" bytes <a href='/download?path=");
|
||||
body += urlEncode(childPath);
|
||||
body += F("'>download</a>");
|
||||
if (config_.enableDelete) {
|
||||
body += F(" <form method='post' action='/delete' style='display:inline'>");
|
||||
body += F("<input type='hidden' name='path' value='");
|
||||
body += htmlEscape(childPath);
|
||||
body += F("'><button type='submit'>delete</button></form>");
|
||||
}
|
||||
}
|
||||
body += F("</li>");
|
||||
entry.close();
|
||||
entry = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
}
|
||||
|
||||
bool TBeamWeb::normalizePath(const String& input, char* out, size_t outSize) const {
|
||||
if (!out || outSize < 2 || input.length() == 0 || input.indexOf("..") >= 0) {
|
||||
return false;
|
||||
}
|
||||
const int n = input[0] == '/' ? snprintf(out, outSize, "%s", input.c_str()) : snprintf(out, outSize, "/%s", input.c_str());
|
||||
return n > 0 && (size_t)n < outSize;
|
||||
}
|
||||
|
||||
String TBeamWeb::htmlEscape(const String& in) const {
|
||||
String out;
|
||||
out.reserve(in.length() + 8);
|
||||
for (size_t i = 0; i < in.length(); ++i) {
|
||||
const char c = in[i];
|
||||
if (c == '&') out += F("&");
|
||||
else if (c == '<') out += F("<");
|
||||
else if (c == '>') out += F(">");
|
||||
else if (c == '"') out += F(""");
|
||||
else if (c == '\'') out += F("'");
|
||||
else out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String TBeamWeb::urlEncode(const String& in) const {
|
||||
String out;
|
||||
char hex[4];
|
||||
for (size_t i = 0; i < in.length(); ++i) {
|
||||
const unsigned char c = (unsigned char)in[i];
|
||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
|
||||
out += (char)c;
|
||||
} else {
|
||||
snprintf(hex, sizeof(hex), "%%%02X", c);
|
||||
out += hex;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String TBeamWeb::contentTypeFor(const String& path) const {
|
||||
if (path.endsWith(".html")) return "text/html";
|
||||
if (path.endsWith(".csv")) return "text/csv";
|
||||
if (path.endsWith(".json")) return "application/json";
|
||||
if (path.endsWith(".txt") || path.endsWith(".log")) return "text/plain";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
void TBeamWeb::setError(const char* message) {
|
||||
strlcpy(lastError_, message ? message : "", sizeof(lastError_));
|
||||
}
|
||||
|
||||
void TBeamWeb::clearError() {
|
||||
lastError_[0] = '\0';
|
||||
}
|
||||
|
||||
void TBeamWeb::logf(const char* fmt, ...) {
|
||||
if (!config_.enableSerialLog) {
|
||||
return;
|
||||
}
|
||||
char msg[196];
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(msg, sizeof(msg), fmt, args);
|
||||
va_end(args);
|
||||
diagnostic_.printf("[%10lu][web] %s\r\n", (unsigned long)millis(), msg);
|
||||
}
|
||||
|
||||
} // namespace tbeam
|
||||
69
lib/tbeam_web/src/TBeamWeb.h
Normal file
69
lib/tbeam_web/src/TBeamWeb.h
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WebServer.h>
|
||||
#include <WiFi.h>
|
||||
#include <TBeamStorage.h>
|
||||
|
||||
namespace tbeam {
|
||||
|
||||
struct WebConfig {
|
||||
const char* ssidPrefix = "TBEAM";
|
||||
const char* boardId = "NODE";
|
||||
const char* password = nullptr;
|
||||
uint8_t ipOctet = 25;
|
||||
uint16_t port = 80;
|
||||
bool enableDelete = true;
|
||||
bool enableSerialLog = true;
|
||||
};
|
||||
|
||||
class TBeamWeb {
|
||||
public:
|
||||
explicit TBeamWeb(Print& diagnostic = Serial);
|
||||
|
||||
bool begin(TBeamStorage& storage, const WebConfig& config = WebConfig{});
|
||||
void update();
|
||||
void stop();
|
||||
|
||||
bool ready() const { return ready_; }
|
||||
const char* ssid() const { return ssid_; }
|
||||
IPAddress ip() const { return ip_; }
|
||||
const char* lastError() const { return lastError_; }
|
||||
uint8_t stationCount() const;
|
||||
|
||||
private:
|
||||
static TBeamWeb* active_;
|
||||
static void handleRootThunk();
|
||||
static void handleStatusThunk();
|
||||
static void handleFilesThunk();
|
||||
static void handleDownloadThunk();
|
||||
static void handleDeleteThunk();
|
||||
static void handleNotFoundThunk();
|
||||
|
||||
void handleRoot();
|
||||
void handleStatus();
|
||||
void handleFiles();
|
||||
void handleDownload();
|
||||
void handleDelete();
|
||||
void handleNotFound();
|
||||
|
||||
void listDirectoryHtml(String& body, const char* path, uint8_t depth);
|
||||
bool normalizePath(const String& input, char* out, size_t outSize) const;
|
||||
String htmlEscape(const String& in) const;
|
||||
String urlEncode(const String& in) const;
|
||||
String contentTypeFor(const String& path) const;
|
||||
void setError(const char* message);
|
||||
void clearError();
|
||||
void logf(const char* fmt, ...);
|
||||
|
||||
Print& diagnostic_;
|
||||
WebServer server_;
|
||||
TBeamStorage* storage_ = nullptr;
|
||||
WebConfig config_{};
|
||||
bool ready_ = false;
|
||||
IPAddress ip_{0, 0, 0, 0};
|
||||
char ssid_[40] = {};
|
||||
char lastError_[128] = {};
|
||||
};
|
||||
|
||||
} // namespace tbeam
|
||||
Loading…
Add table
Add a link
Reference in a new issue