Files

933 lines
34 KiB
C++

// 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 <Arduino.h>
#include <ArduinoJson.h>
#include <AsyncJson.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <ESPmDNS.h>
#include <LittleFS.h>
#include <OtaUpdater.h>
#include <Preferences.h>
#include <WiFi.h>
#if CONFIG_IDF_TARGET_ESP32
#include <ETH.h>
#endif
#include <esp_image_format.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_random.h>
#include <algorithm>
#include <vector>
#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; // smallest .bin accepted by the upload, anything smaller is rejected
static constexpr uint32_t RESTART_DELAY_MS = 500; // delay before restarting, so the HTTP response is sent first
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<AdminUser> 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<PartitionInfo> 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;
static size_t upload_size = 0; // content length, for the progress
static size_t upload_step = 0; // last progress step logged, in tens of percent
// ----------------------------------------------------------------------------
// 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<JsonArray>()) {
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);
LOG("EMS-ESP switched to the recovery: %s", recovery_reason.c_str());
}
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;
// EMS-ESP stores the install date when it installs the recovery. Not set if it was flashed over serial
if (has_nvs) {
info.install_date = nvs.getULong(("d_" + info.label).c_str(), 0);
}
} 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;
}
}
LOG("Partition %s: %s%s, %uKB at 0x%x%s%s%s%s",
info.label.c_str(),
info.factory ? "factory, " : "",
info.valid ? "valid" : "empty",
static_cast<unsigned>(info.size / 1024),
static_cast<unsigned>(info.address),
info.version.isEmpty() ? "" : ", v",
info.version.c_str(),
info.ota_state.isEmpty() ? "" : ", ota state ",
info.ota_state.c_str());
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)) {
LOG("Cannot open NVS, EMS-ESP won't know it's a new firmware");
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) {
const bool ok = !session_token.isEmpty() && request->hasHeader("Authorization") && request->header("Authorization") == "Bearer " + session_token;
if (!ok) {
LOG("Unauthorized request to %s", request->url().c_str());
}
return ok;
}
// ----------------------------------------------------------------------------
// 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<JsonObject>();
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<JsonArray>();
for (const auto & info : partitions) {
JsonObject p = parts.add<JsonObject>();
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<JsonObject>();
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) {
LOG("Cannot boot from %s: no such partition", label.c_str());
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;
}
LOG("Factory reset requested");
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, const char * reason) {
if (upload_error) {
return;
}
upload_error = code;
LOG("Upload error %d: %s", code, reason);
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_size = 0;
upload_step = 0;
upload_target.clear();
if (!isAuthorized(request)) {
uploadFail(request, 403, "not signed in");
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, "invalid MD5 digest");
}
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<unsigned>(filesize));
uploadFail(request, 406, "unsupported file");
return;
}
if (!OtaUpdater::isCompatibleFirmware(data, len)) {
uploadFail(request, 503, "not a firmware image for this chip");
return;
}
if (OtaUpdater::isRecoveryFirmware(data, len)) {
uploadFail(request, 409, "the recovery firmware can't be installed from the recovery");
return;
}
const esp_partition_t * target = uploadTarget(request);
if (target == nullptr) {
if (request->hasParam("partition")) {
LOG("Invalid upload target %s", request->getParam("partition")->value().c_str());
}
uploadFail(request, 400, "no valid target partition");
return;
}
if (!ota.beginFirmware(filesize, target)) {
LOG("Cannot write to %s: %s", target->label, ota.errorString());
uploadFail(request, 507, "cannot start the update");
return;
}
upload_target = target->label;
upload_size = filesize;
LOG("Uploading %s (%uKB) to %s (%s)%s",
filename.c_str(),
static_cast<unsigned>(filesize / 1024),
target->label,
request->hasParam("partition") ? "selected" : "default target",
ota.md5Applied() ? ", MD5 check enabled" : "");
request->onDisconnect([] {
if (!upload_done && !upload_target.isEmpty()) {
LOG("Upload aborted at %uKB, the client disconnected", static_cast<unsigned>(ota.progress() / 1024));
ota.abort();
}
});
}
if (upload_error || upload_target.isEmpty()) {
return;
}
if (!ota.write(data, len)) {
LOG("Write failed at %u: %s", static_cast<unsigned>(ota.progress()), ota.errorString());
ota.abort();
uploadFail(request, 500, "write failed");
return;
}
// log the progress every 10%, the content length is a little more than the image so it may stop just short of 100%
if (upload_size > 0) {
const size_t step = std::min<size_t>(ota.progress() * 10 / upload_size, 10);
if (step > upload_step) {
upload_step = step;
LOG("Uploading to %s, %u%%", upload_target.c_str(), static_cast<unsigned>(step * 10));
}
}
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, "image check failed");
return;
}
LOG("Upload finished, the image is verified");
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<JsonObject>();
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<JsonObject>();
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<eth_clock_mode_t>(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() {
// the reason is logged once per change, since a missing network disconnects again every few seconds
WiFi.onEvent(
[](arduino_event_id_t, arduino_event_info_t info) {
static uint8_t last_reason = 0;
if (info.wifi_sta_disconnected.reason != last_reason) {
last_reason = info.wifi_sta_disconnected.reason;
LOG("WiFi disconnected, reason %d", last_reason);
}
},
ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
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);
const esp_partition_t * boot = esp_ota_get_boot_partition();
LOG("Reset reason: %s, running %s, next boot %s", resetReasonName(esp_reset_reason()), esp_ota_get_running_partition()->label, boot ? boot->label : "unknown");
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());
} else {
LOG("WiFi connection lost");
}
}
#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);
}