/////////////////////////////////////////////////////////////////////////// // 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 supplements the software side of the HAM radio antenna // // commuter project that can be found at the following addresss: // // * https://grimore.org/ham_radio/ // // designing_a_remotely_controlled_motorized_antenna_commuter // // // // The template is used for remotely controlling an antenna switcher // // such that a single antenna can be used for two different radios. The // // template also implements the WiFi preboot environment from: // // * https://grimore.org/arduino/wifipreboot // // in order to ensure that the device can be easily connected to the // // WiFi network even after a disconnect. // // // // HAM antenna switch-specific variables are: // // * GPIO_BUTTON_A // // * GPIO_BUTTON_B // // * SERVO_GPIO_PIN // // * SERVO_ANGLE_A // // * SERVO_ANGLE_B // // that correspond to the "A", respectively "B" buttons on the remote // // and "SERVO_GPIO_PIN" coresponds to the GPIO pin connected to the // // servo signal pin. Similarly, "SERVO_ANGLE_A" and "SERVO_ANGLE_B" are // // both the angles that correspond to the button "A", respectively the // // button "B" on the remote also corresponding to the "A", respectively // // "B" output ports of the antenna switch. // // // // These variables must be set before uploading the sketch because they // // cannot be set later at runtime. Setting the angle of the servo is // // mostly a matter of trial and error and there is no magic that would // // yield the correct values for the servo. // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // 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 // antenna commuter application #define GPIO_BUTTON_A D5 #define GPIO_BUTTON_B D7 #define SERVO_GPIO_PIN D4 #define SERVO_ANGLE_A 0 #define SERVO_ANGLE_B 130 /////////////////////////////////////////////////////////////////////////// // includes // /////////////////////////////////////////////////////////////////////////// #include #if defined(ARDUINO_ARCH_ESP32) #include #include #elif defined(ESP8266) #include #include #include #endif #include #include #include // Arduino OTA #include #include #include // antenna commuter application #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); // antenna commuter application void buttonReadTickCallback(void); void antennaSwitchTickCallback(void); void antennaSwitchOffTickCallback(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, 250); 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; // antenna commuter application TickTwo buttonReadTick(buttonReadTickCallback, 500); TickTwo antennaSwitchTick(antennaSwitchTickCallback, 500); TickTwo antennaSwitchOffTick(antennaSwitchOffTickCallback, 1000); typedef enum { BUTTON_NONE, BUTTON_A, BUTTON_B } antennaSwitch; antennaSwitch antennaDirection = BUTTON_NONE; // antenna commuter application Servo antennaServo; /////////////////////////////////////////////////////////////////////////// // 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(); // antenna commuter application pinMode(GPIO_BUTTON_A, INPUT_PULLUP); pinMode(GPIO_BUTTON_B, INPUT_PULLUP); buttonReadTick.start(); antennaSwitchTick.start(); } void loop() { arduinoOtaTick.update(); rebootTick.update(); clientWifiTick.update(); serverWifiTick.update(); blinkDigitsDitTick.update(); blinkDigitsDahTick.update(); blinkDigitsBlinkTick.update(); // antenna commuter application buttonReadTick.update(); antennaSwitchTick.update(); antennaSwitchOffTick.update(); } /////////////////////////////////////////////////////////////////////////// // end Arduino // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // antenna commuter application // /////////////////////////////////////////////////////////////////////////// void buttonReadTickCallback(void) { // D5 -> A, D7 -> B int a = digitalRead(GPIO_BUTTON_A); int b = digitalRead(GPIO_BUTTON_B); #ifdef DEBUG Serial.printf("Button A: %d, button B: %d\n", a, b); #endif if(a == LOW) { antennaDirection = BUTTON_A; return; } if(b == LOW) { antennaDirection = BUTTON_B; return; } antennaDirection = BUTTON_NONE; } void antennaSwitchOffTickCallback(void) { antennaSwitchOffTick.pause(); antennaServo.detach(); } void antennaSwitchTickCallback(void) { if(antennaDirection == BUTTON_NONE) { return; } antennaServo.attach(SERVO_GPIO_PIN); switch(antennaDirection) { case BUTTON_A: antennaServo.write(SERVO_ANGLE_A); break; case BUTTON_B: antennaServo.write(SERVO_ANGLE_B); break; } antennaSwitchOffTick.interval(1000); antennaSwitchOffTick.resume(); antennaDirection = BUTTON_NONE; } /////////////////////////////////////////////////////////////////////////// // 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); }