// EMS-ESP Recovery // // Minimal application that lives in the factory ("boot") partition. It brings up a WiFi access point // and connects to the network with the saved EMS-ESP settings (Ethernet, else WiFi, with the static IP // if one is set), and serves a cut-down WebUI to upload a new EMS-ESP firmware into the OTA partitions // or select which OTA partition to boot. // // The EMS-ESP settings in LittleFS are only ever read, never written, except for a factory reset // which erases them. #include #include #include #include #include #include #include #include #include #include #if CONFIG_IDF_TARGET_ESP32 #include #endif #include #include #include #include #include #include "WWWData.h" // generated by `pnpm build-recovery` in ./interface #include "recovery_version.h" #ifndef FACTORY_AP_CHANNEL #define FACTORY_AP_CHANNEL 1 #endif static constexpr size_t MIN_FIRMWARE_SIZE = 512 * 1024; static constexpr uint32_t RESTART_DELAY_MS = 500; static constexpr uint32_t ETH_WAIT_MS = 15000; // how long Ethernet gets to obtain an address before WiFi is tried static constexpr char NVS_NAMESPACE[] = "ems-esp"; static constexpr char NVS_FRESH_FW[] = "fresh_firmware"; // EMSESP_NVS_BOOT_NEW_FIRMWARE in emsesp.h static constexpr char NVS_REASON[] = "rec_reason"; // EMSESP_NVS_RECOVERY_REASON in emsesp.h #define LOG(...) \ do { \ Serial.print("[recovery] "); \ Serial.printf(__VA_ARGS__); \ Serial.println(); \ } while (0) struct AdminUser { String username; String password; }; struct Settings { String ap_ssid = FACTORY_AP_SSID; String ap_password = FACTORY_AP_PASSWORD; uint8_t ap_channel = FACTORY_AP_CHANNEL; IPAddress ap_ip; IPAddress ap_gateway; IPAddress ap_subnet; String wifi_ssid = FACTORY_WIFI_SSID; String wifi_password = FACTORY_WIFI_PASSWORD; String hostname = FACTORY_NETWORK_HOSTNAME; bool static_ip = false; IPAddress local_ip; IPAddress gateway_ip; IPAddress subnet_mask; IPAddress dns_ip_1; IPAddress dns_ip_2; bool eth_10mbit = false; uint8_t phy_type = 0; // PHY_type in src/core/system.h, 0 is no Ethernet int8_t eth_power = -1; uint8_t eth_phy_addr = 0; uint8_t eth_clock_mode = 0; bool disable_reset = false; // factory reset is disabled in the EMS-ESP settings std::vector admins; }; struct PartitionInfo { String label; bool factory; uint32_t address; uint32_t size; bool valid; uint32_t image_size; String version; uint32_t install_date; // UTC epoch, 0 if unknown String ota_state; // bootloader rollback state, empty if the partition has never been selected }; static AsyncWebServer server(80); static DNSServer dnsServer; static Settings settings; static OtaUpdater ota; static std::vector partitions; static uint32_t restart_at = 0; static String session_token; static String ap_ssid; static String recovery_reason; // set by EMS-ESP when it switched to the recovery static const char * eth_state = ""; // "", "disabled", "failed" or "started" static uint32_t wifi_join_at = 0; // when to try WiFi if Ethernet has no address, 0 if not waiting static bool wifi_started = false; static bool ethConnected(); // upload state, only one upload at a time static int upload_error = 0; static bool upload_done = false; static String upload_target; // ---------------------------------------------------------------------------- // settings (read-only) // ---------------------------------------------------------------------------- static bool readJsonFile(const char * path, JsonDocument & doc) { File file = LittleFS.open(path, "r"); if (!file) { return false; } const DeserializationError error = deserializeJson(doc, file); file.close(); if (error) { LOG("Failed to parse %s: %s", path, error.c_str()); return false; } return true; } static IPAddress parseIP(JsonVariantConst value, const char * fallback) { IPAddress ip; if (!ip.fromString(value | fallback)) { ip.fromString(fallback); } return ip; } static void loadSettings() { // never format the filesystem, it holds the EMS-ESP settings const bool mounted = LittleFS.begin(false); LOG("LittleFS %s", mounted ? "mounted" : "not available, using factory settings"); JsonDocument doc; if (mounted && readJsonFile("/config/apSettings.json", doc)) { settings.ap_ssid = doc["ssid"] | FACTORY_AP_SSID; settings.ap_password = doc["password"] | FACTORY_AP_PASSWORD; settings.ap_channel = doc["channel"] | FACTORY_AP_CHANNEL; } settings.ap_ip = parseIP(doc["local_ip"], FACTORY_AP_LOCAL_IP); settings.ap_gateway = parseIP(doc["gateway_ip"], FACTORY_AP_GATEWAY_IP); settings.ap_subnet = parseIP(doc["subnet_mask"], FACTORY_AP_SUBNET_MASK); doc.clear(); if (mounted && readJsonFile("/config/networkSettings.json", doc)) { settings.wifi_ssid = doc["ssid"] | FACTORY_WIFI_SSID; settings.wifi_password = doc["password"] | FACTORY_WIFI_PASSWORD; settings.hostname = doc["hostname"] | FACTORY_NETWORK_HOSTNAME; settings.eth_10mbit = doc["eth_10mbit"] | false; settings.local_ip.fromString(doc["local_ip"] | ""); settings.gateway_ip.fromString(doc["gateway_ip"] | ""); settings.subnet_mask.fromString(doc["subnet_mask"] | ""); settings.dns_ip_1.fromString(doc["dns_ip_1"] | ""); settings.dns_ip_2.fromString(doc["dns_ip_2"] | ""); settings.static_ip = (doc["static_ip_config"] | false) && settings.local_ip != IPAddress(); } // the Ethernet PHY settings come from the board profile, stored with the EMS-ESP application settings doc.clear(); if (mounted && readJsonFile("/config/emsespSettings.json", doc)) { settings.phy_type = doc["phy_type"] | 0; settings.eth_power = doc["eth_power"] | -1; settings.eth_phy_addr = doc["eth_phy_addr"] | 0; settings.eth_clock_mode = doc["eth_clock_mode"] | 0; settings.disable_reset = doc["disable_reset"] | false; } doc.clear(); if (mounted && readJsonFile("/config/securitySettings.json", doc)) { for (JsonObject user : doc["users"].as()) { if (user["admin"] | false) { settings.admins.push_back({user["username"] | "", user["password"] | ""}); } } } if (settings.admins.empty()) { // the admin user couldn't be read, so sign in with the factory login but never allow a factory reset settings.disable_reset = true; settings.admins.push_back({FACTORY_ADMIN_USERNAME, FACTORY_ADMIN_PASSWORD}); } if (mounted) { LittleFS.end(); } } // ---------------------------------------------------------------------------- // partitions // ---------------------------------------------------------------------------- static bool openNvs(Preferences & nvs, bool readOnly) { if (esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "nvs1")) { return nvs.begin(NVS_NAMESPACE, readOnly, "nvs1"); } return nvs.begin(NVS_NAMESPACE, readOnly, "nvs"); } static const char * otaStateName(esp_ota_img_states_t state) { switch (state) { case ESP_OTA_IMG_NEW: return "new"; case ESP_OTA_IMG_PENDING_VERIFY: return "pending"; case ESP_OTA_IMG_VALID: return "valid"; case ESP_OTA_IMG_INVALID: return "invalid"; case ESP_OTA_IMG_ABORTED: return "aborted"; default: return ""; } } static const char * resetReasonName(esp_reset_reason_t reason) { switch (reason) { case ESP_RST_POWERON: return "power_on"; case ESP_RST_EXT: return "external"; case ESP_RST_SW: return "software"; case ESP_RST_PANIC: return "panic"; case ESP_RST_INT_WDT: case ESP_RST_TASK_WDT: case ESP_RST_WDT: return "watchdog"; case ESP_RST_BROWNOUT: return "brownout"; default: return "other"; } } // one-shot, so a later restart into the recovery doesn't show a stale reason static void loadRecoveryReason() { Preferences nvs; if (!openNvs(nvs, false)) { return; } if (nvs.isKey(NVS_REASON)) { recovery_reason = nvs.getString(NVS_REASON); nvs.remove(NVS_REASON); } nvs.end(); } static void loadPartitions() { partitions.clear(); Preferences nvs; const bool has_nvs = openNvs(nvs, true); const esp_partition_t * running = esp_ota_get_running_partition(); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); for (; it != nullptr; it = esp_partition_next(it)) { const esp_partition_t * part = esp_partition_get(it); PartitionInfo info; info.label = part->label; info.factory = part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY; info.address = part->address; info.size = part->size; info.image_size = 0; info.install_date = 0; esp_image_metadata_t meta = {}; esp_partition_pos_t pos = {.offset = part->address, .size = part->size}; info.valid = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &pos, &meta) == ESP_OK; if (info.valid) { info.image_size = meta.image_len; } esp_ota_img_states_t state; if (!info.factory && esp_ota_get_state_partition(part, &state) == ESP_OK) { info.ota_state = otaStateName(state); } if (part == running) { info.version = RECOVERY_VERSION; } else if (info.valid) { // EMS-ESP stores the version and install date of each partition in NVS, keyed by the label if (has_nvs && nvs.isKey(part->label)) { info.version = nvs.getString(part->label); info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0); } esp_app_desc_t desc; if (info.version.isEmpty() && esp_ota_get_partition_description(part, &desc) == ESP_OK) { info.version = desc.version; } } partitions.push_back(info); } esp_partition_iterator_release(it); if (has_nvs) { nvs.end(); } } static const esp_partition_t * findAppPartition(const char * label) { return esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, label); } static bool failedToStart(const PartitionInfo & info) { return info.ota_state == "aborted" || info.ota_state == "invalid"; } // An empty OTA partition if there is one, then one whose firmware failed to start, so a working firmware // is kept. Otherwise the one with the oldest install date, keeping the most recent firmware as a fallback. static const esp_partition_t * defaultUploadTarget() { const PartitionInfo * best = nullptr; auto rank = [](const PartitionInfo & info) { return !info.valid ? 0 : failedToStart(info) ? 1 : 2; }; for (const auto & info : partitions) { if (info.factory) { continue; } if (best == nullptr || rank(info) < rank(*best) || (rank(info) == rank(*best) && info.install_date < best->install_date)) { best = &info; } } return best ? findAppPartition(best->label.c_str()) : esp_ota_get_next_update_partition(nullptr); } static const esp_partition_t * uploadTarget(AsyncWebServerRequest * request) { if (!request->hasParam("partition")) { return defaultUploadTarget(); } const esp_partition_t * part = findAppPartition(request->getParam("partition")->value().c_str()); if (part == nullptr || part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY || part == esp_ota_get_running_partition()) { return nullptr; } return part; } // tells EMS-ESP it's running a newly flashed firmware, and drops the now stale version info for that partition static void markFreshFirmware(const String & label) { Preferences nvs; if (!openNvs(nvs, false)) { return; } nvs.putBool(NVS_FRESH_FW, true); if (!label.isEmpty()) { nvs.remove(label.c_str()); nvs.remove(("d_" + label).c_str()); } nvs.end(); } // ---------------------------------------------------------------------------- // authentication // ---------------------------------------------------------------------------- static String generateToken() { uint8_t bytes[16]; esp_fill_random(bytes, sizeof(bytes)); String token; token.reserve(sizeof(bytes) * 2); for (uint8_t b : bytes) { char hex[3]; snprintf(hex, sizeof(hex), "%02x", b); token += hex; } return token; } static bool isAuthorized(AsyncWebServerRequest * request) { if (session_token.isEmpty() || !request->hasHeader("Authorization")) { return false; } return request->header("Authorization") == "Bearer " + session_token; } // ---------------------------------------------------------------------------- // web handlers // ---------------------------------------------------------------------------- class StaticContentHandler : public AsyncWebHandler { public: bool canHandle(AsyncWebServerRequest * request) const override { return request->method() == HTTP_GET || request->method() == HTTP_HEAD; } void handleRequest(AsyncWebServerRequest * request) override { const WWWAsset * asset = lookup(request->url().c_str()); if (asset == nullptr) { asset = lookup("/index.html"); // SPA fallback, also catches captive portal probes } if (asset == nullptr) { request->send(404); return; } const String & inm = request->header(asyncsrv::T_INM); if (inm.length() != 0 && strcmp(inm.c_str(), asset->etag) == 0) { request->send(304); return; } AsyncWebServerResponse * response = request->beginResponse(200, asset->contentType, asset->content, asset->len); response->addHeader(asyncsrv::T_Content_Encoding, asyncsrv::T_gzip, false); response->addHeader(asyncsrv::T_ETag, asset->etag, false); request->send(response); } private: static const WWWAsset * lookup(const char * url) { for (size_t i = 0; i < WWW_ASSETS_COUNT; i++) { if (strcmp(WWW_ASSETS[i].uri, url) == 0) { return &WWW_ASSETS[i]; } } return nullptr; } }; static void handleStatus(AsyncWebServerRequest * request) { auto * response = new AsyncJsonResponse(); JsonObject root = response->getRoot().to(); root["recovery_version"] = RECOVERY_VERSION; root["authenticated"] = isAuthorized(request); root["hostname"] = settings.hostname; root["chip"] = ESP.getChipModel(); root["flash_size"] = ESP.getFlashChipSize(); root["psram_size"] = ESP.getPsramSize(); root["free_heap"] = ESP.getFreeHeap(); root["mac"] = WiFi.macAddress(); root["uptime"] = millis() / 1000; root["reason"] = recovery_reason; root["reset_reason"] = resetReasonName(esp_reset_reason()); root["ap_ssid"] = ap_ssid; root["ap_ip"] = WiFi.softAPIP().toString(); root["ap_clients"] = WiFi.softAPgetStationNum(); root["wifi_ssid"] = settings.wifi_ssid; root["wifi_connected"] = WiFi.isConnected(); if (WiFi.isConnected()) { root["wifi_ip"] = WiFi.localIP().toString(); root["wifi_rssi"] = WiFi.RSSI(); } root["wifi_started"] = wifi_started; root["static_ip"] = settings.static_ip; root["disable_reset"] = settings.disable_reset; // only on boards with Ethernet configured if (eth_state[0] != '\0') { #if CONFIG_IDF_TARGET_ESP32 if (ethConnected()) { root["eth_state"] = "connected"; root["eth_ip"] = ETH.localIP().toString(); } else if (strcmp(eth_state, "started") == 0) { root["eth_state"] = ETH.linkUp() ? "connecting" : "no_link"; } else #endif { root["eth_state"] = eth_state; } } const esp_partition_t * boot = esp_ota_get_boot_partition(); const esp_partition_t * running = esp_ota_get_running_partition(); const esp_partition_t * target = defaultUploadTarget(); root["running"] = running ? running->label : ""; root["boot"] = boot ? boot->label : ""; root["upload_target"] = target ? target->label : ""; JsonArray parts = root["partitions"].to(); for (const auto & info : partitions) { JsonObject p = parts.add(); p["label"] = info.label; p["factory"] = info.factory; p["address"] = info.address; p["size"] = info.size; p["valid"] = info.valid; p["image_size"] = info.image_size; p["version"] = info.version; p["install_date"] = info.install_date; p["ota_state"] = info.ota_state; } response->setLength(); request->send(response); } static void handleSignIn(AsyncWebServerRequest * request, JsonVariant & json) { const String username = json["username"] | ""; const String password = json["password"] | ""; for (const auto & admin : settings.admins) { if (admin.username == username && admin.password == password) { session_token = generateToken(); auto * response = new AsyncJsonResponse(); JsonObject root = response->getRoot().to(); root["access_token"] = session_token; response->setLength(); request->send(response); LOG("User %s signed in", username.c_str()); return; } } LOG("Failed sign in for user %s", username.c_str()); request->send(401); } static void handleSetBoot(AsyncWebServerRequest * request, JsonVariant & json) { if (!isAuthorized(request)) { request->send(401); return; } const String label = json["partition"] | ""; const esp_partition_t * part = findAppPartition(label.c_str()); if (part == nullptr) { request->send(404); return; } // esp_ota_set_boot_partition() validates the image first const esp_err_t err = esp_ota_set_boot_partition(part); if (err != ESP_OK) { LOG("Cannot boot from %s: %s", part->label, esp_err_to_name(err)); request->send(400); return; } LOG("Next boot from %s, restarting", part->label); request->send(200); restart_at = millis() + RESTART_DELAY_MS; } static void handleRestart(AsyncWebServerRequest * request) { if (!isAuthorized(request)) { request->send(401); return; } LOG("Restart requested"); request->send(200); restart_at = millis() + RESTART_DELAY_MS; } // factory reset, erases all EMS-ESP settings and restarts // only signed in admins are authorized (the recovery only accepts admin users), and only if not disabled in the settings static void handleFactoryReset(AsyncWebServerRequest * request) { if (!isAuthorized(request)) { request->send(401); return; } if (settings.disable_reset) { LOG("Factory reset disabled"); request->send(403); return; } const bool ok = LittleFS.format(); // the only place the recovery writes to the filesystem LOG("Factory reset %s", ok ? "done" : "failed"); if (!ok) { request->send(500); return; } request->send(200); restart_at = millis() + RESTART_DELAY_MS; } static void uploadFail(AsyncWebServerRequest * request, int code) { if (upload_error) { return; } upload_error = code; LOG("Upload error %d", code); request->send(code); if (code == 406) { request->client()->close(); } } static void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final) { if (!index) { upload_error = 0; upload_done = false; upload_target.clear(); if (!isAuthorized(request)) { uploadFail(request, 403); return; } const size_t filesize = request->contentLength(); switch (OtaUpdater::classify(filename, filesize, MIN_FIRMWARE_SIZE)) { case OtaUpdater::FileType::FIRMWARE: break; case OtaUpdater::FileType::MD5: if (ota.setMd5(data, len)) { LOG("MD5 digest received (%s)", ota.md5()); } else { uploadFail(request, 406); } return; default: // the recovery app only flashes firmware, it never touches the settings ota.clearMd5(); LOG("Unsupported file %s (%u bytes)", filename.c_str(), static_cast(filesize)); uploadFail(request, 406); return; } if (!OtaUpdater::isCompatibleFirmware(data, len)) { uploadFail(request, 503); return; } if (OtaUpdater::isRecoveryFirmware(data, len)) { uploadFail(request, 409); return; } const esp_partition_t * target = uploadTarget(request); if (target == nullptr) { uploadFail(request, 400); return; } if (!ota.beginFirmware(filesize, target)) { LOG("Cannot write to %s: %s", target->label, ota.errorString()); uploadFail(request, 507); return; } upload_target = target->label; LOG("Uploading %s (%uKB) to %s%s", filename.c_str(), static_cast(filesize / 1024), target->label, ota.md5Applied() ? ", MD5 check enabled" : ""); request->onDisconnect([] { if (!upload_done && !upload_target.isEmpty()) { ota.abort(); } }); } if (upload_error || upload_target.isEmpty()) { return; } if (!ota.write(data, len)) { LOG("Write failed at %u: %s", static_cast(ota.progress()), ota.errorString()); ota.abort(); uploadFail(request, 500); return; } if (final) { // on success this also makes the new partition the boot partition if (!ota.end()) { LOG("Upload failed: %s", ota.errorString()); uploadFail(request, 500); return; } upload_done = true; } } static void handleUploadComplete(AsyncWebServerRequest * request) { if (upload_error) { return; // response already sent } if (upload_done) { LOG("Firmware written to %s", upload_target.c_str()); markFreshFirmware(upload_target); loadPartitions(); // treat the new upload as the most recent, so it isn't the default target for the next one for (auto & info : partitions) { if (info.label == upload_target) { info.install_date = UINT32_MAX; } } if (ota.md5Applied()) { auto * response = new AsyncJsonResponse(); JsonObject root = response->getRoot().to(); root["md5_ok"] = true; response->setLength(); request->send(response); } else { request->send(200); } ota.clearMd5(); return; } if (ota.hasMd5()) { auto * response = new AsyncJsonResponse(); JsonObject root = response->getRoot().to(); root["md5"] = ota.md5(); response->setLength(); request->send(response); return; } request->send(500); } // ---------------------------------------------------------------------------- // setup & loop // ---------------------------------------------------------------------------- static void joinWiFi() { wifi_join_at = 0; if (settings.wifi_ssid.isEmpty()) { return; } WiFi.mode(WIFI_AP_STA); if (settings.static_ip) { WiFi.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2); } WiFi.begin(settings.wifi_ssid.c_str(), settings.wifi_password.c_str()); wifi_started = true; LOG("Connecting to WiFi %s%s", settings.wifi_ssid.c_str(), settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : ""); } // same bring-up as Network::startEthernet() in src/core/network.cpp static bool startEthernet() { #if CONFIG_IDF_TARGET_ESP32 if (settings.phy_type == 0) { return false; } const auto clock = static_cast(settings.eth_clock_mode); if (ESP.getPsramSize() && (clock == ETH_CLOCK_GPIO16_OUT || clock == ETH_CLOCK_GPIO17_OUT)) { LOG("Ethernet clock mode %d conflicts with PSRAM, Ethernet disabled", settings.eth_clock_mode); eth_state = "disabled"; return false; } // reset the PHY, it doesn't always start after a warm boot if (settings.eth_power != -1) { pinMode(settings.eth_power, OUTPUT); digitalWrite(settings.eth_power, LOW); delay(500); digitalWrite(settings.eth_power, HIGH); } if (settings.eth_10mbit) { ETH.setAutoNegotiation(false); ETH.setLinkSpeed(10); ETH.setFullDuplex(false); } const eth_phy_type_t type = settings.phy_type == 1 ? ETH_PHY_LAN8720 : settings.phy_type == 2 ? ETH_PHY_TLK110 : ETH_PHY_RTL8201; if (!ETH.begin(type, settings.eth_phy_addr, 23, 18, settings.eth_power, clock)) { LOG("Ethernet failed to start, no PHY found"); eth_state = "failed"; return false; } if (settings.static_ip) { ETH.config(settings.local_ip, settings.gateway_ip, settings.subnet_mask, settings.dns_ip_1, settings.dns_ip_2); } eth_state = "started"; LOG("Ethernet started%s", settings.static_ip ? (" with IP " + settings.local_ip.toString()).c_str() : ""); return true; #else return false; #endif } static bool ethConnected() { #if CONFIG_IDF_TARGET_ESP32 return strcmp(eth_state, "started") == 0 && ETH.hasIP(); #else return false; #endif } static void startNetwork() { WiFi.persistent(false); WiFi.setHostname(settings.hostname.c_str()); // also the default for the Ethernet interface WiFi.mode(WIFI_AP); // max SSID length is 32 ap_ssid = settings.ap_ssid.substring(0, 23) + "-recovery"; WiFi.softAPConfig(settings.ap_ip, settings.ap_gateway, settings.ap_subnet); // WPA2 needs at least 8 characters, otherwise fall back to the factory password const String & ap_password = settings.ap_password.length() >= 8 ? settings.ap_password : String(FACTORY_AP_PASSWORD); WiFi.softAP(ap_ssid.c_str(), ap_password.c_str(), settings.ap_channel); LOG("Access point %s started on %s", ap_ssid.c_str(), WiFi.softAPIP().toString().c_str()); // like EMS-ESP, Ethernet first and WiFi only if Ethernet doesn't get an address, as both share a static IP if (startEthernet()) { wifi_join_at = millis() + ETH_WAIT_MS; } else { joinWiFi(); } dnsServer.start(53, "*", WiFi.softAPIP()); if (MDNS.begin(settings.hostname.c_str())) { MDNS.addService("http", "tcp", 80); } } static void startWebServer() { server.on("/rest/recovery/status", HTTP_GET, handleStatus); server.on("/rest/recovery/restart", HTTP_POST, handleRestart); server.on("/rest/recovery/format", HTTP_POST, handleFactoryReset); server.on("/rest/uploadFile", HTTP_POST, handleUploadComplete, handleUpload); server.addHandler(new AsyncCallbackJsonWebHandler("/rest/signIn", handleSignIn)); server.addHandler(new AsyncCallbackJsonWebHandler("/rest/recovery/boot", handleSetBoot)); server.addHandler(new StaticContentHandler()); // must be last server.begin(); } void setup() { Serial.begin(115200); LOG("EMS-ESP Recovery v%s", RECOVERY_VERSION); loadSettings(); loadRecoveryReason(); loadPartitions(); startNetwork(); startWebServer(); } void loop() { dnsServer.processNextRequest(); static bool was_connected = false; if (WiFi.isConnected() != was_connected) { was_connected = WiFi.isConnected(); if (was_connected) { LOG("WiFi connected, IP %s", WiFi.localIP().toString().c_str()); } } #if CONFIG_IDF_TARGET_ESP32 static bool eth_was_connected = false; if (ethConnected() != eth_was_connected) { eth_was_connected = ethConnected(); if (eth_was_connected) { LOG("Ethernet connected, IP %s", ETH.localIP().toString().c_str()); } } #endif if (wifi_join_at && (int32_t)(millis() - wifi_join_at) >= 0) { if (ethConnected()) { wifi_join_at = 0; } else { LOG("No Ethernet connection"); joinWiFi(); } } if (restart_at && (int32_t)(millis() - restart_at) >= 0) { ESP.restart(); } delay(10); }