/* * This file is part of the "bluetoothheater" distribution * (https://gitlab.com/mrjones.id.au/bluetoothheater) * * Copyright (C) 2018 Ray Jones * Copyright (C) 2018 James Clark * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * */ #define USE_EMBEDDED_WEBUPDATECODE #include #include "BTCWifi.h" #include "BTCWebServer.h" #include "BTCota.h" #include "../Utility/DebugPort.h" #include "../Protocol/TxManage.h" #include "../Utility/helpers.h" #include "../cfg/pins.h" #include "../cfg/BTCConfig.h" #include "../Utility/BTC_JSON.h" #include "../Utility/Moderator.h" #include "../../lib/WiFiManager-dev/WiFiManager.h" #include #include "../Utility/NVStorage.h" #include #include #include #include "BrowserUpload.h" #include #include "WebContentDL.h" extern WiFiManager wm; extern const char* stdHeader; extern const char* formatIndex; extern const char* updateIndex; extern const char* formatDoneContent; extern const char* rebootIndex; extern void checkSplashScreenUpdate(); sBrowserUpload BrowserUpload; WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(81); CGetWebContent GetWebContent; bool bRxWebData = false; // flags for OLED animation bool bTxWebData = false; bool bUpdateAccessed = false; // flag used to ensure browser update always starts via /update. direct accesses to /updatenow will FAIL bool bFormatAccessed = false; bool bFormatPerformed = false; long _SuppliedFileSize = 0; void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length); bool checkFile(File &file); void addTableData(String& HTML, String dta); void rootRedirect(); String getContentType(String filename); bool handleFileRead(String path); void onNotFound(); void onErase(); void onFormatSPIFFS(); void onFormatNow(); void onFormatDone(); void onReboot(); void onDoReboot(); void onWMConfig(); void onResetWifi(); void onUploadBegin(); void onUploadCompletion(); void onUploadProgression(); void onRename(); void build404Response(String& content, String file); void build500Response(String& content, String file); const char* getWebContent(bool start) { if(isWifiSTAConnected() && start) { GetWebContent.start(); } return GetWebContent.getFilename(); } void initWebServer(void) { if (MDNS.begin("Afterburner")) { DebugPort.println("MDNS responder started"); } server.on("/wmconfig", onWMConfig); server.on("/resetwifi", onResetWifi); server.on("/erase", HTTP_POST, onErase); // erase file from SPIFFS // Magical code originally shamelessly lifted from Arduino WebUpdate example, then greatly modified // This allows pushing new firmware to the ESP from a WEB BROWSER! // Added authentication and a sequencing flag to ensure this is not bypassed // You can also upload files to SPIFFS via this same portal // // Initial launch page server.on("/update", HTTP_GET, onUploadBegin); // handle attempts to browse the /updatenow path - force redirect to root server.on("/updatenow", HTTP_GET, []() { DebugPort.println("WEB: GET /updatenow - ILLEGAL - root redirect"); rootRedirect(); }); // valid upload attempts must use post, AND they must have also passed thru /update (bUpdateAccessed = true) server.on("/updatenow", HTTP_POST, onUploadCompletion, onUploadProgression); // SPIFFS formatting server.on("/formatspiffs", HTTP_GET, onFormatSPIFFS); server.on("/formatnow", HTTP_GET, []() { // deny browse access DebugPort.println("WEB: GET /formatnow - ILLEGAL - root redirect"); rootRedirect(); }); server.on("/formatnow", HTTP_POST, onFormatNow); // access via POST is legal, but only if bFormatAccess == true server.on("/reboot", HTTP_GET, onReboot); // access via POST is legal, but only if bFormatAccess == true server.on("/reboot", HTTP_POST, onDoReboot); // access via POST is legal, but only if bFormatAccess == true server.on("/rename", HTTP_POST, onRename); // access via POST is legal, but only if bFormatAccess == true // NOTE: this serves the default home page, and favicon.ico server.onNotFound([]() { // If the client requests any URI if (!handleFileRead(server.uri())) { // send it if it exists onNotFound(); } }); server.begin(); webSocket.begin(); webSocket.onEvent(webSocketEvent); DebugPort.println("HTTP server started"); // initWebPageUpdate(); } // called by main sketch loop() bool doWebServer(void) { webSocket.loop(); server.handleClient(); GetWebContent.manage(); return true; } String getContentType(String filename) { // convert the file extension to the MIME type if (filename.endsWith(".html")) return "text/html"; else if (filename.endsWith(".css")) return "text/css"; else if (filename.endsWith(".js")) return "application/javascript"; else if (filename.endsWith(".ico")) return "image/x-icon"; else if (filename.endsWith(".bin")) return "application/octet-stream"; else if (filename.endsWith(".zip")) return "application/x-zip"; else if (filename.endsWith(".gz")) return "application/x-gzip"; return "text/plain"; } bool handleFileRead(String path) { // send the right file to the client (if it exists) DebugPort.println("handleFileRead: " + path); if (path.endsWith("/")) path += "index.html"; // If a folder is requested, send the index file if(path.indexOf("index.html") >= 0) { sCredentials creds = NVstore.getCredentials(); if (!server.authenticate(creds.webUsername, creds.webPassword)) { server.requestAuthentication(); return true; // not entirely correct, but avoids 404 response } } path.replace("%20", " "); // convert HTML spaces to normal spaces String contentType = getContentType(path); // Get the MIME type String pathWithGz = path + ".gz"; if(SPIFFS.exists(pathWithGz) || SPIFFS.exists(path)) { // If the file exists as a compressed archive, or normal if (SPIFFS.exists(pathWithGz)) // If the compressed file exists path += ".gz"; File file = SPIFFS.open(path, "r"); // Open it if(!checkFile(file)) { // check it is readable file.close(); // if not, close the file } if(!file) { DebugPort.println("\tFile exists, but could not be read?"); // dodgy file - throw error back to client String content; build500Response(content, path); server.send(500, "text/html", content); return false; // If the file is broken, return false } else { server.streamFile(file, contentType); // File good, send it to the client file.close(); // Then close the file return true; } } DebugPort.println("\tFile Not Found"); return false; // If the file doesn't exist, return false } const char* stdHeader = R"=====( )====="; const char* updateIndex = R"=====( Afterburner update

