/////////////////////////////////////////////////////////////////////////// // 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) // // * TickTwo (timer library for asynchronous processing) // /////////////////////////////////////////////////////////////////////////// // 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 #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 arduinoOtaTickCallback(void); void blinkDigitsDahTickCallback(void); void blinkDigitsDitTickCallback(void); void blinkDigitsBlinkTickCallback(void); void clientWifiTickCallback(void); void serverWifiTickCallback(void); void handleServerWifi(void); void handleClientWifi(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 rebootTickCallback(void); /////////////////////////////////////////////////////////////////////////// // variable declarations // /////////////////////////////////////////////////////////////////////////// #if defined(ARDUINO_ARCH_ESP8266) ESP8266WebServer server(80); #elif defined(ARDUINO_ARCH_ESP32) WebServer server(80); #endif TickTwo arduinoOtaTick(arduinoOtaTickCallback, 1000); TickTwo rebootTick(rebootTickCallback, 1000); TickTwo clientWifiTick(clientWifiTickCallback, 25); TickTwo serverWifiTick(serverWifiTickCallback, 250); TickTwo blinkDigitsDahTick(blinkDigitsDahTickCallback, BLINK_DAH_LENGTH); TickTwo blinkDigitsDitTick(blinkDigitsDitTickCallback, BLINK_DIT_LENGTH); TickTwo blinkDigitsBlinkTick(blinkDigitsBlinkTickCallback, 25); char* authenticationCookie = NULL; bool otaStarted; bool networkConnected; int connectionTries; bool rebootPending; int temporarySsidLength; int temporarySsidIndex; int* temporarySsidNumbers; int blinkLedState; /////////////////////////////////////////////////////////////////////////// // 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 // start soft AP rebootTick.start(); serverWifiTick.start(); return; } #ifdef DEBUG Serial.printf("No stored STA Ssid found, proceeding to soft AP...\n"); #endif clientWifiTick.start(); // setup OTA ArduinoOTA.setHostname(configuration["name"].as()); // allow flashing with the master password 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"); } }); // start timers / threads arduinoOtaTick.start(); rebootTick.start(); } void loop() { arduinoOtaTick.update(); rebootTick.update(); clientWifiTick.update(); serverWifiTick.update(); blinkDigitsDitTick.update(); blinkDigitsDahTick.update(); blinkDigitsBlinkTick.update(); } /////////////////////////////////////////////////////////////////////////// // end Arduino // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // OTA updates // /////////////////////////////////////////////////////////////////////////// void arduinoOtaTickCallback(void) { ArduinoOTA.handle(); if(!networkConnected) { return; } if(!otaStarted) { ArduinoOTA.begin(); otaStarted = true; } } /////////////////////////////////////////////////////////////////////////// // system-wide reboot // /////////////////////////////////////////////////////////////////////////// void rebootTickCallback(void) { // check if a reboot has been scheduled. if(!rebootPending) { return; } #ifdef DEBUG Serial.printf("Reboot pending, restarting in 1s...\n"); #endif ESP.restart(); } /////////////////////////////////////////////////////////////////////////// // 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 [%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(); #ifdef DEBUG Serial.printf("Trying connection to %s with %s...\n", Ssid, password); #endif WiFi.begin(Ssid, password); } /////////////////////////////////////////////////////////////////////////// // blink the temporary Ssid // /////////////////////////////////////////////////////////////////////////// void blinkDigitsDahTickCallback(void) { // wait for the dits to complete if(blinkDigitsDitTick.state() != STOPPED) { return; } if(temporarySsidIndex >= temporarySsidLength) { blinkDigitsDahTick.stop(); blinkDigitsDitTick.stop(); blinkDigitsBlinkTick.stop(); free(temporarySsidNumbers); #ifdef DEBUG Serial.println(); Serial.println("Dah-dit blink sequence completed."); #endif return; } #ifdef DEBUG Serial.printf("Starting to blink %d times: ", temporarySsidNumbers[temporarySsidIndex]); #endif pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, LOW); blinkDigitsDitTick.start(); } void blinkDigitsDitTickCallback(void) { #ifdef DEBUG Serial.printf("Dit: %d/%d\n", blinkDigitsDitTick.counter(), temporarySsidNumbers[temporarySsidIndex]); #endif if(blinkDigitsDitTick.counter() > temporarySsidNumbers[temporarySsidIndex]) { blinkDigitsDitTick.stop(); ++temporarySsidIndex; #ifdef DEBUG Serial.println("Dits completed..."); #endif return; } blinkDigitsDitTick.pause(); blinkDigitsBlinkTick.start(); } void blinkDigitsBlinkTickCallback(void) { if(blinkDigitsBlinkTick.counter() > 2) { blinkDigitsBlinkTick.stop(); blinkDigitsDitTick.resume(); return; } blinkLedState = !blinkLedState; digitalWrite(LED_BUILTIN, blinkLedState); }