Add durable TempRadio, ACL, clock, bridge, GPS/I2C, USB logging, and Indicator recovery safeguards together with expanded automation, build/profile contracts, release metadata, and the hardware validation ledger.
1737 lines
64 KiB
C++
1737 lines
64 KiB
C++
#include "IndicatorFontClient.h"
|
|
|
|
#include <Arduino.h>
|
|
#include <esp32-hal-psram.h>
|
|
|
|
#include "IndicatorFontRecoveryPolicy.h"
|
|
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
#include <atomic>
|
|
#include <helpers/IndicatorFontStageV2Protocol.h>
|
|
#include <helpers/UsbLogging.h>
|
|
#include <helpers/esp32/SntpOperationCoordinator.h>
|
|
#include <helpers/esp32/TlsClockValidity.h>
|
|
#include <WiFi.h>
|
|
#include <WiFiClientSecure.h>
|
|
#include <esp_sntp.h>
|
|
#include <freertos/FreeRTOS.h>
|
|
#include <freertos/task.h>
|
|
#include <mbedtls/sha256.h>
|
|
#include <strings.h>
|
|
#include <time.h>
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
static const uint32_t FONT_UART_BAUD = 1000000;
|
|
static const int FONT_UART_RX = 20;
|
|
static const int FONT_UART_TX = 19;
|
|
static const size_t MAX_FONT_BYTES = 1536 * 1024;
|
|
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
// api.github.com's current all-ECC chain terminates at Sectigo Public Server
|
|
// Authentication Root E46. Trusting this P-384 root per client avoids both the
|
|
// process-global compressed-bundle state and the RSA-4096 allocation failures
|
|
// seen on Arduino-ESP32 2.x. Source-certificate SHA-256:
|
|
// c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383
|
|
// The self-signed root expires 2046-03-21.
|
|
static const char FONT_TLS_GITHUB_ROOT_CA[] PROGMEM = R"CERT(
|
|
-----BEGIN CERTIFICATE-----
|
|
MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQsw
|
|
CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T
|
|
ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcN
|
|
MjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYG
|
|
A1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT
|
|
ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA
|
|
IgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccC
|
|
WvkEN/U0NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+
|
|
6xnOQ6OjQjBAMB0GA1UdDgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8B
|
|
Af8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNnADBkAjAn7qRa
|
|
qCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RHlAFWovgzJQxC36oCMB3q
|
|
4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21USAGKcw==
|
|
-----END CERTIFICATE-----
|
|
)CERT";
|
|
|
|
static const uint8_t EXPECTED_SHA256[32] = {
|
|
0x61, 0xbc, 0xe9, 0x66, 0x2d, 0xb3, 0x14, 0x05,
|
|
0x4e, 0x7b, 0xcf, 0xaa, 0x26, 0x14, 0x7a, 0x28,
|
|
0xad, 0x7b, 0x50, 0x0b, 0x51, 0xba, 0xac, 0x4c,
|
|
0xae, 0x1c, 0xaa, 0xcc, 0xe9, 0x0b, 0x74, 0x21,
|
|
};
|
|
#endif
|
|
|
|
enum class FontInfoResult : uint8_t {
|
|
Present,
|
|
Missing,
|
|
Unavailable,
|
|
};
|
|
|
|
struct LoadedFont {
|
|
uint8_t* data = nullptr;
|
|
size_t size = 0;
|
|
FontInfoResult info = FontInfoResult::Unavailable;
|
|
bool currentAsset = false;
|
|
};
|
|
|
|
uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) {
|
|
for (size_t i = 0; i < size; ++i) {
|
|
crc ^= data[i];
|
|
for (uint8_t bit = 0; bit < 8; ++bit) {
|
|
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
|
}
|
|
}
|
|
return crc;
|
|
}
|
|
|
|
bool readLine(HardwareSerial& serial, char* line, size_t capacity,
|
|
uint32_t timeout_millis) {
|
|
size_t length = 0;
|
|
uint32_t started = millis();
|
|
while (millis() - started < timeout_millis) {
|
|
while (serial.available()) {
|
|
int value = serial.read();
|
|
if (value < 0) break;
|
|
if (value == '\n') {
|
|
line[length] = 0;
|
|
return true;
|
|
}
|
|
if (value != '\r' && length + 1 < capacity) {
|
|
line[length++] = (char)value;
|
|
}
|
|
}
|
|
delay(1);
|
|
}
|
|
line[0] = 0;
|
|
return false;
|
|
}
|
|
|
|
FontInfoResult parseInfo(const char* line, size_t& size, uint32_t& crc) {
|
|
unsigned long parsed_size = 0;
|
|
unsigned long parsed_crc = 0;
|
|
if (sscanf(line, "MCFONT 1 %lu %lx", &parsed_size, &parsed_crc) == 2) {
|
|
if (parsed_size < 64 || parsed_size > MAX_FONT_BYTES) {
|
|
return FontInfoResult::Unavailable;
|
|
}
|
|
size = (size_t)parsed_size;
|
|
crc = (uint32_t)parsed_crc;
|
|
return FontInfoResult::Present;
|
|
}
|
|
|
|
int present = -1;
|
|
if (sscanf(line, "MCFONT %d", &present) == 1 && present == 0) {
|
|
return FontInfoResult::Missing;
|
|
}
|
|
return FontInfoResult::Unavailable;
|
|
}
|
|
|
|
FontInfoResult requestInfo(HardwareSerial& serial, size_t& size,
|
|
uint32_t& crc) {
|
|
char line[64];
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
while (serial.available()) serial.read();
|
|
serial.print("MCFONT INFO\n");
|
|
serial.flush();
|
|
if (readLine(serial, line, sizeof(line), 1000)) {
|
|
FontInfoResult result = parseInfo(line, size, crc);
|
|
if (result != FontInfoResult::Unavailable) return result;
|
|
}
|
|
delay(100);
|
|
}
|
|
return FontInfoResult::Unavailable;
|
|
}
|
|
|
|
void synchronizeCommandParser(HardwareSerial& serial) {
|
|
// End a command fragment left by an ESP/RP reset or an interrupted prior
|
|
// transaction. The RP2040's parser answers the empty/partial line, which we
|
|
// discard before sending a real command.
|
|
serial.print('\n');
|
|
serial.flush();
|
|
delay(25);
|
|
while (serial.available()) serial.read();
|
|
}
|
|
|
|
bool receiveFont(HardwareSerial& serial, uint8_t* data, size_t size,
|
|
uint32_t expected_crc, uint8_t digest[32]) {
|
|
char line[64];
|
|
serial.print("MCFONT GET\n");
|
|
serial.flush();
|
|
|
|
size_t response_size = 0;
|
|
uint32_t response_crc = 0;
|
|
if (!readLine(serial, line, sizeof(line), 1500)
|
|
|| parseInfo(line, response_size, response_crc) != FontInfoResult::Present
|
|
|| response_size != size
|
|
|| response_crc != expected_crc) {
|
|
return false;
|
|
}
|
|
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
mbedtls_sha256_context sha;
|
|
mbedtls_sha256_init(&sha);
|
|
if (mbedtls_sha256_starts_ret(&sha, 0) != 0) {
|
|
mbedtls_sha256_free(&sha);
|
|
return false;
|
|
}
|
|
#else
|
|
(void)digest;
|
|
#endif
|
|
|
|
size_t received = 0;
|
|
uint32_t crc = 0xFFFFFFFFUL;
|
|
uint32_t last_progress = millis();
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
bool sha_ok = true;
|
|
#endif
|
|
while (received < size && millis() - last_progress < 2000) {
|
|
int available = serial.available();
|
|
if (available <= 0) {
|
|
delay(1);
|
|
continue;
|
|
}
|
|
size_t count = (size_t)available;
|
|
if (count > size - received) count = size - received;
|
|
size_t actual = serial.readBytes(data + received, count);
|
|
if (actual == 0) continue;
|
|
crc = updateCrc32(crc, data + received, actual);
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
if (mbedtls_sha256_update_ret(&sha, data + received, actual) != 0) {
|
|
sha_ok = false;
|
|
break;
|
|
}
|
|
#endif
|
|
received += actual;
|
|
last_progress = millis();
|
|
}
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
if (sha_ok) sha_ok = mbedtls_sha256_finish_ret(&sha, digest) == 0;
|
|
mbedtls_sha256_free(&sha);
|
|
return sha_ok && received == size && ~crc == expected_crc;
|
|
#else
|
|
return received == size && ~crc == expected_crc;
|
|
#endif
|
|
}
|
|
|
|
LoadedFont loadFromService() {
|
|
LoadedFont loaded;
|
|
if (!psramFound()) return loaded;
|
|
|
|
Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX);
|
|
delay(50);
|
|
synchronizeCommandParser(Serial2);
|
|
|
|
uint32_t expected_crc = 0;
|
|
loaded.info = requestInfo(Serial2, loaded.size, expected_crc);
|
|
if (loaded.info != FontInfoResult::Present) {
|
|
Serial2.end();
|
|
loaded.size = 0;
|
|
return loaded;
|
|
}
|
|
|
|
loaded.data = (uint8_t*)ps_malloc(loaded.size);
|
|
uint8_t digest[32] = {};
|
|
if (loaded.data == nullptr
|
|
|| !receiveFont(Serial2, loaded.data, loaded.size, expected_crc, digest)) {
|
|
free(loaded.data);
|
|
loaded.data = nullptr;
|
|
Serial2.end();
|
|
return loaded;
|
|
}
|
|
Serial2.end();
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
loaded.currentAsset = loaded.size == mesh::indicator_font::kAssetSize
|
|
&& expected_crc == mesh::indicator_font::kAssetCrc32
|
|
&& memcmp(digest, EXPECTED_SHA256, sizeof(digest)) == 0;
|
|
#endif
|
|
return loaded;
|
|
}
|
|
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
|
|
FontInfoResult probeServiceInfo(size_t& size, uint32_t& crc) {
|
|
size = 0;
|
|
crc = 0;
|
|
Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX);
|
|
delay(50);
|
|
synchronizeCommandParser(Serial2);
|
|
const FontInfoResult result = requestInfo(Serial2, size, crc);
|
|
Serial2.end();
|
|
return result;
|
|
}
|
|
|
|
enum class RecoveryState : uint8_t {
|
|
Dormant,
|
|
ProbeWaiting,
|
|
ProbeRunning,
|
|
PostCommitProbeWaiting,
|
|
PostCommitProbeRunning,
|
|
Waiting,
|
|
Running,
|
|
Ready,
|
|
Complete,
|
|
Exhausted,
|
|
};
|
|
|
|
portMUX_TYPE recoveryMux = portMUX_INITIALIZER_UNLOCKED;
|
|
RecoveryState recoveryState = RecoveryState::Dormant;
|
|
mesh::indicator_font::RecoveryNeed recoveryNeed =
|
|
mesh::indicator_font::RecoveryNeed::None;
|
|
uint8_t recoveryAttempts = 0;
|
|
uint8_t serviceProbeAttempts = 0;
|
|
uint8_t postCommitProbeAttempts = 0;
|
|
uint32_t recoveryNextAttempt = 0;
|
|
bool runtimeFontInstalled = false;
|
|
bool recoveryActivateLive = false;
|
|
uint8_t* recoveredFont = nullptr;
|
|
size_t recoveredFontSize = 0;
|
|
std::atomic<bool> fontNtpTimeReceived{false};
|
|
std::atomic<uint32_t> fontNtpProofMillis{0};
|
|
std::atomic<uint32_t> fontNtpProofGeneration{0};
|
|
std::atomic<uint32_t> fontNtpExpectedGeneration{0};
|
|
std::atomic<uint32_t> fontNtpOperationGeneration{0};
|
|
|
|
static constexpr uint32_t HTTP_LINE_TIMEOUT_MS = 15000UL;
|
|
static constexpr uint32_t HTTP_HEADER_TOTAL_TIMEOUT_MS = 30000UL;
|
|
// Stay below the RP2040's 10-second staged-write idle timeout so a network
|
|
// failure is recognized before that peer leaves binary receive mode.
|
|
static constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 8000UL;
|
|
static constexpr uint32_t DOWNLOAD_TOTAL_TIMEOUT_MS = 180000UL;
|
|
// A normal buffered download completes in a few seconds. Two bounded Range
|
|
// reconnects cover transient early closes without letting one recovery
|
|
// attempt consume an unbounded share of GitHub's anonymous API quota.
|
|
static constexpr uint8_t DOWNLOAD_MAX_RESUME_RECONNECTS = 2;
|
|
static constexpr size_t HTTP_ETAG_CAPACITY = 96;
|
|
// Count fields, not the terminating empty line. GitHub's normal response is
|
|
// already fairly header-heavy, so leave room for future extension fields.
|
|
static constexpr size_t HTTP_HEADER_LIMIT = 64;
|
|
// Retain only the prefix needed to classify a field, but never drain an
|
|
// unbounded wire line. The per-line deadline is also checked for every byte
|
|
// while an oversized extension field is being discarded.
|
|
static constexpr size_t HTTP_WIRE_LINE_LIMIT = 2048;
|
|
static constexpr uint32_t HTTP_RATE_LIMIT_MIN_DELAY_MS = 60000UL;
|
|
static constexpr uint32_t HTTP_RATE_LIMIT_FALLBACK_DELAY_MS = 900000UL;
|
|
// deadlineReached() uses signed wrap-safe subtraction, so custom delays must
|
|
// stay comfortably below INT32_MAX. Larger server delays stop retries for the
|
|
// rest of this boot instead.
|
|
static constexpr uint32_t HTTP_RATE_LIMIT_MAX_DELAY_MS = 86400000UL;
|
|
static constexpr uint32_t STAGE_ACK_TIMEOUT_MS = 5000UL;
|
|
static constexpr uint32_t STAGE_TOTAL_TIMEOUT_MS = 170000UL;
|
|
// Older RP2040 font services accept only the original unacknowledged stream.
|
|
// Leave enough receiver-idle time after each 512-byte burst for a 1 MHz SD
|
|
// write. Updated services use an ACK after every write and do not need this.
|
|
static constexpr uint32_t LEGACY_STAGE_PACE_MS = 20UL;
|
|
// Arduino-ESP32 2.x mbedTLS allocates two 16 KiB record buffers from internal
|
|
// RAM. A 24 KiB worker stack left the Indicator's largest block too small and
|
|
// made every handshake fail with MBEDTLS_ERR_SSL_ALLOC_FAILED. The normal
|
|
// Arduino loop stack is 8 KiB; this worker has the same bounded call depth.
|
|
static constexpr uint32_t RECOVERY_TASK_STACK_BYTES = 8192UL;
|
|
// The longest HTTP body attempt is three minutes. Leave bounded headroom for
|
|
// the initial and one deadline-edge TLS/header exchange, but never let a proof
|
|
// become an unbounded process-lifetime boolean.
|
|
static constexpr uint32_t FONT_TLS_PROOF_MAX_AGE_MS = 300000UL;
|
|
|
|
#define FONT_RECOVERY_LOG(...) \
|
|
do { \
|
|
if (mesh::isUsbLoggingEnabled()) { \
|
|
mesh::usbLoggingPort().printf("Indicator font: " __VA_ARGS__); \
|
|
} \
|
|
} while (0)
|
|
|
|
void clearFontNtpCallback() {
|
|
// This hook runs while the coordinator still owns the process-global SNTP
|
|
// slot, so it cannot clear a newer feature's callback.
|
|
fontNtpOperationGeneration.store(0, std::memory_order_release);
|
|
sntp_set_time_sync_notification_cb(nullptr);
|
|
}
|
|
|
|
void noteFontNtpTime(struct timeval* value) {
|
|
const uint32_t generation =
|
|
fontNtpOperationGeneration.load(std::memory_order_acquire);
|
|
if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return;
|
|
if (value != nullptr
|
|
&& value->tv_sec >= (time_t)mesh::indicator_font::kAssetPublishedEpoch) {
|
|
fontNtpProofMillis.store(millis(), std::memory_order_release);
|
|
fontNtpProofGeneration.store(generation, std::memory_order_release);
|
|
fontNtpTimeReceived.store(true, std::memory_order_release);
|
|
}
|
|
}
|
|
|
|
bool fontTlsClockProofValid(time_t now) {
|
|
const bool fresh = fontNtpTimeReceived.load(std::memory_order_acquire);
|
|
const uint32_t proven_at =
|
|
fontNtpProofMillis.load(std::memory_order_acquire);
|
|
const uint32_t proof_generation =
|
|
fontNtpProofGeneration.load(std::memory_order_acquire);
|
|
const uint32_t expected_generation =
|
|
fontNtpExpectedGeneration.load(std::memory_order_acquire);
|
|
return mesh::tls_clock::proofGenerationIsValid(
|
|
fresh, proof_generation, expected_generation)
|
|
&& mesh::tls_clock::proofIsValid(
|
|
fresh, WiFi.status() == WL_CONNECTED, now)
|
|
&& mesh::tls_clock::proofAgeIsValid(
|
|
fresh, millis(), proven_at, FONT_TLS_PROOF_MAX_AGE_MS);
|
|
}
|
|
|
|
bool prepareTlsClock() {
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
FONT_RECOVERY_LOG("NTP skipped; WiFi is disconnected\n");
|
|
return false;
|
|
}
|
|
|
|
mesh::sntp_coord::OperationLease sntp_operation(
|
|
mesh::sntp_coord::processWideCoordinator(), clearFontNtpCallback);
|
|
if (!sntp_operation.tryAcquire()) {
|
|
FONT_RECOVERY_LOG("NTP busy in another firmware service\n");
|
|
return false;
|
|
}
|
|
|
|
// A plausible retained RTC or mesh timestamp is not enough for this path.
|
|
// Observe a fresh SNTP response in this recovery attempt before opening the
|
|
// HTTPS socket. NTP is not a content trust anchor: the CA bundle and the
|
|
// compiled asset SHA-256 remain mandatory below.
|
|
fontNtpTimeReceived.store(false, std::memory_order_release);
|
|
fontNtpProofMillis.store(0, std::memory_order_release);
|
|
fontNtpProofGeneration.store(0, std::memory_order_release);
|
|
fontNtpExpectedGeneration.store(
|
|
sntp_operation.generation(), std::memory_order_release);
|
|
fontNtpOperationGeneration.store(
|
|
sntp_operation.generation(), std::memory_order_release);
|
|
sntp_set_time_sync_notification_cb(noteFontNtpTime);
|
|
sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
|
|
FONT_RECOVERY_LOG("requesting fresh NTP time before download\n");
|
|
configTime(0, 0, "time.cloudflare.com", "time.google.com", "pool.ntp.org");
|
|
const uint32_t started = millis();
|
|
while (millis() - started < mesh::indicator_font::kNtpSyncWaitMillis) {
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
FONT_RECOVERY_LOG("NTP aborted after %lu ms; WiFi disconnected\n",
|
|
(unsigned long)(millis() - started));
|
|
return false;
|
|
}
|
|
const time_t now = time(nullptr);
|
|
if (fontTlsClockProofValid(now)
|
|
&& now >= (time_t)mesh::indicator_font::kAssetPublishedEpoch) {
|
|
FONT_RECOVERY_LOG("fresh NTP time %lu received in %lu ms\n",
|
|
(unsigned long)now,
|
|
(unsigned long)(millis() - started));
|
|
return true;
|
|
}
|
|
delay(100);
|
|
}
|
|
FONT_RECOVERY_LOG("NTP timed out after %lu ms (clock %lu)\n",
|
|
(unsigned long)(millis() - started),
|
|
(unsigned long)time(nullptr));
|
|
return false;
|
|
}
|
|
|
|
uint32_t remainingHttpHeaderTimeout(uint32_t started) {
|
|
const uint32_t elapsed = millis() - started;
|
|
if (elapsed >= HTTP_HEADER_TOTAL_TIMEOUT_MS) return 0;
|
|
const uint32_t remaining = HTTP_HEADER_TOTAL_TIMEOUT_MS - elapsed;
|
|
return remaining < HTTP_LINE_TIMEOUT_MS ? remaining : HTTP_LINE_TIMEOUT_MS;
|
|
}
|
|
|
|
void armRecovery(mesh::indicator_font::RecoveryNeed need,
|
|
bool activateLive = false) {
|
|
if (!mesh::indicator_font::shouldFetch(need)) return;
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
recoveryNeed = need;
|
|
recoveryAttempts = 0;
|
|
postCommitProbeAttempts = 0;
|
|
recoveryNextAttempt = 0;
|
|
recoveryActivateLive = activateLive
|
|
|| mesh::indicator_font::shouldActivateLive(need);
|
|
recoveryState = RecoveryState::Waiting;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
void armServiceProbe() {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
recoveryNeed = mesh::indicator_font::RecoveryNeed::None;
|
|
recoveryAttempts = 0;
|
|
serviceProbeAttempts = 0;
|
|
postCommitProbeAttempts = 0;
|
|
recoveryNextAttempt = 0;
|
|
recoveryActivateLive = false;
|
|
recoveryState = RecoveryState::ProbeWaiting;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
void armPostCommitProbe(bool activateLive) {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
// COMMIT has already accepted the integrity-checked local pair. Preserve
|
|
// the network-attempt count and move to a disjoint local-only budget so a
|
|
// transient INFO/GET failure cannot launch the same HTTPS download again.
|
|
recoveryNeed = mesh::indicator_font::RecoveryNeed::None;
|
|
postCommitProbeAttempts = 0;
|
|
recoveryNextAttempt = 0;
|
|
recoveryActivateLive = activateLive;
|
|
recoveryState = RecoveryState::PostCommitProbeWaiting;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
enum class HttpLineResult : uint8_t {
|
|
Complete,
|
|
Overflow,
|
|
TooLong,
|
|
Failed,
|
|
};
|
|
|
|
HttpLineResult readHttpLine(Client& client, char* line, size_t capacity,
|
|
uint32_t timeout_millis) {
|
|
if (capacity == 0) return HttpLineResult::Failed;
|
|
size_t length = 0;
|
|
size_t wire_length = 0;
|
|
bool overflow = false;
|
|
uint32_t started = millis();
|
|
while (millis() - started < timeout_millis) {
|
|
while (client.available()) {
|
|
// client.available() can remain nonzero while a peer streams an
|
|
// attacker-controlled line. Enforce the deadline inside the drain loop,
|
|
// not just while waiting for another byte.
|
|
if (millis() - started >= timeout_millis) {
|
|
line[0] = 0;
|
|
return HttpLineResult::Failed;
|
|
}
|
|
int value = client.read();
|
|
if (value < 0) break;
|
|
if (++wire_length > HTTP_WIRE_LINE_LIMIT) {
|
|
line[0] = 0;
|
|
return HttpLineResult::TooLong;
|
|
}
|
|
if (value == '\n') {
|
|
if (length && line[length - 1] == '\r') --length;
|
|
line[length] = 0;
|
|
return overflow ? HttpLineResult::Overflow
|
|
: HttpLineResult::Complete;
|
|
}
|
|
if (length + 1 < capacity) {
|
|
line[length++] = (char)value;
|
|
} else {
|
|
overflow = true;
|
|
}
|
|
}
|
|
if (!client.connected() && !client.available()) break;
|
|
delay(1);
|
|
}
|
|
if (capacity) line[0] = 0;
|
|
return HttpLineResult::Failed;
|
|
}
|
|
|
|
bool isSecurityCriticalHttpHeader(const char* line) {
|
|
return strncasecmp(line, "Content-Length:", 15) == 0
|
|
|| strncasecmp(line, "Transfer-Encoding:", 18) == 0
|
|
|| strncasecmp(line, "Content-Encoding:", 17) == 0
|
|
|| strncasecmp(line, "Content-Range:", 14) == 0
|
|
|| strncasecmp(line, "ETag:", 5) == 0
|
|
|| strncasecmp(line, "Retry-After:", 12) == 0
|
|
|| strncasecmp(line, "X-RateLimit-Reset:", 18) == 0
|
|
|| line[0] == ' ' || line[0] == '\t';
|
|
}
|
|
|
|
bool parseUnsignedDecimalHeader(const char* value, uint64_t& parsed) {
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
if (*value < '0' || *value > '9') return false;
|
|
|
|
uint64_t result = 0;
|
|
do {
|
|
const uint8_t digit = (uint8_t)(*value - '0');
|
|
if (result > (UINT64_MAX - digit) / 10U) return false;
|
|
result = result * 10U + digit;
|
|
++value;
|
|
} while (*value >= '0' && *value <= '9');
|
|
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
if (*value != 0) return false;
|
|
parsed = result;
|
|
return true;
|
|
}
|
|
|
|
bool parseDecimalToken(const char*& value, uint64_t& parsed) {
|
|
if (*value < '0' || *value > '9') return false;
|
|
uint64_t result = 0;
|
|
do {
|
|
const uint8_t digit = (uint8_t)(*value - '0');
|
|
if (result > (UINT64_MAX - digit) / 10U) return false;
|
|
result = result * 10U + digit;
|
|
++value;
|
|
} while (*value >= '0' && *value <= '9');
|
|
parsed = result;
|
|
return true;
|
|
}
|
|
|
|
bool parseContentRangeHeader(const char* value, size_t& first, size_t& last,
|
|
size_t& total) {
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
if (strncasecmp(value, "bytes ", 6) != 0) return false;
|
|
value += 6;
|
|
|
|
uint64_t parsed_first = 0;
|
|
uint64_t parsed_last = 0;
|
|
uint64_t parsed_total = 0;
|
|
if (!parseDecimalToken(value, parsed_first) || *value++ != '-'
|
|
|| !parseDecimalToken(value, parsed_last) || *value++ != '/'
|
|
|| !parseDecimalToken(value, parsed_total)) {
|
|
return false;
|
|
}
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
if (*value != 0 || parsed_first > parsed_last
|
|
|| parsed_last >= parsed_total || parsed_total > MAX_FONT_BYTES) {
|
|
return false;
|
|
}
|
|
first = (size_t)parsed_first;
|
|
last = (size_t)parsed_last;
|
|
total = (size_t)parsed_total;
|
|
return true;
|
|
}
|
|
|
|
bool parseStrongEtagHeader(const char* value, char* etag, size_t capacity) {
|
|
if (etag == nullptr || capacity == 0) return false;
|
|
etag[0] = 0;
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
const char* end = value + strlen(value);
|
|
while (end > value && (end[-1] == ' ' || end[-1] == '\t')) --end;
|
|
const size_t length = (size_t)(end - value);
|
|
if (length < 2 || length >= capacity || value[0] != '"'
|
|
|| value[length - 1] != '"') {
|
|
return false;
|
|
}
|
|
// A strong entity tag is a quoted opaque value, never W/"...". Restrict
|
|
// the reflected If-Range value to printable RFC etagc bytes and reject an
|
|
// embedded quote so response data cannot create another request header.
|
|
for (size_t i = 1; i + 1 < length; ++i) {
|
|
const uint8_t byte = (uint8_t)value[i];
|
|
if (byte < 0x21 || byte > 0x7e || byte == '"') return false;
|
|
}
|
|
memcpy(etag, value, length);
|
|
etag[length] = 0;
|
|
return true;
|
|
}
|
|
|
|
bool parseHttpStatusCode(const char* line, int& status_code) {
|
|
if (strncmp(line, "HTTP/", 5) != 0) return false;
|
|
const char* status = strchr(line, ' ');
|
|
if (status == nullptr) return false;
|
|
++status;
|
|
if (status[0] < '0' || status[0] > '9'
|
|
|| status[1] < '0' || status[1] > '9'
|
|
|| status[2] < '0' || status[2] > '9'
|
|
|| (status[3] != 0 && status[3] != ' ' && status[3] != '\t')) {
|
|
return false;
|
|
}
|
|
status_code = (status[0] - '0') * 100
|
|
+ (status[1] - '0') * 10 + (status[2] - '0');
|
|
return status_code >= 100 && status_code <= 599;
|
|
}
|
|
|
|
enum class RecoveryAttemptDisposition : uint8_t {
|
|
Succeeded,
|
|
CommittedNeedsProbe,
|
|
RetryableFailure,
|
|
PermanentFailure,
|
|
RateLimited,
|
|
};
|
|
|
|
struct RecoveryAttemptOutcome {
|
|
RecoveryAttemptDisposition disposition;
|
|
uint32_t retryDelayMillis;
|
|
};
|
|
|
|
RecoveryAttemptOutcome recoveryOutcome(
|
|
RecoveryAttemptDisposition disposition,
|
|
uint32_t retry_delay_millis = 0) {
|
|
RecoveryAttemptOutcome outcome = {disposition, retry_delay_millis};
|
|
return outcome;
|
|
}
|
|
|
|
RecoveryAttemptOutcome classifyHttpResponse(
|
|
int status_code, bool retry_after_valid, uint64_t retry_after_seconds,
|
|
bool rate_reset_valid, uint64_t rate_reset_epoch) {
|
|
if (status_code == 200 || status_code == 206) {
|
|
return recoveryOutcome(RecoveryAttemptDisposition::Succeeded);
|
|
}
|
|
if (status_code == 403 || status_code == 429) {
|
|
uint64_t delay_seconds = 0;
|
|
bool delay_from_server = false;
|
|
if (retry_after_valid) {
|
|
delay_seconds = retry_after_seconds;
|
|
delay_from_server = true;
|
|
}
|
|
if (rate_reset_valid) {
|
|
const time_t now = time(nullptr);
|
|
uint64_t reset_delay = 0;
|
|
if (now > 0 && rate_reset_epoch > (uint64_t)now) {
|
|
// Avoid retrying on the exact reset boundary.
|
|
reset_delay = rate_reset_epoch - (uint64_t)now + 2U;
|
|
}
|
|
if (!delay_from_server || reset_delay > delay_seconds) {
|
|
delay_seconds = reset_delay;
|
|
}
|
|
delay_from_server = true;
|
|
}
|
|
if (!delay_from_server) {
|
|
FONT_RECOVERY_LOG(
|
|
"HTTP %d rate limit had no usable reset header; waiting 15 minutes\n",
|
|
status_code);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RateLimited,
|
|
HTTP_RATE_LIMIT_FALLBACK_DELAY_MS);
|
|
}
|
|
|
|
const uint64_t maximum_seconds =
|
|
HTTP_RATE_LIMIT_MAX_DELAY_MS / 1000UL;
|
|
if (delay_seconds > maximum_seconds) {
|
|
FONT_RECOVERY_LOG(
|
|
"HTTP %d rate-limit delay exceeds scheduler range; stopping this boot\n",
|
|
status_code);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);
|
|
}
|
|
uint32_t delay_millis = (uint32_t)delay_seconds * 1000UL;
|
|
if (delay_millis < HTTP_RATE_LIMIT_MIN_DELAY_MS) {
|
|
delay_millis = HTTP_RATE_LIMIT_MIN_DELAY_MS;
|
|
}
|
|
FONT_RECOVERY_LOG("HTTP %d rate limited; next attempt in %lu ms\n",
|
|
status_code, (unsigned long)delay_millis);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RateLimited,
|
|
delay_millis);
|
|
}
|
|
if (status_code >= 500 && status_code <= 599) {
|
|
FONT_RECOVERY_LOG("HTTP %d server failure is retryable\n", status_code);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
// Redirects are deliberately refused: only the commit-pinned TLS origin is
|
|
// trusted. Other success codes cannot carry the requested representation,
|
|
// and all remaining 4xx responses are permanent for this boot.
|
|
FONT_RECOVERY_LOG("HTTP %d response is permanent for this boot\n",
|
|
status_code);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);
|
|
}
|
|
|
|
bool splitHttpsUrl(const char* url, char* host, size_t host_capacity,
|
|
char* path, size_t path_capacity) {
|
|
static const char prefix[] = "https://";
|
|
if (strncmp(url, prefix, sizeof(prefix) - 1) != 0) return false;
|
|
const char* authority = url + sizeof(prefix) - 1;
|
|
const char* slash = strchr(authority, '/');
|
|
if (slash == nullptr) return false;
|
|
size_t host_length = (size_t)(slash - authority);
|
|
size_t path_length = strlen(slash);
|
|
if (host_length == 0 || host_length >= host_capacity
|
|
|| path_length == 0 || path_length >= path_capacity
|
|
|| memchr(authority, '@', host_length) != nullptr
|
|
|| memchr(authority, ':', host_length) != nullptr) {
|
|
return false;
|
|
}
|
|
memcpy(host, authority, host_length);
|
|
host[host_length] = 0;
|
|
memcpy(path, slash, path_length + 1);
|
|
return true;
|
|
}
|
|
|
|
RecoveryAttemptOutcome openAssetResponse(
|
|
WiFiClientSecure& client, size_t requested_offset,
|
|
const char* expected_etag, char* captured_etag,
|
|
size_t captured_etag_capacity) {
|
|
// An interrupted first block resumes at byte zero, so the presence of the
|
|
// captured entity tag—not a nonzero offset—distinguishes a Range request.
|
|
const bool is_resume = expected_etag != nullptr;
|
|
if (requested_offset >= mesh::indicator_font::kAssetSize
|
|
|| (is_resume && expected_etag[0] == 0)
|
|
|| (!is_resume
|
|
&& (captured_etag == nullptr || captured_etag_capacity == 0))) {
|
|
FONT_RECOVERY_LOG("invalid HTTP range request state\n");
|
|
return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);
|
|
}
|
|
if (captured_etag != nullptr && captured_etag_capacity != 0) {
|
|
captured_etag[0] = 0;
|
|
}
|
|
|
|
char host[96];
|
|
char path[224];
|
|
if (!splitHttpsUrl(mesh::indicator_font::kAssetUrl, host, sizeof(host),
|
|
path, sizeof(path))) {
|
|
FONT_RECOVERY_LOG("asset URL is invalid\n");
|
|
return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);
|
|
}
|
|
|
|
// WiFiClientSecure retains this per-client TLS configuration across stop()
|
|
// and reconnect. Reapplying Stream::setTimeout() to Arduino-ESP32 2.x's
|
|
// just-closed socket emits a misleading EBADF diagnostic, so configure the
|
|
// client once for the initial request and reuse it for validated ranges.
|
|
if (!is_resume) {
|
|
client.setCACert(FONT_TLS_GITHUB_ROOT_CA);
|
|
#if ESP_ARDUINO_VERSION_MAJOR >= 3
|
|
client.setTimeout(HTTP_LINE_TIMEOUT_MS);
|
|
#else
|
|
// Arduino-ESP32 2.x takes seconds here, unlike Arduino 3.x's millisecond
|
|
// overload. Passing 15000 would leave a failed socket blocked for 4.2 hours.
|
|
client.setTimeout(HTTP_LINE_TIMEOUT_MS / 1000UL);
|
|
#endif
|
|
client.setHandshakeTimeout(15);
|
|
}
|
|
// prepareTlsClock() observes one fresh SNTP reply for this bounded recovery
|
|
// operation. Recheck that proof, WiFi, and the signed wall clock directly
|
|
// before every handshake, including each Range reconnect, without adding a
|
|
// second NTP round trip to a transient HTTP recovery.
|
|
const time_t tls_now = time(nullptr);
|
|
if (!fontTlsClockProofValid(tls_now)
|
|
|| tls_now < (time_t)mesh::indicator_font::kAssetPublishedEpoch) {
|
|
FONT_RECOVERY_LOG("TLS clock proof is no longer valid\n");
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
FONT_RECOVERY_LOG("opening TLS connection to %s\n", host);
|
|
if (!client.connect(host, 443)) {
|
|
FONT_RECOVERY_LOG("TLS connection failed\n");
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
client.printf(
|
|
"GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: MeshCore-Indicator-Font\r\n"
|
|
"Accept: application/vnd.github.raw+json\r\n"
|
|
"X-GitHub-Api-Version: 2026-03-10\r\nAccept-Encoding: identity\r\n"
|
|
"Cache-Control: no-cache\r\n",
|
|
path, host);
|
|
if (is_resume) {
|
|
client.printf("Range: bytes=%lu-%lu\r\nIf-Range: %s\r\n",
|
|
(unsigned long)requested_offset,
|
|
(unsigned long)(mesh::indicator_font::kAssetSize - 1),
|
|
expected_etag);
|
|
}
|
|
client.print("Connection: close\r\n\r\n");
|
|
|
|
char line[256];
|
|
const uint32_t headerStarted = millis();
|
|
uint32_t lineTimeout = remainingHttpHeaderTimeout(headerStarted);
|
|
int status_code = 0;
|
|
if (lineTimeout == 0
|
|
|| readHttpLine(client, line, sizeof(line), lineTimeout)
|
|
!= HttpLineResult::Complete
|
|
|| !parseHttpStatusCode(line, status_code)) {
|
|
FONT_RECOVERY_LOG("HTTP status line failed\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
bool headers_complete = false;
|
|
bool content_length_seen = false;
|
|
bool content_length_valid = true;
|
|
size_t content_length = 0;
|
|
bool transfer_encoding_seen = false;
|
|
bool content_encoding_seen = false;
|
|
bool content_encoding_identity = true;
|
|
bool content_range_seen = false;
|
|
bool content_range_valid = true;
|
|
size_t content_range_first = 0;
|
|
size_t content_range_last = 0;
|
|
size_t content_range_total = 0;
|
|
bool etag_seen = false;
|
|
bool etag_valid = true;
|
|
char response_etag[HTTP_ETAG_CAPACITY] = {};
|
|
bool critical_header_overflow = false;
|
|
bool retry_after_valid = false;
|
|
uint64_t retry_after_seconds = 0;
|
|
bool rate_reset_valid = false;
|
|
uint64_t rate_reset_epoch = 0;
|
|
size_t header_count = 0;
|
|
while (header_count <= HTTP_HEADER_LIMIT) {
|
|
lineTimeout = remainingHttpHeaderTimeout(headerStarted);
|
|
if (lineTimeout == 0) {
|
|
break;
|
|
}
|
|
const HttpLineResult line_result =
|
|
readHttpLine(client, line, sizeof(line), lineTimeout);
|
|
if (line_result == HttpLineResult::Failed) break;
|
|
if (line_result == HttpLineResult::TooLong) {
|
|
FONT_RECOVERY_LOG("HTTP header exceeds %lu wire bytes\n",
|
|
(unsigned long)HTTP_WIRE_LINE_LIMIT);
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
// RFC 7230 obsolete line folding is ambiguous to intermediaries. Reject
|
|
// every continuation line, including a normal-size one, before deciding
|
|
// whether an oversized field would otherwise be harmless.
|
|
if (line[0] == ' ' || line[0] == '\t') {
|
|
FONT_RECOVERY_LOG("refusing obsolete folded HTTP header\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
if (line_result == HttpLineResult::Overflow) {
|
|
// GitHub currently returns a long Access-Control-Expose-Headers field.
|
|
// Drain and ignore oversized extension headers, but never permit a
|
|
// truncated framing header or obsolete folded continuation to hide body
|
|
// boundaries from a successful response. Keep parsing so a non-200
|
|
// response can still provide rate-limit metadata later in the block.
|
|
if (isSecurityCriticalHttpHeader(line)) {
|
|
critical_header_overflow = true;
|
|
}
|
|
++header_count;
|
|
continue;
|
|
}
|
|
if (line[0] == 0) {
|
|
headers_complete = true;
|
|
break;
|
|
}
|
|
if (++header_count > HTTP_HEADER_LIMIT) break;
|
|
if (strncasecmp(line, "Content-Length:", 15) == 0) {
|
|
if (content_length_seen) {
|
|
content_length_valid = false;
|
|
} else {
|
|
uint64_t parsed = 0;
|
|
if (!parseUnsignedDecimalHeader(line + 15, parsed)
|
|
|| parsed > MAX_FONT_BYTES) {
|
|
content_length_valid = false;
|
|
} else {
|
|
content_length = (size_t)parsed;
|
|
}
|
|
content_length_seen = true;
|
|
}
|
|
} else if (strncasecmp(line, "Transfer-Encoding:", 18) == 0) {
|
|
transfer_encoding_seen = true;
|
|
} else if (strncasecmp(line, "Content-Encoding:", 17) == 0) {
|
|
const char* value = line + 17;
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
content_encoding_identity = !content_encoding_seen
|
|
&& strcasecmp(value, "identity") == 0;
|
|
content_encoding_seen = true;
|
|
} else if (strncasecmp(line, "Content-Range:", 14) == 0) {
|
|
if (content_range_seen
|
|
|| !parseContentRangeHeader(line + 14, content_range_first,
|
|
content_range_last,
|
|
content_range_total)) {
|
|
content_range_valid = false;
|
|
}
|
|
content_range_seen = true;
|
|
} else if (strncasecmp(line, "ETag:", 5) == 0) {
|
|
if (etag_seen
|
|
|| !parseStrongEtagHeader(line + 5, response_etag,
|
|
sizeof(response_etag))) {
|
|
etag_valid = false;
|
|
}
|
|
etag_seen = true;
|
|
} else if (strncasecmp(line, "Retry-After:", 12) == 0) {
|
|
uint64_t parsed = 0;
|
|
if (parseUnsignedDecimalHeader(line + 12, parsed)) {
|
|
if (!retry_after_valid || parsed > retry_after_seconds) {
|
|
retry_after_seconds = parsed;
|
|
}
|
|
retry_after_valid = true;
|
|
} else {
|
|
FONT_RECOVERY_LOG("ignoring malformed Retry-After header\n");
|
|
}
|
|
} else if (strncasecmp(line, "X-RateLimit-Reset:", 18) == 0) {
|
|
uint64_t parsed = 0;
|
|
if (parseUnsignedDecimalHeader(line + 18, parsed)) {
|
|
if (!rate_reset_valid || parsed > rate_reset_epoch) {
|
|
rate_reset_epoch = parsed;
|
|
}
|
|
rate_reset_valid = true;
|
|
} else {
|
|
FONT_RECOVERY_LOG("ignoring malformed X-RateLimit-Reset header\n");
|
|
}
|
|
}
|
|
}
|
|
if (!headers_complete) {
|
|
FONT_RECOVERY_LOG("HTTP headers incomplete or over limit\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
// Classify only after the complete header block has been parsed. In
|
|
// particular, GitHub's 403/429 rate-limit metadata lives in those headers.
|
|
const RecoveryAttemptOutcome response = classifyHttpResponse(
|
|
status_code, retry_after_valid, retry_after_seconds,
|
|
rate_reset_valid, rate_reset_epoch);
|
|
if (response.disposition != RecoveryAttemptDisposition::Succeeded) {
|
|
client.stop();
|
|
return response;
|
|
}
|
|
|
|
if (critical_header_overflow) {
|
|
FONT_RECOVERY_LOG("oversized critical HTTP header\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
const int expected_status = is_resume ? 206 : 200;
|
|
if (status_code != expected_status) {
|
|
FONT_RECOVERY_LOG(
|
|
"HTTP %d rejected for %s request (expected %d)\n", status_code,
|
|
is_resume ? "resumed" : "initial", expected_status);
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
const size_t expected_content_length =
|
|
mesh::indicator_font::kAssetSize - requested_offset;
|
|
if (!content_length_seen || !content_length_valid
|
|
|| content_length != expected_content_length) {
|
|
FONT_RECOVERY_LOG(
|
|
"HTTP headers invalid (length_seen=%u length_valid=%u length=%lu expected=%lu)\n",
|
|
content_length_seen ? 1U : 0U, content_length_valid ? 1U : 0U,
|
|
(unsigned long)content_length,
|
|
(unsigned long)expected_content_length);
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
if (transfer_encoding_seen) {
|
|
FONT_RECOVERY_LOG("refusing transfer-encoded response\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
if (!content_encoding_identity) {
|
|
FONT_RECOVERY_LOG("refusing encoded response\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
if (!etag_seen || !etag_valid) {
|
|
FONT_RECOVERY_LOG("missing, duplicate, or weak HTTP ETag\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
if (is_resume) {
|
|
if (!content_range_seen || !content_range_valid
|
|
|| content_range_first != requested_offset
|
|
|| content_range_last != mesh::indicator_font::kAssetSize - 1
|
|
|| content_range_total != mesh::indicator_font::kAssetSize
|
|
|| strcmp(response_etag, expected_etag) != 0) {
|
|
FONT_RECOVERY_LOG(
|
|
"resumed HTTP range or ETag mismatch (first=%lu last=%lu total=%lu)\n",
|
|
(unsigned long)content_range_first,
|
|
(unsigned long)content_range_last,
|
|
(unsigned long)content_range_total);
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
} else {
|
|
if (content_range_seen) {
|
|
FONT_RECOVERY_LOG("unexpected Content-Range on initial response\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
const size_t etag_length = strlen(response_etag);
|
|
if (etag_length + 1 > captured_etag_capacity) {
|
|
FONT_RECOVERY_LOG("HTTP ETag output buffer is too small\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);
|
|
}
|
|
memcpy(captured_etag, response_etag, etag_length + 1);
|
|
}
|
|
FONT_RECOVERY_LOG("TLS/HTTP verified; downloading %lu bytes at offset %lu\n",
|
|
(unsigned long)content_length,
|
|
(unsigned long)requested_offset);
|
|
return response;
|
|
}
|
|
|
|
bool writeAll(HardwareSerial& serial, const uint8_t* data, size_t size) {
|
|
size_t written = 0;
|
|
uint32_t last_progress = millis();
|
|
while (written < size && millis() - last_progress < 5000UL) {
|
|
size_t count = serial.write(data + written, size - written);
|
|
if (count == 0) {
|
|
delay(1);
|
|
continue;
|
|
}
|
|
written += count;
|
|
last_progress = millis();
|
|
}
|
|
return written == size;
|
|
}
|
|
|
|
bool commandReply(HardwareSerial& serial, const char* command, char* line,
|
|
size_t line_capacity, uint32_t timeout_millis) {
|
|
if (line_capacity == 0) return false;
|
|
line[0] = 0;
|
|
while (serial.available()) serial.read();
|
|
serial.print(command);
|
|
serial.print('\n');
|
|
serial.flush();
|
|
return readLine(serial, line, line_capacity, timeout_millis);
|
|
}
|
|
|
|
enum class StageTransferMode : uint8_t {
|
|
Acknowledged,
|
|
LegacyPaced,
|
|
};
|
|
|
|
bool beginStagedUpload(HardwareSerial& serial, StageTransferMode& mode) {
|
|
char command[72];
|
|
char reply[64];
|
|
// STAGEV2 intentionally is not prefixed by the legacy "STAGE <size>"
|
|
// grammar. Older sscanf-based services would otherwise parse the `2` in
|
|
// STAGE2 as a two-byte file and answer ERROR SIZE instead of the explicit
|
|
// ERROR COMMAND compatibility signal.
|
|
snprintf(command, sizeof(command), "MCFONT STAGEV2 %lu %08lx %lu",
|
|
(unsigned long)mesh::indicator_font::kAssetSize,
|
|
(unsigned long)mesh::indicator_font::kAssetCrc32,
|
|
(unsigned long)mesh::indicator_font::kStageV2ChunkBytes);
|
|
const bool replied = commandReply(
|
|
serial, command, reply, sizeof(reply), 2000);
|
|
const mesh::indicator_font::StageV2BeginAction action =
|
|
mesh::indicator_font::classifyStageV2BeginReply(replied, reply);
|
|
if (action
|
|
== mesh::indicator_font::StageV2BeginAction::UseAcknowledged) {
|
|
mode = StageTransferMode::Acknowledged;
|
|
FONT_RECOVERY_LOG("RP2040 staging protocol 2 ready (512-byte ACKs)\n");
|
|
return true;
|
|
}
|
|
|
|
// Existing RP2040 releases answer an unknown command with ERROR COMMAND.
|
|
// Fall back only on that explicit pre-transfer rejection. A delayed READY
|
|
// must never be mistaken for permission to inject a legacy command into a
|
|
// receiver that has already entered protocol-2 binary mode.
|
|
if (action != mesh::indicator_font::StageV2BeginAction::UseLegacy) {
|
|
if (!replied) {
|
|
FONT_RECOVERY_LOG("RP2040 staging protocol 2 timed out\n");
|
|
} else {
|
|
FONT_RECOVERY_LOG("RP2040 rejected staging protocol 2: %s\n", reply);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
FONT_RECOVERY_LOG(
|
|
"RP2040 staging protocol 2 unavailable; using paced legacy stream\n");
|
|
snprintf(command, sizeof(command), "MCFONT STAGE %lu %08lx",
|
|
(unsigned long)mesh::indicator_font::kAssetSize,
|
|
(unsigned long)mesh::indicator_font::kAssetCrc32);
|
|
if (!commandReply(serial, command, reply, sizeof(reply), 2000)
|
|
|| strcmp(reply, "READY") != 0) {
|
|
FONT_RECOVERY_LOG("RP2040 rejected legacy staging: %s\n",
|
|
reply[0] ? reply : "<timeout>");
|
|
return false;
|
|
}
|
|
mode = StageTransferMode::LegacyPaced;
|
|
return true;
|
|
}
|
|
|
|
bool stagedChunkAcknowledged(HardwareSerial& serial, size_t expected_offset) {
|
|
char reply[64];
|
|
serial.flush();
|
|
if (!readLine(serial, reply, sizeof(reply), STAGE_ACK_TIMEOUT_MS)) {
|
|
FONT_RECOVERY_LOG("RP2040 stage ACK timed out at byte %lu\n",
|
|
(unsigned long)expected_offset);
|
|
return false;
|
|
}
|
|
if (!mesh::indicator_font::parseStageV2Ack(reply, expected_offset)) {
|
|
FONT_RECOVERY_LOG(
|
|
"RP2040 stage ACK invalid or out of sequence at byte %lu: %s\n",
|
|
(unsigned long)expected_offset, reply);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
RecoveryAttemptOutcome downloadAndInstallAsset() {
|
|
WiFiClientSecure client;
|
|
if (!prepareTlsClock()) {
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
char asset_etag[HTTP_ETAG_CAPACITY] = {};
|
|
RecoveryAttemptOutcome response = openAssetResponse(
|
|
client, 0, nullptr, asset_etag, sizeof(asset_etag));
|
|
if (response.disposition != RecoveryAttemptDisposition::Succeeded) {
|
|
return response;
|
|
}
|
|
|
|
// Do not hold the HTTPS response open while the RP2040 performs SD writes.
|
|
// GitHub can close a slow response near its service deadline, and the
|
|
// Indicator has enough PSRAM to keep this immutable 1.3 MiB object separate
|
|
// even when an older runtime font is still active.
|
|
uint8_t* asset = static_cast<uint8_t*>(
|
|
ps_malloc(mesh::indicator_font::kAssetSize));
|
|
if (asset == nullptr) {
|
|
FONT_RECOVERY_LOG("PSRAM allocation failed for download\n");
|
|
client.stop();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
mbedtls_sha256_context sha;
|
|
mbedtls_sha256_init(&sha);
|
|
bool ok = mbedtls_sha256_starts_ret(&sha, 0) == 0;
|
|
const char* failure = ok ? nullptr : "SHA-256 initialization";
|
|
size_t received = 0;
|
|
uint32_t started = millis();
|
|
uint32_t last_progress = started;
|
|
size_t next_progress = 256 * 1024;
|
|
uint8_t resume_reconnects = 0;
|
|
bool response_failure = false;
|
|
while (ok && received < mesh::indicator_font::kAssetSize) {
|
|
bool reconnect_needed = false;
|
|
size_t discarded_partial = 0;
|
|
while (ok && received < mesh::indicator_font::kAssetSize
|
|
&& !reconnect_needed) {
|
|
if (millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS) {
|
|
failure = "HTTP body total timeout";
|
|
ok = false;
|
|
break;
|
|
}
|
|
|
|
// `received` is both the SHA stream offset and the only legal Range
|
|
// resume boundary. Bytes from a short 16 KiB block remain un-hashed and
|
|
// are deliberately overwritten if this response closes early.
|
|
size_t wanted = mesh::indicator_font::kAssetSize - received;
|
|
if (wanted > 16 * 1024) wanted = 16 * 1024;
|
|
size_t filled = 0;
|
|
while (filled < wanted) {
|
|
if (millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS) {
|
|
failure = "HTTP body total timeout";
|
|
ok = false;
|
|
break;
|
|
}
|
|
if (millis() - last_progress >= DOWNLOAD_IDLE_TIMEOUT_MS) {
|
|
failure = "HTTP body idle timeout";
|
|
discarded_partial = filled;
|
|
reconnect_needed = true;
|
|
break;
|
|
}
|
|
int available = client.available();
|
|
if (available <= 0) {
|
|
if (!client.connected() && !client.available()) {
|
|
failure = "HTTP body ended early";
|
|
discarded_partial = filled;
|
|
reconnect_needed = true;
|
|
break;
|
|
}
|
|
delay(1);
|
|
continue;
|
|
}
|
|
size_t count_wanted = wanted - filled;
|
|
if (count_wanted > (size_t)available) {
|
|
count_wanted = (size_t)available;
|
|
}
|
|
int count = client.read(asset + received + filled, count_wanted);
|
|
if (count <= 0) continue;
|
|
filled += (size_t)count;
|
|
last_progress = millis();
|
|
}
|
|
if (!ok || reconnect_needed) break;
|
|
|
|
if (mbedtls_sha256_update_ret(&sha, asset + received, wanted) != 0) {
|
|
failure = "SHA-256 update";
|
|
ok = false;
|
|
break;
|
|
}
|
|
received += wanted;
|
|
if (received >= next_progress) {
|
|
FONT_RECOVERY_LOG("downloaded %lu of %lu bytes in %lu ms\n",
|
|
(unsigned long)received,
|
|
(unsigned long)mesh::indicator_font::kAssetSize,
|
|
(unsigned long)(millis() - started));
|
|
next_progress += 256 * 1024;
|
|
}
|
|
}
|
|
|
|
if (!ok || received == mesh::indicator_font::kAssetSize) break;
|
|
client.stop();
|
|
if (!reconnect_needed) {
|
|
failure = "HTTP body incomplete";
|
|
ok = false;
|
|
break;
|
|
}
|
|
if (millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS) {
|
|
failure = "HTTP body total timeout";
|
|
ok = false;
|
|
break;
|
|
}
|
|
if (resume_reconnects >= DOWNLOAD_MAX_RESUME_RECONNECTS) {
|
|
failure = "HTTP Range reconnect limit";
|
|
ok = false;
|
|
break;
|
|
}
|
|
|
|
bool range_open = false;
|
|
while (resume_reconnects < DOWNLOAD_MAX_RESUME_RECONNECTS
|
|
&& millis() - started < DOWNLOAD_TOTAL_TIMEOUT_MS) {
|
|
++resume_reconnects;
|
|
FONT_RECOVERY_LOG(
|
|
"response interrupted; Range resume %u/%u at byte %lu (discarding %lu unverified bytes)\n",
|
|
(unsigned int)resume_reconnects,
|
|
(unsigned int)DOWNLOAD_MAX_RESUME_RECONNECTS,
|
|
(unsigned long)received, (unsigned long)discarded_partial);
|
|
response = openAssetResponse(
|
|
client, received, asset_etag, nullptr, 0);
|
|
if (response.disposition == RecoveryAttemptDisposition::Succeeded) {
|
|
range_open = true;
|
|
break;
|
|
}
|
|
if (response.disposition
|
|
!= RecoveryAttemptDisposition::RetryableFailure) {
|
|
failure = "HTTP Range response rejected";
|
|
response_failure = true;
|
|
break;
|
|
}
|
|
client.stop();
|
|
if (resume_reconnects < DOWNLOAD_MAX_RESUME_RECONNECTS
|
|
&& millis() - started < DOWNLOAD_TOTAL_TIMEOUT_MS) {
|
|
FONT_RECOVERY_LOG(
|
|
"Range connection failed; retrying the same verified offset\n");
|
|
delay(250);
|
|
}
|
|
}
|
|
if (!range_open) {
|
|
if (failure == nullptr || !response_failure) {
|
|
failure = millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS
|
|
? "HTTP body total timeout" : "HTTP Range reconnect limit";
|
|
}
|
|
ok = false;
|
|
break;
|
|
}
|
|
// Header parsing and a resumed TLS handshake have their own deadlines.
|
|
// Only the body-idle clock restarts; `started` remains the one deadline for
|
|
// all response bodies and reconnects in this recovery attempt.
|
|
last_progress = millis();
|
|
}
|
|
const bool network_connected_at_end = client.connected();
|
|
client.stop();
|
|
|
|
uint8_t digest[32] = {};
|
|
if (ok && mbedtls_sha256_finish_ret(&sha, digest) != 0) {
|
|
failure = "SHA-256 finish";
|
|
ok = false;
|
|
}
|
|
mbedtls_sha256_free(&sha);
|
|
if (!ok || received != mesh::indicator_font::kAssetSize) {
|
|
FONT_RECOVERY_LOG(
|
|
"download failed (%s; bytes=%lu elapsed=%lu idle=%lu connected=%u)\n",
|
|
failure ? failure : "incomplete body", (unsigned long)received,
|
|
(unsigned long)(millis() - started),
|
|
(unsigned long)(millis() - last_progress),
|
|
network_connected_at_end ? 1U : 0U);
|
|
free(asset);
|
|
return response_failure
|
|
? response
|
|
: recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
bool digest_ok = memcmp(digest, EXPECTED_SHA256, sizeof(digest)) == 0;
|
|
FONT_RECOVERY_LOG("download complete in %lu ms; SHA-256=%s\n",
|
|
(unsigned long)(millis() - started),
|
|
digest_ok ? "verified" : "mismatch");
|
|
if (!digest_ok) {
|
|
free(asset);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX);
|
|
delay(50);
|
|
synchronizeCommandParser(Serial2);
|
|
StageTransferMode stage_mode = StageTransferMode::Acknowledged;
|
|
if (!beginStagedUpload(Serial2, stage_mode)) {
|
|
Serial2.end();
|
|
free(asset);
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
bool stage_ok = true;
|
|
const char* stage_failure = nullptr;
|
|
size_t staged_bytes = 0;
|
|
const uint32_t stage_started = millis();
|
|
next_progress = 256 * 1024;
|
|
while (staged_bytes < mesh::indicator_font::kAssetSize) {
|
|
if (millis() - stage_started >= STAGE_TOTAL_TIMEOUT_MS) {
|
|
stage_failure = "total timeout";
|
|
stage_ok = false;
|
|
break;
|
|
}
|
|
const size_t wanted = mesh::indicator_font::stageV2ChunkSize(
|
|
mesh::indicator_font::kAssetSize, staged_bytes);
|
|
if (!writeAll(Serial2, asset + staged_bytes, wanted)) {
|
|
stage_failure = "ESP32 UART write";
|
|
stage_ok = false;
|
|
break;
|
|
}
|
|
size_t next_staged = 0;
|
|
if (!mesh::indicator_font::advanceStageV2Offset(
|
|
mesh::indicator_font::kAssetSize, staged_bytes, wanted,
|
|
next_staged)) {
|
|
stage_failure = "offset accounting";
|
|
stage_ok = false;
|
|
break;
|
|
}
|
|
if (stage_mode == StageTransferMode::Acknowledged) {
|
|
if (!stagedChunkAcknowledged(Serial2, next_staged)) {
|
|
stage_failure = "RP2040 acknowledgement";
|
|
stage_ok = false;
|
|
break;
|
|
}
|
|
} else {
|
|
// write() may return after queueing bytes. Drain the UART before the
|
|
// receiver-safe pause so each legacy burst stays bounded to one block.
|
|
Serial2.flush();
|
|
delay(LEGACY_STAGE_PACE_MS);
|
|
}
|
|
staged_bytes = next_staged;
|
|
if (staged_bytes >= next_progress) {
|
|
FONT_RECOVERY_LOG("staged %lu of %lu bytes in %lu ms\n",
|
|
(unsigned long)staged_bytes,
|
|
(unsigned long)mesh::indicator_font::kAssetSize,
|
|
(unsigned long)(millis() - stage_started));
|
|
next_progress += 256 * 1024;
|
|
}
|
|
}
|
|
free(asset);
|
|
asset = nullptr;
|
|
if (!stage_ok) {
|
|
// The RP2040's bounded receive removes its separate staging file. Do not
|
|
// inject ABORT into a peer that may still be in its binary receive state.
|
|
FONT_RECOVERY_LOG("staging failed (%s; bytes=%lu elapsed=%lu)\n",
|
|
stage_failure ? stage_failure : "unknown",
|
|
(unsigned long)staged_bytes,
|
|
(unsigned long)(millis() - stage_started));
|
|
Serial2.end();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
char line[64];
|
|
Serial2.flush();
|
|
bool staged = readLine(Serial2, line, sizeof(line), 20000)
|
|
&& strcmp(line, "STAGED") == 0;
|
|
FONT_RECOVERY_LOG("staging complete in %lu ms; RP2040 reply='%s'\n",
|
|
(unsigned long)(millis() - stage_started),
|
|
line[0] ? line : "<timeout>");
|
|
if (!staged) {
|
|
Serial2.end();
|
|
return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure);
|
|
}
|
|
|
|
char commitCommand[64];
|
|
snprintf(commitCommand, sizeof(commitCommand), "MCFONT COMMIT %lu %08lx",
|
|
(unsigned long)mesh::indicator_font::kAssetSize,
|
|
(unsigned long)mesh::indicator_font::kAssetCrc32);
|
|
char commitReply[64];
|
|
const bool commitReplied = commandReply(
|
|
Serial2, commitCommand, commitReply, sizeof(commitReply), 30000);
|
|
const bool committed = commitReplied && strcmp(commitReply, "OK") == 0;
|
|
const bool commitExplicitlyRejected = commitReplied
|
|
&& strncmp(commitReply, "ERROR ", 6) == 0;
|
|
FONT_RECOVERY_LOG("RP2040 commit %s (reply='%s')\n",
|
|
committed ? "accepted"
|
|
: commitExplicitlyRejected ? "rejected" : "uncertain",
|
|
commitReplied ? commitReply : "<timeout>");
|
|
bool postCommitVerified = false;
|
|
if (committed) {
|
|
size_t size = 0;
|
|
uint32_t crc = 0;
|
|
postCommitVerified =
|
|
requestInfo(Serial2, size, crc) == FontInfoResult::Present
|
|
&& size == mesh::indicator_font::kAssetSize
|
|
&& crc == mesh::indicator_font::kAssetCrc32;
|
|
FONT_RECOVERY_LOG("post-commit font size=%lu crc=%08lx result=%s\n",
|
|
(unsigned long)size, (unsigned long)crc,
|
|
postCommitVerified ? "verified" : "probe required");
|
|
}
|
|
Serial2.end();
|
|
if (!committed) {
|
|
// A missing or malformed reply is not proof that COMMIT failed: the
|
|
// RP2040 can finish its durable rename just as the UART response is lost.
|
|
// Reclassify that ambiguous result using only the local service so it can
|
|
// never trigger another download of the identical immutable blob. A
|
|
// syntactically explicit ERROR remains a retryable rejected transaction.
|
|
return recoveryOutcome(commitExplicitlyRejected
|
|
? RecoveryAttemptDisposition::RetryableFailure
|
|
: RecoveryAttemptDisposition::CommittedNeedsProbe);
|
|
}
|
|
return recoveryOutcome(postCommitVerified
|
|
? RecoveryAttemptDisposition::Succeeded
|
|
: RecoveryAttemptDisposition::CommittedNeedsProbe);
|
|
}
|
|
|
|
void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome) {
|
|
uint32_t delay_millis =
|
|
mesh::indicator_font::retryDelayAfter(recoveryAttempts);
|
|
if (outcome.disposition == RecoveryAttemptDisposition::RateLimited
|
|
&& outcome.retryDelayMillis > delay_millis) {
|
|
delay_millis = outcome.retryDelayMillis;
|
|
}
|
|
uint32_t next = millis() + delay_millis;
|
|
if (next == 0) next = 1;
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
if (outcome.disposition
|
|
== RecoveryAttemptDisposition::CommittedNeedsProbe
|
|
|| outcome.disposition == RecoveryAttemptDisposition::PermanentFailure
|
|
|| recoveryAttempts >= mesh::indicator_font::kMaximumAttemptsPerBoot) {
|
|
recoveryState = RecoveryState::Exhausted;
|
|
} else {
|
|
recoveryNextAttempt = next;
|
|
recoveryState = RecoveryState::Waiting;
|
|
}
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
void finishRecoveryFailure() {
|
|
finishRecoveryFailure(
|
|
recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure));
|
|
}
|
|
|
|
void finishServiceProbeFailure() {
|
|
uint32_t next = millis()
|
|
+ mesh::indicator_font::serviceProbeRetryDelayAfter(
|
|
serviceProbeAttempts);
|
|
if (next == 0) next = 1;
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
if (serviceProbeAttempts
|
|
>= mesh::indicator_font::kMaximumServiceProbeAttemptsPerBoot) {
|
|
recoveryState = RecoveryState::Exhausted;
|
|
} else {
|
|
recoveryNextAttempt = next;
|
|
recoveryState = RecoveryState::ProbeWaiting;
|
|
}
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
void finishPostCommitProbeFailure() {
|
|
uint32_t next = millis()
|
|
+ mesh::indicator_font::postCommitProbeRetryDelayAfter(
|
|
postCommitProbeAttempts);
|
|
if (next == 0) next = 1;
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
if (postCommitProbeAttempts
|
|
>= mesh::indicator_font::kMaximumPostCommitProbeAttemptsPerBoot) {
|
|
// COMMIT was acknowledged, so redownloading the identical immutable blob
|
|
// is neither a repair nor safe progress. Leave the fallback/old runtime
|
|
// font in place and let the next boot reclassify the local service.
|
|
recoveryState = RecoveryState::Exhausted;
|
|
} else {
|
|
recoveryNextAttempt = next;
|
|
recoveryState = RecoveryState::PostCommitProbeWaiting;
|
|
}
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
void serviceProbeTask(void*) {
|
|
LoadedFont loaded = loadFromService();
|
|
if (loaded.data != nullptr) {
|
|
if (loaded.currentAsset) {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
recoveredFont = loaded.data;
|
|
recoveredFontSize = loaded.size;
|
|
recoveryState = RecoveryState::Ready;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
} else {
|
|
// Startup is still using the fallback because the first INFO exchange
|
|
// was inconclusive. Replace an older valid file live after recovery;
|
|
// unlike the normal version-mismatch path, no old PSRAM font is active.
|
|
free(loaded.data);
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::VersionMismatch, true);
|
|
}
|
|
} else if (loaded.info == FontInfoResult::Missing) {
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::Missing);
|
|
} else if (loaded.info == FontInfoResult::Present) {
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::Corrupt);
|
|
} else {
|
|
finishServiceProbeFailure();
|
|
}
|
|
vTaskDelete(nullptr);
|
|
}
|
|
|
|
void postCommitProbeTask(void*) {
|
|
bool activateLive;
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
activateLive = recoveryActivateLive;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
|
|
if (!activateLive) {
|
|
// A valid older runtime font is deliberately retained until reboot. The
|
|
// RP2040's COMMIT path re-reads the complete staged file and verifies its
|
|
// CRC before publishing this exact metadata, so an INFO match is enough to
|
|
// classify the local install without allocating a second 1.3 MiB buffer.
|
|
size_t size = 0;
|
|
uint32_t crc = 0;
|
|
const FontInfoResult info = probeServiceInfo(size, crc);
|
|
if (info == FontInfoResult::Present
|
|
&& size == mesh::indicator_font::kAssetSize
|
|
&& crc == mesh::indicator_font::kAssetCrc32) {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
recoveryState = RecoveryState::Complete;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
} else {
|
|
FONT_RECOVERY_LOG(
|
|
"committed asset INFO re-probe failed (info=%u size=%lu crc=%08lx)\n",
|
|
(unsigned int)info, (unsigned long)size, (unsigned long)crc);
|
|
finishPostCommitProbeFailure();
|
|
}
|
|
vTaskDelete(nullptr);
|
|
return;
|
|
}
|
|
|
|
LoadedFont loaded = loadFromService();
|
|
if (loaded.data != nullptr && loaded.currentAsset) {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
recoveredFont = loaded.data;
|
|
recoveredFontSize = loaded.size;
|
|
recoveryState = RecoveryState::Ready;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
} else {
|
|
FONT_RECOVERY_LOG(
|
|
"committed asset re-probe failed (info=%u data=%u current=%u)\n",
|
|
(unsigned int)loaded.info, loaded.data != nullptr ? 1U : 0U,
|
|
loaded.currentAsset ? 1U : 0U);
|
|
free(loaded.data);
|
|
finishPostCommitProbeFailure();
|
|
}
|
|
vTaskDelete(nullptr);
|
|
}
|
|
|
|
void recoveryTask(void*) {
|
|
bool hadRuntimeFont;
|
|
bool activateLive;
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
hadRuntimeFont = runtimeFontInstalled;
|
|
activateLive = recoveryActivateLive;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
|
|
const RecoveryAttemptOutcome outcome = downloadAndInstallAsset();
|
|
const bool activateCommittedLive = !hadRuntimeFont && activateLive;
|
|
if (outcome.disposition
|
|
== RecoveryAttemptDisposition::CommittedNeedsProbe) {
|
|
armPostCommitProbe(activateCommittedLive);
|
|
vTaskDelete(nullptr);
|
|
return;
|
|
}
|
|
if (outcome.disposition != RecoveryAttemptDisposition::Succeeded) {
|
|
finishRecoveryFailure(outcome);
|
|
vTaskDelete(nullptr);
|
|
return;
|
|
}
|
|
|
|
uint8_t* data = nullptr;
|
|
size_t size = 0;
|
|
if (activateCommittedLive) {
|
|
LoadedFont loaded = loadFromService();
|
|
if (loaded.data == nullptr || !loaded.currentAsset) {
|
|
free(loaded.data);
|
|
// The RP2040 already acknowledged COMMIT and its INFO metadata matched.
|
|
// A failed follow-up GET is a local transport/service problem, not
|
|
// authorization to download the same immutable Git blob again.
|
|
armPostCommitProbe(true);
|
|
vTaskDelete(nullptr);
|
|
return;
|
|
}
|
|
data = loaded.data;
|
|
size = loaded.size;
|
|
}
|
|
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
recoveredFont = data;
|
|
recoveredFontSize = size;
|
|
recoveryState = data != nullptr ? RecoveryState::Ready
|
|
: RecoveryState::Complete;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
vTaskDelete(nullptr);
|
|
}
|
|
|
|
#endif // INDICATOR_WIFI_FONT_RECOVERY
|
|
|
|
} // namespace
|
|
|
|
uint8_t* IndicatorFontClient::load(size_t& size) {
|
|
LoadedFont loaded = loadFromService();
|
|
size = loaded.size;
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
if (loaded.data != nullptr) {
|
|
if (!loaded.currentAsset) {
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::VersionMismatch);
|
|
}
|
|
} else if (loaded.info == FontInfoResult::Missing) {
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::Missing);
|
|
} else if (loaded.info == FontInfoResult::Present) {
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::Corrupt);
|
|
} else {
|
|
// The RP2040 can still be mounting SD when the ESP32 first asks. Once
|
|
// station Wi-Fi is connected, re-probe it on the background worker before
|
|
// deciding whether a network repair is needed.
|
|
armServiceProbe();
|
|
}
|
|
#endif
|
|
return loaded.data;
|
|
}
|
|
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
uint8_t* IndicatorFontClient::serviceRecovery(size_t& size) {
|
|
size = 0;
|
|
uint8_t* ready = nullptr;
|
|
bool launchProbe = false;
|
|
bool launchPostCommitProbe = false;
|
|
bool launchRecovery = false;
|
|
uint32_t now = millis();
|
|
const bool wifiConnected = WiFi.status() == WL_CONNECTED;
|
|
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
if (recoveryState == RecoveryState::Ready) {
|
|
ready = recoveredFont;
|
|
size = recoveredFontSize;
|
|
recoveredFont = nullptr;
|
|
recoveredFontSize = 0;
|
|
recoveryState = RecoveryState::Complete;
|
|
} else if (recoveryState == RecoveryState::PostCommitProbeWaiting
|
|
&& mesh::indicator_font::deadlineReached(
|
|
now, recoveryNextAttempt)) {
|
|
// This path is local UART/SD recovery after an acknowledged COMMIT. It
|
|
// deliberately does not require Wi-Fi and cannot transition to Waiting.
|
|
recoveryState = RecoveryState::PostCommitProbeRunning;
|
|
++postCommitProbeAttempts;
|
|
launchPostCommitProbe = true;
|
|
} else if (recoveryState == RecoveryState::ProbeWaiting
|
|
&& wifiConnected
|
|
&& mesh::indicator_font::deadlineReached(
|
|
now, recoveryNextAttempt)) {
|
|
recoveryState = RecoveryState::ProbeRunning;
|
|
++serviceProbeAttempts;
|
|
launchProbe = true;
|
|
} else if (recoveryState == RecoveryState::Waiting
|
|
&& wifiConnected
|
|
&& mesh::indicator_font::deadlineReached(
|
|
now, recoveryNextAttempt)) {
|
|
recoveryState = RecoveryState::Running;
|
|
++recoveryAttempts;
|
|
launchRecovery = true;
|
|
}
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
|
|
if (launchProbe) {
|
|
TaskHandle_t task = nullptr;
|
|
if (xTaskCreatePinnedToCore(serviceProbeTask, "indicator-font-probe",
|
|
RECOVERY_TASK_STACK_BYTES, nullptr, 2, &task,
|
|
1) != pdPASS) {
|
|
finishServiceProbeFailure();
|
|
}
|
|
} else if (launchPostCommitProbe) {
|
|
TaskHandle_t task = nullptr;
|
|
if (xTaskCreatePinnedToCore(
|
|
postCommitProbeTask, "indicator-font-committed-probe",
|
|
RECOVERY_TASK_STACK_BYTES, nullptr, 2, &task, 1) != pdPASS) {
|
|
finishPostCommitProbeFailure();
|
|
}
|
|
} else if (launchRecovery) {
|
|
TaskHandle_t task = nullptr;
|
|
if (xTaskCreatePinnedToCore(recoveryTask, "indicator-font",
|
|
RECOVERY_TASK_STACK_BYTES, nullptr, 2, &task,
|
|
1) != pdPASS) {
|
|
finishRecoveryFailure();
|
|
}
|
|
}
|
|
return ready;
|
|
}
|
|
|
|
void IndicatorFontClient::noteRuntimeFontInstalled() {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
runtimeFontInstalled = true;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
|
|
void IndicatorFontClient::noteRuntimeFontInvalid() {
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
runtimeFontInstalled = false;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
armRecovery(mesh::indicator_font::RecoveryNeed::Corrupt);
|
|
}
|
|
|
|
void IndicatorFontClient::noteRecoveredFontInvalid() {
|
|
// Every buffer returned by serviceRecovery() has already matched the exact
|
|
// compiled size, CRC32, and SHA-256. If the runtime parser rejects those
|
|
// immutable bytes, neither another local stream nor another HTTPS download
|
|
// can change the result. Stop for this boot instead of wasting bandwidth or
|
|
// creating a download/activation loop; the built-in fallback remains live.
|
|
portENTER_CRITICAL(&recoveryMux);
|
|
runtimeFontInstalled = false;
|
|
recoveryNeed = mesh::indicator_font::RecoveryNeed::None;
|
|
recoveryActivateLive = false;
|
|
recoveryState = RecoveryState::Exhausted;
|
|
portEXIT_CRITICAL(&recoveryMux);
|
|
}
|
|
#endif
|