This commit is contained in:
Thomas Peterson 2026-01-23 16:46:09 +01:00
parent 3440e1af58
commit 8720b56749
87 changed files with 1172 additions and 412 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,11 @@
idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "Touch.cpp" "Gui.cpp" "Wifi.cpp" "LvglIdle.c" "Gui/WifiSetting.cpp" "Gui/EthSetting.cpp" "Hardware/Eth.cpp" "WidgetManager.cpp" "WebServer.cpp" "SdCard.cpp"
idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "Touch.cpp" "Gui.cpp" "Wifi.cpp" "LvglIdle.c" "Gui/WifiSetting.cpp" "Gui/EthSetting.cpp" "Hardware/Eth.cpp" "WidgetManager.cpp" "SdCard.cpp"
"webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp"
"webserver/KnxHandlers.cpp"
"webserver/StatusHandlers.cpp"
"webserver/FileManagerHandlers.cpp"
PRIV_REQUIRES spi_flash esp_driver_ppa esp_lcd usb
REQUIRES esp_mm esp_eth esp_driver_ppa esp_timer lvgl knx ethernet_init esp_wifi_remote esp_netif esp_event nvs_flash esp_http_server fatfs sdmmc json tinyusb
INCLUDE_DIRS "")
INCLUDE_DIRS "webserver"
EMBED_TXTFILES "embedded/filemanager.html")

View File

@ -1,374 +0,0 @@
#include "WebServer.hpp"
#include "SdCard.hpp"
#include "WidgetManager.hpp"
#include "KnxWorker.hpp"
#include "Gui.hpp"
#include "esp_log.h"
#include "esp_system.h"
#include <cstring>
#include <cstdio>
#include <sys/stat.h>
static const char* TAG = "WebServer";
WebServer& WebServer::instance() {
static WebServer instance;
return instance;
}
WebServer::~WebServer() {
stop();
}
void WebServer::start() {
if (server_ != nullptr) {
ESP_LOGW(TAG, "Server already running");
return;
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
config.max_uri_handlers = 8;
ESP_LOGI(TAG, "Starting HTTP server on port %d", config.server_port);
if (httpd_start(&server_, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return;
}
// GET / - Web page (loads index.html from SD card)
httpd_uri_t root = {
.uri = "/",
.method = HTTP_GET,
.handler = rootHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &root);
// GET /webseite/* - Static files from SD card
httpd_uri_t webseite = {
.uri = "/webseite/*",
.method = HTTP_GET,
.handler = staticFileHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &webseite);
// GET /images/* - Images from SD card
httpd_uri_t images = {
.uri = "/images/*",
.method = HTTP_GET,
.handler = imagesHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &images);
// GET /api/config - Get full configuration
httpd_uri_t getConfig = {
.uri = "/api/config",
.method = HTTP_GET,
.handler = getConfigHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &getConfig);
// POST /api/config - Update configuration (from editor)
httpd_uri_t postConfig = {
.uri = "/api/config",
.method = HTTP_POST,
.handler = postConfigHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &postConfig);
// POST /api/save - Save and apply configuration
httpd_uri_t postSave = {
.uri = "/api/save",
.method = HTTP_POST,
.handler = postSaveHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &postSave);
// POST /api/reset - Reset to defaults
httpd_uri_t postReset = {
.uri = "/api/reset",
.method = HTTP_POST,
.handler = postResetHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &postReset);
// GET /api/knx/addresses - Get KNX group addresses
httpd_uri_t getKnxAddresses = {
.uri = "/api/knx/addresses",
.method = HTTP_GET,
.handler = getKnxAddressesHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &getKnxAddresses);
// POST /api/usb-mode - Enable USB Mass Storage mode
httpd_uri_t postUsbMode = {
.uri = "/api/usb-mode",
.method = HTTP_POST,
.handler = postUsbModeHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &postUsbMode);
// GET /api/status - Get system status
httpd_uri_t getStatus = {
.uri = "/api/status",
.method = HTTP_GET,
.handler = getStatusHandler,
.user_ctx = nullptr
};
httpd_register_uri_handler(server_, &getStatus);
ESP_LOGI(TAG, "HTTP server started successfully");
}
void WebServer::stop() {
if (server_ != nullptr) {
httpd_stop(server_);
server_ = nullptr;
ESP_LOGI(TAG, "HTTP server stopped");
}
}
const char* WebServer::getContentType(const char* filepath) {
const char* ext = strrchr(filepath, '.');
if (!ext) return "application/octet-stream";
if (strcasecmp(ext, ".html") == 0 || strcasecmp(ext, ".htm") == 0) return "text/html; charset=utf-8";
if (strcasecmp(ext, ".css") == 0) return "text/css";
if (strcasecmp(ext, ".js") == 0) return "application/javascript";
if (strcasecmp(ext, ".json") == 0) return "application/json";
if (strcasecmp(ext, ".png") == 0) return "image/png";
if (strcasecmp(ext, ".jpg") == 0 || strcasecmp(ext, ".jpeg") == 0) return "image/jpeg";
if (strcasecmp(ext, ".gif") == 0) return "image/gif";
if (strcasecmp(ext, ".svg") == 0) return "image/svg+xml";
if (strcasecmp(ext, ".ico") == 0) return "image/x-icon";
return "application/octet-stream";
}
esp_err_t WebServer::sendFile(httpd_req_t* req, const char* filepath) {
struct stat st;
if (stat(filepath, &st) != 0) {
ESP_LOGE(TAG, "File not found: %s", filepath);
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
return ESP_FAIL;
}
FILE* f = fopen(filepath, "r");
if (!f) {
ESP_LOGE(TAG, "Failed to open file: %s", filepath);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to open file");
return ESP_FAIL;
}
httpd_resp_set_type(req, getContentType(filepath));
// Send file in chunks
char* chunk = new char[1024];
if (!chunk) {
fclose(f);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
size_t read;
while ((read = fread(chunk, 1, 1024, f)) > 0) {
if (httpd_resp_send_chunk(req, chunk, read) != ESP_OK) {
delete[] chunk;
fclose(f);
ESP_LOGE(TAG, "Failed to send chunk");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Send failed");
return ESP_FAIL;
}
}
delete[] chunk;
fclose(f);
// End chunked response
httpd_resp_send_chunk(req, nullptr, 0);
return ESP_OK;
}
esp_err_t WebServer::rootHandler(httpd_req_t* req) {
if (!SdCard::instance().isMounted()) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
const char* errorPage = "<!DOCTYPE html><html><body><h1>SD Card Error</h1>"
"<p>SD card not mounted. Please insert SD card with /webseite/index.html</p></body></html>";
httpd_resp_send(req, errorPage, strlen(errorPage));
return ESP_OK;
}
return sendFile(req, "/sdcard/webseite/index.html");
}
esp_err_t WebServer::staticFileHandler(httpd_req_t* req) {
// URI: /webseite/filename -> /sdcard/webseite/filename
char filepath[CONFIG_HTTPD_MAX_URI_LEN + 8];
snprintf(filepath, sizeof(filepath), "/sdcard%.*s",
(int)(sizeof(filepath) - 8), req->uri);
return sendFile(req, filepath);
}
esp_err_t WebServer::imagesHandler(httpd_req_t* req) {
// URI: /images/filename -> /sdcard/images/filename
char filepath[CONFIG_HTTPD_MAX_URI_LEN + 8];
snprintf(filepath, sizeof(filepath), "/sdcard%.*s",
(int)(sizeof(filepath) - 8), req->uri);
return sendFile(req, filepath);
}
esp_err_t WebServer::getConfigHandler(httpd_req_t* req) {
// Allocate buffer for JSON (widgets can be large)
char* buf = new char[8192];
if (buf == nullptr) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
WidgetManager::instance().getConfigJson(buf, 8192);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, buf, strlen(buf));
delete[] buf;
return ESP_OK;
}
esp_err_t WebServer::postConfigHandler(httpd_req_t* req) {
// Read request body
int total_len = req->content_len;
if (total_len > 8192) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Content too large");
return ESP_FAIL;
}
char* buf = new char[total_len + 1];
if (buf == nullptr) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int received = 0;
while (received < total_len) {
int ret = httpd_req_recv(req, buf + received, total_len - received);
if (ret <= 0) {
delete[] buf;
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Receive failed");
return ESP_FAIL;
}
received += ret;
}
buf[received] = '\0';
// Update config (does NOT apply yet)
bool success = WidgetManager::instance().updateConfigFromJson(buf);
delete[] buf;
if (success) {
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "{\"status\":\"ok\"}", 15);
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
}
return success ? ESP_OK : ESP_FAIL;
}
esp_err_t WebServer::postSaveHandler(httpd_req_t* req) {
ESP_LOGI(TAG, "Saving and applying configuration");
WidgetManager::instance().saveAndApply();
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "{\"status\":\"ok\"}", 15);
return ESP_OK;
}
esp_err_t WebServer::postResetHandler(httpd_req_t* req) {
ESP_LOGI(TAG, "Resetting to defaults");
WidgetManager::instance().resetToDefaults();
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "{\"status\":\"ok\"}", 15);
return ESP_OK;
}
esp_err_t WebServer::getKnxAddressesHandler(httpd_req_t* req) {
char buf[2048];
int pos = 0;
pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
// Get group objects from KnxWorker
KnxWorker& knxWorker = Gui::knxWorker;
size_t count = knxWorker.getGroupObjectCount();
for (size_t i = 1; i <= count && pos < (int)sizeof(buf) - 100; i++) {
KnxGroupObjectInfo info;
if (knxWorker.getGroupObjectInfo(i, info)) {
char addrStr[16];
KnxWorker::formatGroupAddress(info.groupAddress, addrStr, sizeof(addrStr));
if (i > 1) pos += snprintf(buf + pos, sizeof(buf) - pos, ",");
pos += snprintf(buf + pos, sizeof(buf) - pos,
"{\"index\":%d,\"addr\":%d,\"addrStr\":\"%s\",\"comm\":%s,\"read\":%s,\"write\":%s}",
info.goIndex,
info.groupAddress,
addrStr,
info.commFlag ? "true" : "false",
info.readFlag ? "true" : "false",
info.writeFlag ? "true" : "false"
);
}
}
snprintf(buf + pos, sizeof(buf) - pos, "]");
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, buf, strlen(buf));
return ESP_OK;
}
esp_err_t WebServer::postUsbModeHandler(httpd_req_t* req) {
ESP_LOGI(TAG, "Enabling USB Mass Storage mode");
bool success = SdCard::instance().enableUsbMsc();
if (success) {
httpd_resp_set_type(req, "application/json");
const char* response = "{\"status\":\"ok\",\"message\":\"USB MSC enabled. Connect USB cable to access SD card. Reboot to return to normal mode.\"}";
httpd_resp_send(req, response, strlen(response));
} else {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to enable USB MSC");
}
return success ? ESP_OK : ESP_FAIL;
}
esp_err_t WebServer::getStatusHandler(httpd_req_t* req) {
char buf[256];
snprintf(buf, sizeof(buf),
"{\"sdMounted\":%s,\"usbMscActive\":%s}",
SdCard::instance().isMounted() ? "true" : "false",
SdCard::instance().isUsbMscActive() ? "true" : "false"
);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, buf, strlen(buf));
return ESP_OK;
}

