Table of Contents

ChangeLog

29th of September 2024

  • initial release

Boilerplate WiFi Connect, OTA and pin toggling for ESP8266 and ESP32

The template is based on the wifipreboot template and implements a captive portal-like environment to configure the initial WiFi network, OTA and then obsoletes the older esp-pin-toggle and esp-pin-toggle-analog templates by making the features of both those templates available in one single all-encompassing template.

Usage

The template must be configured in order to set the master password at the top and preferably configure MQTT such that when the template connects to the WiFi network it will be able to connect and subscribe to an MQTT broker. In case no MQTT topic is configured by editing the template header, then the template will subscribe to the MQTT broker on the same topic name as the name that will be configured for the device via the WiFi preboot environment. That being said, after configuring, the template can be flashed to an ESP32 or an ESP8266 device and after a reset, the template will start flashing the AP code required to connect to. By connecting to the AP named after the number that corresponds to the blink sequence of the ESP built-in LED, the template can be configured to connect to a WiFi network.

Upon connect and subscribe, the template will send a message to the MQTT topic indicating that the device has just connected and subscribed to the MQTT topic and is ready to process requests. From then on, it is a matter of sending specific JSON payloads to the MQTT topic that the template is listening on in order to make the template either trigger GPIO pins, read digital pins or measure the value of analog pins. Here is a table of actions available, the possible options and an example for each:

Action Parameter Possible Value Description Example JSON
get pin any digital pin number get the digital level of a GPIO pin indicated by pin
{
    "action": "get",
    "pin": 4
}
set pin any digital pin number set the digital level of a pin depending on the state parameter to either HIGH (on) or LOW (off)
{
    "action": "set",
    "pin": 4,
    "state": "on"
}
state on, off
debounce pin any digital pin number the debounce action can be used to simulate a digital button press for devices and gadgets that require simulating pushing and releasing a button with a certain time interval between the two (ie: long-press); the action allows to specify sleep, the amount of time between the figurative press and release, as well as mode set to either HTL or LTH meaning setting the digital pin level from HIGHT to LOW, respectively LOW to HIGH with HTL being the default
{
  "pin": "2",
  "sleep": "2500",
  "action": "debounce"
}
sleep an amount of time to sleep between toggling the pin between HIGH or LOW
mode HTL from HIGH to LOW, or LTH from LOW to HIGH
measure pin any analog pin number get the analog value of the GPIO pin indicated by pin
{
    "action": "measure",
    "pin": 0
}

Upon publishing the payload, the template will respond on the same topic using a matching JSON payload containing the results and the execute key set to the original JSON payload that was sent by the user for the purpose of linearizing requests and responses.

Code

///////////////////////////////////////////////////////////////////////////
//  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.        //
///////////////////////////////////////////////////////////////////////////
// As it is very much needed sometimes to manipulate GPIO pins, and then //
// by combining the wifipreboot environment, the following template for  //
// the Arduino can be used to manipulate GPIO pins on the low level via  //
// a common MQTT broker that the template is meant to connect to.        //
//                                                                       //
// The full documentation can be found on:                               //
//   * https://grimore.org/arduino/gpio_tool                             //
///////////////////////////////////////////////////////////////////////////
 
///////////////////////////////////////////////////////////////////////////
//  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
 
// GPIO application
// The MQTT broker to connect to.
#define MQTT_HOST ""
// The MQTT broker username.
#define MQTT_USERNAME ""
// The MQTT broker password.
#define MQTT_PASSWORD ""
// The MQTT broker port.
#define MQTT_PORT 1883
// The MQTT topic
#define MQTT_TOPIC ""
// Maximal size of an MQTT payload
#define MQTT_PAYLOAD_MAX_LENGTH 256
 
///////////////////////////////////////////////////////////////////////////
//  includes                                                             //
///////////////////////////////////////////////////////////////////////////
#include <Arduino.h>
#if defined(ARDUINO_ARCH_ESP32)
#include <WiFi.h>
#include <WebServer.h>
#elif defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h>
#include <ESP8266WebServer.h>
#endif
 
#include <FS.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
// Arduino OTA
#include <WiFiUdp.h>
#include <ArduinoOTA.h>
#include <TickTwo.h>
 
// GPIO application
#include <PubSubClient.h>
 
// 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);
 
