mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-10-28 11:14:58 +00:00
933 lines
34 KiB
C++
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);
|
|
}
|