Afterburner update

)====="; void onWMConfig() { DebugPort.println("WEB: GET /wmconfig"); server.send(200, "text/plain", "Start Config Portal - Retaining credential"); DebugPort.println("Starting web portal for wifi config"); delay(500); wifiEnterConfigPortal(true, false, 3000); } void onResetWifi() { DebugPort.println("WEB: GET /resetwifi"); server.send(200, "text/plain", "Start Config Portal - Resetting Wifi credentials!"); DebugPort.println("diconnecting client and wifi, then rebooting"); delay(500); wifiEnterConfigPortal(true, true, 3000); } void onNotFound() { String path = server.uri(); if (path.endsWith("/")) path += "index.html"; // If a folder is requested, send the index file String message; build404Response(message, path); server.send(404, "text/html", message); } void rootRedirect() { server.sendHeader("Location","/"); // reselect the update page server.send(303); } bool sendWebSocketString(const char* Str) { #ifdef WEBTIMES CProfile profile; #endif if(webSocket.connectedClients()) { #ifdef WEBTIMES unsigned long tCon = profile.elapsed(true); #endif bTxWebData = true; // OLED tx data animation flag bool retval = webSocket.broadcastTXT(Str); #ifdef WEBTIMES unsigned long tWeb = profile.elapsed(true); DebugPort.printf("Websend times : %ld,%ld\r\n", tCon, tWeb); #endif feedWatchdog(); return retval; } return false; } void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) { if (type == WStype_TEXT) { bRxWebData = true; char cmd[256]; memset(cmd, 0, 256); for (int i = 0; i < length && i < 256; i++) { cmd[i] = payload[i]; } interpretJsonCommand(cmd); // send to the main heater controller decode routine } } bool isWebSocketClientChange() { static int prevNumClients = -1; int numClients = webSocket.connectedClients(); if(numClients != prevNumClients) { bool retval = numClients > prevNumClients; prevNumClients = numClients; if(retval) { DebugPort.println("Increased number of web socket clients, should reset JSON moderator"); return true; } } return false; } bool hasWebClientSpoken(bool reset) { bool retval = bRxWebData; if(reset) bRxWebData = false; return retval; } bool hasWebServerSpoken(bool reset) { bool retval = bTxWebData; if(reset) bTxWebData = false; return retval; } void setUploadSize(long val) { _SuppliedFileSize = val; }; // Sometimes SPIFFS gets corrupted (WTF?) // When this happens, you can see the files exist, but you cannot read them // This routine checks the file is readable. // Typical failure mechanism is read returns 0, and the WifiClient upload never progresses // The software watchdog then steps in after 15 seconds of that nonsense bool checkFile(File &file) { uint8_t buf[128]; bool bOK = true; size_t available = file.available(); while(available) { int toRead = (available > 128) ? 128 : available; int Read = file.read(buf, toRead); if(Read != toRead) { bOK = false; DebugPort.printf("SPIFFS precautionary file check failed for %s\r\n", file.name()); break; } available = file.available(); } file.seek(0); return bOK; } void listSPIFFS(const char * dirname, uint8_t levels, String& HTMLreport, int withHTMLanchors) { char msg[128]; File root = SPIFFS.open(dirname); if (!root) { sprintf(msg, "Failed to open directory \"%s\"", dirname); DebugPort.println(msg); HTMLreport += msg; HTMLreport += "
"; return; } if (!root.isDirectory()) { sprintf(msg, "\"%s\" is not a directory", dirname); DebugPort.println(msg); HTMLreport += msg; HTMLreport += "
"; return; } HTMLreport += "

