diff --git a/interface/src/api/endpoints.ts b/interface/src/api/endpoints.ts index e598d7aad..db867385a 100644 --- a/interface/src/api/endpoints.ts +++ b/interface/src/api/endpoints.ts @@ -57,6 +57,3 @@ export const alovaInstance = createAlova({ onSuccess: handleResponse } }); - -export const DOCS_BASE_URL = - process.env.NODE_ENV === 'development' ? '/emsesp.org' : 'https://emsesp.org'; diff --git a/interface/src/api/system.ts b/interface/src/api/system.ts index 6910c2df2..43829025a 100644 --- a/interface/src/api/system.ts +++ b/interface/src/api/system.ts @@ -1,6 +1,6 @@ import type { LogSettings, SystemStatus } from 'types'; -import { DOCS_BASE_URL, alovaInstance } from './endpoints'; +import { alovaInstance } from './endpoints'; // systemStatus - also used to ping in System Monitor for pinging export const readSystemStatus = () => @@ -13,14 +13,6 @@ export const updateLogSettings = (data: LogSettings) => alovaInstance.Post('/rest/logSettings', data); export const fetchLogES = () => alovaInstance.Get('/es/log'); -// get versions from emsesp.org/versions.json -// uses native fetch (no custom headers) to keep this as a "simple" CORS -export const getVersions = async (): Promise => { - const res = await fetch(`${DOCS_BASE_URL}/versions.json`); - if (!res.ok) throw new Error(res.statusText); - return res.json() as Promise; -}; - const UPLOAD_TIMEOUT = 60000; // 1 minute export const uploadFile = (file: File) => { diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index 64a5b1cd6..b2aa166fa 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -1,12 +1,4 @@ -import { - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState -} from 'react'; +import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { Link } from 'react-router'; import { toast } from 'react-toastify'; @@ -34,7 +26,6 @@ import { import * as SystemApi from 'api/system'; import { API, callAction } from 'api/app'; -import { getVersions } from 'api/system'; import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; @@ -79,21 +70,24 @@ interface VersionData { developer_mode: boolean; } -interface UpgradeCheckData { - emsesp_version: string; - dev_upgradeable: boolean; - stable_upgradeable: boolean; -} - interface VersionInfo { version: string; date: string; } -interface Versions { - stable: VersionInfo; - dev: VersionInfo; - last_updated: string; +interface RemoteVersionInfo extends VersionInfo { + upgradeable: boolean; +} + +interface CurrentVersionInfo extends VersionInfo { + type: 'stable' | 'dev'; +} + +// Response payload from the `getVersions` action +interface VersionsResponse { + current: CurrentVersionInfo; + stable?: RemoteVersionInfo; + dev?: RemoteVersionInfo; } // Memoized components for better performance @@ -465,15 +459,6 @@ const Version = () => { const [showVersionInfo, setShowVersionInfo] = useState(0); // 1 = stable, 2 = dev, 3 = partition const [firmwareSize, setFirmwareSize] = useState(0); - const { send: sendCheckUpgrade } = useRequest( - (versions: string) => callAction({ action: 'checkUpgrade', param: versions }), - { immediate: false } - ).onSuccess((event) => { - const data = event.data as UpgradeCheckData; - setDevUpgradeAvailable(data.dev_upgradeable); - setStableUpgradeAvailable(data.stable_upgradeable); - }); - const { send: sendSetPartition } = useRequest( (partition: string) => callAction({ action: 'setPartition', param: partition }), { immediate: false } @@ -490,7 +475,6 @@ const Version = () => { if (systemData.arduino_version.startsWith('Tasmota')) { setDownloadOnly(true); } - setUsingDevVersion(systemData.emsesp_version.includes('dev')); }); const { send: sendUploadURL } = useRequest( @@ -498,32 +482,33 @@ const Version = () => { { immediate: false } ); - // Fetch versions.json from emsesp.org once on mount. - // Uses plain fetch (not alova) so the request stays a "simple" CORS request and avoids a preflight that emsesp.org rejects. - // sendCheckUpgrade is stored in a ref because alova's useRequest returns a new function reference each render - const sendCheckUpgradeRef = useRef(sendCheckUpgrade); - sendCheckUpgradeRef.current = sendCheckUpgrade; - useEffect(() => { - let cancelled = false; - getVersions() - .then((versions) => { - if (cancelled) return; - setLatestVersion(versions.stable); - setLatestDevVersion(versions.dev); - sendCheckUpgradeRef.current( - `${versions.stable.version},${versions.dev.version}` - ); - setInternetLive(true); - }) - .catch((error: unknown) => { - if (cancelled) return; - toast.error(error instanceof Error ? error.message : 'An error occurred'); - setInternetLive(false); - }); - return () => { - cancelled = true; - }; - }, []); + // Fetch latest stable/dev versions via the device. The ESP32 calls + // emsesp.org/versions.json itself and includes its own `current` info plus + // upgradeable flags. If the device has no internet, `stable`/`dev` are + // absent and we surface that as "internet not live". + useRequest(() => callAction({ action: 'getVersions' })) + .onSuccess((event) => { + const versions = event.data as VersionsResponse; + setUsingDevVersion(versions.current?.type === 'dev'); + if (versions.stable) { + setLatestVersion({ + version: versions.stable.version, + date: versions.stable.date + }); + setStableUpgradeAvailable(versions.stable.upgradeable); + } + if (versions.dev) { + setLatestDevVersion({ + version: versions.dev.version, + date: versions.dev.date + }); + setDevUpgradeAvailable(versions.dev.upgradeable); + } + setInternetLive(Boolean(versions.stable || versions.dev)); + }) + .onError(() => { + setInternetLive(false); + }); const { send: sendAPI } = useRequest((data: APIcall) => API(data), { immediate: false diff --git a/interface/vite.config.ts b/interface/vite.config.ts index c8194c202..8bea7e00d 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -229,8 +229,7 @@ export default defineConfig( changeOrigin: true, secure: false }, - '/rest': 'http://localhost:3080', - '/emsesp.org': 'http://localhost:3080' + '/rest': 'http://localhost:3080' } }, build: { diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index 247827b09..0ac776333 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -6,7 +6,6 @@ const router = AutoRouter(); const REST_ENDPOINT_ROOT = '/rest/'; const API_ENDPOINT_ROOT = '/api/'; -const EMSESP_DOCS_ENDPOINT = '/emsesp.org/'; // for mock emsesp.org/versions.json // HTTP HEADERS for msgpack const headers = { @@ -302,10 +301,10 @@ function updateMask(entity: any, de: any, dd: any) { const old_custom_name = dd.nodes[dd_objIndex].cn; console.log( 'comparing names, old (' + - old_custom_name + - ') with new (' + - new_custom_name + - ')' + old_custom_name + + ') with new (' + + new_custom_name + + ')' ); if (old_custom_name !== new_custom_name) { changed = true; @@ -415,36 +414,54 @@ function upgradeImportantMessages(version: string) { return { upgradeImportantMessageType: upgradeImportantMessageType_n }; } -// called by Action endpoint checkUpgrade -function check_upgrade(version: string) { - let data = {}; - if (version) { - const dev_version = version.split(',')[0]; - const stable_version = version.split(',')[1]; +// called by Action endpoint getVersions +// Mirrors the C++ WebStatusService::getVersions() payload: +// { current: { version, type, date }, +// stable?: { version, date, upgradeable }, +// dev?: { version, date, upgradeable } } +// Set MOCK_OFFLINE = true to simulate a device with no internet (omits stable/dev). +const MOCK_OFFLINE = false; +function get_versions() { + const isDev = THIS_VERSION.includes('dev'); + const data: { + current: { version: string; type: 'stable' | 'dev'; date: string }; + stable?: { version: string; date: string; upgradeable: boolean }; + dev?: { version: string; date: string; upgradeable: boolean }; + } = { + current: { + version: THIS_VERSION, + type: isDev ? 'dev' : 'stable', + date: '2026-04-25T12:00:00' + } + }; - console.log( - 'Upgrade this version (' + - THIS_VERSION + - ') to dev (' + - dev_version + - ') is ' + - (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + - ' and to stable (' + - stable_version + - ') is ' + - (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') - ); - data = { - emsesp_version: THIS_VERSION, - dev_upgradeable: DEV_VERSION_IS_UPGRADEABLE, - stable_upgradeable: STABLE_VERSION_IS_UPGRADEABLE + if (!MOCK_OFFLINE) { + data.stable = { + version: LATEST_STABLE_VERSION, + date: '2026-04-25', + upgradeable: STABLE_VERSION_IS_UPGRADEABLE }; - } else { - console.log('requesting ems-esp version (' + THIS_VERSION + ')'); - data = { - emsesp_version: THIS_VERSION + data.dev = { + version: LATEST_DEV_VERSION, + date: '2026-04-25', + upgradeable: DEV_VERSION_IS_UPGRADEABLE }; } + + console.log( + 'getVersions: current=' + + THIS_VERSION + + ' stable=' + + LATEST_STABLE_VERSION + + ' (upgradeable=' + + (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + + ') dev=' + + LATEST_DEV_VERSION + + ' (upgradeable=' + + (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + + ')' + + (MOCK_OFFLINE ? ' [offline]' : '') + ); return data; } @@ -706,7 +723,6 @@ const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action'; // these are used in the API calls only const EMSESP_SYSTEM_INFO_ENDPOINT = API_ENDPOINT_ROOT + 'system/info'; -const EMSESP_VERSIONS_ENDPOINT = EMSESP_DOCS_ENDPOINT + 'versions.json'; const emsesp_info = { System: { @@ -5173,13 +5189,9 @@ router } else if (action === 'getCustomSupport') { // send custom support return custom_support(); - } else if (action === 'checkUpgrade') { - // check upgrade - // check if content has a param - if (!content.param) { - return check_upgrade(''); - } - return check_upgrade(content.param); + } else if (action === 'getVersions') { + // get versions + return get_versions(); } else if (action === 'uploadURL') { // upload URL console.log('upload File from URL', content.param); @@ -5234,23 +5246,6 @@ router return status(404); // not found }); -// Mock emsesp.org/versions.json -router.get(EMSESP_VERSIONS_ENDPOINT, () => { - const data = { - stable: { - version: LATEST_STABLE_VERSION, - date: '2026-04-25' - }, - dev: { - version: LATEST_DEV_VERSION, - date: '2026-04-25' - }, - last_updated: new Date().toISOString() - }; - console.log('sending versions.json: ', data); - return data; -}); - // const logger: ResponseHandler = (response, request) => { // console.log( // response.status, diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp index 9d583e427..dcd6f70da 100644 --- a/src/core/emsesp.cpp +++ b/src/core/emsesp.cpp @@ -1865,6 +1865,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 diff --git a/src/core/system.cpp b/src/core/system.cpp index 45e5c6777..e2a8fb8ba 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -899,9 +899,7 @@ 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 (!ethernet_connected_) { int8_t rssi = WiFi.RSSI(); output["rssi"] = rssi; @@ -909,6 +907,11 @@ void System::heartbeat_json(JsonObject output) { output["wifireconnects"] = EMSESP::esp32React.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 diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index d97c4d4e8..5cc062804 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -20,6 +20,7 @@ #ifndef EMSESP_STANDALONE #include +#include #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) @@ -311,46 +310,169 @@ 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 info plus the cached "stable" and "dev" +// entries from emsesp.org/versions.json. The remote fetch is NOT done here: it +// runs from the main loop task via WebStatusService::loop() so we never block +// the AsyncTCP callback (which has a tiny ~6 KB stack — far too small for an +// HTTPS handshake). If we have no cached data yet (no internet, fetch still +// pending, parse error) the "stable" and "dev" sections are simply omitted so +// the client can detect the offline case. +void WebStatusService::getVersions(JsonObject root) { + version::EMSESP_Version 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(); + 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]. The upgradeable bool was computed + // once during refresh_versions_cache() so we just read it here. + auto add_section = [&](const char * key, const VersionInfo & info) { + if (info.version.empty()) { + return; + } + JsonObject out = root[key].to(); + 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(); + stable_out["version"] = "3.8.2"; + stable_out["date"] = "2026-04-25"; + stable_out["upgradeable"] = version::EMSESP_Version("3.8.2") > current_version; + + JsonObject dev_out = root["dev"].to(); + dev_out["version"] = "3.8.3-dev.2"; + dev_out["date"] = "2026-04-25"; + dev_out["upgradeable"] = version::EMSESP_Version("3.8.3-dev.2") > 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, so it's safe to do HTTPS here. +void WebStatusService::loop() { +#ifndef EMSESP_STANDALONE + // need a network + if (!EMSESP::system_.ethernet_connected() && (WiFi.status() != WL_CONNECTED)) { + return; + } + + uint32_t now = uuid::get_uptime(); + + // first call after we have a network: schedule the initial fetch a little + // later so we give NTP / DNS a chance to settle + if (versions_next_fetch_ms_ == 0) { + versions_next_fetch_ms_ = now + VERSIONS_INITIAL_DELAY_MS; + if (versions_next_fetch_ms_ == 0) { + versions_next_fetch_ms_ = 1; // avoid the "never scheduled" sentinel + } + return; + } + + // not time yet (signed difference handles uint32 wrap) + if ((int32_t)(now - 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("https://emsesp.org/versions.json")) { + EMSESP::logger().debug("versions.json: failed to start HTTPS request"); + return false; + } + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + EMSESP::logger().debug("versions.json: HTTP %d", httpCode); + http.end(); + return false; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, http.getStream()); + http.end(); + if (err) { + EMSESP::logger().debug("versions.json: parse error (%s)", err.c_str()); + return false; + } + + version::EMSESP_Version current_version(current_version_s); + + auto read_section = [&doc, ¤t_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() && version::EMSESP_Version(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)", versions_stable_.version.c_str(), versions_dev_.version.c_str()); +#endif return true; +#endif +} + +// returns if current dev/stable is upgradeable +bool WebStatusService::current_upgradeable() const { + if (!versions_cache_valid_) { + return false; + } + version::EMSESP_Version 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 diff --git a/src/web/WebStatusService.h b/src/web/WebStatusService.h index 6d3f59f1a..706d5d956 100644 --- a/src/web/WebStatusService.h +++ b/src/web/WebStatusService.h @@ -19,6 +19,17 @@ 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_; + } + + 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 +41,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 +50,23 @@ 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 = ASAP + + 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 + static constexpr uint32_t VERSIONS_INITIAL_DELAY_MS = 30UL * 1000UL; // wait 30s after boot }; } // namespace emsesp