// GPIO application
void gpioDebounceTickCallback(void);
void mqttTickCallback(void);
String getMqttTopic(void);
String getMqttId(void);
void mqttGpioSet(DynamicJsonDocument doc);
DynamicJsonDocument mqttGpioGet(const DynamicJsonDocument doc);
void mqttGpioDebounce(DynamicJsonDocument doc);
DynamicJsonDocument mqttGpioMeasure(const DynamicJsonDocument doc);
constexpr unsigned int mqttGpioActionHash(const char *s, int off = 0);
char *mqttSerialize(const JsonDocument doc, size_t maxLength);
 
///////////////////////////////////////////////////////////////////////////
//  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;
 
// GPIO application
WiFiClient espClient;
PubSubClient mqttClient(espClient);
 
// GPIO application
TickTwo gpioDebounceTick(gpioDebounceTickCallback, 25, 1);
TickTwo mqttTick(mqttTickCallback, 250);
 
// GPIO application
int gpioDebouncePin;
int gpioDebounceSleep;
String gpioDebounceMode;
 
///////////////////////////////////////////////////////////////////////////
//  HTML templates                                                       //
///////////////////////////////////////////////////////////////////////////
const char* HTML_BOOT_TEMPLATE = R"html(
<!DOCTYPE html>
<html lang="en">
   <head>
      <title>ESP Setup</title>
   </head>
   <body>
      <h1>ESP Setup</h1>
      <hr>
      AP: %AP%<br>
      MAC: %MAC%<br>
      <hr>
      <form method="POST" action="/setup">
         <label for="name">Name: </label>
         <input id="name" type="text" name="name" value="%NAME%">
         <br>
         <label for="Ssid">Ssid: </label>
         <input id="Ssid" type="text" name="Ssid">
         <br>
         <label for="password">Password: </label>
         <input id="password" type="password" name="password">
         <hr>
         <input type="submit" value="submit">
      </form>
   </body>
</html>
)html";
 
const char* HTML_AUTH_TEMPLATE = R"html(
<!DOCTYPE html>
<html lang="en">
   <head>
      <title>Preboot Access</title>
   </head>
   <body>
      <h1>Preboot Access</h1>
      <form method="POST">
         <label for="password">Master password: </label>
         <input id="password" type="password" name="password">
         <hr>
         <input type="submit" value="submit">
      </form>
   </body>
</html>
)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<const char*>());
  // 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();
 
  // GPIO application
  mqttTick.start();
}
 
void loop() {
  arduinoOtaTick.update();
  rebootTick.update();
  clientWifiTick.update();
  serverWifiTick.update();
  blinkDigitsDitTick.update();
  blinkDigitsDahTick.update();
  blinkDigitsBlinkTick.update();
 
  // GPIO application
  gpioDebounceTick.update();
  mqttTick.update();
}
 
///////////////////////////////////////////////////////////////////////////
//  end Arduino                                                          //
///////////////////////////////////////////////////////////////////////////
 
///////////////////////////////////////////////////////////////////////////
//  GPIO-MQTT                                                            //
///////////////////////////////////////////////////////////////////////////
constexpr unsigned int mqttGpioActionHash(const char *s, int off) {                        
    return !s[off] ? 5381 : (mqttGpioActionHash(s, off+1)*33) ^ s[off];                           
}
 
void gpioDebounceTickCallback(void) {
  gpioDebounceTick.pause();
#ifdef DEBUG
  Serial.printf("Pin %d debounce.\n", gpioDebouncePin);
#endif
  switch(mqttGpioActionHash(gpioDebounceMode.c_str())) {
    case mqttGpioActionHash("LTH"):
      digitalWrite(gpioDebouncePin, HIGH);
      break;
    default:
      digitalWrite(gpioDebouncePin, LOW);
      break;
  }
}
 
String getMqttTopic(void) {
  String mqttTopic = String(MQTT_TOPIC);
  if(mqttTopic == NULL || mqttTopic.length() == 0) {
    DynamicJsonDocument configuration = getConfiguration(CONFIGURATION_FILE_NAME, CONFIGURATION_MAX_LENGTH);
    if(configuration.containsKey("name")) {
      mqttTopic = configuration["name"].as<const char*>();
    }
  }
  return mqttTopic;
}
 
String getMqttId(void) {
  String mqttId = String(HOSTNAME());
  DynamicJsonDocument configuration = getConfiguration(CONFIGURATION_FILE_NAME, CONFIGURATION_MAX_LENGTH);
  if(configuration.containsKey("name")) {
    mqttId = configuration["name"].as<const char*>();
  }
  return mqttId;
}
 