Current SPIFFS contents:

"; // create HTML table header HTMLreport += R"=====( )====="; File file = root.openNextFile(); while (file) { HTMLreport += "\n"; if (file.isDirectory()) { addTableData(HTMLreport, "DIR"); addTableData(HTMLreport, file.name()); addTableData(HTMLreport, ""); addTableData(HTMLreport, ""); addTableData(HTMLreport, ""); sprintf(msg, " DIR : %s", file.name()); DebugPort.println(msg); if (levels) { listSPIFFS(file.name(), levels - 1, HTMLreport); } } else { String fn = file.name(); String ers; String rename; if(withHTMLanchors == 2) { String htmlNm = fn; htmlNm.replace(" ", "%20"); rename = ""; ers = ""; } if(withHTMLanchors) { String fn2; if(fn.endsWith(".html")) { // can hyperlink .html files fn2 = fn; } else if(fn.endsWith(".html.gz")) { // we can hyperlink .html.gz files but we must strip .gz extension for // the hyperlink otherwise you get asked if you want to download the .gz, not view web page! fn2 = fn; fn2.remove(fn2.length()-3, 3); // strip trailing ".gz" } if(fn2.length() != 0) { fn2.replace(" ", "%20"); // create hyperlink if web page file fn = "" + file.name() + ""; } } String sz( int(file.size())); addTableData(HTMLreport, ""); addTableData(HTMLreport, fn); addTableData(HTMLreport, sz); addTableData(HTMLreport, rename); addTableData(HTMLreport, ers); sprintf(msg, " FILE: %s SIZE: %d", fn.c_str(), file.size()); DebugPort.println(msg); } HTMLreport += "\n"; file = root.openNextFile(); } HTMLreport += "
Name Size
\n"; if(withHTMLanchors) { char usage[128]; int used = SPIFFS.usedBytes(); int total = SPIFFS.totalBytes(); float percent = used * 100. / total; sprintf(usage, "

Usage
%d / %d bytes (%.1f%%)\n

