Merge remote-tracking branch 'origin/dev' into core3

This commit is contained in:
proddy
2026-05-02 09:35:31 +02:00
85 changed files with 6841 additions and 5121 deletions

View File

@@ -1,126 +0,0 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef EMSESP_Version_H
#define EMSESP_Version_H
#include <cstdio>
#include <cstring>
#include <string>
// Drop-in lightweight replacement for the subset of the semver library actually used by EMS-ESP.
// The previous semver library (lib/semver) builds a std::map + std::function-based state machine on
// every parse, which fragments the internal heap on the ESP32. This replacement does no heap
// allocation beyond the std::string member for the prerelease tag, and matches the API surface
// we consume: construction from string, major()/minor()/patch()/prerelease(), and operator>/</==.
//
// Only strict numeric precedence (major.minor.patch) is used for comparison in EMS-ESP, so we
// intentionally ignore prerelease tags during comparison rather than implement the full semver
// ordering rules. This is consistent with how the old code was used (callers only check major/
// minor/patch numerically; prerelease() is only read for logging).
namespace version {
class EMSESP_Version {
public:
EMSESP_Version() = default;
// Construct from a version string like "3.9.0-dev.14" or "3.9.0".
// Anything past a '-' or '+' is kept as the prerelease string and not interpreted.
explicit EMSESP_Version(const std::string & s) {
parse(s.c_str());
}
explicit EMSESP_Version(const char * s) {
parse(s ? s : "");
}
int major() const {
return major_;
}
int minor() const {
return minor_;
}
int patch() const {
return patch_;
}
const std::string & prerelease() const {
return prerelease_;
}
// Numeric-only comparison (major.minor.patch). Prerelease tags are ignored on purpose.
friend bool operator<(const EMSESP_Version & a, const EMSESP_Version & b) {
if (a.major_ != b.major_)
return a.major_ < b.major_;
if (a.minor_ != b.minor_)
return a.minor_ < b.minor_;
if (a.patch_ != b.patch_)
return a.patch_ < b.patch_;
return a.prerelease_ < b.prerelease_;
}
friend bool operator>(const EMSESP_Version & a, const EMSESP_Version & b) {
return b < a;
}
friend bool operator==(const EMSESP_Version & a, const EMSESP_Version & b) {
return a.major_ == b.major_ && a.minor_ == b.minor_ && a.patch_ == b.patch_ && a.prerelease_ == b.prerelease_;
}
friend bool operator!=(const EMSESP_Version & a, const EMSESP_Version & b) {
return !(a == b);
}
friend bool operator>=(const EMSESP_Version & a, const EMSESP_Version & b) {
return !(a < b);
}
friend bool operator<=(const EMSESP_Version & a, const EMSESP_Version & b) {
return !(b < a);
}
private:
int major_ = 0;
int minor_ = 0;
int patch_ = 0;
std::string prerelease_;
void parse(const char * s) {
major_ = minor_ = patch_ = 0;
prerelease_.clear();
if (s == nullptr || *s == '\0') {
return;
}
// parse numeric major.minor.patch; accept partial ("3", "3.9", "3.9.0")
sscanf(s, "%d.%d.%d", &major_, &minor_, &patch_);
// capture prerelease tag after '-' if present (stop at '+' which is build metadata)
const char * dash = strchr(s, '-');
if (dash != nullptr) {
const char * plus = strchr(dash, '+');
if (plus != nullptr) {
prerelease_.assign(dash + 1, plus - dash - 1);
} else {
prerelease_.assign(dash + 1);
}
}
}
};
} // namespace version
#endif

View File