void mqttGpioSet(DynamicJsonDocument doc) {
  String state = doc["state"].as<const char *>();
  const int pin = doc["pin"].as<const int>();
#ifdef DEBUG
  Serial.printf("Setting pin %d to state %s...\n", pin, state);
#endif
  pinMode(pin, OUTPUT);
 
  if (state == "on") {
    digitalWrite(pin, HIGH);
    int pinStatus = digitalRead(pin);
#ifdef DEBUG
    Serial.printf("Pin %d state is now %d.\n", pin, pinStatus);
#endif
    return;
  }
 
  digitalWrite(pin, LOW);
  int pinStatus = digitalRead(pin);
#ifdef DEBUG
  Serial.printf("Pin %s state is now %d.\n", pinStatus);
#endif
}
 
void mqttGpioDebounce(DynamicJsonDocument doc) {
  gpioDebounceSleep = doc["sleep"].as<const int>();
  gpioDebouncePin = doc["pin"].as<const int>();
  if(doc.containsKey("mode")) {
    gpioDebounceMode = String(doc["mode"].as<const char*>());
    gpioDebounceMode.trim();
    gpioDebounceMode.toUpperCase();
  }
 
#ifdef DEBUG
  Serial.printf("Pin %d bounce.\n", gpioDebouncePin);
#endif
  pinMode(gpioDebouncePin, OUTPUT);
  switch(mqttGpioActionHash(gpioDebounceMode.c_str())) {
    case mqttGpioActionHash("LTH"):
      digitalWrite(gpioDebouncePin, LOW);
      break;
    default:
      digitalWrite(gpioDebouncePin, HIGH);
      break;
  }
#ifdef DEBUG
  Serial.printf("Debouncing pin %d with sleep %d and mode %s.\n", gpioDebouncePin, gpioDebounceSleep, gpioDebounceMode.c_str());
#endif
 
  gpioDebounceTick.interval(gpioDebounceSleep);
  gpioDebounceTick.resume();
}
 
DynamicJsonDocument mqttGpioGet(const DynamicJsonDocument doc) {
  const int pin = doc["pin"].as<const int>();
#ifdef DEBUG
  Serial.printf("Getting pin: %d state.\n", pin);
#endif
  // Set up digital read pins.
  pinMode(3, FUNCTION_3);
  int pinStatus = digitalRead(pin);
#ifdef DEBUG
  Serial.printf("Pin %d state is %d.\n", pin, pinStatus);
#endif
  // Announce the action.
  DynamicJsonDocument msg(MQTT_PAYLOAD_MAX_LENGTH);
  msg["pin"] = pin;
  switch (pinStatus) {
    case 1:
      msg["state"] = "on";
      break;
    case 0:
      msg["state"] = "off";
      break;
    default:
      msg["state"] = "unknown";
      break;
  }
 
  return msg;
}
 
DynamicJsonDocument mqttGpioMeasure(const DynamicJsonDocument doc) {
  const int pin = doc["pin"].as<const int>();
#ifdef DEBUG
  Serial.printf("Getting analog pin %d state...\n", pin);
#endif
  int analogValue = analogRead(pin);
#ifdef DEBUG
  Serial.printf("Value of analog pin %d is %d.\n", pin, analogValue);
#endif
  // Announce the analog value.
  DynamicJsonDocument msg(MQTT_PAYLOAD_MAX_LENGTH);
  msg["pin"] = pin;
  msg["value"] = analogValue;
  return msg;
}
 
char *mqttSerialize(const JsonDocument doc, size_t maxLength) {
  char* buff = (char*) malloc(maxLength * sizeof(char));
  serializeJson(doc, buff, maxLength);
  return buff;
}
 