", used, total, percent); HTMLreport += usage; } } void addTableData(String& HTML, String dta) { HTML += ""; HTML += dta; HTML += "\n"; } // erase a file from SPIFFS partition void onErase() { String filename = server.arg("filename"); // get request argument value by name filename.replace("%20", " "); // convert HTML spaces to real spaces if(filename.length() != 0) { DebugPort.printf("onErase: %s ", filename.c_str()); if(SPIFFS.exists(filename.c_str())) { SPIFFS.remove(filename.c_str()); DebugPort.println("ERASED\r\n"); } else DebugPort.println("NOT FOUND\r\n"); } } // function called upon completion of file (form) upload void onUploadCompletion() { _SuppliedFileSize = 0; DebugPort.println("WEB: POST /updatenow completion"); // completion functionality if(BrowserUpload.isSPIFFSupload()) { if(BrowserUpload.isOK()) { checkSplashScreenUpdate(); DebugPort.println("WEB: SPIFFS OK"); server.send(200, "text/plain", "OK - File uploaded to SPIFFS"); // javascript reselects the /update page! } else { DebugPort.println("WEB: SPIFFS FAIL"); server.send(500, "text/plain", "500: couldn't create file"); } BrowserUpload.reset(); ShowOTAScreen(-1, eOTAbrowser); // browser update } else { if(BrowserUpload.isOK()) { DebugPort.println("WEB: FIRMWARE UPDATE OK"); server.send(200, "text/plain", "OK - Afterburner will reboot shortly"); } else { DebugPort.println("WEB: FIRMWARE UPDATE FAIL"); server.send(200, "text/plain", "FAIL - Afterburner will reboot shortly"); } forceBootInit(); delay(1000); // javascript redirects to root page so we go there after reboot! ESP.restart(); // reboot } } void onUploadBegin() { DebugPort.println("WEB: GET /update"); sCredentials creds = NVstore.getCredentials(); if (!server.authenticate(creds.webUpdateUsername, creds.webUpdatePassword)) { return server.requestAuthentication(); } bUpdateAccessed = true; bFormatAccessed = false; bFormatPerformed = false; #ifdef USE_EMBEDDED_WEBUPDATECODE String SPIFFSinfo; listSPIFFS("/", 2, SPIFFSinfo, 2); String content = stdHeader; content += updateIndex + SPIFFSinfo; content += "

"; content += ""; server.send(200, "text/html", content ); #else handleFileRead("/uploadfirmware.html"); #endif } void onUploadProgression() { char JSON[64]; if(bUpdateAccessed) { // only allow progression via /update, attempts to directly access /updatenow will fail HTTPUpload& upload = server.upload(); String filename = upload.filename; if(!filename.startsWith("/")) filename = "/"+filename; if (upload.status == UPLOAD_FILE_START) { int sts = BrowserUpload.begin(filename, _SuppliedFileSize); sprintf(JSON, "{\"progress\":%d}", sts); sendWebSocketString(JSON); // feedback proper byte count of update to browser via websocket } // handle file fragments of form upload else if (upload.status == UPLOAD_FILE_WRITE) { feedWatchdog(); // we get stuck here for a while, don't let the watchdog bite! int sts = BrowserUpload.fragment(upload); if(sts < 0) { sprintf(JSON, "{\"progress\":%d}", sts); sendWebSocketString(JSON); // feedback -ve byte count of update to browser via websocket - write error } else { // upload still in progress? if(BrowserUpload.bUploadActive) { // show progress unless a write error has occured DebugPort.print("."); if(upload.totalSize) { // feed back bytes received over web socket for progressbar update on browser (via javascript) sprintf(JSON, "{\"progress\":%d}", upload.totalSize); sendWebSocketString(JSON); // feedback proper byte count of update to browser via websocket } // show percentage on OLED int percent = 0; if(_SuppliedFileSize) percent = 100 * upload.totalSize / _SuppliedFileSize; ShowOTAScreen(percent, eOTAbrowser); // browser update } } } // handle end of upload else if (upload.status == UPLOAD_FILE_END) { int sts = BrowserUpload.end(upload); sprintf(JSON, "{\"progress\":%d}", sts); sendWebSocketString(JSON); // feedback proper byte count of update to browser via websocket delay(2000); bUpdateAccessed = false; // close gate on POST to /updatenow } else { DebugPort.printf("Update Failed Unexpectedly (likely broken connection): status=%d\r\n", upload.status); bUpdateAccessed = false; // close gate on POST to /updatenow } } else { // attempt to POST without using /update - forced redirect to root DebugPort.println("WEB: POST /updatenow forbidden entry"); rootRedirect(); } } /*************************************************************************************** * FORMAT SPIFFS HANDLING * * User must first access /formatspiffs. * If not already authenticated, an Username/Password challenge is presented * If that passes, bFormatAccessed is set, unlocking access to the /formatnow path * The presneted web page offers Format and Cancel button. * Cancel will immediatly return to the file upload path '/update' * Format will then present a confirmation dialog, user must press Yes to proceed. * * Assuming Yes was pressed, a HTTP POST to /format now with the payload 'confirm'='yes' is performed * The /formatnow handler will check that confirm does equal yes, and that bFormatAccessed was set * If all good SPIFFS is re-formatted - no response is set. * The javascript though from the /formatspiffs page performs a reload shortly after the post (200ms timeout) * * As bFormatAccessed is still set, a confimration page is the presented advising files now need to be uploaded * A button allows direct access to /update */ void onFormatSPIFFS() { DebugPort.println("WEB: GET /formatspiffs"); bUpdateAccessed = false; String content = stdHeader; if(!bFormatPerformed) { sCredentials creds = NVstore.getCredentials(); if (!server.authenticate(creds.webUpdateUsername, creds.webUpdatePassword)) { return server.requestAuthentication(); } bFormatAccessed = true; // only set after we pass authentication content += formatIndex; } else { bFormatAccessed = false; bFormatPerformed = false; content += formatDoneContent; } server.send(200, "text/html", content ); } const char* formatDoneContent = R"=====(