@@ -1817,10 +1817,10 @@ void EMSESP::start() {
analogsensor_.start(factory_settings); // Analog external sensors
// start web services
LOG_INFO("Starting Web Server");
webLogService.start(); // apply settings to weblog service
webModulesService.begin(); // setup the external library modules
webServer.begin(); // start the web server
LOG_INFO("Starting Web Server");
}
void EMSESP::start_serial_console() {
@@ -1874,6 +1874,7 @@ void EMSESP::loop() {
}
// loop through the services
webStatusService.loop(); // periodic refresh of cached versions.json
rxservice_.loop(); // process any incoming Rx telegrams
shower_.loop(); // check for shower on/off
temperaturesensor_.loop(); // read sensor temperatures

View File

@@ -0,0 +1,100 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "firmwareVersion.h"
#include <cstdio>
#include <cstring>
namespace emsesp {
FirmwareVersion::FirmwareVersion(const std::string & s) {
parse(s.c_str());
}
FirmwareVersion::FirmwareVersion(const char * s) {
parse(s ? s : "");
}
int FirmwareVersion::major() const {
return major_;
}
int FirmwareVersion::minor() const {
return minor_;
}
int FirmwareVersion::patch() const {
return patch_;
}
const std::string & FirmwareVersion::prerelease() const {
return prerelease_;
}
bool operator<(const FirmwareVersion & a, const FirmwareVersion & b) {
if (a.major_ != b.major_)
return a.major_ < b.major_;
if (a.minor_ != b.minor_)
return a.minor_ < b.minor_;
if (a.patch_ != b.patch_)
return a.patch_ < b.patch_;
return a.prerelease_ < b.prerelease_;
}
bool operator>(const FirmwareVersion & a, const FirmwareVersion & b) {
return b < a;
}
bool operator==(const FirmwareVersion & a, const FirmwareVersion & b) {
return a.major_ == b.major_ && a.minor_ == b.minor_ && a.patch_ == b.patch_ && a.prerelease_ == b.prerelease_;
}
bool operator!=(const FirmwareVersion & a, const FirmwareVersion & b) {
return !(a == b);
}
bool operator>=(const FirmwareVersion & a, const FirmwareVersion & b) {
return !(a < b);
}
bool operator<=(const FirmwareVersion & a, const FirmwareVersion & b) {
return !(b < a);
}
void FirmwareVersion::parse(const char * s) {
major_ = minor_ = patch_ = 0;
prerelease_.clear();
if (s == nullptr || *s == '\0') {
return;
}
// parse numeric major.minor.patch; accept partial ("3", "3.9", "3.9.0")
sscanf(s, "%d.%d.%d", &major_, &minor_, &patch_);
// capture prerelease tag after '-' if present (stop at '+' which is build metadata)
const char * dash = strchr(s, '-');
if (dash != nullptr) {
const char * plus = strchr(dash, '+');
if (plus != nullptr) {
prerelease_.assign(dash + 1, plus - dash - 1);
} else {
prerelease_.assign(dash + 1);
}
}
}
} // namespace emsesp

View File

@@ -0,0 +1,59 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef firmwareVersion_H
#define firmwareVersion_H
#include <string>
namespace emsesp {
class FirmwareVersion {
public:
FirmwareVersion() = default;
// Construct from a version string like "3.9.0-dev.14" or "3.9.0".
// Anything past a '-' or '+' is kept as the prerelease string and not interpreted.
explicit FirmwareVersion(const std::string & s);
explicit FirmwareVersion(const char * s);
int major() const;
int minor() const;
int patch() const;
const std::string & prerelease() const;
// Numeric-only comparison (major.minor.patch). Prerelease tags are ignored on purpose.
friend bool operator<(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator>(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator==(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator!=(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator>=(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator<=(const FirmwareVersion & a, const FirmwareVersion & b);
private:
int major_ = 0;
int minor_ = 0;
int patch_ = 0;
std::string prerelease_;
void parse(const char * s);
};
} // namespace emsesp
#endif

View File

@@ -32,7 +32,7 @@
#include <HTTPClient.h>
#include <map>
#include "EMSESP_Version.h"
#include "firmwareVersion.h"
#if defined(EMSESP_TEST)
#include "../test/test.h"
@@ -991,7 +991,6 @@ void System::heartbeat_json(JsonObject output) {
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2
output["temperature"] = (int)temperature_;
#endif
#endif
#ifndef EMSESP_STANDALONE
if (!EMSESP::network_.ethernet_connected()) {
@@ -1001,6 +1000,11 @@ void System::heartbeat_json(JsonObject output) {
output["wifireconnects"] = EMSESP::network_.getWifiReconnects();
}
#endif
// see if there is a newer version available
if (EMSESP::webStatusService.versions_cache_valid()) {
output["upgradeable"] = EMSESP::webStatusService.current_upgradeable();
}
}
// send periodic MQTT message with system information
@@ -1570,8 +1574,8 @@ bool System::check_upgrade() {
settingsVersion = "3.5.0"; // this was the last stable version without version info
}
version::EMSESP_Version settings_version(settingsVersion);
version::EMSESP_Version this_version(EMSESP_APP_VERSION);
FirmwareVersion settings_version(settingsVersion);
FirmwareVersion this_version(EMSESP_APP_VERSION);
std::string settings_version_type = settings_version.prerelease().empty() ? "" : ("-" + settings_version.prerelease());
std::string this_version_type = this_version.prerelease().empty() ? "" : ("-" + this_version.prerelease());

View File

@@ -173,7 +173,7 @@ Thermostat::Thermostat(uint8_t device_type, uint8_t device_id, uint8_t product_i
for (uint8_t i = 0; i < monitor_size; i++) {
register_telegram_type(monitor_typeids[i], "RC300Monitor", false, MAKE_PF_CB(process_RC300Monitor), 33);
register_telegram_type(set_typeids[i], "RC300Set", false, MAKE_PF_CB(process_RC300Set), 29);
register_telegram_type(summer_typeids[i], "RC300Summer", false, MAKE_PF_CB(process_RC300Summer), 13);
register_telegram_type(summer_typeids[i], "RC300Summer", false, MAKE_PF_CB(process_RC300Summer), 14);
register_telegram_type(curve_typeids[i], "RC300Curves", false, MAKE_PF_CB(process_RC300Curve), 9);
register_telegram_type(summer2_typeids[i], "RC300Summer2", false, MAKE_PF_CB(process_RC300Summer2), 8);
}

View File

@@ -1329,16 +1329,6 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
// request.url("/rest/action");
// EMSESP::webStatusService.action(&request, doc.as<JsonVariant>());
// test version checks
// use same data as in restServer.ts
// log shows first if you can upgrade to dev, and then if you can upgrade to stable
// request.url("/rest/action");
// std::string LATEST_STABLE_VERSION = "3.8.0";
// std::string LATEST_DEV_VERSION = "3.8.1-dev.3";
// std::string param = LATEST_DEV_VERSION + "," + LATEST_STABLE_VERSION;
// std::string action = "{\"action\":\"checkUpgrade\", \"param\":\"" + param + "\"}";
// deserializeJson(doc, action);
// // case 0: on latest stable, can upgrade to dev only. So true, false
// EMSESP::webStatusService.set_current_version(LATEST_STABLE_VERSION);
// EMSESP::webStatusService.action(&request, doc.as<JsonVariant>());

View File

@@ -20,6 +20,7 @@
#ifndef EMSESP_STANDALONE
#include <esp_ota_ops.h>
#include <HTTPClient.h>
#endif
namespace emsesp {
@@ -205,11 +206,11 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json)
bool is_admin = AuthenticationPredicates::IS_ADMIN(authentication);
// call action command
bool ok = false;
bool ok = true;
std::string action = json["action"];
if (action == "checkUpgrade") {
ok = checkUpgrade(root, param); // param could be empty, if so only send back version
if (action == "getVersions") {
getVersions(root);
} else if (action == "setPartition") {
ok = EMSESP::system_.set_partition(param.c_str());
} else if (action == "export") {
@@ -224,10 +225,8 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json)
ok = setSystemStatus(param.c_str());
} else if (action == "resetMQTT" && is_admin) {
EMSESP::mqtt_.reset_mqtt();
ok = true;
} else if (action == "upgradeImportantMessages") {
root["upgradeImportantMessageType"] = upgradeImportantMessages(param);
ok = true;
}
#if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY)
@@ -262,7 +261,7 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) {
// it's a filename with a .bin or .md extension, try and extract the version from it
// e.g. EMS-ESP-3_8_2-dev_13-ESP32-16MB+.bin -> major=3 minor=8 patch=2
version::EMSESP_Version latest_version;
FirmwareVersion latest_version;
if ((version.find(".bin") != std::string::npos) || (version.find(".md") != std::string::npos)) {
std::string filename = version;
auto pos = filename.find("EMS-ESP-");
@@ -283,18 +282,18 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) {
std::string major_version = filename.substr(pos, underscore1 - pos);
std::string minor_version = filename.substr(underscore1 + 1, underscore2 - underscore1 - 1);
std::string patch_version = filename.substr(underscore2 + 1, dash - underscore2 - 1);
latest_version = version::EMSESP_Version(major_version + "." + minor_version + "." + patch_version);
latest_version = FirmwareVersion(major_version + "." + minor_version + "." + patch_version);
} else {
// if it's .json file exit
if (version.find(".json") != std::string::npos) {
return 0;
} else {
// treat it like a version string like "3.9.0"
latest_version = version::EMSESP_Version(version);
latest_version = FirmwareVersion(version);
}
}
version::EMSESP_Version current_version(current_version_s); // get current version
FirmwareVersion current_version(current_version_s); // get current version
if ((current_version.major() <= 3 && current_version.minor() <= 8) && (latest_version.major() == 3 && latest_version.minor() == 9)) {
return 1; // if moving from below 3.8.x to 3.9.x return 1
@@ -311,46 +310,164 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) {
return 0; // if it's not a valid version upgrade return 0
}
// action = checkUpgrade
// versions holds the latest development version and stable version in one string, comma separated
bool WebStatusService::checkUpgrade(JsonObject root, std::string & version) {
if (!version.empty()) {
version::EMSESP_Version current_version(current_version_s);
version::EMSESP_Version latest_dev_version(version.substr(0, version.find(',')));
version::EMSESP_Version latest_stable_version(version.substr(version.find(',') + 1));
// action = getVersions
// returns the device's current version for dev and stable
// The remote fetch runs from the main loop task via WebStatusService::loop() so that we never block the AsyncTCP callback
void WebStatusService::getVersions(JsonObject root) {
FirmwareVersion current_version(current_version_s);
bool is_dev = current_version.prerelease().find("dev") != std::string::npos;
bool dev_upgradeable = latest_dev_version > current_version;
bool stable_upgradeable = latest_stable_version > current_version;
JsonObject current = root["current"].to<JsonObject>();
current["version"] = current_version_s;
current["type"] = is_dev ? "dev" : "stable";
current["date"] = "";
current["upgradeable"] = current_upgradeable(); // false if cache not valid yet
#if defined(EMSESP_DEBUG)
// look for dev in the name to determine if we're using a dev release
bool using_dev_version = !current_version.prerelease().find("dev");
EMSESP::logger()
.debug("Checking version upgrade. This version=%d.%d.%d-%s (%s),latest dev=%d.%d.%d-%s (%s upgradeable),latest stable=%d.%d.%d-%s (%s upgradeable)",
current_version.major(),
current_version.minor(),
current_version.patch(),
current_version.prerelease().c_str(),
using_dev_version ? "Dev" : "Stable",
latest_dev_version.major(),
latest_dev_version.minor(),
latest_dev_version.patch(),
latest_dev_version.prerelease().c_str(),
dev_upgradeable ? "is" : "is not",
latest_stable_version.major(),
latest_stable_version.minor(),
latest_stable_version.patch(),
latest_stable_version.prerelease().c_str(),
stable_upgradeable ? "is" : "is not");
#endif
root["dev_upgradeable"] = dev_upgradeable;
root["stable_upgradeable"] = stable_upgradeable;
#ifndef EMSESP_STANDALONE
// pull the install_date for the running partition (if known)
const esp_partition_t * running = esp_ota_get_running_partition();
if (running != nullptr) {
const auto & info = EMSESP::system_.partition_info_;
auto it = info.find(running->label);
if (it != info.end() && it->second.install_date > 0) {
char time_string[25];
time_t d = it->second.install_date;
strftime(time_string, sizeof(time_string), "%FT%T", localtime(&d));
current["date"] = time_string;
}
}
root["emsesp_version"] = current_version_s; // always send back current version
if (!versions_cache_valid_) {
// no successful fetch yet (no network, fetch pending, or parse error)
return;
}
// copies a cached entry into root[key]
auto add_section = [&](const char * key, const VersionInfo & info) {
if (info.version.empty()) {
return;
}
JsonObject out = root[key].to<JsonObject>();
out["version"] = info.version;
out["date"] = info.date;
out["upgradeable"] = info.upgradeable;
};
add_section("stable", versions_stable_);
add_section("dev", versions_dev_);
#else
// standalone/test build: provide deterministic dummy data
JsonObject stable_out = root["stable"].to<JsonObject>();
stable_out["version"] = "3.8.2";
stable_out["date"] = "2026-04-25";
stable_out["upgradeable"] = FirmwareVersion("3.8.2") > current_version;
JsonObject dev_out = root["dev"].to<JsonObject>();
dev_out["version"] = "3.9.0-dev.1";
dev_out["date"] = "2026-04-25";
dev_out["upgradeable"] = FirmwareVersion("3.9.0-dev.1") > current_version;
#endif
}
// periodic refresh (1 hour) of the cached versions.json
// runs on the main loop task, which has a much bigger stack than AsyncTCP needed for https
void WebStatusService::loop() {
#ifndef EMSESP_STANDALONE
// need a network
if (!EMSESP::system_.ethernet_connected() && (WiFi.status() != WL_CONNECTED)) {
return;
}
// 0 = idle, nothing scheduled
if (versions_next_fetch_ms_ == 0) {
return;
}
// not time yet (signed difference handles uint32 wrap)
if ((int32_t)(uuid::get_uptime() - versions_next_fetch_ms_) < 0) {
return;
}
bool ok = refresh_versions_cache();
uint32_t next = uuid::get_uptime() + (ok ? VERSIONS_REFRESH_INTERVAL_MS : VERSIONS_RETRY_INTERVAL_MS);
if (next == 0) {
next = 1;
}
versions_next_fetch_ms_ = next;
#endif
}
// runs on the main loop task — never call this from an AsyncWebServer handler
bool WebStatusService::refresh_versions_cache() {
#ifdef EMSESP_STANDALONE
return false;
#else
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(5000);
http.useHTTP10(true);
if (!http.begin(EMSESP_VERSIONS_URL)) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: failed to start HTTPS request");
#endif
return false;
}
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: HTTP %d", httpCode);
#endif
http.end();
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, http.getStream());
http.end();
if (err) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: parse error");
#endif
return false;
}
FirmwareVersion current_version(current_version_s);
auto read_section = [&doc, &current_version](const char * key, VersionInfo & out) {
JsonObjectConst section = doc[key];
if (section.isNull()) {
out = {};
return;
}
out.version = section["version"] | "";
out.date = section["date"] | "";
out.upgradeable = !out.version.empty() && FirmwareVersion(out.version) > current_version;
};
read_section("stable", versions_stable_);
read_section("dev", versions_dev_);
versions_cache_valid_ = true;
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: refreshed (stable=%s dev=%s), current=%s",
versions_stable_.version.c_str(),
versions_dev_.version.c_str(),
current_version_s.c_str());
#endif
return true;
#endif
}
// returns if current dev/stable is upgradeable
bool WebStatusService::current_upgradeable() const {
if (!versions_cache_valid_) {
return false;
}
FirmwareVersion current_version(current_version_s);
bool is_dev = current_version.prerelease().find("dev") != std::string::npos;
return is_dev ? versions_dev_.upgradeable : versions_stable_.upgradeable;
}
// action = allvalues

View File

@@ -4,7 +4,9 @@
#define EMSESP_SYSTEM_STATUS_SERVICE_PATH "/rest/systemStatus"
#define EMSESP_ACTION_SERVICE_PATH "/rest/action"
#include "../core/EMSESP_Version.h"
#define EMSESP_VERSIONS_URL "http://emsesp.org/versions.json"
#include "../core/firmwareVersion.h"
#include "../emsesp_version.h"
namespace emsesp {
@@ -19,6 +21,22 @@ class WebStatusService {
return current_version_s;
}
// called from EMSESP::loop() to refresh the cached versions.json from emsesp.org
// so that the web request handler never has to do blocking HTTPS on the small AsyncTCP stack
void loop();
// true once we've had at least one successful versions.json fetch
bool versions_cache_valid() const {
return versions_cache_valid_;
}
// refresh the versions.json cache
void schedule_versions_refresh() {
versions_next_fetch_ms_ = 1;
}
bool current_upgradeable() const; // true if a newer version is available
// make action function public so we can test in the debug and standalone mode
#ifndef EMSESP_STANDALONE
protected:
@@ -30,7 +48,7 @@ class WebStatusService {
SecurityManager * _securityManager;
// actions
bool checkUpgrade(JsonObject root, std::string & latest_version);
void getVersions(JsonObject root);
bool exportData(JsonObject root, std::string & type);
bool getCustomSupport(JsonObject root);
bool uploadURL(const char * url);
@@ -39,6 +57,22 @@ class WebStatusService {
uint8_t upgradeImportantMessages(std::string & version);
std::string current_version_s = EMSESP_APP_VERSION;
// cached emsesp.org/versions.json. Refreshed from the main loop task, which has more stack.
struct VersionInfo {
std::string version;
std::string date;
bool upgradeable = false;
};
VersionInfo versions_stable_;
VersionInfo versions_dev_;
bool versions_cache_valid_ = false; // true once we've had at least one successful fetch
uint32_t versions_next_fetch_ms_ = 0; // uuid::get_uptime() of the next attempt; 0 = idle
bool refresh_versions_cache(); // does the actual HTTPS fetch + parse, returns true on success
static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success
static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after failure
};
} // namespace emsesp