/////////////////////////////////////////////////////////////////////////// // Copyright (C) Wizardry and Steamworks 2024 - License: GNU MIT // // Please see: http://www.gnu.org/licenses/gpl.html for legal details, // // rights of fair usage, the disclaimer and warranty conditions. // /////////////////////////////////////////////////////////////////////////// // This template is a resilient implementation of a pre-WiFi connection // // environment that allows the user to configure the Ssid and password // // for the WiFi network via a built-in web-server that is automatically // // started by the template when no WiFi network has been configured. // /////////////////////////////////////////////////////////////////////////// // Purpose //////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // One of the problems is that given the cost, ESP devices are bought in // // bulk, programmed and then sprawled out allover a site but typically // // Arduino templates have little accountibility or resillience built-in // // that would make the templates resist ESP resets and still be able to // // connect to the WiFi network. Similarly, in case the site is mobile, // // and in case the WiFi network changes, then all the ESPs will just // // have to be reprogrammed manually by the user which is a daunting task // // relative to the amount of ESP devices in use. This template addresses // // that issue by creating a robust mechanism where the ESP device will // // reboot in a preboot AP mode in case the WiFi network cannot be found // // or connected to in order to allow the user to reconfigure the ESP. // /////////////////////////////////////////////////////////////////////////// // Example Usage ////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // * configure the template parameters as need be within the // // "configurable parameters" section of this template, the password // // defined as a "master password" will grant access to configurng // // the template and will also be used as the OTA password // // * add any user-code to the connectedLoop() function that will be // // called by the Arduino loop with a delay of 1 millisecond (the // // user must include a delay in order to not throttle the CPU) // // * when booting, the template will generate an Ssid based on the ESP // // CPU identifier consisting of up to two digits and will blink the // // built-in ESP LED in sequence in order to give away the AP // // * connect to the numeric AP started by the ESP and configure the // // network Ssid and password // // * the template will now connect to the WiFi network using the // // provided Ssid and password; iff. the WiFi disconnects from the // // WiFi network for more than the amount of milliseconds given by: // // // // WIFI_RETRY_TIMEOUT * WIFI_CONNECT_TRIES // // // // then the template will restart again in AP mode, blink the LED // // of the numeric Ssid and wait to be configured for a WiFi network // /////////////////////////////////////////////////////////////////////////// // Libraries ////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // The libraries used are minimal and the kind of libraries that have a // // wide-range of applications, in particular if the ESP device is WiFi // // enabled. Here is a complete list of libraries used by the template: // // * ArduinoJson (very popular JSON library) // /////////////////////////////////////////////////////////////////////////// // Credits //////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // The template is loosely inspired by the many captive portal solutions // // out there but with some minimalism in mind and additionally exposing // // various configurable parameters such as the HTML webpage. Other close // // similarities consist in the Tasmota firmware that accomplishes more // // or less the same switch between connected to a WiFi network and AP // // mode that allows the user to configure the WiFi network. // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // configurable parameters // /////////////////////////////////////////////////////////////////////////// // comment out to enable debugging //#define DEBUG // set the master password for OTA updates and access to the soft AP #define PREBOOT_MASTER_PASSWORD "" // the name and length of the cookie to use for authentication #define PREBOOT_COOKIE_NAME "ArduinoPrebootCookie" #define PREBOOT_COOKIE_MAX_LENGTH 256 // timeout to establish STA connection in milliseconds #define WIFI_RETRY_TIMEOUT 10000 // retries as multiples of WIFI_RETRY_TIMEOUT milliseconds #define WIFI_CONNECT_TRIES 30 // the time between blinking a single digit #define BLINK_DIT_LENGTH 250 // the time between blinking the whole number #define BLINK_DAH_LENGTH 2500 /////////////////////////////////////////////////////////////////////////// // includes // /////////////////////////////////////////////////////////////////////////// #include #if defined(ARDUINO_ARCH_ESP32) #include #include #elif defined(ESP8266) #include #include #include #endif #include #include #include // Arduino OTA #include #include // Platform specific defines. #if defined(ARDUINO_ARCH_ESP8266) #define GET_CHIP_ID() (ESP.getChipId()) #elif defined(ARDUINO_ARCH_ESP32) #define GET_CHIP_ID() ((uint16_t)(ESP.getEfuseMac() >> 32)) #endif #define HOSTNAME() String("esp-" + String(GET_CHIP_ID(), HEX)) #define CONFIGURATION_FILE_NAME "/config.json" #define CONFIGURATION_MAX_LENGTH 1024 /////////////////////////////////////////////////////////////////////////// // function definitions // /////////////////////////////////////////////////////////////////////////// byte* getHardwareAddress(void); char* getHardwareAddress(char colon); String computeTemporarySsid(void); void blinkDigits(int* digits, int count, void (*callback)(), int dit = 250, int dah = 2500); void clientWifi(void); void serverWifi(void); void blinkDigitsIdle(void); void setConfiguration(const char* configurationFile, DynamicJsonDocument configuration, int bufferSize); DynamicJsonDocument getConfiguration(const char* configurationFile, int bufferSize); void handleRootHttpRequest(void); void handleSetupHttpRequest(void); void handleRootHttpGet(void); void handleSetupHttpGet(void); void handleRootHttpPost(void); void handleSetupHttpPost(void); void handleHttpNotFound(void); bool fsWriteFile(fs::FS &fs, const char *path, const char *payload); bool fsReadFile(fs::FS &fs, const char *path, char *payload, size_t maxLength); void arduinoLoop(void); /////////////////////////////////////////////////////////////////////////// // variable declarations // /////////////////////////////////////////////////////////////////////////// #if defined(ARDUINO_ARCH_ESP8266) ESP8266WebServer server(80); #elif defined(ARDUINO_ARCH_ESP32) WebServer server(80); #endif int connectionTries; bool rebootPending; typedef enum { NONE, SERVER, CLIENT } ExecuteState; ExecuteState runtimeExecutionState; unsigned long lastWifiExecuteTime; char* authenticationCookie = NULL; bool otaStarted; /////////////////////////////////////////////////////////////////////////// // HTML templates // /////////////////////////////////////////////////////////////////////////// const char* HTML_BOOT_TEMPLATE = R"html( ESP Setup