SPIFFS partition has been formatted

You must now upload the web content.

Latest web content can be downloaded from http://afterburner.mrjones.id.au/firmware.html

Please ensure you unzip the web page content, then upload all the files contained.

)====="; const char* formatIndex = R"=====( Afterburner SPIFFS format

Format SPIFFS partition

CAUTION! This will erase all web content


)====="; void onFormatNow() { // HTTP POST handler, do not need to return a web page! DebugPort.println("WEB: POST /formatnow"); String confirm = server.arg("confirm"); // get request argument value by name if(confirm == "yes" && bFormatAccessed) { // confirm user agrees, and we did pass thru /formatspiffs first DebugPort.println("Formatting SPIFFS partition"); SPIFFS.format(); // re-format the SPIFFS partition bFormatPerformed = true; } else { bFormatAccessed = false; // user cancelled upon last confirm popup, or not authenticated access bFormatPerformed = false; rootRedirect(); } } void onReboot() { DebugPort.println("WEB: GET /reboot"); String content = stdHeader; content += rebootIndex; server.send(200, "text/html", content ); } void onDoReboot() { // HTTP POST handler, do not need to return a web page! DebugPort.println("WEB: POST /reboot"); String confirm = server.arg("reboot"); // get request argument value by name if(confirm == "yes") { // confirm user agrees, and we did pass thru /formatspiffs first DebugPort.println("Rebooting via /reboot"); ESP.restart(); } } const char* rebootIndex = R"=====( Afterburner Reboot

Reboot Afterburner

     )====="; void onRename() { // HTTP POST handler, do not need to return a web page! DebugPort.println("WEB: POST /reboot"); String oldname = server.arg("oldname"); // get request argument value by name String newname = server.arg("newname"); // get request argument value by name newname.replace("%20", " "); // convert html spaces to real spaces oldname.replace("%20", " "); if(oldname != "" && newname != "") { DebugPort.printf("Renaming %s to %s\r\n", oldname.c_str(), newname.c_str()); SPIFFS.rename(oldname.c_str(), newname.c_str()); checkSplashScreenUpdate(); } } /*************************************************************************************** * HTTP RESPONSE 404 - FILE NOT FOUND HANDLING */ void build404Response(String& content, String file) { content += stdHeader; content += R"=====(

404: File Not Found

URI: )====="; content += file; content += R"=====(
Method: )====="; content += (server.method() == HTTP_GET) ? "GET" : "POST"; content += "
Arguments: "; for (uint8_t i = 0; i < server.args(); i++) { content += " " + server.argName(i) + ": " + server.arg(i) + "
"; } content += R"=====(


Please check the URL.
If OK please try uploading the file from the web content.

Latest web content can be downloaded from http://afterburner.mrjones.id.au/firmware.html

Please ensure you unzip the web page content, then upload all the files contained.


)====="; String SPIFFSinfo; listSPIFFS("/", 2, SPIFFSinfo, 1); content += SPIFFSinfo; content += ""; content += ""; } /*************************************************************************************** * HTTP RESPONSE 500 - SERVER ERROR HANDLING */ void build500Response(String& content, String file) { content = stdHeader; content += R"=====(

500: Internal Server Error

Sorry, cannot open file

")====="; content += file; content += R"=====(" exists, but cannot be streamed?


Recommended remedy is to re-format the SPIFFS partition, then reload the web content files.
Latest web content can be downloaded from http://afterburner.mrjones.id.au/firmware.html (opens in new page).

To format the SPIFFS partition, press

You will then need to upload each file of the web content by using the subsequent "Upload" button.


Please ensure you unzip the web page content, then upload all the files contained.

)====="; }