void mqttCallback(char *topic, byte *payload, unsigned int length) {
  String msgTopic = String(topic);
  // do not listen on topics not subscribed to or on empty topics
  if(msgTopic.length() == 0 || !msgTopic.equals(getMqttTopic())) {
    return;
  }
 
  // payload is not null terminated and casting will not work
  char* msgPayload = (char*) malloc((length +1) * sizeof(char));
  snprintf(msgPayload, length + 1, "%s", payload);
 
#ifdef DEBUG
  Serial.printf("Message received on topic %s with payload %s...\n", topic, msgPayload);
#endif
 
  // Parse the payload sent to the MQTT topic as a JSON document.
  DynamicJsonDocument doc(MQTT_PAYLOAD_MAX_LENGTH);
#ifdef DEBUG
  Serial.println("Deserializing message...");
#endif
  DeserializationError error = deserializeJson(doc, msgPayload);
  if (error) {
#ifdef DEBUG
    Serial.println("Failed to parse MQTT payload as JSON: " + String(error.c_str()));
#endif
    free(msgPayload);
    return;
  }
 
  free(msgPayload);
 
  // Do not process messages without an action key.
  if (!doc.containsKey("action")) {
    return;
  }
 
  String action = String(doc["action"].as<const char*>());
  // normalize action
  action.trim();
  action.toUpperCase();
  if(action.length() == 0) {
#ifdef DEBUG
    Serial.println("Empty action provided.");
#endif
    return;
  }
  DynamicJsonDocument msg(MQTT_PAYLOAD_MAX_LENGTH);
  msg["execute"] = doc;
  switch(mqttGpioActionHash(action.c_str())) {
    case mqttGpioActionHash("SET"):
      mqttGpioSet(doc);
      break;
    case mqttGpioActionHash("GET"):
      msg = mqttGpioGet(doc);
      break;
    case mqttGpioActionHash("DEBOUNCE"):
      mqttGpioDebounce(doc);
      break;
    case mqttGpioActionHash("MEASURE"):
      msg = mqttGpioMeasure(doc);
      break;
  }
 
  msgPayload = mqttSerialize(msg, MQTT_PAYLOAD_MAX_LENGTH);
  mqttClient.publish(getMqttTopic().c_str(), msgPayload);
  free(msgPayload);
}
 
bool mqttConnect() {
#ifdef DEBUG
  Serial.println("Attempting to connect to MQTT broker: " + String(MQTT_HOST));
#endif
  mqttClient.setServer(MQTT_HOST, MQTT_PORT);
 
  DynamicJsonDocument msg(MQTT_PAYLOAD_MAX_LENGTH);
  if (mqttClient.connect(getMqttId().c_str(), MQTT_USERNAME, MQTT_PASSWORD)) {
#ifdef DEBUG
    Serial.println("Established connection with MQTT broker using client ID: " + getMqttId());
#endif
    mqttClient.setCallback(mqttCallback);
    msg["action"] = "connected";
    char *payload = mqttSerialize(msg, MQTT_PAYLOAD_MAX_LENGTH);
    mqttClient.publish(getMqttTopic().c_str(), payload);
    free(payload);
#ifdef DEBUG
    Serial.println("Attempting to subscribe to MQTT topic: " + getMqttTopic());
#endif
    if (!mqttClient.subscribe(getMqttTopic().c_str())) {
#ifdef DEBUG
      Serial.println("Failed to subscribe to MQTT topic: " + getMqttTopic());
#endif
      return false;
    }
#ifdef DEBUG
    Serial.println("Subscribed to MQTT topic: " + getMqttTopic());
#endif
    msg["action"] = "subscribed";
    payload = mqttSerialize(msg, MQTT_PAYLOAD_MAX_LENGTH);
    mqttClient.publish(getMqttTopic().c_str(), payload);
    free(payload);
    return true;
  }
 
#ifdef DEBUG
  Serial.println("Connection to MQTT broker failed with MQTT client state: " + String(mqttClient.state()));
#endif
  return false;
}
 
///////////////////////////////////////////////////////////////////////////
//  Arduino loop                                                         //
///////////////////////////////////////////////////////////////////////////
void mqttTickCallback(void) {
  if(!networkConnected) {
    return;
  }
 
  // Process MQTT client loop.
  if (mqttClient.connected()) {
    mqttClient.loop();
    return;
  }
 
  if (!mqttConnect()) {
#ifdef DEBUG
    Serial.printf("Unable to connect to MQTT\n");
#endif
  }
}
 
///////////////////////////////////////////////////////////////////////////
//  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<const char*>();
  }
  // 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<length; ++i) {
    payload[i] = alphabet[random(16)];
  }
  payload[i] = '\0';
  return payload;
}
 
///////////////////////////////////////////////////////////////////////////
//  get WiFi MAC address                                                 //
///////////////////////////////////////////////////////////////////////////
byte* getHardwareAddress(void) {
  // get mac address
  byte* mac = (byte *)malloc(6 * sizeof(byte));
#if defined(ARDUINO_ARCH_ESP8266)
  WiFi.macAddress(mac);
#elif defined(ARDUINO_ARCH_ESP32)
  Network.macAddress(mac);
#endif
  return mac;
}
 
