arduino-sketches – Rev 36
?pathlinks?
///////////////////////////////////////////////////////////////////////////
// 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 <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>
// 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(
<!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">
<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
// set server mode
runtimeExecutionState = SERVER;
return;
}
runtimeExecutionState = CLIENT;
// setup OTA
ArduinoOTA.setHostname(configuration["name"].as<const char *>());
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<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 serverWifi(void) {
if(rebootPending) {
return;
}
String softAp = WiFi.softAPSSID();
#ifdef DEBUG
Serial.printf("Checking whether the Wifi server is running %s\n", softAp);
#endif
// create the boot Ssid
String temporarySsid = computeTemporarySsid();
if(softAp.equals(temporarySsid)) {
// run WiFi server loops
server.handleClient();
#ifdef DEBUG
Serial.printf("ESP not configured, blinking soft AP SSID %s\n", temporarySsid.c_str());
#endif
int lengthSsid = temporarySsid.length();
int buff[lengthSsid];
for(int i = 0; i < lengthSsid; ++i) {
buff[i] = temporarySsid[i] - '0';
}
blinkDigits(buff, lengthSsid, blinkDigitsIdle);
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 clientWifi(void) {
if(rebootPending) {
return;
}
// only execute the check every WIFI_RETRY_TIMEOUT milliseconds
const unsigned long currentTime = millis();
if(currentTime - lastWifiExecuteTime < WIFI_RETRY_TIMEOUT) {
return;
}
// if WiFi is already connected or a reboot is pending just bail out
if(WiFi.status() == WL_CONNECTED || rebootPending) {
#ifdef DEBUG
Serial.printf("WiFi IP: %s\n", WiFi.localIP().toString().c_str());
#endif
if(otaStarted) {
ArduinoOTA.begin();
otaStarted = true;
}
lastWifiExecuteTime = millis();
return;
}
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 with Ssid [%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>();
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();
}