mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-10-28 11:14:58 +00:00
add recovery firmware with OTA updater and recovery web interface
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,840 @@
|
||||
// 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.
|
||||
|
||||
#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 <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;
|
||||
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;
|
||||
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;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
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()) {
|
||||
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<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;
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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<unsigned>(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<unsigned>(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<unsigned>(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<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() {
|
||||
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/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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#define RECOVERY_VERSION "1.0.0"
|
||||
Reference in New Issue
Block a user