///////////////////////////////////////////////////////////////////////////
//  convert MAC address to string                                        //
///////////////////////////////////////////////////////////////////////////
char* getHardwareAddress(char colon) {
  byte* mac = getHardwareAddress();
  char* buff = (char *)malloc(18 * sizeof(char));
  sprintf(buff, "%02x%c%02x%c%02x%c%02x%c%02x%c%02x", 
    mac[0], 
    colon,
    mac[1],
    colon,
    mac[2],
    colon, 
    mac[3],
    colon, 
    mac[4],
    colon, 
    mac[5]
  );
 
  free(mac);
  return buff;
}
 
///////////////////////////////////////////////////////////////////////////
//  get WiFi soft AP                                                     //
///////////////////////////////////////////////////////////////////////////
String computeTemporarySsid(void) {
  byte* mac = getHardwareAddress();
  String ssid = String(mac[0] ^ mac[1] ^ mac[2] ^ mac[3] ^ mac[4] ^ mac[5], DEC);
  free(mac);
  return ssid;
}
 
///////////////////////////////////////////////////////////////////////////
//  serve WiFi AP                                                        //
///////////////////////////////////////////////////////////////////////////
void serverWifiTickCallback(void) {
  if(rebootPending) {
    return;
  }
 
  // create the boot Ssid
  String temporarySsid = computeTemporarySsid();
  if(WiFi.softAPSSID().equals(temporarySsid)) {
    // run WiFi server loops
    server.handleClient();
 
    if(blinkDigitsDahTick.state() == STOPPED) {
      temporarySsidLength = temporarySsid.length();
      temporarySsidNumbers = (int *) malloc(temporarySsidLength * sizeof(int));
      for(int i = 0; i < temporarySsidLength; ++i) {
        temporarySsidNumbers[i] = temporarySsid[i] - '0';
      }
#ifdef DEBUG
      //Serial.printf("Started blinking...\n");
#endif
      temporarySsidIndex = 0;
      blinkDigitsDahTick.start();
    }
    return;
  }
 
#ifdef DEBUG
  Serial.println("Starting HTTP server for Wifi server.");
#endif
  // handle HTTP REST requests
  server.on("/", handleRootHttpRequest);
  server.on("/setup", handleSetupHttpRequest);
  server.onNotFound(handleHttpNotFound);
 
#ifdef DEBUG
  Serial.println("Ensure HTTP headers are collected by the HTTP server.");
#endif
#if defined(ARDUINO_ARCH_ESP8266)
  server.collectHeaders("Cookie");
#elif defined(ARDUINO_ARCH_ESP32)
  const char* collectHeaders[] = { "Cookie" };
  size_t headerkeyssize = sizeof(collectHeaders) / sizeof(char *);
  server.collectHeaders(collectHeaders, headerkeyssize);
#endif
 
  // the soft AP (or WiFi) must be started before the HTTP server or it will result in a crash on ESP32
#ifdef DEBUG
  Serial.println("Starting temporary AP.");
#endif
  WiFi.softAP(temporarySsid, String(), 1, false, 1);
 
#ifdef DEBUG
  Serial.println("Starting HTTP server.");
#endif
  server.begin();
}
 
///////////////////////////////////////////////////////////////////////////
//  connect to WiFi                                                      //
///////////////////////////////////////////////////////////////////////////
void clientWifiTickCallback(void) {
  if(rebootPending) {
    return;
  }
 
  unsigned long callbackCount = clientWifiTick.counter();
#ifdef DEBUG
  //Serial.printf("Client tick %lu\n", callbackCount);
#endif
  if(callbackCount == 1) {
    #ifdef DEBUG
    Serial.printf("Rescheduling client WiFi to check every 10s...\n");
    #endif
    clientWifiTick.interval(WIFI_RETRY_TIMEOUT);
    clientWifiTick.resume();
  }
 
  // if WiFi is already connected or a reboot is pending just bail out
  if(WiFi.status() == WL_CONNECTED) {
#ifdef DEBUG
    Serial.printf("WiFi IP: %s, Subscribed to MQTT topic '%s' on %s\n", WiFi.localIP().toString().c_str(), getMqttTopic().c_str(), MQTT_HOST);
#endif
    connectionTries = 0;
    networkConnected = true;
    return;
  }
 
  networkConnected = false;
 
  DynamicJsonDocument configuration = getConfiguration(CONFIGURATION_FILE_NAME, CONFIGURATION_MAX_LENGTH);
  // too many retries so reboot to soft AP
  if(++connectionTries > 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<String>());
#elif defined(ARDUINO_ARCH_ESP32)
  WiFi.setHostname(configuration["name"].as<const char*>());
#endif
  String Ssid = configuration["Ssid"].as<String>();
  String password = configuration["password"].as<String>();
#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);
}