View File

@ -1,36 +0,0 @@
#pragma once
#include "esp_http_server.h"
class WebServer {
public:
static WebServer& instance();
void start();
void stop();
bool isRunning() const { return server_ != nullptr; }
private:
WebServer() = default;
~WebServer();
WebServer(const WebServer&) = delete;
WebServer& operator=(const WebServer&) = delete;
httpd_handle_t server_ = nullptr;
// HTTP handlers
static esp_err_t rootHandler(httpd_req_t* req);
static esp_err_t staticFileHandler(httpd_req_t* req);
static esp_err_t imagesHandler(httpd_req_t* req);
static esp_err_t getConfigHandler(httpd_req_t* req);
static esp_err_t postConfigHandler(httpd_req_t* req);
static esp_err_t postSaveHandler(httpd_req_t* req);
static esp_err_t postResetHandler(httpd_req_t* req);
static esp_err_t getKnxAddressesHandler(httpd_req_t* req);
static esp_err_t postUsbModeHandler(httpd_req_t* req);
static esp_err_t getStatusHandler(httpd_req_t* req);
// Helper functions
static const char* getContentType(const char* filepath);
static esp_err_t sendFile(httpd_req_t* req, const char* filepath);
};

View File

@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SD Card File Manager</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#1a1a2e;color:#eee;min-height:100vh;padding:1rem}
.container{max-width:900px;margin:0 auto}
h1{color:#00d4ff;margin-bottom:1rem;font-size:1.5rem}
.status{background:#16213e;padding:0.75rem;border-radius:8px;margin-bottom:1rem;display:flex;gap:1rem;flex-wrap:wrap;align-items:center}
.status-item{display:flex;align-items:center;gap:0.5rem}
.status-dot{width:10px;height:10px;border-radius:50%}
.status-dot.ok{background:#0f0}
.status-dot.err{background:#f00}
.toolbar{display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap}
.btn{background:#0f3460;color:#fff;border:none;padding:0.5rem 1rem;border-radius:6px;cursor:pointer;font-size:0.9rem;transition:background 0.2s}
.btn:hover{background:#1a4f8c}
.btn:disabled{opacity:0.5;cursor:not-allowed}
.btn-danger{background:#c0392b}
.btn-danger:hover{background:#e74c3c}
.btn-small{padding:0.3rem 0.6rem;font-size:0.8rem}
.path-bar{background:#16213e;padding:0.75rem;border-radius:8px;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}
.path-segment{color:#00d4ff;cursor:pointer;text-decoration:underline}
.path-segment:hover{color:#fff}
.file-list{background:#16213e;border-radius:8px;overflow:hidden}
.file-item{display:flex;align-items:center;padding:0.75rem 1rem;border-bottom:1px solid #0f3460;cursor:pointer;transition:background 0.2s}
.file-item:hover{background:#1a3a5c}
.file-item.selected{background:#0f3460}
.file-item:last-child{border-bottom:none}
.file-icon{width:24px;margin-right:0.75rem;text-align:center;font-size:1.2rem}
.file-name{flex:1;word-break:break-all}
.file-size{color:#888;font-size:0.85rem;margin-left:1rem}
.file-actions{display:flex;gap:0.5rem}
.drop-zone{border:2px dashed #0f3460;border-radius:8px;padding:2rem;text-align:center;margin-bottom:1rem;transition:all 0.2s}
.drop-zone.drag-over{border-color:#00d4ff;background:rgba(0,212,255,0.1)}
.drop-zone input{display:none}
.modal{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);display:none;align-items:center;justify-content:center;z-index:100}
.modal.active{display:flex}
.modal-content{background:#16213e;padding:1.5rem;border-radius:8px;min-width:300px;max-width:90%}
.modal-content h2{margin-bottom:1rem;font-size:1.2rem}
.modal-content input{width:100%;padding:0.5rem;border:1px solid #0f3460;border-radius:4px;background:#1a1a2e;color:#fff;margin-bottom:1rem}
.modal-buttons{display:flex;gap:0.5rem;justify-content:flex-end}
.modal-editor{width:90vw;max-width:800px;height:80vh;display:flex;flex-direction:column}
.modal-editor h2{flex-shrink:0}
.modal-editor textarea{flex:1;width:100%;padding:0.75rem;border:1px solid #0f3460;border-radius:4px;background:#1a1a2e;color:#fff;font-family:monospace;font-size:0.9rem;resize:none;margin-bottom:1rem}
.modal-editor .modal-buttons{flex-shrink:0}
.progress{height:4px;background:#0f3460;border-radius:2px;margin-top:0.5rem;overflow:hidden;display:none}
.progress.active{display:block}
.progress-bar{height:100%;background:#00d4ff;width:0;transition:width 0.3s}
.toast{position:fixed;bottom:1rem;right:1rem;background:#0f3460;padding:1rem;border-radius:8px;max-width:300px;transform:translateY(100px);opacity:0;transition:all 0.3s}
.toast.active{transform:translateY(0);opacity:1}
.toast.error{background:#c0392b}
.empty{text-align:center;padding:2rem;color:#888}
@media(max-width:600px){.file-size{display:none}.toolbar{flex-direction:column}.btn{width:100%}}
</style>
</head>
<body>
<div class="container">
<h1>SD Card File Manager</h1>
<div class="status">
<div class="status-item"><span class="status-dot" id="sdStatus"></span><span id="sdText">SD Card</span></div>
<div class="status-item"><span class="status-dot" id="usbStatus"></span><span id="usbText">USB MSC</span></div>
</div>
<div class="drop-zone" id="dropZone">
<p>Dateien hier ablegen oder <label style="color:#00d4ff;cursor:pointer">ausw&auml;hlen<input type="file" id="fileInput" multiple></label></p>
<div class="progress" id="uploadProgress"><div class="progress-bar" id="uploadBar"></div></div>
</div>
<div class="toolbar">
<button class="btn" id="btnRefresh">Aktualisieren</button>
<button class="btn" id="btnNewFolder">Neuer Ordner</button>
<button class="btn" id="btnUp" disabled>Nach oben</button>
<button class="btn btn-danger" id="btnDelete" disabled>L&ouml;schen</button>
</div>
<div class="path-bar">Pfad: <span id="pathDisplay">/</span></div>
<div class="file-list" id="fileList"><div class="empty">Laden...</div></div>
</div>
<div class="modal" id="folderModal">
<div class="modal-content">
<h2>Neuer Ordner</h2>
<input type="text" id="folderName" placeholder="Ordnername">
<div class="modal-buttons">
<button class="btn" id="btnCancelFolder">Abbrechen</button>
<button class="btn" id="btnCreateFolder">Erstellen</button>
</div>
</div>
</div>
<div class="modal" id="editorModal">
<div class="modal-content modal-editor">
<h2 id="editorTitle">Datei bearbeiten</h2>
<textarea id="editorContent" spellcheck="false"></textarea>
<div class="modal-buttons">
<button class="btn" id="btnCancelEdit">Abbrechen</button>
<button class="btn" id="btnSaveEdit">Speichern</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const API={
list:p=>fetch('/api/files/list?path='+encodeURIComponent(p)).then(r=>r.json()),
upload:(p,f)=>{const fd=new FormData();fd.append('file',f);return fetch('/api/files/upload?path='+encodeURIComponent(p),{method:'POST',body:fd})},
download:f=>'/api/files/download?file='+encodeURIComponent(f),
delete:f=>fetch('/api/files/delete?file='+encodeURIComponent(f),{method:'DELETE'}),
mkdir:p=>fetch('/api/files/mkdir?path='+encodeURIComponent(p),{method:'POST'}),
status:()=>fetch('/api/status').then(r=>r.json()),
read:f=>fetch('/api/files/read?file='+encodeURIComponent(f)).then(r=>r.ok?r.text():Promise.reject(r)),
write:(f,c)=>fetch('/api/files/write?file='+encodeURIComponent(f),{method:'POST',body:c,headers:{'Content-Type':'text/plain'}})
};
const EDITABLE=['.txt','.json','.html','.htm','.css','.js','.xml','.md','.cfg','.ini','.csv','.log'];
let currentPath='/',selected=null,editingFile=null;
const $=id=>document.getElementById(id);
function isEditable(name){const n=name.toLowerCase();return EDITABLE.some(e=>n.endsWith(e))}
function toast(msg,err){const t=$('toast');t.textContent=msg;t.className='toast active'+(err?' error':'');setTimeout(()=>t.className='toast',3000)}
function formatSize(b){if(b<1024)return b+' B';if(b<1024*1024)return(b/1024).toFixed(1)+' KB';return(b/(1024*1024)).toFixed(1)+' MB'}
function updatePath(){
const parts=currentPath.split('/').filter(Boolean);
let html='<span class="path-segment" data-path="/">/</span>';
let p='';
parts.forEach(part=>{p+='/'+part;html+=' / <span class="path-segment" data-path="'+p+'">'+part+'</span>'});
$('pathDisplay').innerHTML=html;
$('pathDisplay').querySelectorAll('.path-segment').forEach(el=>el.onclick=()=>{currentPath=el.dataset.path||'/';loadFiles()});
$('btnUp').disabled=currentPath==='/';
}
function getFilePath(name){return currentPath+(currentPath==='/'?'':'/')+name}
async function loadFiles(){
selected=null;$('btnDelete').disabled=true;
try{
const data=await API.list(currentPath);
if(!data.success){toast(data.error||'Fehler',true);$('fileList').innerHTML='<div class="empty">'+(data.error||'Fehler beim Laden')+'</div>';return}
updatePath();
if(!data.files||data.files.length===0){$('fileList').innerHTML='<div class="empty">Verzeichnis ist leer</div>';return}
$('fileList').innerHTML=data.files.map(f=>{
const fp=getFilePath(f.name);
const canEdit=!f.isDir&&isEditable(f.name);
return `<div class="file-item" data-name="${f.name}" data-dir="${f.isDir}">
<span class="file-icon">${f.isDir?'📁':'📄'}</span>
<span class="file-name">${f.name}</span>
<span class="file-size">${f.isDir?'':formatSize(f.size)}</span>
<div class="file-actions">
${canEdit?'<button class="btn btn-small" onclick="editFile(\''+fp.replace(/'/g,"\\'")+'\');event.stopPropagation()">✏️</button>':''}
${f.isDir?'':'<a class="btn btn-small" href="'+API.download(fp)+'" download>⬇️</a>'}
</div></div>`}).join('');
$('fileList').querySelectorAll('.file-item').forEach(el=>{
el.onclick=e=>{
if(e.target.tagName==='A'||e.target.tagName==='BUTTON')return;
if(el.dataset.dir==='true'){currentPath+=(currentPath==='/'?'':'/')+el.dataset.name;loadFiles()}
else{$('fileList').querySelectorAll('.file-item').forEach(i=>i.classList.remove('selected'));el.classList.add('selected');selected=el.dataset.name;$('btnDelete').disabled=false}
}});
}catch(e){toast('Netzwerkfehler',true);$('fileList').innerHTML='<div class="empty">Verbindungsfehler</div>'}}
async function editFile(path){
editingFile=path;
$('editorTitle').textContent='Bearbeiten: '+path.split('/').pop();
$('editorContent').value='Laden...';
$('editorModal').classList.add('active');
try{
const content=await API.read(path);
$('editorContent').value=content;
}catch(e){
$('editorContent').value='Fehler beim Laden der Datei';
toast('Fehler beim Laden',true);
}}
async function saveFile(){
if(!editingFile)return;
try{
const r=await API.write(editingFile,$('editorContent').value);
const j=await r.json();
if(j.success){toast('Gespeichert');$('editorModal').classList.remove('active');editingFile=null;loadFiles()}
else toast(j.error||'Fehler beim Speichern',true);
}catch(e){toast('Fehler beim Speichern',true)}}
async function updateStatus(){
try{const s=await API.status();
$('sdStatus').className='status-dot '+(s.sdMounted?'ok':'err');
$('sdText').textContent='SD: '+(s.sdMounted?'OK':'Nicht eingebunden');
$('usbStatus').className='status-dot '+(s.usbMscActive?'ok':'err');
$('usbText').textContent='USB: '+(s.usbMscActive?'Aktiv':'Inaktiv');
}catch(e){}}
async function uploadFiles(files){
const bar=$('uploadBar'),prog=$('uploadProgress');
prog.classList.add('active');
for(let i=0;i<files.length;i++){
bar.style.width=((i/files.length)*100)+'%';
try{
const r=await API.upload(currentPath,files[i]);
const j=await r.json();
if(!j.success)toast('Upload fehlgeschlagen: '+files[i].name,true);
}catch(e){toast('Upload fehlgeschlagen: '+files[i].name,true)}}
bar.style.width='100%';
setTimeout(()=>{prog.classList.remove('active');bar.style.width='0'},500);
toast(files.length+' Datei(en) hochgeladen');
loadFiles()}
$('btnRefresh').onclick=()=>{loadFiles();updateStatus()};
$('btnUp').onclick=()=>{if(currentPath!=='/'){const parts=currentPath.split('/').filter(Boolean);parts.pop();currentPath='/'+parts.join('/');loadFiles()}};
$('btnNewFolder').onclick=()=>$('folderModal').classList.add('active');
$('btnCancelFolder').onclick=()=>{$('folderModal').classList.remove('active');$('folderName').value=''};
$('btnCreateFolder').onclick=async()=>{
const name=$('folderName').value.trim();
if(!name){toast('Bitte Namen eingeben',true);return}
try{const r=await API.mkdir(currentPath+(currentPath==='/'?'':'/')+name);const j=await r.json();
if(j.success){toast('Ordner erstellt');loadFiles()}else toast(j.error||'Fehler',true);
}catch(e){toast('Fehler',true)}
$('folderModal').classList.remove('active');$('folderName').value=''};
$('btnDelete').onclick=async()=>{
if(!selected||!confirm('Wirklich löschen: '+selected+'?'))return;
try{const r=await API.delete(currentPath+(currentPath==='/'?'':'/')+selected);const j=await r.json();
if(j.success){toast('Gelöscht');loadFiles()}else toast(j.error||'Fehler',true);
}catch(e){toast('Fehler',true)}};
$('btnCancelEdit').onclick=()=>{$('editorModal').classList.remove('active');editingFile=null};
$('btnSaveEdit').onclick=saveFile;
$('folderName').onkeydown=e=>{if(e.key==='Enter')$('btnCreateFolder').click()};
$('editorContent').onkeydown=e=>{if(e.ctrlKey&&e.key==='s'){e.preventDefault();saveFile()}};
const dz=$('dropZone');
dz.ondragover=e=>{e.preventDefault();dz.classList.add('drag-over')};
dz.ondragleave=()=>dz.classList.remove('drag-over');
dz.ondrop=e=>{e.preventDefault();dz.classList.remove('drag-over');if(e.dataTransfer.files.length)uploadFiles(e.dataTransfer.files)};
$('fileInput').onchange=e=>{if(e.target.files.length)uploadFiles(e.target.files);e.target.value=''};
window.editFile=editFile;
loadFiles();updateStatus();setInterval(updateStatus,10000);
</script>
</body>
</html>

View File

@ -0,0 +1,78 @@
#include "WebServer.hpp"
#include "../WidgetManager.hpp"
#include "esp_log.h"
#include <cstring>
static const char* TAG = "WebServer";
esp_err_t WebServer::getConfigHandler(httpd_req_t* req) {
char* buf = new char[8192];
if (buf == nullptr) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
WidgetManager::instance().getConfigJson(buf, 8192);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, buf, strlen(buf));
delete[] buf;
return ESP_OK;
}
esp_err_t WebServer::postConfigHandler(httpd_req_t* req) {
int total_len = req->content_len;
if (total_len > 8192) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Content too large");
return ESP_FAIL;
}
char* buf = new char[total_len + 1];
if (buf == nullptr) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int received = 0;
while (received < total_len) {
int ret = httpd_req_recv(req, buf + received, total_len - received);
if (ret <= 0) {
delete[] buf;
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Receive failed");
return ESP_FAIL;
}
received += ret;
}
buf[received] = '\0';
bool success = WidgetManager::instance().updateConfigFromJson(buf);
delete[] buf;
cJSON* json = cJSON_CreateObject();
if (success) {
cJSON_AddStringToObject(json, "status", "ok");
} else {
cJSON_AddStringToObject(json, "status", "error");
cJSON_AddStringToObject(json, "message", "Invalid JSON");
}
return sendJsonObject(req, json);
}
esp_err_t WebServer::postSaveHandler(httpd_req_t* req) {
ESP_LOGI(TAG, "Saving and applying configuration");
WidgetManager::instance().saveAndApply();
cJSON* json = cJSON_CreateObject();
cJSON_AddStringToObject(json, "status", "ok");
return sendJsonObject(req, json);
}
esp_err_t WebServer::postResetHandler(httpd_req_t* req) {
ESP_LOGI(TAG, "Resetting to defaults");
WidgetManager::instance().resetToDefaults();
cJSON* json = cJSON_CreateObject();
cJSON_AddStringToObject(json, "status", "ok");
return sendJsonObject(req, json);
}

View File

@ -0,0 +1,392 @@
#include "WebServer.hpp"
#include "../SdCard.hpp"
#include "esp_log.h"
#include <cstring>
#include <cstdio>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#include <errno.h>
static const char* TAG = "WebServer";
// Embedded file manager HTML (from Flash)
extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start");
extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end");
// Maximum upload size (2 MB)
static constexpr size_t MAX_UPLOAD_SIZE = 2 * 1024 * 1024;
// GET /files - Serve embedded file manager HTML
esp_err_t WebServer::fileManagerHandler(httpd_req_t* req) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
size_t len = filemanager_html_end - filemanager_html_start;
httpd_resp_send(req, (const char*)filemanager_html_start, len);
return ESP_OK;
}
// GET /api/files/list?path=/ - List directory contents
esp_err_t WebServer::filesListHandler(httpd_req_t* req) {
char path[128] = "/";
getQueryParam(req, "path", path, sizeof(path));
char fullPath[256];
snprintf(fullPath, sizeof(fullPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
DIR* dir = opendir(fullPath);
if (!dir) {
return sendJsonError(req, "Cannot open directory");
}
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", true);
cJSON_AddStringToObject(json, "path", path);
cJSON* files = cJSON_AddArrayToObject(json, "files");
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
char entryPath[512];
size_t fullPathLen = strlen(fullPath);
const char* sep = (fullPathLen > 0 && fullPath[fullPathLen-1] == '/') ? "" : "/";
snprintf(entryPath, sizeof(entryPath), "%.255s%s%.255s", fullPath, sep, entry->d_name);
struct stat st;
bool isDir = false;
size_t fileSize = 0;
if (stat(entryPath, &st) == 0) {
isDir = S_ISDIR(st.st_mode);
fileSize = st.st_size;
} else {
isDir = (entry->d_type == DT_DIR);
}
cJSON* fileObj = cJSON_CreateObject();
cJSON_AddStringToObject(fileObj, "name", entry->d_name);
cJSON_AddBoolToObject(fileObj, "isDir", isDir);
cJSON_AddNumberToObject(fileObj, "size", (double)fileSize);
cJSON_AddItemToArray(files, fileObj);
}
closedir(dir);
return sendJsonObject(req, json);
}
// POST /api/files/upload?path=/ - Upload file (multipart/form-data)
esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
char path[128] = "/";
getQueryParam(req, "path", path, sizeof(path));
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
if (req->content_len > MAX_UPLOAD_SIZE) {
return sendJsonError(req, "File too large (max 2MB)");
}
char contentType[128] = "";
if (httpd_req_get_hdr_value_str(req, "Content-Type", contentType, sizeof(contentType)) != ESP_OK) {
return sendJsonError(req, "Missing Content-Type");
}
char* boundaryStart = strstr(contentType, "boundary=");
if (!boundaryStart) {
return sendJsonError(req, "Invalid multipart boundary");
}
boundaryStart += 9;
char boundary[72];
snprintf(boundary, sizeof(boundary), "--%.68s", boundaryStart);
size_t contentLen = req->content_len;
char* fullData = new char[contentLen + 1];
if (!fullData) {
return sendJsonError(req, "Out of memory");
}
size_t totalReceived = 0;
while (totalReceived < contentLen) {
int ret = httpd_req_recv(req, fullData + totalReceived, contentLen - totalReceived);
if (ret <= 0) {
delete[] fullData;
return sendJsonError(req, "Receive failed");
}
totalReceived += ret;
}
fullData[totalReceived] = '\0';
char* filenameStart = strstr(fullData, "filename=\"");
if (!filenameStart) {
delete[] fullData;
return sendJsonError(req, "No filename found");
}
filenameStart += 10;
char* filenameEnd = strchr(filenameStart, '"');
if (!filenameEnd) {
delete[] fullData;
return sendJsonError(req, "Invalid filename");
}
size_t filenameLen = filenameEnd - filenameStart;
char filename[128];
if (filenameLen >= sizeof(filename)) filenameLen = sizeof(filename) - 1;
memcpy(filename, filenameStart, filenameLen);
filename[filenameLen] = '\0';
char* dataStart = strstr(filenameEnd, "\r\n\r\n");
if (!dataStart) {
delete[] fullData;
return sendJsonError(req, "Invalid multipart format");
}
dataStart += 4;
char endBoundary[80];
snprintf(endBoundary, sizeof(endBoundary), "\r\n%.72s", boundary);
char* dataEnd = strstr(dataStart, endBoundary);
if (!dataEnd) {
dataEnd = strstr(dataStart, boundary);
}
if (!dataEnd) {
delete[] fullData;
return sendJsonError(req, "End boundary not found");
}
size_t fileSize = dataEnd - dataStart;
char resolvedDir[384];
char targetDirPath[256];
snprintf(targetDirPath, sizeof(targetDirPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
const char* actualDir = targetDirPath;
if (resolveCaseInsensitivePath(targetDirPath, resolvedDir, sizeof(resolvedDir))) {
actualDir = resolvedDir;
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.250s/%.120s", actualDir, filename);
ESP_LOGI(TAG, "Uploading: %s (%zu bytes)", fullPath, fileSize);
struct stat dirStat;
if (stat(actualDir, &dirStat) != 0) {
if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) {
delete[] fullData;
return sendJsonError(req, "Cannot create directory");
}
}
FILE* f = fopen(fullPath, "wb");
if (!f) {
ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno);
delete[] fullData;
return sendJsonError(req, "Cannot create file");
}
size_t written = fwrite(dataStart, 1, fileSize, f);
fclose(f);
delete[] fullData;
if (written != fileSize) {
return sendJsonError(req, "Write failed");
}
ESP_LOGI(TAG, "Upload complete: %s", fullPath);
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", true);
cJSON_AddStringToObject(json, "filename", filename);
cJSON_AddNumberToObject(json, "size", (double)fileSize);
return sendJsonObject(req, json);
}
// GET /api/files/download?file=/path/to/file - Download file
esp_err_t WebServer::filesDownloadHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing file parameter");
return ESP_FAIL;
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
const char* basename = strrchr(file, '/');
basename = basename ? basename + 1 : file;
char disposition[256];
snprintf(disposition, sizeof(disposition), "attachment; filename=\"%.200s\"", basename);
httpd_resp_set_hdr(req, "Content-Disposition", disposition);
return sendFile(req, fullPath);
}
// DELETE /api/files/delete?file=/path/to/file - Delete file or empty directory
esp_err_t WebServer::filesDeleteHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
return sendJsonError(req, "Missing file parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
ESP_LOGI(TAG, "Deleting: %s", fullPath);
struct stat st;
if (stat(fullPath, &st) != 0) {
return sendJsonError(req, "File not found");
}
int result;
if (S_ISDIR(st.st_mode)) {
result = rmdir(fullPath);
} else {
result = unlink(fullPath);
}
if (result == 0) {
return sendJsonSuccess(req);
}
return sendJsonError(req, "Delete failed (directory not empty?)");
}
// POST /api/files/mkdir?path=/path/to/new/dir - Create directory
esp_err_t WebServer::filesMkdirHandler(httpd_req_t* req) {
char path[256] = "";
if (!getQueryParam(req, "path", path, sizeof(path)) || strlen(path) == 0) {
return sendJsonError(req, "Missing path parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
char fullPath[384];
snprintf(fullPath, sizeof(fullPath), "%.7s%.255s", SdCard::MOUNT_POINT, path);
ESP_LOGI(TAG, "Creating directory: %s", fullPath);
if (mkdir(fullPath, 0755) == 0) {
return sendJsonSuccess(req);
}
return sendJsonError(req, "Cannot create directory");
}
// GET /api/files/read?file=/path/to/file - Read text file content for editor
esp_err_t WebServer::filesReadHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
return sendJsonError(req, "Missing file parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
char targetPath[384];
snprintf(targetPath, sizeof(targetPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
char resolvedPath[384];
const char* actualPath = targetPath;
if (resolveCaseInsensitivePath(targetPath, resolvedPath, sizeof(resolvedPath))) {
actualPath = resolvedPath;
}
struct stat st;
if (stat(actualPath, &st) != 0) {
return sendJsonError(req, "File not found");
}
if (st.st_size > 65536) {
return sendJsonError(req, "File too large for editor (max 64KB)");
}
FILE* f = fopen(actualPath, "r");
if (!f) {
return sendJsonError(req, "Cannot open file");
}
char* content = new char[st.st_size + 1];
if (!content) {
fclose(f);
return sendJsonError(req, "Out of memory");
}
size_t bytesRead = fread(content, 1, st.st_size, f);
fclose(f);
content[bytesRead] = '\0';
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, content, bytesRead);
delete[] content;
return ESP_OK;
}
// POST /api/files/write?file=/path/to/file - Write text file content from editor
esp_err_t WebServer::filesWriteHandler(httpd_req_t* req) {
char file[256] = "";
if (!getQueryParam(req, "file", file, sizeof(file)) || strlen(file) == 0) {
return sendJsonError(req, "Missing file parameter");
}
if (!SdCard::instance().isMounted()) {
return sendJsonError(req, "SD card not mounted");
}
if (req->content_len > 65536) {
return sendJsonError(req, "Content too large (max 64KB)");
}
char targetPath[384];
snprintf(targetPath, sizeof(targetPath), "%.7s%.255s", SdCard::MOUNT_POINT, file);
char resolvedPath[384];
const char* actualPath = targetPath;
if (resolveCaseInsensitivePath(targetPath, resolvedPath, sizeof(resolvedPath))) {
actualPath = resolvedPath;
}
char* content = new char[req->content_len + 1];
if (!content) {
return sendJsonError(req, "Out of memory");
}
size_t received = 0;
while (received < req->content_len) {
int ret = httpd_req_recv(req, content + received, req->content_len - received);
if (ret <= 0) {
delete[] content;
return sendJsonError(req, "Receive failed");
}
received += ret;
}
content[received] = '\0';
ESP_LOGI(TAG, "Writing file: %s (%zu bytes)", actualPath, received);
FILE* f = fopen(actualPath, "w");
if (!f) {
delete[] content;
return sendJsonError(req, "Cannot create file");
}
size_t written = fwrite(content, 1, received, f);
fclose(f);
delete[] content;
if (written != received) {
return sendJsonError(req, "Write incomplete");
}
return sendJsonSuccess(req);
}

View File

@ -0,0 +1,35 @@
#include "WebServer.hpp"
#include "../KnxWorker.hpp"
#include "../Gui.hpp"
#include <cstring>
esp_err_t WebServer::getKnxAddressesHandler(httpd_req_t* req) {
KnxWorker& knxWorker = Gui::knxWorker;
size_t count = knxWorker.getGroupObjectCount();
cJSON* arr = cJSON_CreateArray();
for (size_t i = 1; i <= count; i++) {
KnxGroupObjectInfo info;
if (knxWorker.getGroupObjectInfo(i, info)) {
char addrStr[16];
KnxWorker::formatGroupAddress(info.groupAddress, addrStr, sizeof(addrStr));
cJSON* obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "index", info.goIndex);
cJSON_AddNumberToObject(obj, "addr", info.groupAddress);
cJSON_AddStringToObject(obj, "addrStr", addrStr);
cJSON_AddBoolToObject(obj, "comm", info.commFlag);
cJSON_AddBoolToObject(obj, "read", info.readFlag);
cJSON_AddBoolToObject(obj, "write", info.writeFlag);
cJSON_AddItemToArray(arr, obj);
}
}
char* str = cJSON_PrintUnformatted(arr);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, str, strlen(str));
free(str);
cJSON_Delete(arr);
return ESP_OK;
}

View File

@ -0,0 +1,35 @@
#include "WebServer.hpp"
#include "../SdCard.hpp"
#include "esp_log.h"
#include <cstring>
static const char* TAG = "WebServer";
esp_err_t WebServer::rootHandler(httpd_req_t* req) {
if (!SdCard::instance().isMounted()) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
const char* errorPage = "<!DOCTYPE html><html><body><h1>SD Card Error</h1>"
"<p>SD card not mounted. Please insert SD card with /webseite/index.html</p>"
"<p><a href=\"/files\">Open File Manager</a></p></body></html>";
httpd_resp_send(req, errorPage, strlen(errorPage));
return ESP_OK;
}
return sendFile(req, "/sdcard/webseite/index.html");
}
esp_err_t WebServer::staticFileHandler(httpd_req_t* req) {
char filepath[CONFIG_HTTPD_MAX_URI_LEN + 8];
snprintf(filepath, sizeof(filepath), "/sdcard%.*s",
(int)(sizeof(filepath) - 8), req->uri);
return sendFile(req, filepath);
}
esp_err_t WebServer::imagesHandler(httpd_req_t* req) {
char filepath[CONFIG_HTTPD_MAX_URI_LEN + 8];
snprintf(filepath, sizeof(filepath), "/sdcard%.*s",
(int)(sizeof(filepath) - 8), req->uri);
return sendFile(req, filepath);
}

View File

@ -0,0 +1,29 @@
#include "WebServer.hpp"
#include "../SdCard.hpp"
#include "esp_log.h"
#include <cstring>
static const char* TAG = "WebServer";
esp_err_t WebServer::postUsbModeHandler(httpd_req_t* req) {
ESP_LOGI(TAG, "Enabling USB Mass Storage mode");
bool success = SdCard::instance().enableUsbMsc();
cJSON* json = cJSON_CreateObject();
if (success) {
cJSON_AddStringToObject(json, "status", "ok");
cJSON_AddStringToObject(json, "message", "USB MSC enabled. Connect USB cable to access SD card. Reboot to return to normal mode.");
} else {
cJSON_AddStringToObject(json, "status", "error");
cJSON_AddStringToObject(json, "message", "Failed to enable USB MSC");
}
return sendJsonObject(req, json);
}
esp_err_t WebServer::getStatusHandler(httpd_req_t* req) {
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "sdMounted", SdCard::instance().isMounted());
cJSON_AddBoolToObject(json, "usbMscActive", SdCard::instance().isUsbMscActive());
return sendJsonObject(req, json);
}

View File

@ -0,0 +1,306 @@
#include "WebServer.hpp"
#include "../SdCard.hpp"
#include "esp_log.h"
#include <cstring>
#include <sys/stat.h>
#include <dirent.h>
static const char* TAG = "WebServer";
WebServer& WebServer::instance() {
static WebServer instance;
return instance;
}
WebServer::~WebServer() {
stop();
}
void WebServer::start() {
if (server_ != nullptr) {
ESP_LOGW(TAG, "Server already running");
return;
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
config.max_uri_handlers = 20;
ESP_LOGI(TAG, "Starting HTTP server on port %d", config.server_port);
if (httpd_start(&server_, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return;
}
// Static file routes
httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = rootHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &root);
httpd_uri_t webseite = { .uri = "/webseite/*", .method = HTTP_GET, .handler = staticFileHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &webseite);
httpd_uri_t images = { .uri = "/images/*", .method = HTTP_GET, .handler = imagesHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &images);
// Config routes
httpd_uri_t getConfig = { .uri = "/api/config", .method = HTTP_GET, .handler = getConfigHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &getConfig);
httpd_uri_t postConfig = { .uri = "/api/config", .method = HTTP_POST, .handler = postConfigHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &postConfig);
httpd_uri_t postSave = { .uri = "/api/save", .method = HTTP_POST, .handler = postSaveHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &postSave);
httpd_uri_t postReset = { .uri = "/api/reset", .method = HTTP_POST, .handler = postResetHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &postReset);
// KNX routes
httpd_uri_t getKnxAddresses = { .uri = "/api/knx/addresses", .method = HTTP_GET, .handler = getKnxAddressesHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &getKnxAddresses);
// Status routes
httpd_uri_t postUsbMode = { .uri = "/api/usb-mode", .method = HTTP_POST, .handler = postUsbModeHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &postUsbMode);
httpd_uri_t getStatus = { .uri = "/api/status", .method = HTTP_GET, .handler = getStatusHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &getStatus);
// File manager routes
httpd_uri_t fileManager = { .uri = "/files", .method = HTTP_GET, .handler = fileManagerHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &fileManager);
httpd_uri_t filesList = { .uri = "/api/files/list", .method = HTTP_GET, .handler = filesListHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesList);
httpd_uri_t filesUpload = { .uri = "/api/files/upload", .method = HTTP_POST, .handler = filesUploadHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesUpload);
httpd_uri_t filesDownload = { .uri = "/api/files/download", .method = HTTP_GET, .handler = filesDownloadHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesDownload);
httpd_uri_t filesDelete = { .uri = "/api/files/delete", .method = HTTP_DELETE, .handler = filesDeleteHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesDelete);
httpd_uri_t filesMkdir = { .uri = "/api/files/mkdir", .method = HTTP_POST, .handler = filesMkdirHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesMkdir);
httpd_uri_t filesRead = { .uri = "/api/files/read", .method = HTTP_GET, .handler = filesReadHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesRead);
httpd_uri_t filesWrite = { .uri = "/api/files/write", .method = HTTP_POST, .handler = filesWriteHandler, .user_ctx = nullptr };
httpd_register_uri_handler(server_, &filesWrite);
ESP_LOGI(TAG, "HTTP server started successfully");
}
void WebServer::stop() {
if (server_ != nullptr) {
httpd_stop(server_);
server_ = nullptr;
ESP_LOGI(TAG, "HTTP server stopped");
}
}
// ============================================================================
// Shared Utility Functions
// ============================================================================
const char* WebServer::getContentType(const char* filepath) {
const char* ext = strrchr(filepath, '.');
if (!ext) return "application/octet-stream";
if (strcasecmp(ext, ".html") == 0 || strcasecmp(ext, ".htm") == 0) return "text/html; charset=utf-8";
if (strcasecmp(ext, ".css") == 0) return "text/css";
if (strcasecmp(ext, ".js") == 0) return "application/javascript";
if (strcasecmp(ext, ".json") == 0) return "application/json";
if (strcasecmp(ext, ".png") == 0) return "image/png";
if (strcasecmp(ext, ".jpg") == 0 || strcasecmp(ext, ".jpeg") == 0) return "image/jpeg";
if (strcasecmp(ext, ".gif") == 0) return "image/gif";
if (strcasecmp(ext, ".svg") == 0) return "image/svg+xml";
if (strcasecmp(ext, ".ico") == 0) return "image/x-icon";
return "application/octet-stream";
}
// Case-insensitive path resolution for FAT filesystem
bool resolveCaseInsensitivePath(const char* requestedPath, char* resolvedPath, size_t maxLen) {
strncpy(resolvedPath, SdCard::MOUNT_POINT, maxLen);
resolvedPath[maxLen - 1] = '\0';
const char* relPath = requestedPath;
if (strncmp(requestedPath, SdCard::MOUNT_POINT, strlen(SdCard::MOUNT_POINT)) == 0) {
relPath = requestedPath + strlen(SdCard::MOUNT_POINT);
}
if (!relPath || !*relPath || (relPath[0] == '/' && !relPath[1])) {
return true;
}
char component[256];
const char* start = relPath;
while (*start) {
while (*start == '/') start++;
if (!*start) break;
const char* end = start;
while (*end && *end != '/') end++;
size_t compLen = end - start;
if (compLen >= sizeof(component)) compLen = sizeof(component) - 1;
memcpy(component, start, compLen);
component[compLen] = '\0';
DIR* dir = opendir(resolvedPath);
if (!dir) return false;
bool found = false;
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
if (strcasecmp(entry->d_name, component) == 0) {
size_t curLen = strlen(resolvedPath);
snprintf(resolvedPath + curLen, maxLen - curLen, "/%s", entry->d_name);
found = true;
break;
}
}
closedir(dir);
if (!found) return false;
start = end;
}
return true;
}
esp_err_t WebServer::sendFile(httpd_req_t* req, const char* filepath) {
char resolvedPath[384];
const char* actualPath = filepath;
struct stat st;
if (stat(filepath, &st) != 0) {
if (resolveCaseInsensitivePath(filepath, resolvedPath, sizeof(resolvedPath))) {
if (stat(resolvedPath, &st) == 0) {
actualPath = resolvedPath;
}
}
}
if (stat(actualPath, &st) != 0) {
ESP_LOGE(TAG, "File not found: %s", filepath);
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
return ESP_FAIL;
}
FILE* f = fopen(actualPath, "r");
if (!f) {
ESP_LOGE(TAG, "Failed to open file: %s", actualPath);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to open file");
return ESP_FAIL;
}
httpd_resp_set_type(req, getContentType(actualPath));
char* chunk = new char[1024];
if (!chunk) {
fclose(f);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
size_t read;
while ((read = fread(chunk, 1, 1024, f)) > 0) {
if (httpd_resp_send_chunk(req, chunk, read) != ESP_OK) {
delete[] chunk;
fclose(f);
return ESP_FAIL;
}
}
delete[] chunk;
fclose(f);
httpd_resp_send_chunk(req, nullptr, 0);
return ESP_OK;
}
// URL decode helper (in-place)
void urlDecode(char* str) {
char* dst = str;
char* src = str;
while (*src) {
if (*src == '%' && src[1] && src[2]) {
int hi = src[1];
int lo = src[2];
hi = (hi >= '0' && hi <= '9') ? hi - '0' :
(hi >= 'A' && hi <= 'F') ? hi - 'A' + 10 :
(hi >= 'a' && hi <= 'f') ? hi - 'a' + 10 : -1;
lo = (lo >= '0' && lo <= '9') ? lo - '0' :
(lo >= 'A' && lo <= 'F') ? lo - 'A' + 10 :
(lo >= 'a' && lo <= 'f') ? lo - 'a' + 10 : -1;
if (hi >= 0 && lo >= 0) {
*dst++ = (char)((hi << 4) | lo);
src += 3;
continue;
}
} else if (*src == '+') {
*dst++ = ' ';
src++;
continue;
}
*dst++ = *src++;
}
*dst = '\0';
}
bool WebServer::getQueryParam(httpd_req_t* req, const char* key, char* value, size_t maxLen) {
size_t queryLen = httpd_req_get_url_query_len(req);
if (queryLen == 0) return false;
char* query = new char[queryLen + 1];
if (!query) return false;
if (httpd_req_get_url_query_str(req, query, queryLen + 1) != ESP_OK) {
delete[] query;
return false;
}
esp_err_t ret = httpd_query_key_value(query, key, value, maxLen);
delete[] query;
if (ret == ESP_OK) {
urlDecode(value);
return true;
}
return false;
}
// JSON response helpers
esp_err_t WebServer::sendJsonObject(httpd_req_t* req, cJSON* json) {
char* str = cJSON_PrintUnformatted(json);
if (!str) {
cJSON_Delete(json);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "JSON print failed");
return ESP_FAIL;
}
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, str, strlen(str));
free(str);
cJSON_Delete(json);
return ESP_OK;
}
esp_err_t WebServer::sendJsonError(httpd_req_t* req, const char* error) {
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", false);
cJSON_AddStringToObject(json, "error", error);
return sendJsonObject(req, json);
}
esp_err_t WebServer::sendJsonSuccess(httpd_req_t* req) {
cJSON* json = cJSON_CreateObject();
cJSON_AddBoolToObject(json, "success", true);
return sendJsonObject(req, json);
}

View File

@ -0,0 +1,66 @@
#pragma once
#include "esp_http_server.h"
#include "cJSON.h"
class WebServer {
public:
static WebServer& instance();
void start();
void stop();
bool isRunning() const { return server_ != nullptr; }
private:
WebServer() = default;
~WebServer();
WebServer(const WebServer&) = delete;
WebServer& operator=(const WebServer&) = delete;
httpd_handle_t server_ = nullptr;
// Static file handlers (StaticFileHandlers.cpp)
static esp_err_t rootHandler(httpd_req_t* req);
static esp_err_t staticFileHandler(httpd_req_t* req);
static esp_err_t imagesHandler(httpd_req_t* req);
// Config handlers (ConfigHandlers.cpp)
static esp_err_t getConfigHandler(httpd_req_t* req);
static esp_err_t postConfigHandler(httpd_req_t* req);
static esp_err_t postSaveHandler(httpd_req_t* req);
static esp_err_t postResetHandler(httpd_req_t* req);
// KNX handlers (KnxHandlers.cpp)
static esp_err_t getKnxAddressesHandler(httpd_req_t* req);
// Status handlers (StatusHandlers.cpp)
static esp_err_t postUsbModeHandler(httpd_req_t* req);
static esp_err_t getStatusHandler(httpd_req_t* req);
// File manager handlers (FileManagerHandlers.cpp)
static esp_err_t fileManagerHandler(httpd_req_t* req);
static esp_err_t filesListHandler(httpd_req_t* req);
static esp_err_t filesUploadHandler(httpd_req_t* req);
static esp_err_t filesDownloadHandler(httpd_req_t* req);
static esp_err_t filesDeleteHandler(httpd_req_t* req);
static esp_err_t filesMkdirHandler(httpd_req_t* req);
static esp_err_t filesReadHandler(httpd_req_t* req);
static esp_err_t filesWriteHandler(httpd_req_t* req);
public:
// Shared utility functions (used by handlers)
static const char* getContentType(const char* filepath);
static esp_err_t sendFile(httpd_req_t* req, const char* filepath);
static bool getQueryParam(httpd_req_t* req, const char* key, char* value, size_t maxLen);
// JSON response helpers
static esp_err_t sendJsonObject(httpd_req_t* req, cJSON* json);
static esp_err_t sendJsonError(httpd_req_t* req, const char* error);
static esp_err_t sendJsonSuccess(httpd_req_t* req);
};
// Case-insensitive path resolution for FAT filesystem
bool resolveCaseInsensitivePath(const char* requestedPath, char* resolvedPath, size_t maxLen);
// URL decode helper
void urlDecode(char* str);