ESP Setup


AP: %AP%
MAC: %MAC%




)html"; const char* HTML_AUTH_TEMPLATE = R"html( Preboot Access

Preboot Access


)html"; /////////////////////////////////////////////////////////////////////////// // begin Arduino // /////////////////////////////////////////////////////////////////////////// void setup() { #ifdef DEBUG Serial.begin(115200); // wait for serial while (!Serial) { delay(100); } Serial.println(); #endif #if defined(ARDUINO_ARCH_ESP8266) if (!LittleFS.begin()) { #ifdef DEBUG Serial.println("LittleFS mount failed, formatting and rebooting..."); #endif LittleFS.format(); delay(1000); ESP.restart(); #elif defined(ARDUINO_ARCH_ESP32) if (!LittleFS.begin(true)) { #endif Serial.println("LittleFS mount failed..."); return; } #ifdef DEBUG Serial.printf("Checking if WiFi server must be started...\n"); #endif // check if Ssid is set and start soft AP or STA mode DynamicJsonDocument configuration = getConfiguration(CONFIGURATION_FILE_NAME, CONFIGURATION_MAX_LENGTH); if(configuration.isNull() || !configuration.containsKey("Ssid")) { #ifdef DEBUG Serial.printf("No stored STA Ssid found, proceeding to soft AP...\n"); #endif // set server mode runtimeExecutionState = SERVER; return; } runtimeExecutionState = CLIENT; // setup OTA ArduinoOTA.setHostname(configuration["name"].as()); ArduinoOTA.setPassword(PREBOOT_MASTER_PASSWORD); ArduinoOTA.onStart([]() { String type; if (ArduinoOTA.getCommand() == U_FLASH) { type = "sketch"; } else { // U_FS type = "filesystem"; } // NOTE: if updating FS this would be the place to unmount FS using FS.end() Serial.println("Start updating " + type); }); ArduinoOTA.onEnd([]() { Serial.println("\nEnd"); }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { Serial.printf("Progress: %u%%\r", (progress / (total / 100))); }); ArduinoOTA.onError([](ota_error_t error) { Serial.printf("Error[%u]: ", error); if (error == OTA_AUTH_ERROR) { Serial.println("Auth Failed"); } else if (error == OTA_BEGIN_ERROR) { Serial.println("Begin Failed"); } else if (error == OTA_CONNECT_ERROR) { Serial.println("Connect Failed"); } else if (error == OTA_RECEIVE_ERROR) { Serial.println("Receive Failed"); } else if (error == OTA_END_ERROR) { Serial.println("End Failed"); } }); } void loop() { // check if a reboot has been scheduled. if(rebootPending) { #ifdef DEBUG Serial.printf("Reboot pending, restarting in 1s...\n"); #endif delay(1000); ESP.restart(); } if(runtimeExecutionState == SERVER) { serverWifi(); delay(250); return; } clientWifi(); arduinoLoop(); delay(1); } /////////////////////////////////////////////////////////////////////////// // end Arduino // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // user code goes here, connectedLoop invoked from Arduino loop() // /////////////////////////////////////////////////////////////////////////// void arduinoLoop(void) { // USER CODE, USER CODE, USER CODE, USER CODE, USER CODE, USER CODE, ... Serial.printf("User code...\n"); ArduinoOTA.handle(); delay(1000); } /////////////////////////////////////////////////////////////////////////// // HTTP route handling // /////////////////////////////////////////////////////////////////////////// void handleRootHttpPost(void) { String password; for(int i = 0; i < server.args(); ++i) { if(server.argName(i) == "password") { password = server.arg(i); continue; } } if(!password.equals(PREBOOT_MASTER_PASSWORD)) { server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } #ifdef DEBUG Serial.println("Authentication succeeded, setting cookie and redirecting."); #endif // clear old authentication cookie if(authenticationCookie != NULL) { free(authenticationCookie); authenticationCookie = NULL; } authenticationCookie = randomStringHex(8); char* buff = (char*) malloc(PREBOOT_COOKIE_MAX_LENGTH * sizeof(char)); snprintf(buff, PREBOOT_COOKIE_MAX_LENGTH, "%s=%s; Max-Age=600; SameSite=Strict", PREBOOT_COOKIE_NAME, authenticationCookie); #ifdef DEBUG Serial.printf("Preboot cookie set to: %s\n", buff); #endif server.sendHeader("Set-Cookie", buff); server.sendHeader("Location", "/setup"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); free(buff); } void handleSetupHttpPost(void) { String espName, staSsid, password; for(int i = 0; i < server.args(); ++i) { if(server.argName(i) == "name") { espName = server.arg(i); continue; } if(server.argName(i) == "Ssid") { staSsid = server.arg(i); continue; } if(server.argName(i) == "password") { password = server.arg(i); continue; } } if(espName == NULL || staSsid == NULL || password == NULL) { server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } #ifdef DEBUG Serial.printf("Ssid %s and password %s received from web application.\n", staSsid, password); #endif DynamicJsonDocument configuration(CONFIGURATION_MAX_LENGTH); configuration["name"] = espName; configuration["Ssid"] = staSsid; configuration["password"] = password; setConfiguration(CONFIGURATION_FILE_NAME, configuration, CONFIGURATION_MAX_LENGTH); server.send(200, "text/plain", "Parameters applied. Scheduling reboot..."); #ifdef DEBUG Serial.printf("Configuration applied...\n"); #endif rebootPending = true; } void handleRootHttpGet(void) { // send login form #ifdef DEBUG Serial.printf("Sending authentication webpage.\n"); #endif String processTemplate = String(HTML_AUTH_TEMPLATE); server.send(200, "text/html", processTemplate); } void handleSetupHttpGet(void) { DynamicJsonDocument configuration = getConfiguration(CONFIGURATION_FILE_NAME, CONFIGURATION_MAX_LENGTH); String espName = HOSTNAME(); if(configuration.containsKey("name")) { espName = configuration["name"].as(); } // send default boot webpage #ifdef DEBUG Serial.printf("Sending configuration form webpage.\n"); #endif String processTemplate = String(HTML_BOOT_TEMPLATE); processTemplate.replace("%AP%", computeTemporarySsid()); processTemplate.replace("%MAC%", getHardwareAddress(':')); processTemplate.replace("%NAME%", espName); server.send(200, "text/html", processTemplate); } void handleRootHttpRequest(void) { switch(server.method()) { case HTTP_GET: handleRootHttpGet(); break; case HTTP_POST: handleRootHttpPost(); break; } } void handleSetupHttpRequest(void) { #ifdef DEBUG Serial.println("HTTP setup request received."); #endif if(!server.hasHeader("Cookie")) { #ifdef DEBUG Serial.println("No cookie header found."); #endif server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } String cookie = server.header("Cookie"); if(authenticationCookie == NULL || cookie.indexOf(authenticationCookie) == -1) { #ifdef DEBUG Serial.println("Authentication failed."); #endif server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } switch(server.method()) { case HTTP_GET: #ifdef DEBUG Serial.printf("HTTP GET request received for setup.\n"); #endif handleSetupHttpGet(); break; case HTTP_POST: #ifdef DEBUG Serial.printf("HTTP POST request received for setup.\n"); #endif handleSetupHttpPost(); break; } } void handleHttpNotFound(void) { server.sendHeader("Location", "/"); server.send(302); } /////////////////////////////////////////////////////////////////////////// // LittleFS file operations // /////////////////////////////////////////////////////////////////////////// bool fsWriteFile(fs::FS &fs, const char *path, const char *payload) { #if defined(ARDUINO_ARCH_ESP8266) File file = fs.open(path, "w"); #elif defined(ARDUINO_ARCH_ESP32) File file = fs.open(path, FILE_WRITE); #endif if (!file) { #ifdef DEBUG Serial.println("Failed to open file for writing."); #endif return false; } bool success = file.println(payload); file.close(); return success; } bool fsReadFile(fs::FS &fs, const char *path, char *payload, size_t maxLength) { #if defined(ARDUINO_ARCH_ESP8266) File file = fs.open(path, "r"); #elif defined(ARDUINO_ARCH_ESP32) File file = fs.open(path); #endif if (!file || file.isDirectory()) { #ifdef DEBUG Serial.println("Failed to open file for reading."); #endif return false; } int i = 0; while(file.available() && i < maxLength) { payload[i] = file.read(); ++i; } file.close(); payload[i] = '\0'; return true; } /////////////////////////////////////////////////////////////////////////// // set the current configuration // /////////////////////////////////////////////////////////////////////////// void setConfiguration(const char* configurationFile, DynamicJsonDocument configuration, int bufferSize) { char payload[bufferSize]; serializeJson(configuration, payload, bufferSize); if(!fsWriteFile(LittleFS, configurationFile, payload)) { #ifdef DEBUG Serial.printf("Unable to store configuration.\n"); #endif } } /////////////////////////////////////////////////////////////////////////// // get the current configuration // /////////////////////////////////////////////////////////////////////////// DynamicJsonDocument getConfiguration(const char* configurationFile, int bufferSize) { DynamicJsonDocument configuration(bufferSize); #ifdef DEBUG Serial.printf("Attempting to read configuration...\n"); #endif char* payload = (char *) malloc(bufferSize * sizeof(char)); if (fsReadFile(LittleFS, configurationFile, payload, bufferSize)) { #ifdef DEBUG Serial.printf("Found a valid configuration payload...\n"); #endif DeserializationError error = deserializeJson(configuration, payload); if(error) { #ifdef DEBUG Serial.printf("Deserialization of configuration failed.\n"); #endif } } #ifdef DEBUG Serial.printf("Configuration read complete.\n"); #endif free(payload); return configuration; } /////////////////////////////////////////////////////////////////////////// // generate random string // /////////////////////////////////////////////////////////////////////////// char* randomStringHex(int length) { const char alphabet[] = "0123456789abcdef"; char* payload = (char*) malloc(length * sizeof(char)); int i; for (i=0; i WIFI_CONNECT_TRIES) { // zap the Ssid in order to start softAP if(configuration.containsKey("Ssid")) { configuration.remove("Ssid"); } if(configuration.containsKey("password")) { configuration.remove("password"); } setConfiguration(CONFIGURATION_FILE_NAME, configuration, CONFIGURATION_MAX_LENGTH); #ifdef DEBUG Serial.printf("Restarting in 1 second...\n"); #endif rebootPending = true; return; } #ifdef DEBUG Serial.printf("Attempting to establish WiFi STA connecton with Ssid [%d/%d]\n", (WIFI_CONNECT_TRIES - connectionTries) + 1, WIFI_CONNECT_TRIES); #endif #if defined(ARDUINO_ARCH_ESP8266) WiFi.hostname(configuration["name"].as()); #elif defined(ARDUINO_ARCH_ESP32) WiFi.setHostname(configuration["name"].as()); #endif String Ssid = configuration["Ssid"].as(); String password = configuration["password"].as(); WiFi.begin(Ssid, password); lastWifiExecuteTime = millis(); } /////////////////////////////////////////////////////////////////////////// // blink a string of numbers // /////////////////////////////////////////////////////////////////////////// void blinkDigits(int* digits, int count, void (*callback)(), int dit, int dah) { pinMode(LED_BUILTIN, OUTPUT); for(int i = 0; i < count; ++i) { do { digitalWrite(LED_BUILTIN, HIGH); delay(dit); callback(); digitalWrite(LED_BUILTIN, LOW); delay(dit); callback(); } while(--digits[i] > 0); delay(dah); callback(); } } /////////////////////////////////////////////////////////////////////////// // blinkDigits callback // /////////////////////////////////////////////////////////////////////////// void blinkDigitsIdle() { server.handleClient(); }