From 6edbac86e203ca306b05b707e359e10ffdd43c8d Mon Sep 17 00:00:00 2001 From: MichaelDvP Date: Fri, 24 Apr 2026 14:46:53 +0200 Subject: [PATCH 01/33] fix legegram length, #2969 --- src/devices/thermostat.cpp | 2 +- src/emsesp_version.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/devices/thermostat.cpp b/src/devices/thermostat.cpp index aa909b515..ceba2fc86 100644 --- a/src/devices/thermostat.cpp +++ b/src/devices/thermostat.cpp @@ -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); } diff --git a/src/emsesp_version.h b/src/emsesp_version.h index 4909b018f..c98cd567e 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.8.2-dev.18" +#define EMSESP_APP_VERSION "3.8.2-dev.19" From 112adf9eb063fc7767c7ab02a4f68842a2abf81a Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 25 Apr 2026 11:19:39 +0200 Subject: [PATCH 02/33] add vscode --- .gitignore | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index bb18f41fa..743e6590a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ -# vscode -.vscode/c_cpp_properties.json -.vscode/extensions.json -.vscode/launch.json + # c++ compiling .clang_complete @@ -63,7 +60,7 @@ words-found-verbose.txt # sonarlint compile_commands.json -# pioarduino + hybrid +# other files managed_components dependencies.lock CMakeLists.txt @@ -75,3 +72,10 @@ pnpm-lock.yaml .cache/ interface/.tsbuildinfo test/test_api/package-lock.json + +# vscode +.vscode/c_cpp_properties.json +.vscode/extensions.json +.vscode/launch.json +.vscode/settings.json +.clangd From 147c09ae64b1dd3249b56f9cf06a762faf72dcc4 Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 25 Apr 2026 11:42:26 +0200 Subject: [PATCH 03/33] automatically update versions in Cloudflare KV store --- .github/workflows/dev_release.yml | 8 ++++++++ .github/workflows/stable_release.yml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index e68ac55b4..04365a655 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -77,3 +77,11 @@ jobs: files: | CHANGELOG_LATEST.md ./build/firmware/*.* + + - name: Update version in Cloudflare KV store + run: | + JSON_DATA='{"version": "${{steps.build_info.outputs.VERSION}}", "date": "$(date -u +%Y-%m-%d)"}' + curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/dev" \ + -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ + -H "Content-Type: text/plain" \ + -d "$JSON_DATA" diff --git a/.github/workflows/stable_release.yml b/.github/workflows/stable_release.yml index 6286e6ea3..d18cd52b9 100644 --- a/.github/workflows/stable_release.yml +++ b/.github/workflows/stable_release.yml @@ -61,3 +61,11 @@ jobs: files: | CHANGELOG.md ./build/firmware/*.* + + - name: Update version in Cloudflare KV store + run: | + JSON_DATA='{"version": "${{ github.event.release.tag_name }}", "date": "$(date -u +%Y-%m-%d)"}' + curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/stable" \ + -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ + -H "Content-Type: text/plain" \ + -d "$JSON_DATA" From 7056c446fa77e09582b181776e36ba18f7bab7c5 Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 25 Apr 2026 20:55:10 +0200 Subject: [PATCH 04/33] use emsesp.org/versions.json --- interface/package.json | 6 +- interface/pnpm-lock.yaml | 250 +++++++++++++-------------- interface/src/api/endpoints.ts | 10 +- interface/src/api/system.ts | 31 +--- interface/src/app/status/Version.tsx | 91 +++++----- interface/vite.config.ts | 2 +- mock-api/package.json | 2 +- mock-api/restServer.ts | 65 ++++--- src/core/system.cpp | 1 - 9 files changed, 223 insertions(+), 235 deletions(-) diff --git a/interface/package.json b/interface/package.json index d2d955b19..ce234abf8 100644 --- a/interface/package.json +++ b/interface/package.json @@ -61,11 +61,11 @@ "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", "rollup-plugin-visualizer": "^7.0.1", - "terser": "^5.46.1", + "terser": "^5.46.2", "typescript-eslint": "^8.59.0", - "vite": "^8.0.9", + "vite": "^8.0.10", "vite-plugin-imagemin": "^0.6.1", "vite-tsconfig-paths": "^6.1.1" }, - "packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820" + "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 735cfd2c5..5df136395 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -83,7 +83,7 @@ importers: version: 10.0.1(eslint@10.2.1) '@preact/preset-vite': specifier: ^2.10.5 - version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.3) @@ -113,22 +113,22 @@ importers: version: 3.8.3 rollup-plugin-visualizer: specifier: ^7.0.1 - version: 7.0.1(rolldown@1.0.0-rc.16)(rollup@4.59.0) + version: 7.0.1(rolldown@1.0.0-rc.17)(rollup@4.59.0) terser: - specifier: ^5.46.1 - version: 5.46.1 + specifier: ^5.46.2 + version: 5.46.2 typescript-eslint: specifier: ^8.59.0 version: 8.59.0(eslint@10.2.1)(typescript@6.0.3) vite: - specifier: ^8.0.9 - version: 8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1) + specifier: ^8.0.10 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) vite-plugin-imagemin: specifier: ^0.6.1 - version: 0.6.1(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)) + version: 0.6.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)) + version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) packages: @@ -237,11 +237,11 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -653,8 +653,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxc-project/types@0.126.0': - resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -690,103 +690,103 @@ packages: preact: ^10.4.0 || ^11.0.0-0 vite: '>=2.0.0' - '@rolldown/binding-android-arm64@1.0.0-rc.16': - resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.16': - resolution: {integrity: sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.16': - resolution: {integrity: sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==} + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.16': - resolution: {integrity: sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': - resolution: {integrity: sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': - resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': - resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': - resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': - resolution: {integrity: sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': - resolution: {integrity: sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': - resolution: {integrity: sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.16': - resolution: {integrity: sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} '@rollup/pluginutils@4.2.1': resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} @@ -1109,8 +1109,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} alova@3.5.1: resolution: {integrity: sha512-avrWPyFFWW51YLoy0S3OleNw1BV0GqNI+DSdWHfFbAoKZp80cXCCc7OtjA6OWeyhCOMglUMwo9O8j5huwnzFtQ==} @@ -1188,8 +1188,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.20: - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} + baseline-browser-mapping@2.10.22: + resolution: {integrity: sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==} engines: {node: '>=6.0.0'} hasBin: true @@ -1523,8 +1523,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.343: - resolution: {integrity: sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==} + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2881,8 +2881,8 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rolldown@1.0.0-rc.16: - resolution: {integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==} + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3134,8 +3134,8 @@ packages: resolution: {integrity: sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==} engines: {node: '>=4'} - terser@5.46.1: - resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} engines: {node: '>=10'} hasBin: true @@ -3279,8 +3279,8 @@ packages: peerDependencies: vite: '*' - vite@8.0.9: - resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3538,13 +3538,13 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@emnapi/core@1.9.2': + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.2': + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true @@ -3879,10 +3879,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -3900,7 +3900,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@oxc-project/types@0.126.0': {} + '@oxc-project/types@0.127.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -3912,19 +3912,19 @@ snapshots: dependencies: preact: 10.29.1 - '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)) + '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1) - vite-prerender-plugin: 0.5.13(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) + vite-prerender-plugin: 0.5.13(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) zimmerframe: 1.1.4 transitivePeerDependencies: - preact @@ -3939,7 +3939,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1))': + '@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2))': dependencies: '@babel/core': 7.29.0 '@prefresh/babel-plugin': 0.5.3 @@ -3947,60 +3947,60 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.29.1 - vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) transitivePeerDependencies: - supports-color - '@rolldown/binding-android-arm64@1.0.0-rc.16': + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.16': + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.16': + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.16': + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true - '@rolldown/pluginutils@1.0.0-rc.16': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} '@rollup/pluginutils@4.2.1': dependencies: @@ -4293,7 +4293,7 @@ snapshots: acorn@8.16.0: {} - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -4355,7 +4355,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.20: {} + baseline-browser-mapping@2.10.22: {} bin-build@3.0.0: dependencies: @@ -4416,9 +4416,9 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.20 + baseline-browser-mapping: 2.10.22 caniuse-lite: 1.0.30001790 - electron-to-chromium: 1.5.343 + electron-to-chromium: 1.5.344 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -4782,7 +4782,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.343: {} + electron-to-chromium@1.5.344: {} emoji-regex@10.6.0: {} @@ -4955,7 +4955,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.14.0 + ajv: 6.15.0 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 @@ -6090,35 +6090,35 @@ snapshots: dependencies: glob: 7.2.3 - rolldown@1.0.0-rc.16: + rolldown@1.0.0-rc.17: dependencies: - '@oxc-project/types': 0.126.0 - '@rolldown/pluginutils': 1.0.0-rc.16 + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.16 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.16 - '@rolldown/binding-darwin-x64': 1.0.0-rc.16 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.16 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.16 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.16 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.16 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.16 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.16 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.16 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.16 + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 - rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.16)(rollup@4.59.0): + rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.17)(rollup@4.59.0): dependencies: open: 11.0.0 picomatch: 4.0.4 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: - rolldown: 1.0.0-rc.16 + rolldown: 1.0.0-rc.17 rollup: 4.59.0 rollup@4.59.0: @@ -6362,7 +6362,7 @@ snapshots: temp-dir: 1.0.0 uuid: 3.4.0 - terser@5.46.1: + terser@5.46.2: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.16.0 @@ -6477,7 +6477,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-plugin-imagemin@0.6.1(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)): + vite-plugin-imagemin@0.6.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -6502,11 +6502,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.13(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)): + vite-prerender-plugin@0.5.13(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -6514,30 +6514,30 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1)): + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) - vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) transitivePeerDependencies: - supports-color - typescript - vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.1): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.10 - rolldown: 1.0.0-rc.16 + rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 esbuild: 0.27.4 fsevents: 2.3.3 - terser: 5.46.1 + terser: 5.46.2 which-typed-array@1.1.20: dependencies: diff --git a/interface/src/api/endpoints.ts b/interface/src/api/endpoints.ts index 95d462689..e598d7aad 100644 --- a/interface/src/api/endpoints.ts +++ b/interface/src/api/endpoints.ts @@ -58,11 +58,5 @@ export const alovaInstance = createAlova({ } }); -export const alovaInstanceGH = createAlova({ - baseURL: - process.env.NODE_ENV === 'development' - ? '/gh' - : 'https://api.github.com/repos/emsesp/EMS-ESP32/releases', - statesHook: ReactHook, - requestAdapter: xhrRequestAdapter() -}); +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 1b9d1a37a..6910c2df2 100644 --- a/interface/src/api/system.ts +++ b/interface/src/api/system.ts @@ -1,6 +1,6 @@ import type { LogSettings, SystemStatus } from 'types'; -import { alovaInstance, alovaInstanceGH } from './endpoints'; +import { DOCS_BASE_URL, alovaInstance } from './endpoints'; // systemStatus - also used to ping in System Monitor for pinging export const readSystemStatus = () => @@ -13,28 +13,13 @@ export const updateLogSettings = (data: LogSettings) => alovaInstance.Post('/rest/logSettings', data); export const fetchLogES = () => alovaInstance.Get('/es/log'); -// Get versions from GitHub -// cache for 10 minutes to stop getting the IP blocked by GitHub -export const getStableVersion = () => - alovaInstanceGH.Get('latest', { - cacheFor: 60 * 10 * 1000, - transform(response: { data: { name: string; published_at: string } }) { - return { - name: response.data.name.substring(1), - published_at: response.data.published_at - }; - } - }); -export const getDevVersion = () => - alovaInstanceGH.Get('tags/latest', { - cacheFor: 60 * 10 * 1000, - transform(response: { data: { name: string; published_at: string } }) { - return { - name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '', - published_at: response.data.published_at - }; - } - }); +// 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 diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index a3326856b..64a5b1cd6 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -34,7 +34,7 @@ import { import * as SystemApi from 'api/system'; import { API, callAction } from 'api/app'; -import { getDevVersion, getStableVersion } from 'api/system'; +import { getVersions } from 'api/system'; import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; @@ -86,8 +86,14 @@ interface UpgradeCheckData { } interface VersionInfo { - name: string; - published_at?: string; + version: string; + date: string; +} + +interface Versions { + stable: VersionInfo; + dev: VersionInfo; + last_updated: string; } // Memoized components for better performance @@ -156,8 +162,8 @@ const VersionInfoDialog = memo( {isPartition ? typeof version === 'string' ? version - : version?.name - : version?.name} + : version?.version + : version?.version} @@ -224,7 +230,7 @@ const VersionInfoDialog = memo( )} - {version?.published_at && ( + {version.date && ( - {prettyDateTime(locale, new Date(version.published_at))} + {prettyDateTime(locale, new Date(version.date))} )} @@ -296,11 +302,11 @@ const InstallDialog = memo( if (!latestVersion || !latestDevVersion) return ''; const version = fetchDevVersion ? latestDevVersion : latestVersion; - const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`; + const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`; return fetchDevVersion ? `${DEV_URL}${filename}` - : `${STABLE_URL}v${version.name}/${filename}`; + : `${STABLE_URL}v${version.version}/${filename}`; }, [fetchDevVersion, latestVersion, latestDevVersion, platform]); return ( @@ -312,7 +318,7 @@ const InstallDialog = memo( {LL.INSTALL_VERSION( downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(), - fetchDevVersion ? latestDevVersion?.name : latestVersion?.name + fetchDevVersion ? latestDevVersion?.version : latestVersion?.version )} {upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()} @@ -436,7 +442,9 @@ const Version = () => { const { LL, locale } = useI18nContext(); const { me } = useContext(AuthenticatedContext); - // State management + const [latestVersion, setLatestVersion] = useState(); + const [latestDevVersion, setLatestDevVersion] = useState(); + const [restarting, setRestarting] = useState(false); const [openInstallDialog, setOpenInstallDialog] = useState(false); @@ -490,8 +498,32 @@ const Version = () => { { immediate: false } ); - const { data: latestVersion } = useRequest(getStableVersion); - const { data: latestDevVersion } = useRequest(getDevVersion); + // 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; + }; + }, []); const { send: sendAPI } = useRequest((data: APIcall) => API(data), { immediate: false @@ -517,10 +549,8 @@ const Version = () => { toast.error(String(error.error?.message || 'An error occurred')); }); - // Memoized values const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]); - // Memoize filtered partitions to avoid recomputing on every render const otherPartitions = useMemo( () => data?.partitions.filter((p) => p.partition !== data.partition) ?? [], [data] @@ -534,8 +564,8 @@ const Version = () => { const partitionData = data?.partitions.find((p) => p.partition === partition); if (partitionData) { setPartitionVersion({ - name: partitionData.version, - published_at: partitionData.install_date ?? '' + version: partitionData.version, + date: partitionData.install_date ?? '' }); setPartition(partitionData.partition); setFirmwareSize(partitionData.size); @@ -576,7 +606,7 @@ const Version = () => { const showPartitionDialog = useCallback( (version: string, partition: string, install_date: string) => { setOpenInstallPartitionDialog(true); - setPartitionVersion({ name: version, published_at: install_date }); + setPartitionVersion({ version: version, date: install_date }); setPartition(partition); }, [] @@ -586,7 +616,7 @@ const Version = () => { (useDevVersion: boolean) => { setFetchDevVersion(useDevVersion); void checkUpgradeImportantMessages( - useDevVersion ? latestDevVersion?.name : latestVersion?.name + useDevVersion ? latestDevVersion?.version : latestVersion?.version ); setOpenInstallDialog(true); }, @@ -607,25 +637,8 @@ const Version = () => { setPartition(''); }, []); - // check upgrades - only once when both versions are available - const upgradeCheckedRef = useRef(false); - useEffect(() => { - if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) { - upgradeCheckedRef.current = true; - const versions = `${latestDevVersion.name},${latestVersion.name}`; - sendCheckUpgrade(versions) - .catch((error: Error) => { - toast.error(`Failed to check for upgrades: ${error.message}`); - }) - .finally(() => { - setInternetLive(true); - }); - } - }, [latestVersion, latestDevVersion, sendCheckUpgrade]); - useLayoutTitle('EMS-ESP Firmware'); - // Memoized button rendering logic const showButtons = useCallback( (showingDev: boolean) => { const choice = showingDev @@ -818,7 +831,7 @@ const Version = () => { - {latestVersion?.name} + {latestVersion?.version} setShowVersionInfo(1)} aria-label={LL.FIRMWARE_VERSION_INFO()} @@ -834,7 +847,7 @@ const Version = () => { - {latestDevVersion?.name} + {latestDevVersion?.version} setShowVersionInfo(2)} aria-label={LL.FIRMWARE_VERSION_INFO()} @@ -880,7 +893,7 @@ const Version = () => { /> { - const data = { - name: 'v' + LATEST_DEV_VERSION, - published_at: new Date().toISOString() // use todays date - }; - console.log('returning latest development version (today): ', data); - return data; - }) - .get(GH_ENDPOINT_ROOT + '/latest', () => { - const data = { - name: 'v' + LATEST_STABLE_VERSION, - published_at: '2025-03-01T13:29:13.999Z' - }; - console.log('returning latest stable version: ', data); - return data; - }); +// 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( diff --git a/src/core/system.cpp b/src/core/system.cpp index b29e3f4c1..45e5c6777 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -2281,7 +2281,6 @@ std::string System::get_metrics_prometheus() { } result += info_metric; - // TODO fix, as local_info_labels is always empty here if (!local_info_labels.empty()) { result += "{"; bool first = true; From 5ecda88457d1af67df259e1551ba927a262b562b Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 25 Apr 2026 21:14:16 +0200 Subject: [PATCH 05/33] inlclude full date/time --- .github/workflows/dev_release.yml | 2 +- .github/workflows/stable_release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index 04365a655..3a66fede2 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -80,7 +80,7 @@ jobs: - name: Update version in Cloudflare KV store run: | - JSON_DATA='{"version": "${{steps.build_info.outputs.VERSION}}", "date": "$(date -u +%Y-%m-%d)"}' + JSON_DATA="{\"version\": \"${{steps.build_info.outputs.VERSION}}\", \"date\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/dev" \ -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ -H "Content-Type: text/plain" \ diff --git a/.github/workflows/stable_release.yml b/.github/workflows/stable_release.yml index d18cd52b9..84df90706 100644 --- a/.github/workflows/stable_release.yml +++ b/.github/workflows/stable_release.yml @@ -64,7 +64,7 @@ jobs: - name: Update version in Cloudflare KV store run: | - JSON_DATA='{"version": "${{ github.event.release.tag_name }}", "date": "$(date -u +%Y-%m-%d)"}' + JSON_DATA="{\"version\": \"${{ github.event.release.tag_name }}\", \"date\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/stable" \ -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ -H "Content-Type: text/plain" \ From ee7be1d907bdeb6818d4643c3cfca615f3fc315c Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 26 Apr 2026 12:20:48 +0200 Subject: [PATCH 06/33] add --- .github/workflows/update_versions.yml | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/update_versions.yml diff --git a/.github/workflows/update_versions.yml b/.github/workflows/update_versions.yml new file mode 100644 index 000000000..6bceead5e --- /dev/null +++ b/.github/workflows/update_versions.yml @@ -0,0 +1,31 @@ +name: 'Update versions' + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-version: + name: 'Update versions in Cloudflare KV store' + runs-on: ubuntu-latest + steps: + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Get and Send EMS-ESP version to Cloudflare + run: | + version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}') + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + KV_ENV="stable" + else + KV_ENV="dev" + fi + JSON_DATA="{\"version\": \"${version}\", \"date\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" + curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/$KV_ENV" \ + -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ + -H "Content-Type: text/plain" \ + -d "$JSON_DATA" + \ No newline at end of file From a9db134d3ad753ce7278e9b64d47fff501a12127 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 26 Apr 2026 13:24:40 +0200 Subject: [PATCH 07/33] version updates --- .github/workflows/dev_release.yml | 21 ++++++++++++++----- .github/workflows/stable_release.yml | 30 +++++++++++++++++++++------ .github/workflows/update_versions.yml | 25 +++++++++++++++------- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index 3a66fede2..97e4f5ffc 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -79,9 +79,20 @@ jobs: ./build/firmware/*.* - name: Update version in Cloudflare KV store + env: + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + VERSION: ${{ steps.build_info.outputs.VERSION }} run: | - JSON_DATA="{\"version\": \"${{steps.build_info.outputs.VERSION}}\", \"date\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" - curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/dev" \ - -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ - -H "Content-Type: text/plain" \ - -d "$JSON_DATA" + JSON_DATA=$(jq -n \ + --arg version "$VERSION" \ + --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{version: $version, date: $date}') + echo "JSON_DATA: $JSON_DATA" + curl -sS --fail-with-body \ + -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$JSON_DATA" + echo diff --git a/.github/workflows/stable_release.yml b/.github/workflows/stable_release.yml index 84df90706..a5fe7b7ae 100644 --- a/.github/workflows/stable_release.yml +++ b/.github/workflows/stable_release.yml @@ -27,10 +27,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - + - name: Enable Corepack run: corepack enable pnpm + + - name: Get the EMS-ESP version + id: build_info + run: | + version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'` + echo "VERSION=$version" >> $GITHUB_OUTPUT + - name: Install PlatformIO run: | python -m pip install --upgrade pip @@ -63,9 +70,20 @@ jobs: ./build/firmware/*.* - name: Update version in Cloudflare KV store + env: + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + VERSION: ${{ steps.build_info.outputs.VERSION }} run: | - JSON_DATA="{\"version\": \"${{ github.event.release.tag_name }}\", \"date\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" - curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/stable" \ - -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ - -H "Content-Type: text/plain" \ - -d "$JSON_DATA" + JSON_DATA=$(jq -n \ + --arg version "$VERSION" \ + --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{version: $version, date: $date}') + echo "JSON_DATA: $JSON_DATA" + curl -sS --fail-with-body \ + -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$JSON_DATA" + echo diff --git a/.github/workflows/update_versions.yml b/.github/workflows/update_versions.yml index 6bceead5e..e246f84c2 100644 --- a/.github/workflows/update_versions.yml +++ b/.github/workflows/update_versions.yml @@ -11,11 +11,14 @@ jobs: name: 'Update versions in Cloudflare KV store' runs-on: ubuntu-latest steps: - - name: Checkout repository uses: actions/checkout@v6 - + - name: Get and Send EMS-ESP version to Cloudflare + env: + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} run: | version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}') if [ "$GITHUB_REF" = "refs/heads/main" ]; then @@ -23,9 +26,15 @@ jobs: else KV_ENV="dev" fi - JSON_DATA="{\"version\": \"${version}\", \"date\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" - curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/storage/kv/namespaces/${{ secrets.CF_NAMESPACE_ID }}/values/$KV_ENV" \ - -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ - -H "Content-Type: text/plain" \ - -d "$JSON_DATA" - \ No newline at end of file + JSON_DATA=$(jq -n \ + --arg version "$version" \ + --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{version: $version, date: $date}') + echo "KV_ENV: $KV_ENV" + echo "JSON_DATA: $JSON_DATA" + curl -sS --fail-with-body \ + -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$JSON_DATA" + echo From 6802336b6b1a7f86f122f74350177a910f6f74ed Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 26 Apr 2026 16:07:29 +0200 Subject: [PATCH 08/33] remove old code --- src/test/test.cpp | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/test/test.cpp b/src/test/test.cpp index 4a7181893..1ea87e770 100644 --- a/src/test/test.cpp +++ b/src/test/test.cpp @@ -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()); - // 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()); From 74062bab57bf59ef06c7308d7caeab7d05fd36f1 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 26 Apr 2026 16:07:45 +0200 Subject: [PATCH 09/33] update tests --- test/test_api/api_test.http | 5 ++--- test/test_api/api_test.sh | 28 +++++++++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/test/test_api/api_test.http b/test/test_api/api_test.http index 1a37372bc..e8a1af255 100755 --- a/test/test_api/api_test.http +++ b/test/test_api/api_test.http @@ -3,9 +3,8 @@ # Open this file in VSC, modify the token, go to the API call and click on 'Send Request' (or Ctrl+Alt+R) # The response will be shown in the right panel -# @host = http://ems-esp.local -@host = http://192.168.1.223 -@host_dev = http://192.168.1.65 +@host = http://ems-esp.local +@host_dev = http://ems-espT.local @host_standalone = http://localhost:3080 @host_standalone2 = http://localhost:3082 diff --git a/test/test_api/api_test.sh b/test/test_api/api_test.sh index 8deaa3013..0de8cde3b 100755 --- a/test/test_api/api_test.sh +++ b/test/test_api/api_test.sh @@ -4,7 +4,8 @@ # Command line test for the API # -emsesp_url="http://192.168.1.223" +# emsesp_url="http://ems-esp.local" +emsesp_url="http://ems-espT.local" # get the token from the Security page. This is the token for the admin user, unless changed it'll always be the same emsesp_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiYWRtaW4iOnRydWV9.2bHpWya2C7Q12WjNUBD6_7N3RCD7CMl-EGhyQVzFdDg" @@ -40,13 +41,22 @@ curl -X POST \ echo "\n" +# Get all versions +curl -X POST \ + -H "Authorization: Bearer ${emsesp_token}" \ + -H "Content-Type: application/json" \ + -d '{"action":"getVersions"}' \ + ${emsesp_url}/rest/action + +echo "\n" + # This example is how to call a service in Home Assistant via the API # Which can be added to an EMS-EPS schedule - -ha_url="http://192.168.1.86:8123" -ha_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMzMyZjU1MjhlZmM0NGIyOTgyMjIxNThiODU1NDkyNSIsImlhdCI6MTcyMTMwNDg2NSwiZXhwIjoyMDM2NjY0ODY1fQ.Q-Y7E_i7clH3ff4Ma-OMmhZfbN7aMi_CahKwmoar" - -curl -X POST \ - ${ha_url}/api/services/script/test_notify \ - -H "Authorization: Bearer ${ha_token}" \ - -H "Content-Type: application/json" +# +# ha_url="http://192.168.1.86:8123" +# ha_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMzMyZjU1MjhlZmM0NGIyOTgyMjIxNThiODU1NDkyNSIsImlhdCI6MTcyMTMwNDg2NSwiZXhwIjoyMDM2NjY0ODY1fQ.Q-Y7E_i7clH3ff4Ma-OMmhZfbN7aMi_CahKwmoar" +# +# curl -X POST \ +# ${ha_url}/api/services/script/test_notify \ +# -H "Authorization: Bearer ${ha_token}" \ +# -H "Content-Type: application/json" From 3a11327e7ea7280a8cc18886b651ebb06ef1081c Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 26 Apr 2026 16:10:30 +0200 Subject: [PATCH 10/33] https://github.com/emsesp/EMS-ESP32/discussions/3044 --- interface/src/api/endpoints.ts | 3 - interface/src/api/system.ts | 10 +- interface/src/app/status/Version.tsx | 97 ++++++------- interface/vite.config.ts | 3 +- mock-api/restServer.ts | 107 +++++++------- src/core/emsesp.cpp | 1 + src/core/system.cpp | 7 +- src/web/WebStatusService.cpp | 200 +++++++++++++++++++++------ src/web/WebStatusService.h | 30 +++- 9 files changed, 290 insertions(+), 168 deletions(-) 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 From 1107e1bdf31b6e99778b022f71ed8759a25cb06a Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 26 Apr 2026 16:10:35 +0200 Subject: [PATCH 11/33] package update --- interface/package.json | 11 +--- interface/pnpm-lock.yaml | 119 +++------------------------------------ 2 files changed, 10 insertions(+), 120 deletions(-) diff --git a/interface/package.json b/interface/package.json index ce234abf8..8bace3c1d 100644 --- a/interface/package.json +++ b/interface/package.json @@ -28,14 +28,11 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", - "@preact/compat": "^18.3.2", "@table-library/react-table-library": "4.1.15", "alova": "3.5.1", "async-validator": "^4.2.5", "etag": "^1.8.1", - "formidable": "^3.5.4", "jwt-decode": "^4.0.0", - "magic-string": "^0.30.21", "mime-types": "^3.0.2", "preact": "^10.29.1", "react": "^19.2.5", @@ -47,16 +44,13 @@ "typescript": "^6.0.3" }, "devDependencies": { - "@babel/core": "^7.29.0", "@eslint/js": "^10.0.1", - "@preact/compat": "^18.3.2", "@preact/preset-vite": "^2.10.5", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "axe-core": "^4.11.3", "concurrently": "^9.2.1", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", @@ -64,8 +58,7 @@ "terser": "^5.46.2", "typescript-eslint": "^8.59.0", "vite": "^8.0.10", - "vite-plugin-imagemin": "^0.6.1", - "vite-tsconfig-paths": "^6.1.1" + "vite-plugin-imagemin": "^0.6.1" }, "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 5df136395..c0ba3f7e6 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@mui/material': specifier: ^9.0.0 version: 9.0.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@preact/compat': - specifier: ^18.3.2 - version: 18.3.2(preact@10.29.1) '@table-library/react-table-library': specifier: 4.1.15 version: 4.1.15(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -38,15 +35,9 @@ importers: etag: specifier: ^1.8.1 version: 1.8.1 - formidable: - specifier: ^3.5.4 - version: 3.5.4 jwt-decode: specifier: ^4.0.0 version: 4.0.0 - magic-string: - specifier: ^0.30.21 - version: 0.30.21 mime-types: specifier: ^3.0.2 version: 3.0.2 @@ -75,9 +66,6 @@ importers: specifier: ^6.0.3 version: 6.0.3 devDependencies: - '@babel/core': - specifier: ^7.29.0 - version: 7.29.0 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.2.1) @@ -96,9 +84,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) - axe-core: - specifier: ^4.11.3 - version: 4.11.3 concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -126,9 +111,6 @@ importers: vite-plugin-imagemin: specifier: ^0.6.1 version: 0.6.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) - vite-tsconfig-paths: - specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)) packages: @@ -637,10 +619,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -656,17 +634,9 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@preact/compat@18.3.2': - resolution: {integrity: sha512-5vSl55K5yLMvocT7PBKxDOHGgYPjMrKQqqr6roSNjIXcJOtSgDDMjpiCAF3s7klRdmGrN75b/Przmjw8gmlg/w==} - peerDependencies: - preact: '*' - '@preact/preset-vite@2.10.5': resolution: {integrity: sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==} peerDependencies: @@ -1155,9 +1125,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -1165,10 +1132,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.3: - resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} - engines: {node: '>=4'} - babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -1287,8 +1250,8 @@ packages: resolution: {integrity: sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==} engines: {node: '>=0.10.0'} - caniuse-lite@1.0.30001790: - resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} caw@2.0.1: resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==} @@ -1475,9 +1438,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1892,10 +1852,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - formidable@3.5.4: - resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} - engines: {node: '>=14.0.0'} - from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -1986,9 +1942,6 @@ packages: resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} engines: {node: '>=8'} - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2721,8 +2674,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.11: + resolution: {integrity: sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3176,16 +3129,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3274,11 +3217,6 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x || 8.x - vite-tsconfig-paths@6.1.1: - resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} - peerDependencies: - vite: '*' - vite@8.0.10: resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3886,8 +3824,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@noble/hashes@1.8.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3902,16 +3838,8 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - '@popperjs/core@2.11.8': {} - '@preact/compat@18.3.2(preact@10.29.1)': - dependencies: - preact: 10.29.1 - '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2))': dependencies: '@babel/core': 7.29.0 @@ -4329,16 +4257,12 @@ snapshots: array-union@2.1.0: {} - asap@2.0.6: {} - async-validator@4.2.5: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.3: {} - babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.2 @@ -4417,7 +4341,7 @@ snapshots: browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.22 - caniuse-lite: 1.0.30001790 + caniuse-lite: 1.0.30001791 electron-to-chromium: 1.5.344 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -4480,7 +4404,7 @@ snapshots: camelcase@2.1.1: {} - caniuse-lite@1.0.30001790: {} + caniuse-lite@1.0.30001791: {} caw@2.0.1: dependencies: @@ -4697,11 +4621,6 @@ snapshots: detect-libc@2.1.2: {} - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -5157,12 +5076,6 @@ snapshots: dependencies: is-callable: 1.2.7 - formidable@3.5.4: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - from2@2.3.0: dependencies: inherits: 2.0.4 @@ -5265,8 +5178,6 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - globrex@0.1.2: {} - gopd@1.2.0: {} got@7.1.0: @@ -5940,7 +5851,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.10: + postcss@8.5.11: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -6400,10 +6311,6 @@ snapshots: dependencies: typescript: 6.0.3 - tsconfck@3.1.6(typescript@6.0.3): - optionalDependencies: - typescript: 6.0.3 - tslib@2.8.1: {} tunnel-agent@0.6.0: @@ -6516,21 +6423,11 @@ snapshots: stack-trace: 1.0.0-pre2 vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2)): - dependencies: - debug: 4.4.3 - globrex: 0.1.2 - tsconfck: 3.1.6(typescript@6.0.3) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) - transitivePeerDependencies: - - supports-color - - typescript - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.11 rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: From d834d4658610468aa5dffd4e2fb191d83bf66d67 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:08:13 +0200 Subject: [PATCH 12/33] rename EMSESP_Version to firmwareVersion --- src/core/EMSESP_Version.h | 126 ----------------------------------- src/core/firmwareVersion.cpp | 100 +++++++++++++++++++++++++++ src/core/firmwareVersion.h | 59 ++++++++++++++++ src/core/system.cpp | 6 +- 4 files changed, 162 insertions(+), 129 deletions(-) delete mode 100644 src/core/EMSESP_Version.h create mode 100644 src/core/firmwareVersion.cpp create mode 100644 src/core/firmwareVersion.h diff --git a/src/core/EMSESP_Version.h b/src/core/EMSESP_Version.h deleted file mode 100644 index 0803be2b5..000000000 --- a/src/core/EMSESP_Version.h +++ /dev/null @@ -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 . - */ - -#ifndef EMSESP_Version_H -#define EMSESP_Version_H - -#include -#include -#include - -// 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>/(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 diff --git a/src/core/firmwareVersion.cpp b/src/core/firmwareVersion.cpp new file mode 100644 index 000000000..de2aed742 --- /dev/null +++ b/src/core/firmwareVersion.cpp @@ -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 . + */ + +#include "firmwareVersion.h" + +#include +#include + +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 diff --git a/src/core/firmwareVersion.h b/src/core/firmwareVersion.h new file mode 100644 index 000000000..8076b14c5 --- /dev/null +++ b/src/core/firmwareVersion.h @@ -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 . + */ + +#ifndef firmwareVersion_H +#define firmwareVersion_H + +#include + +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 diff --git a/src/core/system.cpp b/src/core/system.cpp index e2a8fb8ba..f3ab8073d 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -31,7 +31,7 @@ #include #include -#include "EMSESP_Version.h" +#include "firmwareVersion.h" #if defined(EMSESP_TEST) #include "../test/test.h" @@ -1541,8 +1541,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()); From 1cff1abc3306e5b68cfebcb1a9bd782cc0063306 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:08:22 +0200 Subject: [PATCH 13/33] package update --- interface/pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index c0ba3f7e6..e2032919d 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -1151,8 +1151,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.22: - resolution: {integrity: sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==} + baseline-browser-mapping@2.10.23: + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} engines: {node: '>=6.0.0'} hasBin: true @@ -2674,8 +2674,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.11: - resolution: {integrity: sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -4279,7 +4279,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.22: {} + baseline-browser-mapping@2.10.23: {} bin-build@3.0.0: dependencies: @@ -4340,7 +4340,7 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.22 + baseline-browser-mapping: 2.10.23 caniuse-lite: 1.0.30001791 electron-to-chromium: 1.5.344 node-releases: 2.0.38 @@ -5851,7 +5851,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.11: + postcss@8.5.12: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -6427,7 +6427,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.11 + postcss: 8.5.12 rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: From 7c6259ddddef244078b604c7b830483778bf5048 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:08:52 +0200 Subject: [PATCH 14/33] tidy up comments --- interface/src/app/status/Version.tsx | 6 ++---- mock-api/restServer.ts | 4 ---- src/core/emsesp.cpp | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index b2aa166fa..f434b6dec 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -482,10 +482,8 @@ const Version = () => { { immediate: false } ); - // 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". + // fetch latest stable/dev versions via the device. The C++ code makes a call to emsesp.org/versions.json itself + // if the device has no internet, stable/dev are omitted and the internetLive flag is set to false useRequest(() => callAction({ action: 'getVersions' })) .onSuccess((event) => { const versions = event.data as VersionsResponse; diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index 0ac776333..f8cb3bd15 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -415,10 +415,6 @@ function upgradeImportantMessages(version: string) { } // 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() { diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp index dcd6f70da..38303a3ec 100644 --- a/src/core/emsesp.cpp +++ b/src/core/emsesp.cpp @@ -1811,10 +1811,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() { From 9ac35e2e14852d7127e552cb578cf65b4a11e7e3 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:09:24 +0200 Subject: [PATCH 15/33] fetch emsesp firmware versions after IP connected --- src/ESP32React/NetworkSettingsService.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ESP32React/NetworkSettingsService.cpp b/src/ESP32React/NetworkSettingsService.cpp index ffb6275c9..5aa6b4218 100644 --- a/src/ESP32React/NetworkSettingsService.cpp +++ b/src/ESP32React/NetworkSettingsService.cpp @@ -321,6 +321,7 @@ void NetworkSettingsService::WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) WiFi.localIP().toString().c_str(), WiFi.getHostname(), emsesp::Helpers::render_value(result, ((double)(WiFi.getTxPower()) / 4), 1)); + emsesp::EMSESP::webStatusService.schedule_versions_refresh(); // run the version fetch as soon as the main loop picks it up mDNS_start(); break; @@ -337,6 +338,7 @@ void NetworkSettingsService::WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) if (!emsesp::EMSESP::system_.ethernet_connected()) { emsesp::EMSESP::logger().info("Ethernet connected (Local IP=%s, speed %d Mbps)", ETH.localIP().toString().c_str(), ETH.linkSpeed()); emsesp::EMSESP::system_.ethernet_connected(true); + emsesp::EMSESP::webStatusService.schedule_versions_refresh(); // run the version fetch as soon as the main loop picks it up mDNS_start(); } break; @@ -380,13 +382,15 @@ void NetworkSettingsService::WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) auto ip6 = IPAddress(IPv6, (uint8_t *)info.got_ip6.ip6_info.ip.addr, 0).toString(); #endif const char * link = event == ARDUINO_EVENT_ETH_GOT_IP6 ? "Eth" : "WiFi"; +#if defined(EMSESP_DEBUG) if (ip6.startsWith("fe80")) { - emsesp::EMSESP::logger().info("IPv6 (%s) local: %s", link, ip6.c_str()); + emsesp::EMSESP::logger().debug("IPv6 (%s) local: %s", link, ip6.c_str()); } else if (ip6.startsWith("fd") || ip6.startsWith("fc")) { - emsesp::EMSESP::logger().info("IPv6 (%s) ULA: %s", link, ip6.c_str()); + emsesp::EMSESP::logger().debug("IPv6 (%s) ULA: %s", link, ip6.c_str()); } else { - emsesp::EMSESP::logger().info("IPv6 (%s) global: %s", link, ip6.c_str()); + emsesp::EMSESP::logger().debug("IPv6 (%s) global: %s", link, ip6.c_str()); } +#endif emsesp::EMSESP::system_.has_ipv6(true); } break; From ab67f97b401a677974701b6028cfeb528844e7c5 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:09:34 +0200 Subject: [PATCH 16/33] 3.8.2-dev.20 --- src/emsesp_version.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emsesp_version.h b/src/emsesp_version.h index c98cd567e..e7b3ea13f 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.8.2-dev.19" +#define EMSESP_APP_VERSION "3.8.2-dev.20" From 5e260f02395e740a3c9f2eed1f3d9e2fb3bbb668 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:09:51 +0200 Subject: [PATCH 17/33] refactoring --- src/web/WebStatusService.cpp | 69 +++++++++++++++++------------------- src/web/WebStatusService.h | 16 ++++++--- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 5cc062804..31583585c 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -261,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-"); @@ -282,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,16 +311,13 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) { } // 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. +// 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) { - version::EMSESP_Version current_version(current_version_s); - bool is_dev = current_version.prerelease().find("dev") != std::string::npos; + schedule_versions_refresh(); // force a refresh + + FirmwareVersion current_version(current_version_s); + bool is_dev = current_version.prerelease().find("dev") != std::string::npos; JsonObject current = root["current"].to(); current["version"] = current_version_s; @@ -347,8 +344,7 @@ void WebStatusService::getVersions(JsonObject root) { return; } - // copies a cached entry into root[key]. The upgradeable bool was computed - // once during refresh_versions_cache() so we just read it here. + // copies a cached entry into root[key] auto add_section = [&](const char * key, const VersionInfo & info) { if (info.version.empty()) { return; @@ -366,17 +362,17 @@ void WebStatusService::getVersions(JsonObject root) { 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; + stable_out["upgradeable"] = FirmwareVersion("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; + dev_out["upgradeable"] = FirmwareVersion("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. +// 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 @@ -384,25 +380,17 @@ void WebStatusService::loop() { 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 + // 0 = idle, nothing scheduled 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) { + if ((int32_t)(uuid::get_uptime() - versions_next_fetch_ms_) < 0) { return; } - bool ok = refresh_versions_cache(); - + 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; @@ -421,14 +409,18 @@ bool WebStatusService::refresh_versions_cache() { http.setTimeout(5000); http.useHTTP10(true); - if (!http.begin("https://emsesp.org/versions.json")) { + 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; } @@ -437,11 +429,13 @@ bool WebStatusService::refresh_versions_cache() { DeserializationError err = deserializeJson(doc, http.getStream()); http.end(); if (err) { +#if defined(EMSESP_DEBUG) EMSESP::logger().debug("versions.json: parse error (%s)", err.c_str()); +#endif return false; } - version::EMSESP_Version current_version(current_version_s); + FirmwareVersion current_version(current_version_s); auto read_section = [&doc, ¤t_version](const char * key, VersionInfo & out) { JsonObjectConst section = doc[key]; @@ -451,7 +445,7 @@ bool WebStatusService::refresh_versions_cache() { } out.version = section["version"] | ""; out.date = section["date"] | ""; - out.upgradeable = !out.version.empty() && version::EMSESP_Version(out.version) > current_version; + out.upgradeable = !out.version.empty() && FirmwareVersion(out.version) > current_version; }; read_section("stable", versions_stable_); @@ -459,7 +453,10 @@ bool WebStatusService::refresh_versions_cache() { 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()); + 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 @@ -470,8 +467,8 @@ 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; + 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; } diff --git a/src/web/WebStatusService.h b/src/web/WebStatusService.h index 706d5d956..90414b6b8 100644 --- a/src/web/WebStatusService.h +++ b/src/web/WebStatusService.h @@ -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,8 +21,8 @@ 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 + // 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 @@ -28,6 +30,11 @@ class WebStatusService { 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 @@ -60,13 +67,12 @@ class WebStatusService { 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 + 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 - static constexpr uint32_t VERSIONS_INITIAL_DELAY_MS = 30UL * 1000UL; // wait 30s after boot }; } // namespace emsesp From 43ec5c19253640ae886cdfb3ec48ca40004b30e5 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:30:40 +0200 Subject: [PATCH 18/33] move mockserver to standalone section only --- interface/vite.config.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/interface/vite.config.ts b/interface/vite.config.ts index 8bea7e00d..e04cdde27 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -6,9 +6,6 @@ import { Plugin, PluginOption, defineConfig } from 'vite'; import viteImagemin from 'vite-plugin-imagemin'; import zlib from 'zlib'; -// @ts-expect-error - mock server doesn't have type declarations -import mockServer from '../mock-api/mockServer.js'; - // Constants const KB_DIVISOR = 1024; const REPEAT_CHAR = '='; @@ -100,6 +97,10 @@ const createPreactPlugin = (devToolsEnabled: boolean) => // Patch preact/compat to export stub React 19 APIs (use, useOptimistic) so that // react-router v7 doesn't trigger IMPORT_IS_UNDEFINED warnings from Rolldown. +// Rolldown tracks the constant strings used in `React[REACT_USE]` / +// `React[USE_OPTIMISTIC]` lookups inside react-router and resolves them +// statically, so simply relying on a runtime guard is not enough — we need +// matching (stub) exports on the aliased preact/compat module. const preactCompatPatchPlugin = (): Plugin => ({ name: 'preact-compat-react19-patch', transform(code, id) { @@ -210,9 +211,11 @@ const imageOptimizationPlugin = { }; export default defineConfig( - ({ command, mode }: { command: string; mode: string }) => { + async ({ command, mode }: { command: string; mode: string }) => { if (command === 'serve') { console.log(`Preparing for standalone build with server, mode=${mode}`); + // @ts-expect-error - mock server doesn't have type declarations + const { default: mockServer } = await import('../mock-api/mockServer.js'); return { plugins: [...createBasePlugins(true, true), mockServer()], resolve: { From c5b262af8a0b6b9fe16451575a8690b5a8e6ef11 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 11:34:03 +0200 Subject: [PATCH 19/33] dont update cloudflare KV for forks --- .github/workflows/dev_release.yml | 1 + .github/workflows/stable_release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index 97e4f5ffc..bb97b1863 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -79,6 +79,7 @@ jobs: ./build/firmware/*.* - name: Update version in Cloudflare KV store + if: github.repository == 'emsesp/EMS-ESP32' env: CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }} diff --git a/.github/workflows/stable_release.yml b/.github/workflows/stable_release.yml index a5fe7b7ae..93f45ad3d 100644 --- a/.github/workflows/stable_release.yml +++ b/.github/workflows/stable_release.yml @@ -70,6 +70,7 @@ jobs: ./build/firmware/*.* - name: Update version in Cloudflare KV store + if: github.repository == 'emsesp/EMS-ESP32' env: CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }} From e39af36589e17d77200a47e502bd0469153cd0d3 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 13:23:39 +0200 Subject: [PATCH 20/33] fix lint errors --- interface/src/app/settings/security/SecuritySettings.tsx | 2 +- interface/src/components/upload/DragNdrop.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/interface/src/app/settings/security/SecuritySettings.tsx b/interface/src/app/settings/security/SecuritySettings.tsx index 117a84467..40cac3de6 100644 --- a/interface/src/app/settings/security/SecuritySettings.tsx +++ b/interface/src/app/settings/security/SecuritySettings.tsx @@ -79,7 +79,7 @@ const SecuritySettings = () => { onChange={updateFormValue} margin="normal" /> - + {dirtyFlags && dirtyFlags.length !== 0 && ( @@ -214,7 +190,6 @@ const HelpComponent = () => { ); }; -// Memoize the component to prevent unnecessary re-renders const Help = memo(HelpComponent); export default Help; diff --git a/interface/src/app/main/Modules.tsx b/interface/src/app/main/Modules.tsx index 91d126f5a..82ea16079 100644 --- a/interface/src/app/main/Modules.tsx +++ b/interface/src/app/main/Modules.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -69,58 +69,53 @@ const Modules = () => { } ); - const modules_theme = useTheme( - useMemo( - () => ({ - Table: ` - --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; - `, - BaseRow: ` - font-size: 14px; - .td { - height: 32px; - } - `, - BaseCell: ` - &:nth-of-type(1) { - text-align: center; - } - `, - HeaderRow: ` - text-transform: uppercase; - background-color: black; - color: #90CAF9; - .th { - border-bottom: 1px solid #565656; - height: 36px; - } - `, - Row: ` - background-color: #1e1e1e; - position: relative; - cursor: pointer; - .td { - border-top: 1px solid #565656; - border-bottom: 1px solid #565656; - } - &:hover .td { - border-top: 1px solid #177ac9; - border-bottom: 1px solid #177ac9; - } - &:nth-of-type(odd) .td { - background-color: #303030; - } - ` - }), - [] - ) - ); + const modules_theme = useTheme({ + Table: ` + --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; + `, + BaseRow: ` + font-size: 14px; + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(1) { + text-align: center; + } + `, + HeaderRow: ` + text-transform: uppercase; + background-color: black; + color: #90CAF9; + .th { + border-bottom: 1px solid #565656; + height: 36px; + } + `, + Row: ` + background-color: #1e1e1e; + position: relative; + cursor: pointer; + .td { + border-top: 1px solid #565656; + border-bottom: 1px solid #565656; + } + &:hover .td { + border-top: 1px solid #177ac9; + border-bottom: 1px solid #177ac9; + } + &:nth-of-type(odd) .td { + background-color: #303030; + } + ` + }); - const onDialogClose = useCallback(() => { + const onDialogClose = () => { setDialogOpen(false); - }, []); + }; - const updateModuleItem = useCallback((updatedItem: ModuleItem) => { + const updateModuleItem = (updatedItem: ModuleItem) => { void updateState(readModules(), (data: ModuleItem[]) => { const new_data = data.map((mi) => mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi @@ -128,28 +123,25 @@ const Modules = () => { setNumChanges(new_data.filter(hasModulesChanged).length); return new_data; }); - }, []); + }; - const onDialogSave = useCallback( - (updatedItem: ModuleItem) => { - setDialogOpen(false); - updateModuleItem(updatedItem); - }, - [updateModuleItem] - ); + const onDialogSave = (updatedItem: ModuleItem) => { + setDialogOpen(false); + updateModuleItem(updatedItem); + }; - const editModuleItem = useCallback((mi: ModuleItem) => { + const editModuleItem = (mi: ModuleItem) => { setSelectedModuleItem(mi); setDialogOpen(true); - }, []); + }; - const onCancel = useCallback(async () => { + const onCancel = async () => { await fetchModules().then(() => { setNumChanges(0); }); - }, [fetchModules]); + }; - const saveModules = useCallback(async () => { + const saveModules = async () => { try { await Promise.all( modules.map((condensed_mi: ModuleItem) => @@ -167,9 +159,9 @@ const Modules = () => { await fetchModules(); setNumChanges(0); } - }, [modules, updateModules, LL, fetchModules]); + }; - const content = useMemo(() => { + const renderContent = () => { if (!modules) { return ( @@ -262,22 +254,12 @@ const Modules = () => { ); - }, [ - modules, - fetchModules, - error, - modules_theme, - editModuleItem, - LL, - numChanges, - onCancel, - saveModules - ]); + }; return ( {blocker ? : null} - {content} + {renderContent()} {selectedModuleItem && ( (selectedItem); - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); // Sync form state when dialog opens or selected item changes @@ -54,18 +50,13 @@ const ModulesDialog = ({ } }, [open, selectedItem]); - const handleSave = useCallback(() => { + const handleSave = () => { onSave(editItem); - }, [editItem, onSave]); - - const dialogTitle = useMemo( - () => `${LL.EDIT()} ${editItem.key}`, - [LL, editItem.key] - ); + }; return ( - {dialogTitle} + {`${LL.EDIT()} ${editItem.key}`} { } ); - const hasScheduleChanged = useCallback((si: ScheduleItem) => { + const hasScheduleChanged = (si: ScheduleItem) => { return ( si.id !== si.o_id || (si.name || '') !== (si.o_name || '') || @@ -143,15 +143,13 @@ const Scheduler = () => { si.cmd !== si.o_cmd || si.value !== si.o_value ); - }, []); + }; - const intervalCallback = useCallback(() => { + useInterval(() => { if (numChanges === 0) { void fetchSchedule(); } - }, [numChanges, fetchSchedule]); - - useInterval(intervalCallback, INTERVAL_DELAY); + }, INTERVAL_DELAY); useEffect(() => { const formatter = new Intl.DateTimeFormat(locale, { @@ -169,7 +167,7 @@ const Scheduler = () => { const schedule_theme = useTheme(scheduleTheme); - const saveSchedule = useCallback(async () => { + const saveSchedule = async () => { try { await updateSchedule({ schedule: schedule @@ -192,46 +190,43 @@ const Scheduler = () => { await fetchSchedule(); setNumChanges(0); } - }, [LL, schedule, updateSchedule, fetchSchedule]); + }; - const editScheduleItem = useCallback((si: ScheduleItem) => { + const editScheduleItem = (si: ScheduleItem) => { setCreating(false); setSelectedScheduleItem(si); setDialogOpen(true); if (si.o_name === undefined) { si.o_name = si.name; } - }, []); + }; - const onDialogClose = useCallback(() => { + const onDialogClose = () => { setDialogOpen(false); - }, []); + }; - const onDialogCancel = useCallback(async () => { + const onDialogCancel = async () => { await fetchSchedule().then(() => { setNumChanges(0); }); - }, [fetchSchedule]); + }; - const onDialogSave = useCallback( - (updatedItem: ScheduleItem) => { - setDialogOpen(false); - void updateState(readSchedule(), (data: ScheduleItem[]) => { - const new_data = creating - ? [...data, updatedItem] - : data.map((si) => - si.id === updatedItem.id ? { ...si, ...updatedItem } : si - ); + const onDialogSave = (updatedItem: ScheduleItem) => { + setDialogOpen(false); + void updateState(readSchedule(), (data: ScheduleItem[]) => { + const new_data = creating + ? [...data, updatedItem] + : data.map((si) => + si.id === updatedItem.id ? { ...si, ...updatedItem } : si + ); - setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); + setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); - return new_data; - }); - }, - [creating, hasScheduleChanged] - ); + return new_data; + }); + }; - const addScheduleItem = useCallback(() => { + const addScheduleItem = () => { setCreating(true); const newItem: ScheduleItem = { id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), @@ -239,36 +234,29 @@ const Scheduler = () => { }; setSelectedScheduleItem(newItem); setDialogOpen(true); - }, []); + }; - const filteredAndSortedSchedule = useMemo( - () => - schedule - .filter((si: ScheduleItem) => !si.deleted) - .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags), - [schedule] - ); + const filteredAndSortedSchedule = schedule + .filter((si: ScheduleItem) => !si.deleted) + .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags); - const dayBox = useCallback( - (si: ScheduleItem, flag: number) => { - const dayIndex = Math.log(flag) / LOG_2; - const isActive = (si.flags & flag) === flag; + const dayBox = (si: ScheduleItem, flag: number) => { + const dayIndex = Math.log(flag) / LOG_2; + const isActive = (si.flags & flag) === flag; - return ( - <> - - - {dow[dayIndex]} - - - - - ); - }, - [dow] - ); + return ( + <> + + + {dow[dayIndex]} + + + + + ); + }; - const scheduleType = useCallback((si: ScheduleItem) => { + const scheduleType = (si: ScheduleItem) => { const label = scheduleTypeLabels[si.flags]; return ( @@ -278,9 +266,9 @@ const Scheduler = () => { ); - }, []); + }; - const renderSchedule = useCallback(() => { + const renderSchedule = () => { if (!schedule) { return ( @@ -343,17 +331,7 @@ const Scheduler = () => { )} ); - }, [ - schedule, - error, - fetchSchedule, - filteredAndSortedSchedule, - schedule_theme, - editScheduleItem, - LL, - dayBox, - scheduleType - ]); + }; return ( diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index 27713de86..ca3ff97dd 100644 --- a/interface/src/app/main/SchedulerDialog.tsx +++ b/interface/src/app/main/SchedulerDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -60,6 +60,12 @@ const FLAG_VALUES = [ ScheduleFlag.SCHEDULE_SAT ] as const; +const getFlagDOWnumber = (flags: string[]) => + flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127; + +const getFlagDOWstring = (f: number) => + FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag)); + interface SchedulerDialogProps { open: boolean; creating: boolean; @@ -84,6 +90,7 @@ const SchedulerDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [scheduleType, setScheduleType] = useState(); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue( @@ -112,129 +119,95 @@ const SchedulerDialog = ({ } }, [open, selectedItem]); - // Helper function to handle save operations - const handleSave = useCallback( - async (itemToSave: ScheduleItem) => { - try { - setFieldErrors(undefined); - await validate(validator, itemToSave); - onSave(itemToSave); - } catch (error) { - setFieldErrors((error as ValidationError).fieldErrors); - } - }, - [validator, onSave] - ); - - const save = useCallback(async () => { - await handleSave(editItem); - }, [editItem, handleSave]); - - const saveandactivate = useCallback(async () => { - await handleSave({ ...editItem, active: true }); - }, [editItem, handleSave]); - - const remove = useCallback(() => { - onSave({ ...editItem, deleted: true }); - }, [editItem, onSave]); - - // Optimize DOW flag conversion - const getFlagDOWnumber = useCallback((flags: string[]) => { - return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127; - }, []); - - const getFlagDOWstring = useCallback((f: number) => { - return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => - String(flag) - ); - }, []); - - // Day of week display component - const DayOfWeekButton = useCallback( - (flag: number) => { - const dayIndex = Math.log2(flag); - const isSelected = (editItem.flags & flag) === flag; - return ( - - {dow[dayIndex]} - - ); - }, - [editItem.flags, dow] - ); - - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); - - const handleScheduleTypeChange = useCallback( - (_event: React.SyntheticEvent, flag: ScheduleFlag | null) => { - if (flag !== null) { - setFieldErrors(undefined); // clear any validation errors - setScheduleType(flag); - // wipe the time field when changing the schedule type - // set the flags based on type - const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag; - setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); - } - }, - [] - ); - - const handleDOWChange = useCallback( - (_event: React.SyntheticEvent, flags: string[]) => { - const newFlags = - getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags); - setEditItem((prev) => ({ ...prev, flags: newFlags })); - }, - [getFlagDOWnumber] - ); - - // Memoize derived values - const isDaySchedule = useMemo( - () => scheduleType === ScheduleFlag.SCHEDULE_DAY, - [scheduleType] - ); - const isTimerSchedule = useMemo( - () => scheduleType === ScheduleFlag.SCHEDULE_TIMER, - [scheduleType] - ); - const isImmediateSchedule = useMemo( - () => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE, - [scheduleType] - ); - const needsTimeField = useMemo( - () => isDaySchedule || isTimerSchedule, - [isDaySchedule, isTimerSchedule] - ); - - const dowFlags = useMemo( - () => getFlagDOWstring(editItem.flags), - [editItem.flags, getFlagDOWstring] - ); - - const timeFieldValue = useMemo(() => { - if (needsTimeField) { - return editItem.time === '' ? DEFAULT_TIME : editItem.time; + const handleSave = async (itemToSave: ScheduleItem) => { + try { + setFieldErrors(undefined); + await validate(validator, itemToSave); + onSave(itemToSave); + } catch (error) { + setFieldErrors((error as ValidationError).fieldErrors); } - return editItem.time === DEFAULT_TIME ? '' : editItem.time; - }, [editItem.time, needsTimeField]); + }; - const timeFieldLabel = useMemo(() => { + const save = async () => { + await handleSave(editItem); + }; + + const saveandactivate = async () => { + await handleSave({ ...editItem, active: true }); + }; + + const remove = () => { + onSave({ ...editItem, deleted: true }); + }; + + const DayOfWeekButton = (flag: number) => { + const dayIndex = Math.log2(flag); + const isSelected = (editItem.flags & flag) === flag; + return ( + + {dow[dayIndex]} + + ); + }; + + const handleClose = ( + _event: React.SyntheticEvent, + reason: 'backdropClick' | 'escapeKeyDown' + ) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; + + const handleScheduleTypeChange = ( + _event: React.SyntheticEvent, + flag: ScheduleFlag | null + ) => { + if (flag !== null) { + setFieldErrors(undefined); // clear any validation errors + setScheduleType(flag); + // wipe the time field when changing the schedule type + // set the flags based on type + const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag; + setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); + } + }; + + const handleDOWChange = ( + _event: React.SyntheticEvent, + flags: string[] + ) => { + const newFlags = + getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags); + setEditItem((prev) => ({ ...prev, flags: newFlags })); + }; + + const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY; + const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER; + const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE; + const needsTimeField = isDaySchedule || isTimerSchedule; + + const dowFlags = getFlagDOWstring(editItem.flags); + + const timeFieldValue = needsTimeField + ? editItem.time === '' + ? DEFAULT_TIME + : editItem.time + : editItem.time === DEFAULT_TIME + ? '' + : editItem.time; + + const timeFieldLabel = (() => { if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); return LL.TIME(1); - }, [scheduleType, LL]); + })(); return ( diff --git a/interface/src/app/main/Sensors.tsx b/interface/src/app/main/Sensors.tsx index 028c362fc..b19a5c146 100644 --- a/interface/src/app/main/Sensors.tsx +++ b/interface/src/app/main/Sensors.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; @@ -158,18 +158,16 @@ const Sensors = () => { } ); - const intervalCallback = useCallback(() => { + useInterval(() => { if (!temperatureDialogOpen && !analogDialogOpen) { void fetchSensorData(); } - }, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]); - - useInterval(intervalCallback); + }); const temperature_theme = useTheme([common_theme, temperature_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]); - const getSortIcon = useCallback((state: State, sortKey: unknown) => { + const getSortIcon = (state: State, sortKey: unknown) => { if (state.sortKey === sortKey && state.reverse) { return ; } @@ -177,7 +175,7 @@ const Sensors = () => { return ; } return ; - }, []); + }; const analog_sort = useSort( { nodes: sensorData.as }, @@ -234,119 +232,104 @@ const Sensors = () => { useLayoutTitle(LL.SENSORS()); - const formatDurationMin = useCallback( - (duration_min: number) => { - const totalMs = duration_min * MS_PER_MINUTE; - const days = Math.trunc(totalMs / MS_PER_DAY); - const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; - const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60; + const formatDurationMin = (duration_min: number) => { + const totalMs = duration_min * MS_PER_MINUTE; + const days = Math.trunc(totalMs / MS_PER_DAY); + const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; + const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60; - const parts: string[] = []; - if (days > 0) { - parts.push(LL.NUM_DAYS({ num: days })); - } - if (hours > 0) { - parts.push(LL.NUM_HOURS({ num: hours })); - } - if (minutes > 0) { - parts.push(LL.NUM_MINUTES({ num: minutes })); - } - return parts.join(' '); - }, - [LL] - ); + const parts: string[] = []; + if (days > 0) { + parts.push(LL.NUM_DAYS({ num: days })); + } + if (hours > 0) { + parts.push(LL.NUM_HOURS({ num: hours })); + } + if (minutes > 0) { + parts.push(LL.NUM_MINUTES({ num: minutes })); + } + return parts.join(' '); + }; - const formatValue = useCallback( - (value: unknown, uom: DeviceValueUOM) => { - if (value === undefined) { - return ''; - } - if (typeof value !== 'number') { - return value as string; - } - switch (uom) { - case DeviceValueUOM.HOURS: - return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); - case DeviceValueUOM.MINUTES: - return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); - case DeviceValueUOM.SECONDS: - return LL.NUM_SECONDS({ num: value }); - case DeviceValueUOM.NONE: - return new Intl.NumberFormat().format(value); - case DeviceValueUOM.DEGREES: - case DeviceValueUOM.DEGREES_R: - case DeviceValueUOM.FAHRENHEIT: - return ( - new Intl.NumberFormat(undefined, { - minimumFractionDigits: 1 - }).format(value) + - ' ' + - DeviceValueUOM_s[uom] - ); - default: - return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; - } - }, - [formatDurationMin, LL] - ); + const formatValue = (value: unknown, uom: DeviceValueUOM) => { + if (value === undefined) { + return ''; + } + if (typeof value !== 'number') { + return value as string; + } + switch (uom) { + case DeviceValueUOM.HOURS: + return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); + case DeviceValueUOM.MINUTES: + return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); + case DeviceValueUOM.SECONDS: + return LL.NUM_SECONDS({ num: value }); + case DeviceValueUOM.NONE: + return new Intl.NumberFormat().format(value); + case DeviceValueUOM.DEGREES: + case DeviceValueUOM.DEGREES_R: + case DeviceValueUOM.FAHRENHEIT: + return ( + new Intl.NumberFormat(undefined, { + minimumFractionDigits: 1 + }).format(value) + + ' ' + + DeviceValueUOM_s[uom] + ); + default: + return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; + } + }; - const updateTemperatureSensor = useCallback( - (ts: TemperatureSensor) => { - if (me.admin) { - ts.o_n = ts.n; - setSelectedTemperatureSensor(ts); - setTemperatureDialogOpen(true); - } - }, - [me.admin] - ); + const updateTemperatureSensor = (ts: TemperatureSensor) => { + if (me.admin) { + ts.o_n = ts.n; + setSelectedTemperatureSensor(ts); + setTemperatureDialogOpen(true); + } + }; - const onTemperatureDialogClose = useCallback(() => { + const onTemperatureDialogClose = () => { setTemperatureDialogOpen(false); void fetchSensorData(); - }, [fetchSensorData]); + }; - const onTemperatureDialogSave = useCallback( - async (ts: TemperatureSensor) => { - await sendTemperatureSensor({ - id: ts.id, - name: ts.n, - offset: ts.o, - is_system: ts.s + const onTemperatureDialogSave = async (ts: TemperatureSensor) => { + await sendTemperatureSensor({ + id: ts.id, + name: ts.n, + offset: ts.o, + is_system: ts.s + }) + .then(() => { + toast.success(LL.UPDATED_OF(LL.SENSOR(1))); }) - .then(() => { - toast.success(LL.UPDATED_OF(LL.SENSOR(1))); - }) - .catch(() => { - toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); - }) - .finally(() => { - setTemperatureDialogOpen(false); - setSelectedTemperatureSensor(undefined); - void fetchSensorData(); - }); - }, - [sendTemperatureSensor, LL, fetchSensorData] - ); + .catch(() => { + toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); + }) + .finally(() => { + setTemperatureDialogOpen(false); + setSelectedTemperatureSensor(undefined); + void fetchSensorData(); + }); + }; - const updateAnalogSensor = useCallback( - (as: AnalogSensor) => { - if (me.admin) { - setCreating(false); - as.o_n = as.n; - setSelectedAnalogSensor(as); - setAnalogDialogOpen(true); - } - }, - [me.admin] - ); + const updateAnalogSensor = (as: AnalogSensor) => { + if (me.admin) { + setCreating(false); + as.o_n = as.n; + setSelectedAnalogSensor(as); + setAnalogDialogOpen(true); + } + }; - const onAnalogDialogClose = useCallback(() => { + const onAnalogDialogClose = () => { setAnalogDialogOpen(false); void fetchSensorData(); - }, [fetchSensorData]); + }; - const addAnalogSensor = useCallback(() => { + const addAnalogSensor = () => { if (firstAvailableGPIO.current === undefined) { toast.error(LL.NO_GPIO()); return; @@ -366,194 +349,167 @@ const Sensors = () => { o_n: '' }); setAnalogDialogOpen(true); - }, []); + }; - const onAnalogDialogSave = useCallback( - async (as: AnalogSensor) => { - await sendAnalogSensor({ - id: as.id, - gpio: as.g, - name: as.n, - offset: as.o, - factor: as.f, - uom: as.u, - type: as.t, - deleted: as.d, - is_system: as.s + const onAnalogDialogSave = async (as: AnalogSensor) => { + await sendAnalogSensor({ + id: as.id, + gpio: as.g, + name: as.n, + offset: as.o, + factor: as.f, + uom: as.u, + type: as.t, + deleted: as.d, + is_system: as.s + }) + .then(() => { + toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); }) - .then(() => { - toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); - }) - .catch(() => { - toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); - }) - .finally(() => { - setAnalogDialogOpen(false); - setSelectedAnalogSensor(undefined); - void fetchSensorData(); - }); - }, - [sendAnalogSensor, LL, fetchSensorData] + .catch(() => { + toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); + }) + .finally(() => { + setAnalogDialogOpen(false); + setSelectedAnalogSensor(undefined); + void fetchSensorData(); + }); + }; + + const RenderAnalogSensors = ( + + {(tableList: AnalogSensor[]) => ( + <> +
+ + + + + + + + + + + + + + +
+ + {tableList.map((as: AnalogSensor) => ( + updateAnalogSensor(as)} + > + {as.g} + {as.n} + {AnalogTypeNames[as.t - 1]} + {(as.t === AnalogType.DIGITAL_OUT && + as.g !== GPIO_25 && + as.g !== GPIO_26) || + as.t === AnalogType.DIGITAL_IN || + as.t === AnalogType.PULSE ? ( + {as.v ? LL.ON() : LL.OFF()} + ) : ( + {formatValue(as.v, as.u)} + )} + + ))} + + + )} +
); - const RenderAnalogSensors = useMemo( - () => ( - - {(tableList: AnalogSensor[]) => ( - <> -
- - - - - - - - - - - - - - -
- - {tableList.map((as: AnalogSensor) => ( - updateAnalogSensor(as)} + const RenderTemperatureSensors = ( +
+ {(tableList: TemperatureSensor[]) => ( + <> +
+ + +
- ), - [ - analog_sort, - analog_theme, - getSortIcon, - sensorData.as, - LL, - updateAnalogSensor, - formatValue - ] - ); - - const RenderTemperatureSensors = useMemo( - () => ( - - {(tableList: TemperatureSensor[]) => ( - <> -
- - - - - - - - -
- - {tableList.map((ts: TemperatureSensor) => ( - updateTemperatureSensor(ts)} + {LL.NAME(0)} + + + +
- ), - [ - temperature_sort, - temperature_theme, - getSortIcon, - sensorData.ts, - LL, - updateTemperatureSensor, - formatValue - ] + {LL.VALUE(0)} + + + + + + {tableList.map((ts: TemperatureSensor) => ( + updateTemperatureSensor(ts)} + > + {ts.n} + {formatValue(ts.t, ts.u)} + + ))} + + + )} + ); return ( diff --git a/interface/src/app/main/SensorsAnalogDialog.tsx b/interface/src/app/main/SensorsAnalogDialog.tsx index ffc5a4485..283a00803 100644 --- a/interface/src/app/main/SensorsAnalogDialog.tsx +++ b/interface/src/app/main/SensorsAnalogDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -53,6 +53,7 @@ const SensorsAnalogDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue((updater) => @@ -66,71 +67,45 @@ const SensorsAnalogDialog = ({ [setEditItem] ); - // Memoize helper functions to check sensor type conditions - const isCounterOrRate = useMemo( - () => - editItem.t === AnalogType.COUNTER || - editItem.t === AnalogType.RATE || - (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), - [editItem.t] - ); - const isCounter = useMemo( - () => - editItem.t === AnalogType.COUNTER || - (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), - [editItem.t] - ); - const isFreqType = useMemo( - () => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2, - [editItem.t] - ); - const isPWM = useMemo( - () => - editItem.t === AnalogType.PWM_0 || - editItem.t === AnalogType.PWM_1 || - editItem.t === AnalogType.PWM_2, - [editItem.t] - ); - const isDACOutGPIO = useMemo( - () => - editItem.t === AnalogType.DIGITAL_OUT && - (editItem.g === 25 || editItem.g === 26), - [editItem.t, editItem.g] - ); - const isDigitalOutGPIO = useMemo( - () => - editItem.t === AnalogType.DIGITAL_OUT && - editItem.g !== 25 && - editItem.g !== 26, - [editItem.t, editItem.g] - ); + const isCounterOrRate = + editItem.t === AnalogType.COUNTER || + editItem.t === AnalogType.RATE || + (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); + const isCounter = + editItem.t === AnalogType.COUNTER || + (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); + const isFreqType = + editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2; + const isPWM = + editItem.t === AnalogType.PWM_0 || + editItem.t === AnalogType.PWM_1 || + editItem.t === AnalogType.PWM_2; + const isDACOutGPIO = + editItem.t === AnalogType.DIGITAL_OUT && + (editItem.g === 25 || editItem.g === 26); + const isDigitalOutGPIO = + editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26; - // Memoize menu items to avoid recreation on each render - const analogTypeMenuItems = useMemo( - () => - AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 })) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(({ name, value }) => ( - - {name} - - )), - [disabledTypeList] - ); + const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({ + name: val, + value: i + 1 + })) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(({ name, value }) => ( + + {name} + + )); - const uomMenuItems = useMemo( - () => - DeviceValueUOM_s.map((val, i) => ( - - {val} - - )), - [] - ); + const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( + + {val} + + )); const analogGPIOMenuItems = () => // add selectedItem.g to the list @@ -157,16 +132,16 @@ const SensorsAnalogDialog = ({ } }, [open, selectedItem]); - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); + const handleClose = ( + _event: React.SyntheticEvent, + reason: 'backdropClick' | 'escapeKeyDown' + ) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -174,17 +149,13 @@ const SensorsAnalogDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); + }; - const remove = useCallback(() => { + const remove = () => { onSave({ ...editItem, d: true }); - }, [editItem, onSave]); + }; - const dialogTitle = useMemo( - () => - `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`, - [creating, LL] - ); + const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`; return ( diff --git a/interface/src/app/main/SensorsTemperatureDialog.tsx b/interface/src/app/main/SensorsTemperatureDialog.tsx index 670b34244..21a422a77 100644 --- a/interface/src/app/main/SensorsTemperatureDialog.tsx +++ b/interface/src/app/main/SensorsTemperatureDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -50,6 +50,7 @@ const SensorsTemperatureDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); + // Stable handler reference so the memoized ValidatedTextField can skip re-renders const updateFormValue = useMemo( () => updateValue( @@ -69,16 +70,13 @@ const SensorsTemperatureDialog = ({ } }, [open, selectedItem]); - const handleClose = useCallback( - (_event: React.SyntheticEvent, reason?: string) => { - if (reason !== 'backdropClick') { - onClose(); - } - }, - [onClose] - ); + const handleClose = (_event: React.SyntheticEvent, reason?: string) => { + if (reason !== 'backdropClick') { + onClose(); + } + }; - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -86,29 +84,11 @@ const SensorsTemperatureDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); - - const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]); - - const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]); - - const slotProps = useMemo( - () => ({ - input: { - startAdornment: {TEMP_UNIT} - }, - htmlInput: { - min: OFFSET_MIN, - max: OFFSET_MAX, - step: OFFSET_STEP - } - }), - [] - ); + }; return ( - {dialogTitle} + {`${LL.EDIT()} ${LL.TEMP_SENSOR()}`} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id} @@ -128,12 +108,23 @@ const SensorsTemperatureDialog = ({ {TEMP_UNIT} + ) + }, + htmlInput: { + min: OFFSET_MIN, + max: OFFSET_MAX, + step: OFFSET_STEP + } + }} />
diff --git a/interface/src/app/main/UserProfile.tsx b/interface/src/app/main/UserProfile.tsx index 46928e239..ebcae0be3 100644 --- a/interface/src/app/main/UserProfile.tsx +++ b/interface/src/app/main/UserProfile.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext } from 'react'; +import { memo, useContext } from 'react'; import PersonIcon from '@mui/icons-material/Person'; import { @@ -23,9 +23,9 @@ const UserProfileComponent = () => { useLayoutTitle(LL.USER_PROFILE()); - const handleSignOut = useCallback(() => { + const handleSignOut = () => { signOut(true); - }, [signOut]); + }; return ( diff --git a/interface/src/app/settings/APSettings.tsx b/interface/src/app/settings/APSettings.tsx index 7d8c8875c..257cc0d81 100644 --- a/interface/src/app/settings/APSettings.tsx +++ b/interface/src/app/settings/APSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import WarningIcon from '@mui/icons-material/Warning'; @@ -63,22 +63,16 @@ const APSettings = () => { const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = useMemo( - () => - updateValueDirty( - origData as unknown as Record, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void - ), - [origData, dirtyFlags, setDirtyFlags, updateDataValue] + const updateFormValue = updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void ); - // Memoize AP enabled state - const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]); + const apEnabled = data ? isAPEnabled(data) : false; - // Memoize validation and submit handler - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { if (!data) return; try { @@ -88,7 +82,7 @@ const APSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; const content = () => { if (!data) { diff --git a/interface/src/app/settings/ApplicationSettings.tsx b/interface/src/app/settings/ApplicationSettings.tsx index ce985530b..2135a66f6 100644 --- a/interface/src/app/settings/ApplicationSettings.tsx +++ b/interface/src/app/settings/ApplicationSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -106,49 +106,36 @@ const ApplicationSettings = () => { }); }); - // Memoized input props to prevent recreation on every render - const SecondsInputProps = useMemo( - () => ({ - endAdornment: {LL.SECONDS()} - }), - [LL] - ); + const SecondsInputProps = { + endAdornment: {LL.SECONDS()} + }; - const MinutesInputProps = useMemo( - () => ({ - endAdornment: {LL.MINUTES()} - }), - [LL] - ); + const MinutesInputProps = { + endAdornment: {LL.MINUTES()} + }; - const HoursInputProps = useMemo( - () => ({ - endAdornment: {LL.HOURS()} - }), - [LL] - ); + const HoursInputProps = { + endAdornment: {LL.HOURS()} + }; - const doRestart = useCallback(async () => { + const doRestart = async () => { setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); - }, [sendAPI]); + }; - const updateBoardProfile = useCallback( - async (board_profile: string) => { - await readBoardProfile(board_profile).catch((error: Error) => { - toast.error(error.message); - }); - }, - [readBoardProfile] - ); + const updateBoardProfile = async (board_profile: string) => { + await readBoardProfile(board_profile).catch((error: Error) => { + toast.error(error.message); + }); + }; useLayoutTitle(LL.APPLICATION()); - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { try { setFieldErrors(undefined); await validate(createSettingsValidator(data), data); @@ -157,31 +144,27 @@ const ApplicationSettings = () => { } finally { await saveData(); } - }, [data, saveData]); + }; - const changeBoardProfile = useCallback( - (event: React.ChangeEvent) => { - const boardProfile = event.target.value; - updateFormValue(event); - if (boardProfile === 'CUSTOM') { - updateDataValue({ - ...data, - board_profile: boardProfile - }); - } else { - void updateBoardProfile(boardProfile); - } - }, - [data, updateBoardProfile, updateFormValue, updateDataValue] - ); + const changeBoardProfile = (event: React.ChangeEvent) => { + const boardProfile = event.target.value; + updateFormValue(event); + if (boardProfile === 'CUSTOM') { + updateDataValue({ + ...data, + board_profile: boardProfile + }); + } else { + void updateBoardProfile(boardProfile); + } + }; - const restart = useCallback(async () => { + const restart = async () => { await validateAndSubmit(); await doRestart(); - }, [validateAndSubmit, doRestart]); + }; - // Memoize board profile select items to prevent recreation - const boardProfileItems = useMemo(() => boardProfileSelectItems(), []); + const boardProfileItems = boardProfileSelectItems(); const content = () => { if (!data || !hardwareData) { diff --git a/interface/src/app/settings/DownloadUpload.tsx b/interface/src/app/settings/DownloadUpload.tsx index 02a373e12..b47b7f242 100644 --- a/interface/src/app/settings/DownloadUpload.tsx +++ b/interface/src/app/settings/DownloadUpload.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -57,7 +57,7 @@ const DownloadUpload = () => { const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); - const doRestart = useCallback(async () => { + const doRestart = async () => { setRestarting(true); try { await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); @@ -65,16 +65,33 @@ const DownloadUpload = () => { toast.error((error as Error).message); setRestarting(false); } - }, [sendAPI]); + }; useLayoutTitle(LL.DOWNLOAD_UPLOAD()); - const handleCloseBackupDialog = useCallback(() => { + const handleCloseBackupDialog = () => { setConfirmBackup(false); - }, []); + }; - const renderBackupDialog = useMemo( - () => ( + const handleDownload = (type: string) => () => { + void sendExportData(type); + setConfirmBackup(false); + }; + + if (restarting) { + return ; + } + + if (!data) { + return ( + + + + ); + } + + return ( + { - ), - [confirmBackup, handleCloseBackupDialog, LL] - ); - - const handleDownload = useCallback( - (type: string) => () => { - void sendExportData(type); - setConfirmBackup(false); - }, - [sendExportData] - ); - - if (restarting) { - return ; - } - - if (!data) { - return ( - - - - ); - } - - return ( - - {renderBackupDialog} {LL.DOWNLOAD(0)} diff --git a/interface/src/app/settings/MqttSettings.tsx b/interface/src/app/settings/MqttSettings.tsx index 417fa8190..a1e8efe18 100644 --- a/interface/src/app/settings/MqttSettings.tsx +++ b/interface/src/app/settings/MqttSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -57,7 +57,7 @@ const MqttSettings = () => { const [fieldErrors, setFieldErrors] = useState(); - const sendResetMQTT = useCallback(() => { + const sendResetMQTT = () => { void callAction({ action: 'resetMQTT' }) .then(() => { toast.success('MQTT ' + LL.REFRESH() + ' successful'); @@ -65,29 +65,20 @@ const MqttSettings = () => { .catch((error) => { toast.error(String(error.error?.message || 'An error occurred')); }); - }, []); + }; - const updateFormValue = useMemo( - () => - updateValueDirty( - origData as unknown as Record, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void - ), - [origData, dirtyFlags, setDirtyFlags, updateDataValue] + const updateFormValue = updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void ); - const SecondsInputProps = useMemo( - () => ({ - endAdornment: {LL.SECONDS()} - }), - [LL] - ); + const SecondsInputProps = { + endAdornment: {LL.SECONDS()} + }; - const emptyFieldErrors = useMemo(() => ({}), []); - - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { if (!data) return; try { setFieldErrors(undefined); @@ -96,25 +87,22 @@ const MqttSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; - const publishIntervalFields = useMemo( - () => [ - { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, - { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, - { - name: 'publish_time_thermostat', - label: LL.MQTT_INT_THERMOSTATS(), - validated: false - }, - { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, - { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false }, - { name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false }, - { name: 'publish_time_sensor', label: LL.SENSORS(), validated: false }, - { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } - ], - [LL] - ); + const publishIntervalFields = [ + { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, + { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, + { + name: 'publish_time_thermostat', + label: LL.MQTT_INT_THERMOSTATS(), + validated: false + }, + { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, + { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false }, + { name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false }, + { name: 'publish_time_sensor', label: LL.SENSORS(), validated: false }, + { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } + ]; if (!data) { return ( @@ -154,7 +142,7 @@ const MqttSettings = () => { { { { { {field.validated ? ( { const { LL } = useI18nContext(); useLayoutTitle('NTP'); - // Memoized timezone select items for better performance const timeZoneItems = useTimeZoneSelectItems(); - // Memoized selected timezone value - const selectedTzValue = useMemo( - () => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined), - [data?.tz_label, data?.tz_format] - ); + const selectedTzValue = data + ? selectedTimeZone(data.tz_label, data.tz_format) + : undefined; const [localTime, setLocalTime] = useState(''); const [settingTime, setSettingTime] = useState(false); @@ -82,32 +79,22 @@ const NTPSettings = () => { } ); - // Memoize updateFormValue to prevent recreation on every render - const updateFormValue = useMemo( - () => - updateValueDirty( - origData as unknown as Record, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void - ), - [origData, dirtyFlags, setDirtyFlags, updateDataValue] + const updateFormValue = updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void ); - // Memoize updateLocalTime handler - const updateLocalTime = useCallback( - (event: React.ChangeEvent) => setLocalTime(event.target.value), - [] - ); + const updateLocalTime = (event: React.ChangeEvent) => + setLocalTime(event.target.value); - // Memoize openSetTime handler - const openSetTime = useCallback(() => { + const openSetTime = () => { setLocalTime(formatLocalDateTime(new Date())); setSettingTime(true); - }, []); + }; - // Memoize configureTime handler - const configureTime = useCallback(async () => { + const configureTime = async () => { setProcessing(true); try { @@ -120,13 +107,11 @@ const NTPSettings = () => { } finally { setProcessing(false); } - }, [localTime, updateTime, LL, loadData]); + }; - // Memoize close dialog handler - const handleCloseSetTime = useCallback(() => setSettingTime(false), []); + const handleCloseSetTime = () => setSettingTime(false); - // Memoize validate and submit handler - const validateAndSubmit = useCallback(async () => { + const validateAndSubmit = async () => { if (!data) return; try { setFieldErrors(undefined); @@ -135,23 +120,18 @@ const NTPSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; - // Memoize timezone change handler - const changeTimeZone = useCallback( - (event: React.ChangeEvent) => { - void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ - ...settings, - tz_label: event.target.value, - tz_format: TIME_ZONES[event.target.value] - })); - updateFormValue(event); - }, - [updateFormValue] - ); + const changeTimeZone = (event: React.ChangeEvent) => { + void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ + ...settings, + tz_label: event.target.value, + tz_format: TIME_ZONES[event.target.value] + })); + updateFormValue(event); + }; - // Memoize render content to prevent unnecessary re-renders - const renderContent = useMemo(() => { + const renderContent = () => { if (!data) { return ; } @@ -236,26 +216,12 @@ const NTPSettings = () => { )} ); - }, [ - data, - errorMessage, - loadData, - updateFormValue, - fieldErrors, - selectedTzValue, - changeTimeZone, - timeZoneItems, - dirtyFlags, - openSetTime, - saving, - validateAndSubmit, - LL - ]); + }; return ( {blocker ? : null} - {renderContent} + {renderContent()} {LL.SET_TIME(1)} diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index a8efc8c4b..7a31f66e7 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -43,148 +43,141 @@ const Settings = () => { immediate: false }); - const doFormat = useCallback(async () => { + const doFormat = async () => { await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { setRestarting(true); setConfirmFactoryReset(false); }); - }, [sendAPI]); + }; - const handleFactoryResetClose = useCallback(() => { + const handleFactoryResetClose = () => { setConfirmFactoryReset(false); - }, []); + }; - const handleFactoryResetClick = useCallback(() => { + const handleFactoryResetClick = () => { setConfirmFactoryReset(true); - }, []); + }; - const content = useMemo(() => { - return ( - <> - - + if (restarting) { + return ; + } - + return ( + + + - + - + - + - + - + - - + - - {LL.FACTORY_RESET()} - {LL.SYSTEM_FACTORY_TEXT_DIALOG()} - - - - - + + - - - + + {LL.FACTORY_RESET()} + {LL.SYSTEM_FACTORY_TEXT_DIALOG()} + + - - - ); - }, [ - LL, - handleFactoryResetClick, - handleFactoryResetClose, - doFormat, - confirmFactoryReset, - restarting - ]); + + - return restarting ? : {content}; + + + + + + + ); }; export default Settings; diff --git a/interface/src/app/settings/TZ.tsx b/interface/src/app/settings/TZ.tsx index c734f1809..e0ff35294 100644 --- a/interface/src/app/settings/TZ.tsx +++ b/interface/src/app/settings/TZ.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import { MenuItem } from '@mui/material'; export const TIME_ZONES: Record = { @@ -472,26 +470,16 @@ export function selectedTimeZone(label: string, format: string) { return TIME_ZONES[label] === format ? label : undefined; } -// Memoized version for use in components -export function useTimeZoneSelectItems() { - return useMemo( - () => - TIME_ZONE_LABELS.map((label) => ( - - {label} - - )), - [] - ); -} - -// Fallback export for backward compatibility - now memoized const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( {label} )); +export function useTimeZoneSelectItems() { + return precomputedTimeZoneItems; +} + export function timeZoneSelectItems() { return precomputedTimeZoneItems; } diff --git a/interface/src/app/settings/network/Network.tsx b/interface/src/app/settings/network/Network.tsx index 300010e3f..e90e5b835 100644 --- a/interface/src/app/settings/network/Network.tsx +++ b/interface/src/app/settings/network/Network.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useState } from 'react'; import { Navigate, Route, @@ -40,26 +40,20 @@ const Network = () => { const [selectedNetwork, setSelectedNetwork] = useState(); - const selectNetwork = useCallback( - (network: WiFiNetwork) => { - setSelectedNetwork(network); - void navigate('/settings/network/settings'); - }, - [navigate] - ); + const selectNetwork = (network: WiFiNetwork) => { + setSelectedNetwork(network); + void navigate('/settings/network/settings'); + }; - const deselectNetwork = useCallback(() => { + const deselectNetwork = () => { setSelectedNetwork(undefined); - }, []); + }; - const contextValue = useMemo( - () => ({ - ...(selectedNetwork && { selectedNetwork }), - selectNetwork, - deselectNetwork - }), - [selectedNetwork, selectNetwork, deselectNetwork] - ); + const contextValue = { + ...(selectedNetwork && { selectedNetwork }), + selectNetwork, + deselectNetwork + }; return ( diff --git a/interface/src/app/settings/network/NetworkSettings.tsx b/interface/src/app/settings/network/NetworkSettings.tsx index 5f96aa594..89789ccc2 100644 --- a/interface/src/app/settings/network/NetworkSettings.tsx +++ b/interface/src/app/settings/network/NetworkSettings.tsx @@ -121,19 +121,19 @@ const NetworkSettings = () => { deselectNetwork(); }, [data, saveData, deselectNetwork]); - const setCancel = useCallback(async () => { + const setCancel = async () => { deselectNetwork(); await loadData(); - }, [deselectNetwork, loadData]); + }; - const doRestart = useCallback(async () => { + const doRestart = async () => { setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); - }, [sendAPI]); + }; const content = () => { if (!data) { diff --git a/interface/src/app/settings/network/WiFiNetworkScanner.tsx b/interface/src/app/settings/network/WiFiNetworkScanner.tsx index b4517b0f1..b7d58206c 100644 --- a/interface/src/app/settings/network/WiFiNetworkScanner.tsx +++ b/interface/src/app/settings/network/WiFiNetworkScanner.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useRef, useState } from 'react'; +import { memo, useRef, useState } from 'react'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import { Button } from '@mui/material'; @@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => { } }); - const renderNetworkScanner = useCallback(() => { + const renderNetworkScanner = () => { if (!networkList) { return ; } return ; - }, [networkList, errorMessage]); + }; return ( diff --git a/interface/src/app/settings/network/WiFiNetworkSelector.tsx b/interface/src/app/settings/network/WiFiNetworkSelector.tsx index bfc0f9949..e7b7d327b 100644 --- a/interface/src/app/settings/network/WiFiNetworkSelector.tsx +++ b/interface/src/app/settings/network/WiFiNetworkSelector.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext } from 'react'; +import { memo, useContext } from 'react'; import LockIcon from '@mui/icons-material/Lock'; import LockOpenIcon from '@mui/icons-material/LockOpen'; @@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList }) const wifiConnectionContext = useContext(WiFiConnectionContext); - const renderNetwork = useCallback( - (network: WiFiNetwork) => ( - wifiConnectionContext.selectNetwork(network)} - > - - {isNetworkOpen(network) ? : } - - - - - - - - - ), - [wifiConnectionContext, theme] + const renderNetwork = (network: WiFiNetwork) => ( + wifiConnectionContext.selectNetwork(network)} + > + + {isNetworkOpen(network) ? : } + + + + + + + + ); if (networkList.networks.length === 0) { diff --git a/interface/src/app/settings/security/ManageUsers.tsx b/interface/src/app/settings/security/ManageUsers.tsx index bc17261d8..9dec88ee9 100644 --- a/interface/src/app/settings/security/ManageUsers.tsx +++ b/interface/src/app/settings/security/ManageUsers.tsx @@ -99,34 +99,28 @@ const ManageUsers = () => { [] ); - const noAdminConfigured = useCallback( - () => !data?.users.find((u) => u.admin), - [data] - ); + const noAdminConfigured = () => !data?.users.find((u) => u.admin); - const removeUser = useCallback( - (toRemove: UserType) => { - if (!data) return; - const users = data.users.filter((u) => u.username !== toRemove.username); - updateDataValue({ ...data, users }); - setChanged(changed + 1); - }, - [data, updateDataValue, changed] - ); + const removeUser = (toRemove: UserType) => { + if (!data) return; + const users = data.users.filter((u) => u.username !== toRemove.username); + updateDataValue({ ...data, users }); + setChanged(changed + 1); + }; - const createUser = useCallback(() => { + const createUser = () => { setCreating(true); setUser({ username: '', password: '', admin: true }); - }, []); + }; - const editUser = useCallback((toEdit: UserType) => { + const editUser = (toEdit: UserType) => { setCreating(false); setUser({ ...toEdit }); - }, []); + }; const cancelEditingUser = useCallback(() => { setUser(undefined); @@ -150,20 +144,20 @@ const ManageUsers = () => { setGeneratingToken(undefined); }, []); - const generateTokenForUser = useCallback((username: string) => { + const generateTokenForUser = (username: string) => { setGeneratingToken(username); - }, []); + }; - const onSubmit = useCallback(async () => { + const onSubmit = async () => { await saveData(); await authenticatedContext.refresh(); setChanged(0); - }, [saveData, authenticatedContext]); + }; - const onCancelSubmit = useCallback(async () => { + const onCancelSubmit = async () => { await loadData(); setChanged(0); - }, [loadData]); + }; const content = () => { if (!data) { @@ -177,15 +171,10 @@ const ManageUsers = () => { admin: boolean; } - // add id to the type, needed for the table - const user_table = useMemo( - () => - data.users.map((u) => ({ - ...u, - id: u.username - })) as UserType2[], - [data.users] - ); + const user_table = data.users.map((u) => ({ + ...u, + id: u.username + })) as UserType2[]; return ( <> diff --git a/interface/src/app/settings/security/Security.tsx b/interface/src/app/settings/security/Security.tsx index 012aac55a..5701a907c 100644 --- a/interface/src/app/settings/security/Security.tsx +++ b/interface/src/app/settings/security/Security.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Tab } from '@mui/material'; @@ -15,19 +15,15 @@ const Security = () => { const location = useLocation(); - const matchedRoutes = useMemo( - () => - matchRoutes( - [ - { - path: '/settings/security/settings', - element: - }, - { path: '/settings/security/users', element: } - ], - location - ), - [location] + const matchedRoutes = matchRoutes( + [ + { + path: '/settings/security/settings', + element: + }, + { path: '/settings/security/users', element: } + ], + location ); const routerTab = matchedRoutes?.[0]?.route.path || false; diff --git a/interface/src/app/settings/security/User.tsx b/interface/src/app/settings/security/User.tsx index bf1ce8862..093ad5d33 100644 --- a/interface/src/app/settings/security/User.tsx +++ b/interface/src/app/settings/security/User.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import type { FC } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -62,7 +62,7 @@ const User: FC = ({ } }, [open]); - const validateAndDone = useCallback(async () => { + const validateAndDone = async () => { if (user) { try { setFieldErrors(undefined); @@ -72,7 +72,7 @@ const User: FC = ({ setFieldErrors((error as ValidationError).fieldErrors); } } - }, [user, validator, onDoneEditing]); + }; return ( { useLayoutTitle(LL.DATA_TRAFFIC()); - const stats_theme = tableTheme( - useMemo( - () => ({ - Table: ` + const stats_theme = tableTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -55,7 +51,7 @@ const SystemActivity = () => { border-bottom: 1px solid #565656; } `, - Row: ` + Row: ` .td { padding: 8px; border-top: 1px solid #565656; @@ -69,26 +65,20 @@ const SystemActivity = () => { background-color: #1e1e1e; } `, - BaseCell: ` + BaseCell: ` &:not(:first-of-type) { text-align: center; } ` - }), - [] - ) - ); + }); - const showName = useCallback( - (id: number) => { - const name: keyof Translation['STATUS_NAMES'] = - id.toString() as keyof Translation['STATUS_NAMES']; - return LL.STATUS_NAMES[name](); - }, - [LL] - ); + const showName = (id: number) => { + const name: keyof Translation['STATUS_NAMES'] = + id.toString() as keyof Translation['STATUS_NAMES']; + return LL.STATUS_NAMES[name](); + }; - const showQuality = useCallback((stat: Stat) => { + const showQuality = (stat: Stat) => { if (stat.q === 0 || stat.s + stat.f === 0) { return; } @@ -100,14 +90,18 @@ const SystemActivity = () => { } else { return
{stat.q}%
; } - }, []); - - const content = useMemo(() => { - if (!data) { - return ; - } + }; + if (!data) { return ( + + + + ); + } + + return ( + { )}
- ); - }, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]); - - return {content}; +
+ ); }; export default SystemActivity; diff --git a/interface/src/app/status/MqttStatus.tsx b/interface/src/app/status/MqttStatus.tsx index 7e38b633b..37b2507e4 100644 --- a/interface/src/app/status/MqttStatus.tsx +++ b/interface/src/app/status/MqttStatus.tsx @@ -1,4 +1,4 @@ -import { type FC, memo, useMemo } from 'react'; +import { type FC, memo } from 'react'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import DeviceHubIcon from '@mui/icons-material/DeviceHub'; @@ -127,16 +127,15 @@ const MqttStatus = () => { void loadData(); }); - // Memoize error message separately to avoid re-renders on error object changes const errorMessage = error?.message || ''; - const mqttStatusText = useMemo(() => { - if (!data) return ''; - if (!data.enabled) return LL.NOT_ENABLED(); - return data.connected - ? `${LL.CONNECTED(0)} (${data.connect_count})` - : `${LL.DISCONNECTED()} (${data.connect_count})`; - }, [data, LL]); + const mqttStatusText = !data + ? '' + : !data.enabled + ? LL.NOT_ENABLED() + : data.connected + ? `${LL.CONNECTED(0)} (${data.connect_count})` + : `${LL.DISCONNECTED()} (${data.connect_count})`; if (!data) { return ( diff --git a/interface/src/app/status/NTPStatus.tsx b/interface/src/app/status/NTPStatus.tsx index 8dc019d67..46c234b7d 100644 --- a/interface/src/app/status/NTPStatus.tsx +++ b/interface/src/app/status/NTPStatus.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import AccessTimeIcon from '@mui/icons-material/AccessTime'; import DnsIcon from '@mui/icons-material/Dns'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; @@ -67,12 +65,16 @@ const NTPStatus = () => { } }; - const content = useMemo(() => { - if (!data) { - return ; - } - + if (!data) { return ( + + + + ); + } + + return ( + @@ -121,10 +123,8 @@ const NTPStatus = () => { - ); - }, [data, error, loadData, LL, theme]); - - return {content}; + + ); }; export default NTPStatus; diff --git a/interface/src/app/status/NetworkStatus.tsx b/interface/src/app/status/NetworkStatus.tsx index 6172c8186..e72dcac95 100644 --- a/interface/src/app/status/NetworkStatus.tsx +++ b/interface/src/app/status/NetworkStatus.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DnsIcon from '@mui/icons-material/Dns'; import GiteIcon from '@mui/icons-material/Gite'; @@ -124,16 +122,20 @@ const NetworkStatus = () => { const theme = useTheme(); - const content = useMemo(() => { - if (!data) { - return ; - } - - const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL); - const statusColor = networkStatusHighlight(data, theme); - const qualityColor = networkQualityHighlight(data, theme); - + if (!data) { return ( + + + + ); + } + + const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL); + const statusColor = networkStatusHighlight(data, theme); + const qualityColor = networkQualityHighlight(data, theme); + + return ( + @@ -227,10 +229,8 @@ const NetworkStatus = () => { )} - ); - }, [data, error, loadData, LL, theme]); - - return {content}; + + ); }; export default NetworkStatus; diff --git a/interface/src/app/status/Status.tsx b/interface/src/app/status/Status.tsx index 1d87a5d4d..e5a08e1f5 100644 --- a/interface/src/app/status/Status.tsx +++ b/interface/src/app/status/Status.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useContext, useState } from 'react'; import { toast } from 'react-toastify'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; @@ -43,7 +43,6 @@ import { formatDateTime } from 'utils/time'; import SystemMonitor from './SystemMonitor'; -// Pure functions moved outside component to avoid recreation on each render const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatDurationSec = ( @@ -97,10 +96,8 @@ const SystemStatus = () => { const theme = useTheme(); - // Memoize derived status values to avoid recalculation on every render - const busStatus = useMemo(() => { + const busStatus = (() => { if (!data) return 'EMS state unknown'; - switch (data.bus_status) { case busConnectionStatus.BUS_STATUS_CONNECTED: return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; @@ -111,12 +108,10 @@ const SystemStatus = () => { default: return 'EMS state unknown'; } - }, [data?.bus_status, data?.bus_uptime, LL]); + })(); - // Memoize derived status values to avoid recalculation on every render - const systemStatus = useMemo(() => { + const systemStatus = (() => { if (!data) return '??'; - switch (data.status) { case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: @@ -129,14 +124,12 @@ const SystemStatus = () => { case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: return LL.GPIO_OF(LL.FAILED(0)); default: - // SystemStatusCodes.SYSTEM_STATUS_NORMAL return 'OK'; } - }, [data?.status, LL]); + })(); - const busStatusHighlight = useMemo(() => { + const busStatusHighlight = (() => { if (!data) return theme.palette.warning.main; - switch (data.bus_status) { case busConnectionStatus.BUS_STATUS_TX_ERRORS: return theme.palette.warning.main; @@ -147,11 +140,10 @@ const SystemStatus = () => { default: return theme.palette.warning.main; } - }, [data?.bus_status, theme.palette]); + })(); - const ntpStatus = useMemo(() => { + const ntpStatus = (() => { if (!data) return LL.UNKNOWN(); - switch (data.ntp_status) { case NTPSyncStatus.NTP_DISABLED: return LL.NOT_ENABLED(); @@ -164,11 +156,10 @@ const SystemStatus = () => { default: return LL.UNKNOWN(); } - }, [data?.ntp_status, data?.ntp_time, LL]); + })(); - const ntpStatusHighlight = useMemo(() => { + const ntpStatusHighlight = (() => { if (!data) return theme.palette.error.main; - switch (data.ntp_status) { case NTPSyncStatus.NTP_DISABLED: return theme.palette.info.main; @@ -179,11 +170,10 @@ const SystemStatus = () => { default: return theme.palette.error.main; } - }, [data?.ntp_status, theme.palette]); + })(); - const networkStatusHighlight = useMemo(() => { + const networkStatusHighlight = (() => { if (!data) return theme.palette.warning.main; - switch (data.network_status) { case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: @@ -198,11 +188,10 @@ const SystemStatus = () => { default: return theme.palette.warning.main; } - }, [data?.network_status, theme.palette]); + })(); - const networkStatus = useMemo(() => { + const networkStatus = (() => { if (!data) return LL.UNKNOWN(); - switch (data.network_status) { case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: return LL.INACTIVE(1); @@ -223,15 +212,12 @@ const SystemStatus = () => { default: return LL.UNKNOWN(); } - }, [data?.network_status, data?.wifi_rssi, LL]); + })(); - const activeHighlight = useCallback( - (value: boolean) => - value ? theme.palette.success.main : theme.palette.info.main, - [theme.palette] - ); + const activeHighlight = (value: boolean) => + value ? theme.palette.success.main : theme.palette.info.main; - const doRestart = useCallback(async () => { + const doRestart = async () => { setConfirmRestart(false); setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( @@ -239,14 +225,123 @@ const SystemStatus = () => { toast.error(error.message); } ); - }, [sendAPI]); + }; - const handleCloseRestartDialog = useCallback(() => { - setConfirmRestart(false); - }, []); + const handleCloseRestartDialog = () => setConfirmRestart(false); + + if (restarting) { + return ; + } + + if (!data || !LL) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + {me.admin && ( + + )} + + + + + + + + + + + + + + + + - const renderRestartDialog = useMemo( - () => ( { - ), - [confirmRestart, handleCloseRestartDialog, doRestart, LL] + ); - - // Memoize formatted values - const firmwareVersion = useMemo( - () => `v${data?.emsesp_version || ''}`, - [data?.emsesp_version] - ); - - const uptimeText = useMemo( - () => (data ? formatDurationSec(data.uptime, LL) : ''), - [data?.uptime, LL] - ); - - const freeMemoryText = useMemo( - () => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''), - [data?.free_heap, LL] - ); - - const networkIcon = useMemo( - () => - data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED - ? WifiIcon - : RouterIcon, - [data?.network_status] - ); - - const mqttStatusText = useMemo( - () => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)), - [data?.mqtt_status, LL] - ); - - const apStatusText = useMemo( - () => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)), - [data?.ap_status, LL] - ); - - const handleRestartClick = useCallback(() => { - setConfirmRestart(true); - }, []); - - const content = useMemo(() => { - if (!data || !LL) { - return ; - } - - return ( - <> - - - - - - - - - - - {me.admin && ( - - )} - - - - - - - - - - - - - - - - - - {renderRestartDialog} - - ); - }, [ - data, - LL, - firmwareVersion, - uptimeText, - freeMemoryText, - networkIcon, - mqttStatusText, - apStatusText, - busStatus, - busStatusHighlight, - networkStatusHighlight, - networkStatus, - ntpStatusHighlight, - ntpStatus, - activeHighlight, - me.admin, - handleRestartClick, - error, - loadData, - renderRestartDialog - ]); - - return restarting ? : {content}; }; export default SystemStatus; diff --git a/interface/src/app/status/SystemLog.tsx b/interface/src/app/status/SystemLog.tsx index 628e7a2a1..76d384135 100644 --- a/interface/src/app/status/SystemLog.tsx +++ b/interface/src/app/status/SystemLog.tsx @@ -1,11 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState -} from 'react'; +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -185,8 +178,7 @@ const SystemLog = () => { }; }, [data]); // Recalculate when data changes (in case layout shifts) - // Memoize message handler to avoid recreating on every render - const handleLogMessage = useCallback((message: { data: string }) => { + const handleLogMessage = (message: { data: string }) => { const rawData = message.data; const logentry = JSON.parse(rawData) as LogEntry; setLogEntries((log) => { @@ -200,7 +192,7 @@ const SystemLog = () => { const newLog = [...log, logentry]; return newLog; }); - }, []); + }; useSSE(fetchLogES, { immediate: true, @@ -211,7 +203,7 @@ const SystemLog = () => { toast.error('No connection to Log service'); }); - const onDownload = useCallback(() => { + const onDownload = () => { const result = logEntries .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .join('\n'); @@ -225,11 +217,11 @@ const SystemLog = () => { document.body.appendChild(a); a.click(); document.body.removeChild(a); - }, [logEntries]); + }; - const saveSettings = useCallback(async () => { + const saveSettings = async () => { await saveData(); - }, [saveData]); + }; // handle scrolling - optimized to only scroll when needed const ref = useRef(null); @@ -246,7 +238,7 @@ const SystemLog = () => { } }, [logEntries.length, autoscroll]); - const sendReadCommand = useCallback(() => { + const sendReadCommand = () => { if (readValue === '') { setReadOpen(!readOpen); return; @@ -257,7 +249,7 @@ const SystemLog = () => { setReadOpen(false); setReadValue(''); } - }, [readValue, readOpen, send]); + }; const content = () => { if (!data) { diff --git a/interface/src/app/status/SystemMonitor.tsx b/interface/src/app/status/SystemMonitor.tsx index 4f4423be7..b6fa12d1f 100644 --- a/interface/src/app/status/SystemMonitor.tsx +++ b/interface/src/app/status/SystemMonitor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import { Box, Button, Typography } from '@mui/material'; @@ -57,39 +57,31 @@ const SystemMonitor = () => { void send(); }, 1000); // check every 1 second - const { statusMessage, isUploading, progressValue } = useMemo(() => { - const status = data?.status; + const status = data?.status; - const message = - status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING - ? LL.WAIT_FIRMWARE() - : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART - ? LL.APPLICATION_RESTARTING() - : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL - ? LL.RESTARTING_PRE() - : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD - ? 'Upload Failed' - : LL.RESTARTING_POST(); + const statusMessage = + status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING + ? LL.WAIT_FIRMWARE() + : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART + ? LL.APPLICATION_RESTARTING() + : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL + ? LL.RESTARTING_PRE() + : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD + ? 'Upload Failed' + : LL.RESTARTING_POST(); - const uploading = - status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; - const progress = - uploading && status - ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) - : 0; + const isUploading = + status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; + const progressValue = + isUploading && status + ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) + : 0; - return { - statusMessage: message, - isUploading: uploading, - progressValue: progress - }; - }, [data?.status, LL]); - - const onCancel = useCallback(async () => { + const onCancel = async () => { setErrorMessage(undefined); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); document.location.href = '/'; - }, [setSystemStatus]); + }; return ( )} - {version.date && ( + {version && version.date && ( void; onInstall: (url: string) => void; }) => { - const binURL = useMemo(() => { + const binURL = (() => { if (!latestVersion || !latestDevVersion) return ''; - const version = fetchDevVersion ? latestDevVersion : latestVersion; const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`; - return fetchDevVersion ? `${DEV_URL}${filename}` : `${STABLE_URL}v${version.version}/${filename}`; - }, [fetchDevVersion, latestVersion, latestDevVersion, platform]); + })(); return ( @@ -532,396 +530,340 @@ const Version = () => { toast.error(String(error.error?.message || 'An error occurred')); }); - const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]); + const platform = data ? getPlatform(data) : ''; - const otherPartitions = useMemo( - () => data?.partitions.filter((p) => p.partition !== data.partition) ?? [], - [data] - ); + const otherPartitions = + data?.partitions.filter((p) => p.partition !== data.partition) ?? []; - const setPartitionVersionInfo = useCallback( - (partition: string) => { - setShowVersionInfo(3); + const setPartitionVersionInfo = (partition: string) => { + setShowVersionInfo(3); + const partitionData = data?.partitions.find((p) => p.partition === partition); + if (partitionData) { + setPartitionVersion({ + version: partitionData.version, + date: partitionData.install_date ?? '' + }); + setPartition(partitionData.partition); + setFirmwareSize(partitionData.size); + } + }; - // search for the partition in the data.partitions array - const partitionData = data?.partitions.find((p) => p.partition === partition); - if (partitionData) { - setPartitionVersion({ - version: partitionData.version, - date: partitionData.install_date ?? '' - }); - setPartition(partitionData.partition); - setFirmwareSize(partitionData.size); - } - }, - [data] - ); - - const doRestart = useCallback(async () => { + const doRestart = async () => { await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); setRestarting(true); - }, [sendAPI]); + }; - const installFirmwareURL = useCallback( - async (url: string) => { - await sendUploadURL(url).catch((error: Error) => { - toast.error(error.message); - }); - await doRestart(); - }, - [sendUploadURL, doRestart] - ); + const installFirmwareURL = async (url: string) => { + await sendUploadURL(url).catch((error: Error) => { + toast.error(error.message); + }); + await doRestart(); + }; - const installPartitionFirmware = useCallback( - async (partition: string) => { - await sendSetPartition(partition).catch((error: Error) => { - toast.error(error.message); - }); - setRestarting(true); - }, - [sendSetPartition] - ); + const installPartitionFirmware = async (partition: string) => { + await sendSetPartition(partition).catch((error: Error) => { + toast.error(error.message); + }); + setRestarting(true); + }; - const showPartitionDialog = useCallback( - (version: string, partition: string, install_date: string) => { - setOpenInstallPartitionDialog(true); - setPartitionVersion({ version: version, date: install_date }); - setPartition(partition); - }, - [] - ); + const showPartitionDialog = ( + version: string, + partition: string, + install_date: string + ) => { + setOpenInstallPartitionDialog(true); + setPartitionVersion({ version: version, date: install_date }); + setPartition(partition); + }; - const showFirmwareDialog = useCallback( - (useDevVersion: boolean) => { - setFetchDevVersion(useDevVersion); - void checkUpgradeImportantMessages( - useDevVersion ? latestDevVersion?.version : latestVersion?.version - ); - setOpenInstallDialog(true); - }, - [latestDevVersion, latestVersion, fetchDevVersion] - ); + const showFirmwareDialog = (useDevVersion: boolean) => { + setFetchDevVersion(useDevVersion); + const targetVersion = useDevVersion + ? latestDevVersion?.version + : latestVersion?.version; + if (targetVersion) { + void checkUpgradeImportantMessages(targetVersion); + } + setOpenInstallDialog(true); + }; - const closeInstallDialog = useCallback(() => { - setOpenInstallDialog(false); - }, []); + const closeInstallDialog = () => setOpenInstallDialog(false); + const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false); - const closeInstallPartitionDialog = useCallback(() => { - setOpenInstallPartitionDialog(false); - }, []); - - const handleVersionInfoClose = useCallback(() => { + const handleVersionInfoClose = () => { setShowVersionInfo(0); setPartitionVersion(undefined); setPartition(''); - }, []); + }; useLayoutTitle('EMS-ESP Firmware'); - const showButtons = useCallback( - (showingDev: boolean) => { - const choice = showingDev - ? !usingDevVersion - ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) - : devUpgradeAvailable - ? LL.UPDATE_AVAILABLE() - : undefined - : usingDevVersion - ? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) - : stableUpgradeAvailable - ? LL.UPDATE_AVAILABLE() - : undefined; - - if (!choice) { - return ( - <> - - - {LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} - - - - ); - } - - if (!me.admin) return null; + const showButtons = (showingDev: boolean) => { + const choice = showingDev + ? !usingDevVersion + ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) + : devUpgradeAvailable + ? LL.UPDATE_AVAILABLE() + : undefined + : usingDevVersion + ? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) + : stableUpgradeAvailable + ? LL.UPDATE_AVAILABLE() + : undefined; + if (!choice) { return ( - + <> + + + {LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} + + + ); - }, - [ - usingDevVersion, - devUpgradeAvailable, - stableUpgradeAvailable, - me.admin, - LL, - showFirmwareDialog - ] - ); - - const content = useMemo(() => { - if (!data) { - return ; } + if (!me.admin) return null; + return ( - <> - - - {LL.THIS_VERSION()} - + + ); + }; - - - {LL.VERSION()} - - - - {data.emsesp_version} - {data.build_flags && ( - -   ({data.build_flags}) - - )} - setPartitionVersionInfo(data.partition)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - - + if (restarting) { + return ; + } - - {LL.PLATFORM()} - - - - {platform} + if (!data) { + return ( + + + + ); + } + + return ( + + + + {LL.THIS_VERSION()} + + + + + {LL.VERSION()} + + + + {data.emsesp_version} + {data.build_flags && ( -   ( - {data.psram ? ( - - ) : ( - - )} - PSRAM) +   ({data.build_flags}) - - + )} + setPartitionVersionInfo(data.partition)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + - {internetLive ? ( - <> - - {LL.AVAILABLE_VERSION()} - - - - {otherPartitions.length > 0 && data.developer_mode && ( - <> - - - {LL.STORED_VERSIONS()} - - - - {otherPartitions.map((partition) => ( - - {partition.version} - - setPartitionVersionInfo(partition.partition) - } - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - - - ))} - - + + {LL.PLATFORM()} + + + + {platform} + +   ( + {data.psram ? ( + + ) : ( + )} - - {LL.STABLE()} - - - - {latestVersion?.version} - setShowVersionInfo(1)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(false)} - - - - - {LL.DEVELOPMENT()} - - - - {latestDevVersion?.version} - setShowVersionInfo(2)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(true)} - - - - - ) : ( - - - {LL.INTERNET_CONNECTION_REQUIRED()} - - )} - {me.admin && ( - <> - - - - - {LL.UPLOAD()} + PSRAM) - - - )} - - - ); - }, [ - data, - error, - loadData, - LL, - platform, - internetLive, - latestVersion, - latestDevVersion, - showVersionInfo, - locale, - openInstallDialog, - fetchDevVersion, - downloadOnly, - me.admin, - showButtons, - handleVersionInfoClose, - closeInstallDialog, - installFirmwareURL, - doRestart, - otherPartitions, - setPartitionVersionInfo, - showPartitionDialog, - partitionVersion, - partition, - firmwareSize, - closeInstallPartitionDialog, - installPartitionFirmware - ]); +
+ + - return restarting ? : {content}; + {internetLive ? ( + <> + + {LL.AVAILABLE_VERSION()} + + + + {otherPartitions.length > 0 && data.developer_mode && ( + <> + + {LL.STORED_VERSIONS()} + + + {otherPartitions.map((partition) => ( + + {partition.version} + + setPartitionVersionInfo(partition.partition) + } + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + + + ))} + + + )} + + {LL.STABLE()} + + + + {latestVersion?.version} + setShowVersionInfo(1)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + {showButtons(false)} + + + + + {LL.DEVELOPMENT()} + + + + {latestDevVersion?.version} + setShowVersionInfo(2)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + {showButtons(true)} + + + + + ) : ( + + + {LL.INTERNET_CONNECTION_REQUIRED()} + + )} + {me.admin && ( + <> + + + + + {LL.UPLOAD()} + + + + )} + +
+ ); }; export default memo(Version); diff --git a/interface/src/components/MessageBox.tsx b/interface/src/components/MessageBox.tsx index aa17e6215..ae7d8e177 100644 --- a/interface/src/components/MessageBox.tsx +++ b/interface/src/components/MessageBox.tsx @@ -1,4 +1,4 @@ -import { type FC, type PropsWithChildren, memo, useMemo } from 'react'; +import { type FC, type PropsWithChildren, memo } from 'react'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import ErrorIcon from '@mui/icons-material/Error'; @@ -38,18 +38,17 @@ const MessageBox: FC> = ({ }) => { const theme = useTheme(); - const { Icon, backgroundColor } = useMemo(() => { - const Icon = LEVEL_ICONS[level]; - const palettePath = LEVEL_PALETTE_PATHS[level]; - const [key, shade] = palettePath.split('.') as [ - keyof typeof theme.palette, - string - ]; - const paletteKey = theme.palette[key] as unknown as Record; - const backgroundColor = paletteKey[shade]; - - return { Icon, backgroundColor }; - }, [level, theme]); + const Icon = LEVEL_ICONS[level]; + const palettePath = LEVEL_PALETTE_PATHS[level]; + const [paletteKeyName, shade] = palettePath.split('.') as [ + keyof typeof theme.palette, + string + ]; + const paletteKey = theme.palette[paletteKeyName] as unknown as Record< + string, + string + >; + const backgroundColor = paletteKey[shade]; return ( { const { setLocale, locale, LL } = useContext(I18nContext); - const onLocaleSelected: ChangeEventHandler = useCallback( - async ({ target }) => { - const loc = target.value as Locales; - localStorage.setItem('lang', loc); - await loadLocaleAsync(loc); - setLocale(loc); - }, - [setLocale] - ); - - // Memoize menu items to prevent recreation on every render - const menuItems = useMemo( - () => - LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( - - {label} -  {label} - - )), - [] - ); + const onLocaleSelected: ChangeEventHandler = async ({ + target + }) => { + const loc = target.value as Locales; + localStorage.setItem('lang', loc); + await loadLocaleAsync(loc); + setLocale(loc); + }; return ( { size="small" select > - {menuItems} + {LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( + + {label} +  {label} + + ))} ); }; diff --git a/interface/src/components/inputs/ValidatedPasswordField.tsx b/interface/src/components/inputs/ValidatedPasswordField.tsx index 44ab69995..5529e7d72 100644 --- a/interface/src/components/inputs/ValidatedPasswordField.tsx +++ b/interface/src/components/inputs/ValidatedPasswordField.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react'; +import { memo, useState } from 'react'; import type { FC } from 'react'; import VisibilityIcon from '@mui/icons-material/Visibility'; @@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit; const ValidatedPasswordField: FC = ({ ...props }) => { const [showPassword, setShowPassword] = useState(false); - const togglePasswordVisibility = useCallback(() => { + const togglePasswordVisibility = () => { setShowPassword((prev) => !prev); - }, []); + }; return ( = ({ children }) => { const [title, setTitle] = useState(PROJECT_NAME); const { pathname } = useLocation(); - // Memoize drawer toggle handler to prevent unnecessary re-renders const handleDrawerToggle = useCallback(() => { setMobileOpen((prev) => !prev); }, []); @@ -28,7 +27,6 @@ const LayoutComponent: FC = ({ children }) => { setMobileOpen(false); }, [pathname]); - // Memoize context value to prevent unnecessary re-renders const contextValue = useMemo(() => ({ title, setTitle }), [title]); return ( diff --git a/interface/src/components/layout/LayoutAppBar.tsx b/interface/src/components/layout/LayoutAppBar.tsx index 6bea11462..9ee84303a 100644 --- a/interface/src/components/layout/LayoutAppBar.tsx +++ b/interface/src/components/layout/LayoutAppBar.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; @@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => const navigate = useNavigate(); const location = useLocation(); - const pathnames = useMemo( - () => location.pathname.split('/').filter((x) => x), - [location.pathname] - ); + const pathnames = location.pathname.split('/').filter((x) => x); - const handleBackClick = useCallback(() => { + const handleBackClick = () => { void navigate('/' + pathnames[0]); - }, [navigate, pathnames]); + }; return ( diff --git a/interface/src/components/layout/LayoutDrawer.tsx b/interface/src/components/layout/LayoutDrawer.tsx index c89fb396b..89237615f 100644 --- a/interface/src/components/layout/LayoutDrawer.tsx +++ b/interface/src/components/layout/LayoutDrawer.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; @@ -24,22 +24,18 @@ interface LayoutDrawerProps { } const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { - // Memoize drawer content to prevent unnecessary re-renders - const drawer = useMemo( - () => ( - <> - - - - {PROJECT_NAME} - - - - - - - ), - [] + const drawer = ( + <> + + + + {PROJECT_NAME} + + + + + + ); return ( diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx index 1fa3830b5..48ce3e626 100644 --- a/interface/src/components/layout/LayoutMenu.tsx +++ b/interface/src/components/layout/LayoutMenu.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext, useState } from 'react'; +import { memo, useContext, useState } from 'react'; import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AssessmentIcon from '@mui/icons-material/Assessment'; @@ -22,9 +22,9 @@ const LayoutMenuComponent = () => { const { LL } = useI18nContext(); const [menuOpen, setMenuOpen] = useState(true); - const handleMenuToggle = useCallback(() => { + const handleMenuToggle = () => { setMenuOpen((prev) => !prev); - }, []); + }; return ( <> diff --git a/interface/src/components/layout/LayoutMenuItem.tsx b/interface/src/components/layout/LayoutMenuItem.tsx index a0dbc8354..2ec78433f 100644 --- a/interface/src/components/layout/LayoutMenuItem.tsx +++ b/interface/src/components/layout/LayoutMenuItem.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Link, useLocation } from 'react-router'; import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; @@ -21,50 +21,40 @@ const LayoutMenuItemComponent = ({ }: LayoutMenuItemProps) => { const { pathname } = useLocation(); - const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]); + const selected = routeMatches(to, pathname); - // Memoize dynamic styles based on selected state - const buttonStyles: SxProps = useMemo( - () => ({ - transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', - backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', - borderRadius: '8px', - margin: '2px 8px', - '&:hover': { - backgroundColor: 'rgba(68, 82, 211, 0.39)' - }, - '&::before': { - content: '""', - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: selected ? '3px' : '0px', - backgroundColor: '#90caf9', - transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' - } - }), - [selected] - ); + const buttonStyles: SxProps = { + transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', + borderRadius: '8px', + margin: '2px 8px', + '&:hover': { + backgroundColor: 'rgba(68, 82, 211, 0.39)' + }, + '&::before': { + content: '""', + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: selected ? '3px' : '0px', + backgroundColor: '#90caf9', + transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' + } + }; - const iconStyles: SxProps = useMemo( - () => ({ - color: selected ? '#90caf9' : '#9e9e9e', - transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', - transform: selected ? 'scale(1.1)' : 'scale(1)', - transitionProperty: 'color, transform' - }), - [selected] - ); + const iconStyles: SxProps = { + color: selected ? '#90caf9' : '#9e9e9e', + transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transform: selected ? 'scale(1.1)' : 'scale(1)', + transitionProperty: 'color, transform' + }; - const textStyles: SxProps = useMemo( - () => ({ - color: selected ? '#90caf9' : '#f5f5f5', - transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', - transitionProperty: 'color, font-weight' - }), - [selected] - ); + const textStyles: SxProps = { + color: selected ? '#90caf9' : '#f5f5f5', + transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transitionProperty: 'color, font-weight' + }; return ( { const { LL } = useI18nContext(); - const handleReset = useCallback(() => { + const handleReset = () => { blocker.reset?.(); - }, [blocker]); + }; - const handleProceed = useCallback(() => { + const handleProceed = () => { blocker.proceed?.(); - }, [blocker]); + }; return ( diff --git a/interface/src/components/routing/RouterTabs.tsx b/interface/src/components/routing/RouterTabs.tsx index 9a3d7d31e..f8c6b2a09 100644 --- a/interface/src/components/routing/RouterTabs.tsx +++ b/interface/src/components/routing/RouterTabs.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import type { FC } from 'react'; import { useNavigate } from 'react-router'; @@ -16,12 +16,9 @@ const RouterTabs: FC = ({ value, children }) => { const theme = useTheme(); const smallDown = useMediaQuery(theme.breakpoints.down('sm')); - const handleTabChange = useCallback( - (_event: unknown, path: string) => { - void navigate(path); - }, - [navigate] - ); + const handleTabChange = (_event: unknown, path: string) => { + void navigate(path); + }; return ( = ({ children }) => { void refresh(); }, [refresh]); - // cache object to prevent re-renders const obj = useMemo( () => ({ signIn, diff --git a/interface/src/utils/usePersistState.ts b/interface/src/utils/usePersistState.ts index 2a627a852..43b80afc8 100644 --- a/interface/src/utils/usePersistState.ts +++ b/interface/src/utils/usePersistState.ts @@ -1,34 +1,27 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; export const usePersistState = ( initial_value: T, id: string ): [T, (new_state: T) => void] => { - // Set initial value - only computed once on mount - const _initial_value = useMemo(() => { + const [state, setState] = useState(() => { try { - const local_storage_value_str = localStorage.getItem(`state:${id}`); - // If there is a value stored in localStorage, use that - if (local_storage_value_str) { - return JSON.parse(local_storage_value_str) as T; + const stored = localStorage.getItem(`state:${id}`); + if (stored) { + return JSON.parse(stored) as T; } } catch (error) { - // If parsing fails, fall back to initial_value console.warn( `Failed to parse localStorage value for key "state:${id}"`, error ); } - // Otherwise use initial_value that was passed to the function return initial_value; - }, [id]); // initial_value intentionally omitted - only read on first mount - - const [state, setState] = useState(_initial_value); + }); useEffect(() => { try { - const state_str = JSON.stringify(state); - localStorage.setItem(`state:${id}`, state_str); + localStorage.setItem(`state:${id}`, JSON.stringify(state)); } catch (error) { console.warn( `Failed to save state to localStorage for key "state:${id}"`, From 6473c55317973179be5d04a4ffa45f83c3b7e116 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 18:11:48 +0200 Subject: [PATCH 22/33] don't force an update on each request --- src/web/WebStatusService.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 31583585c..bbd636b51 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -314,8 +314,6 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) { // 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) { - schedule_versions_refresh(); // force a refresh - FirmwareVersion current_version(current_version_s); bool is_dev = current_version.prerelease().find("dev") != std::string::npos; From 6e76bcc9afd2d9a0a0200bd9b1d9d4fae416cc88 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 27 Apr 2026 18:12:05 +0200 Subject: [PATCH 23/33] show badge if there is an update available, which is cached --- interface/src/app/settings/Settings.tsx | 74 +++++++++++++- interface/src/app/status/Status.tsx | 97 +----------------- interface/src/app/status/Version.tsx | 98 ++++++++----------- .../src/components/layout/LayoutMenu.tsx | 5 +- .../src/components/layout/LayoutMenuItem.tsx | 20 +++- .../src/components/layout/ListMenuItem.tsx | 36 ++++++- .../authentication/Authentication.tsx | 32 +++++- .../src/contexts/authentication/context.ts | 4 +- interface/src/types/index.ts | 1 + interface/src/types/versions.ts | 23 +++++ mock-api/restServer.ts | 18 +++- 11 files changed, 240 insertions(+), 168 deletions(-) create mode 100644 interface/src/types/versions.ts diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index 7a31f66e7..9aab496b7 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -1,10 +1,13 @@ -import { useState } from 'react'; +import { useContext, useState } from 'react'; +import { toast } from 'react-toastify'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import BuildIcon from '@mui/icons-material/Build'; import CancelIcon from '@mui/icons-material/Cancel'; import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import ImportExportIcon from '@mui/icons-material/ImportExport'; import LockIcon from '@mui/icons-material/Lock'; +import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; @@ -28,15 +31,23 @@ import { useRequest } from 'alova/client'; import type { APIcall } from 'app/main/types'; import { SectionContent, useLayoutTitle } from 'components'; import ListMenuItem from 'components/layout/ListMenuItem'; +import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import SystemMonitor from '../status/SystemMonitor'; const Settings = () => { const { LL } = useI18nContext(); + const { versions } = useContext(AuthenticatedContext); useLayoutTitle(LL.SETTINGS(0)); + const firmwareText = versions?.current?.version + ? `v${versions.current.version}` + : ''; + const upgradeAvailable = versions?.current?.upgradeable ?? false; + const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); + const [confirmRestart, setConfirmRestart] = useState(false); const [restarting, setRestarting] = useState(); const { send: sendAPI } = useRequest((data: APIcall) => API(data), { @@ -50,6 +61,16 @@ const Settings = () => { }); }; + const doRestart = async () => { + setConfirmRestart(false); + setRestarting(true); + await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( + (error: Error) => { + toast.error(error.message); + } + ); + }; + const handleFactoryResetClose = () => { setConfirmFactoryReset(false); }; @@ -58,6 +79,14 @@ const Settings = () => { setConfirmFactoryReset(true); }; + const handleRestartClose = () => { + setConfirmRestart(false); + }; + + const handleRestartClick = () => { + setConfirmRestart(true); + }; + if (restarting) { return ; } @@ -65,6 +94,15 @@ const Settings = () => { return ( + + { + + {LL.RESTART()} + {LL.RESTART_CONFIRM()} + + + + + + { display: 'flex', justifyContent: 'flex-end', flexWrap: 'nowrap', - whiteSpace: 'nowrap' + whiteSpace: 'nowrap', + gap: 1 }} > + - )} { to="/status/log" /> - - - {LL.RESTART()} - {LL.RESTART_CONFIRM()} - - - - -
); }; diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index a41539053..490566587 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -1,4 +1,4 @@ -import { memo, useContext, useState } from 'react'; +import { memo, useContext, useMemo, useState } from 'react'; import { Link } from 'react-router'; import { toast } from 'react-toastify'; @@ -40,6 +40,7 @@ import { import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import type { TranslationFunctions } from 'i18n/i18n-types'; +import type { VersionInfo } from 'types'; import { prettyDateTime } from 'utils/time'; // Constants moved outside component to avoid recreation @@ -70,26 +71,6 @@ interface VersionData { developer_mode: boolean; } -interface VersionInfo { - version: string; - date: 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 const VersionInfoDialog = memo( ({ @@ -432,10 +413,7 @@ const getPlatform = (data: VersionData): string => { const Version = () => { const { LL, locale } = useI18nContext(); - const { me } = useContext(AuthenticatedContext); - - const [latestVersion, setLatestVersion] = useState(); - const [latestDevVersion, setLatestDevVersion] = useState(); + const { me, versions } = useContext(AuthenticatedContext); const [restarting, setRestarting] = useState(false); const [openInstallDialog, setOpenInstallDialog] = useState(false); @@ -447,16 +425,30 @@ const Version = () => { const [openInstallPartitionDialog, setOpenInstallPartitionDialog] = useState(false); - const [usingDevVersion, setUsingDevVersion] = useState(false); const [fetchDevVersion, setFetchDevVersion] = useState(false); - const [devUpgradeAvailable, setDevUpgradeAvailable] = useState(false); - const [stableUpgradeAvailable, setStableUpgradeAvailable] = - useState(false); - const [internetLive, setInternetLive] = useState(false); const [downloadOnly, setDownloadOnly] = useState(false); const [showVersionInfo, setShowVersionInfo] = useState(0); // 1 = stable, 2 = dev, 3 = partition const [firmwareSize, setFirmwareSize] = useState(0); + const latestVersion = useMemo( + () => + versions?.stable + ? { version: versions.stable.version, date: versions.stable.date } + : undefined, + [versions?.stable] + ); + const latestDevVersion = useMemo( + () => + versions?.dev + ? { version: versions.dev.version, date: versions.dev.date } + : undefined, + [versions?.dev] + ); + const usingDevVersion = versions?.current?.type === 'dev'; + const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false; + const devUpgradeAvailable = versions?.dev?.upgradeable ?? false; + const internetLive = Boolean(versions?.stable || versions?.dev); + const { send: sendSetPartition } = useRequest( (partition: string) => callAction({ action: 'setPartition', param: partition }), { immediate: false } @@ -480,32 +472,6 @@ const Version = () => { { immediate: false } ); - // fetch latest stable/dev versions via the device. The C++ code makes a call to emsesp.org/versions.json itself - // if the device has no internet, stable/dev are omitted and the internetLive flag is set to false - 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 }); @@ -640,15 +606,33 @@ const Version = () => { if (!me.admin) return null; + const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE(); + return ( ); }; diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx index 48ce3e626..5d52921a6 100644 --- a/interface/src/components/layout/LayoutMenu.tsx +++ b/interface/src/components/layout/LayoutMenu.tsx @@ -18,10 +18,12 @@ import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; const LayoutMenuComponent = () => { - const { me } = useContext(AuthenticatedContext); + const { me, versions } = useContext(AuthenticatedContext); const { LL } = useI18nContext(); const [menuOpen, setMenuOpen] = useState(true); + const upgradeAvailable = versions?.current?.upgradeable ?? false; + const handleMenuToggle = () => { setMenuOpen((prev) => !prev); }; @@ -105,6 +107,7 @@ const LayoutMenuComponent = () => { label={LL.SETTINGS(0)} disabled={!me.admin} to="/settings" + badge={upgradeAvailable} /> diff --git a/interface/src/components/layout/LayoutMenuItem.tsx b/interface/src/components/layout/LayoutMenuItem.tsx index 2ec78433f..5cdbf8c58 100644 --- a/interface/src/components/layout/LayoutMenuItem.tsx +++ b/interface/src/components/layout/LayoutMenuItem.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { Link, useLocation } from 'react-router'; -import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; +import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import type { SvgIconProps, SxProps, Theme } from '@mui/material'; import { routeMatches } from 'utils'; @@ -11,13 +11,15 @@ interface LayoutMenuItemProps { label: string; to: string; disabled?: boolean; + badge?: boolean; } const LayoutMenuItemComponent = ({ icon: Icon, label, to, - disabled + disabled, + badge }: LayoutMenuItemProps) => { const { pathname } = useLocation(); @@ -68,6 +70,20 @@ const LayoutMenuItemComponent = ({ {label} + {badge && ( + + )} ); }; diff --git a/interface/src/components/layout/ListMenuItem.tsx b/interface/src/components/layout/ListMenuItem.tsx index db92093de..c78d02ac2 100644 --- a/interface/src/components/layout/ListMenuItem.tsx +++ b/interface/src/components/layout/ListMenuItem.tsx @@ -5,6 +5,7 @@ import { Link } from 'react-router'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import { Avatar, + Box, ListItem, ListItemAvatar, ListItemButton, @@ -20,6 +21,7 @@ interface ListMenuItemProps { text: string; to?: string; disabled?: boolean; + badge?: boolean; } const iconStyles: CSSProperties = { @@ -28,15 +30,40 @@ const iconStyles: CSSProperties = { verticalAlign: 'middle' }; +const Badge = () => ( + +); + const RenderIcon = memo( - ({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => ( + ({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => ( <> - + + {label} + {badge && } + + } + secondary={text} + /> ) ); @@ -47,7 +74,8 @@ const LayoutMenuItem = ({ label, text, to, - disabled + disabled, + badge }: ListMenuItemProps) => ( <> {to && !disabled ? ( @@ -65,6 +93,7 @@ const LayoutMenuItem = ({ {...(bgcolor && { bgcolor })} label={label} text={text} + {...(badge && { badge })} /> @@ -75,6 +104,7 @@ const LayoutMenuItem = ({ {...(bgcolor && { bgcolor })} label={label} text={text} + {...(badge && { badge })} /> )} diff --git a/interface/src/contexts/authentication/Authentication.tsx b/interface/src/contexts/authentication/Authentication.tsx index 84b41e701..669f06e6a 100644 --- a/interface/src/contexts/authentication/Authentication.tsx +++ b/interface/src/contexts/authentication/Authentication.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react'; import { redirect } from 'react-router'; import { toast } from 'react-toastify'; +import { callAction } from 'api/app'; import { ACCESS_TOKEN } from 'api/endpoints'; import * as AuthenticationApi from 'components/routing/authentication'; @@ -10,7 +11,7 @@ import { useRequest } from 'alova/client'; import { LoadingSpinner } from 'components'; import { verifyAuthorization } from 'components/routing/authentication'; import { useI18nContext } from 'i18n/i18n-react'; -import type { Me } from 'types'; +import type { Me, VersionsResponse } from 'types'; import type { RequiredChildrenProps } from 'utils'; import { AuthenticationContext } from './context'; @@ -20,17 +21,34 @@ const Authentication: FC = ({ children }) => { const [initialized, setInitialized] = useState(false); const [me, setMe] = useState(); + const [versions, setVersions] = useState(); const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), { immediate: false }); + const { send: sendGetVersions } = useRequest( + () => callAction({ action: 'getVersions' }), + { immediate: false } + ) + .onSuccess((event) => { + setVersions(event.data as VersionsResponse); + }) + .onError(() => { + setVersions(undefined); + }); + + const refreshVersions = useCallback(async () => { + await sendGetVersions().catch(() => undefined); + }, []); + const signIn = (accessToken: string) => { try { AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken); const decodedMe = AuthenticationApi.decodeMeJWT(accessToken); setMe(decodedMe); toast.success(LL.LOGGED_IN({ name: decodedMe.username })); + void refreshVersions(); } catch { setMe(undefined); throw new Error('Failed to parse JWT'); @@ -40,6 +58,7 @@ const Authentication: FC = ({ children }) => { const signOut = (doRedirect: boolean) => { AuthenticationApi.clearAccessToken(); setMe(undefined); + setVersions(undefined); if (doRedirect) { redirect('/'); } @@ -49,8 +68,9 @@ const Authentication: FC = ({ children }) => { const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); if (accessToken) { await sendVerifyAuthorization() - .then(() => { + .then(async () => { setMe(AuthenticationApi.decodeMeJWT(accessToken)); + await refreshVersions(); setInitialized(true); }) .catch(() => { @@ -61,6 +81,8 @@ const Authentication: FC = ({ children }) => { setMe(undefined); setInitialized(true); } + // refreshVersions and sendVerifyAuthorization are stable + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -72,9 +94,11 @@ const Authentication: FC = ({ children }) => { signIn, signOut, refresh, - ...(me && { me }) + refreshVersions, + ...(me && { me }), + ...(versions && { versions }) }), - [signIn, signOut, me, refresh] + [signIn, signOut, me, refresh, refreshVersions, versions] ); if (initialized) { diff --git a/interface/src/contexts/authentication/context.ts b/interface/src/contexts/authentication/context.ts index d58b320ff..1717bfaef 100644 --- a/interface/src/contexts/authentication/context.ts +++ b/interface/src/contexts/authentication/context.ts @@ -1,12 +1,14 @@ import { createContext } from 'react'; -import type { Me } from 'types'; +import type { Me, VersionsResponse } from 'types'; export interface AuthenticationContextValue { refresh: () => Promise; signIn: (accessToken: string) => void; signOut: (redirect: boolean) => void; me?: Me; + versions?: VersionsResponse; + refreshVersions: () => Promise; } const AuthenticationContextDefaultValue = {} as AuthenticationContextValue; diff --git a/interface/src/types/index.ts b/interface/src/types/index.ts index 8c2f8760c..a4e8726d9 100644 --- a/interface/src/types/index.ts +++ b/interface/src/types/index.ts @@ -7,3 +7,4 @@ export * from './ntp'; export * from './security'; export * from './signin'; export * from './system'; +export * from './versions'; diff --git a/interface/src/types/versions.ts b/interface/src/types/versions.ts new file mode 100644 index 000000000..6f081c613 --- /dev/null +++ b/interface/src/types/versions.ts @@ -0,0 +1,23 @@ +// Types for the `getVersions` action response coming from the device. +// The device proxies the request to emsesp.org/versions.json. If the device +// is offline the `stable` and `dev` fields are omitted. + +export interface VersionInfo { + version: string; + date: string; +} + +export interface RemoteVersionInfo extends VersionInfo { + upgradeable: boolean; +} + +export interface CurrentVersionInfo extends VersionInfo { + type: 'stable' | 'dev'; + upgradeable: boolean; +} + +export interface VersionsResponse { + current: CurrentVersionInfo; + stable?: RemoteVersionInfo; + dev?: RemoteVersionInfo; +} diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index f8cb3bd15..774b92543 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -144,10 +144,10 @@ let LATEST_STABLE_VERSION = '3.8.2'; let LATEST_DEV_VERSION = '3.8.3-dev.2'; // scenarios for testing versioning -let version_test = 0; // on latest stable, or switch to dev +// let version_test = 0; // on latest stable, or switch to dev // let version_test = 1; // on latest dev, or switch back to stable // let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev -// let version_test = 3; // upgrade dev to latest, or switch to stable +let version_test = 3; // upgrade dev to latest, or switch to stable // let version_test = 4; // downgrade to an older dev, or switch back to stable switch (version_test as number) { @@ -419,15 +419,25 @@ function upgradeImportantMessages(version: string) { const MOCK_OFFLINE = false; function get_versions() { const isDev = THIS_VERSION.includes('dev'); + const currentUpgradeable = + !MOCK_OFFLINE && + (isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE); + const data: { - current: { version: string; type: 'stable' | 'dev'; date: string }; + current: { + version: string; + type: 'stable' | 'dev'; + date: string; + upgradeable: boolean; + }; 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' + date: '2026-04-25T12:00:00', + upgradeable: currentUpgradeable } }; From b3a8737a71ae6cabebf409b5ad3f086cb1f345c9 Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 28 Apr 2026 16:27:39 +0200 Subject: [PATCH 24/33] move Version from status to settings --- interface/src/AuthenticatedRouting.tsx | 4 ++-- interface/src/app/settings/Settings.tsx | 8 ++++---- interface/src/app/{status => settings}/Version.tsx | 0 3 files changed, 6 insertions(+), 6 deletions(-) rename interface/src/app/{status => settings}/Version.tsx (100%) diff --git a/interface/src/AuthenticatedRouting.tsx b/interface/src/AuthenticatedRouting.tsx index 8ace232aa..1bab479ae 100644 --- a/interface/src/AuthenticatedRouting.tsx +++ b/interface/src/AuthenticatedRouting.tsx @@ -16,6 +16,7 @@ import DownloadUpload from 'app/settings/DownloadUpload'; import MqttSettings from 'app/settings/MqttSettings'; import NTPSettings from 'app/settings/NTPSettings'; import Settings from 'app/settings/Settings'; +import Version from 'app/settings/Version'; import Network from 'app/settings/network/Network'; import Security from 'app/settings/security/Security'; import APStatus from 'app/status/APStatus'; @@ -26,7 +27,6 @@ import NTPStatus from 'app/status/NTPStatus'; import NetworkStatus from 'app/status/NetworkStatus'; import Status from 'app/status/Status'; import SystemLog from 'app/status/SystemLog'; -import Version from 'app/status/Version'; import { Layout } from 'components'; import { AuthenticatedContext } from 'contexts/authentication'; @@ -49,11 +49,11 @@ const AuthenticatedRouting = memo(() => { } /> } /> } /> - } /> {me.admin && ( <> } /> + } /> } /> } /> } /> diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index 9aab496b7..85d66f39a 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -41,10 +41,10 @@ const Settings = () => { const { versions } = useContext(AuthenticatedContext); useLayoutTitle(LL.SETTINGS(0)); - const firmwareText = versions?.current?.version - ? `v${versions.current.version}` - : ''; const upgradeAvailable = versions?.current?.upgradeable ?? false; + const firmwareText = versions?.current?.version + ? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}` + : ''; const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); const [confirmRestart, setConfirmRestart] = useState(false); @@ -99,7 +99,7 @@ const Settings = () => { bgcolor="#72caf9" label="EMS-ESP Firmware" text={firmwareText} - to="/status/version" + to="/settings/version" badge={upgradeAvailable} /> diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/settings/Version.tsx similarity index 100% rename from interface/src/app/status/Version.tsx rename to interface/src/app/settings/Version.tsx From a3f0faf02272d4c2a76cb367227e81c5edc280b1 Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 28 Apr 2026 16:27:46 +0200 Subject: [PATCH 25/33] package update --- interface/package.json | 6 +- interface/pnpm-lock.yaml | 128 +++++++++++++++++++-------------------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/interface/package.json b/interface/package.json index 8bace3c1d..bff1f9aaf 100644 --- a/interface/package.json +++ b/interface/package.json @@ -3,7 +3,7 @@ "version": "3.8.2", "description": "EMS-ESP WebUI", "homepage": "https://emsesp.org", - "author": "proddy, emsesp.org", + "author": "emsesp.org", "license": "MIT", "private": true, "type": "module", @@ -46,17 +46,17 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@preact/preset-vite": "^2.10.5", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "concurrently": "^9.2.1", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", "rollup-plugin-visualizer": "^7.0.1", "terser": "^5.46.2", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.59.1", "vite": "^8.0.10", "vite-plugin-imagemin": "^0.6.1" }, diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index e2032919d..91ccc1fac 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -103,8 +103,8 @@ importers: specifier: ^5.46.2 version: 5.46.2 typescript-eslint: - specifier: ^8.59.0 - version: 8.59.0(eslint@10.2.1)(typescript@6.0.3) + specifier: ^8.59.1 + version: 8.59.1(eslint@10.2.1)(typescript@6.0.3) vite: specifier: ^8.0.10 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) @@ -1010,63 +1010,63 @@ packages: '@types/svgo@2.6.4': resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} - '@typescript-eslint/eslint-plugin@8.59.0': - resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.0 + '@typescript-eslint/parser': ^8.59.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.0': - resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.0': - resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.59.0': - resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.59.0': - resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.0': - resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.59.0': - resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.59.0': - resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.0': - resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.59.0': - resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -3153,8 +3153,8 @@ packages: peerDependencies: typescript: '>=3.5.1' - typescript-eslint@8.59.0: - resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4124,14 +4124,14 @@ snapshots: dependencies: '@types/node': 25.6.0 - '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.1 eslint: 10.2.1 ignore: 7.0.5 natural-compare: 1.4.0 @@ -4140,41 +4140,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.0(eslint@10.2.1)(typescript@6.0.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 eslint: 10.2.1 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.59.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3) - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) + '@typescript-eslint/types': 8.59.1 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.59.0': + '@typescript-eslint/scope-manager@8.59.1': dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 - '@typescript-eslint/tsconfig-utils@8.59.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.59.0(eslint@10.2.1)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@6.0.3) debug: 4.4.3 eslint: 10.2.1 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4182,14 +4182,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.59.0': {} + '@typescript-eslint/types@8.59.1': {} - '@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.59.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.59.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3) - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/project-service': 8.59.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 @@ -4199,20 +4199,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.0(eslint@10.2.1)(typescript@6.0.3)': + '@typescript-eslint/utils@8.59.1(eslint@10.2.1)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) eslint: 10.2.1 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.59.0': + '@typescript-eslint/visitor-keys@8.59.1': dependencies: - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/types': 8.59.1 eslint-visitor-keys: 5.0.1 acorn-jsx@5.3.2(acorn@8.16.0): @@ -6333,12 +6333,12 @@ snapshots: dependencies: typescript: 6.0.3 - typescript-eslint@8.59.0(eslint@10.2.1)(typescript@6.0.3): + typescript-eslint@8.59.1(eslint@10.2.1)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3) - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@6.0.3) eslint: 10.2.1 typescript: 6.0.3 transitivePeerDependencies: From 381fcf4080bfed40e1e8786b11cdf7517a063f14 Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 28 Apr 2026 16:27:59 +0200 Subject: [PATCH 26/33] ESP32Async/ESPAsyncWebServer @ 3.11.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index c94e1383f..63ba90a67 100644 --- a/platformio.ini +++ b/platformio.ini @@ -105,7 +105,7 @@ board_build.filesystem = littlefs lib_deps = bblanchon/ArduinoJson @ 7.4.3 ESP32Async/AsyncTCP @ 3.4.10 - ESP32Async/ESPAsyncWebServer @ 3.10.3 + ESP32Async/ESPAsyncWebServer @ 3.11.0 ; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8 ; builds the web interface only, not the firmware From 53ac82520e920dda251746627ba662776ab1492f Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 28 Apr 2026 16:28:11 +0200 Subject: [PATCH 27/33] DeserializationError is enum --- src/web/WebStatusService.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index bbd636b51..43800486e 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -428,7 +428,7 @@ bool WebStatusService::refresh_versions_cache() { http.end(); if (err) { #if defined(EMSESP_DEBUG) - EMSESP::logger().debug("versions.json: parse error (%s)", err.c_str()); + EMSESP::logger().debug("versions.json: parse error"); #endif return false; } From 3b765b308ef16977f8a0df386840cd20b2826c35 Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 28 Apr 2026 17:31:50 +0200 Subject: [PATCH 28/33] remove unused useMemo --- .../src/app/main/CustomEntitiesDialog.tsx | 15 ++--- interface/src/app/main/DevicesDialog.tsx | 5 +- interface/src/app/main/SchedulerDialog.tsx | 15 ++--- .../src/app/main/SensorsAnalogDialog.tsx | 21 +++---- .../src/app/main/SensorsTemperatureDialog.tsx | 19 +++---- .../src/app/settings/security/ManageUsers.tsx | 28 ++++------ interface/src/utils/useRest.ts | 55 +++++++------------ 7 files changed, 58 insertions(+), 100 deletions(-) diff --git a/interface/src/app/main/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index 3a2cf945f..8ede016b4 100644 --- a/interface/src/app/main/CustomEntitiesDialog.tsx +++ b/interface/src/app/main/CustomEntitiesDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -68,15 +68,10 @@ const CustomEntitiesDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - // Stable handler reference so the memoized ValidatedTextField can skip re-renders - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); useEffect(() => { diff --git a/interface/src/app/main/DevicesDialog.tsx b/interface/src/app/main/DevicesDialog.tsx index 89a6fae13..706c2d608 100644 --- a/interface/src/app/main/DevicesDialog.tsx +++ b/interface/src/app/main/DevicesDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import WarningIcon from '@mui/icons-material/Warning'; @@ -52,8 +52,7 @@ const DevicesDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - // Stable handler reference so the memoized ValidatedTextField can skip re-renders - const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); + const updateFormValue = updateValue(setEditItem); useEffect(() => { if (open) { diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index ca3ff97dd..d68d9b727 100644 --- a/interface/src/app/main/SchedulerDialog.tsx +++ b/interface/src/app/main/SchedulerDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -90,15 +90,10 @@ const SchedulerDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [scheduleType, setScheduleType] = useState(); - // Stable handler reference so the memoized ValidatedTextField can skip re-renders - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); useEffect(() => { diff --git a/interface/src/app/main/SensorsAnalogDialog.tsx b/interface/src/app/main/SensorsAnalogDialog.tsx index 283a00803..483d337cc 100644 --- a/interface/src/app/main/SensorsAnalogDialog.tsx +++ b/interface/src/app/main/SensorsAnalogDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -53,18 +53,13 @@ const SensorsAnalogDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); - // Stable handler reference so the memoized ValidatedTextField can skip re-renders - const updateFormValue = useMemo( - () => - updateValue((updater) => - setEditItem( - (prev) => - updater( - prev as unknown as Record - ) as unknown as AnalogSensor - ) - ), - [setEditItem] + const updateFormValue = updateValue((updater) => + setEditItem( + (prev) => + updater( + prev as unknown as Record + ) as unknown as AnalogSensor + ) ); const isCounterOrRate = diff --git a/interface/src/app/main/SensorsTemperatureDialog.tsx b/interface/src/app/main/SensorsTemperatureDialog.tsx index 21a422a77..714156a85 100644 --- a/interface/src/app/main/SensorsTemperatureDialog.tsx +++ b/interface/src/app/main/SensorsTemperatureDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -50,17 +50,12 @@ const SensorsTemperatureDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); - // Stable handler reference so the memoized ValidatedTextField can skip re-renders - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as ( - updater: ( - prevState: Readonly> - ) => Record - ) => void - ), - [setEditItem] + const updateFormValue = updateValue( + setEditItem as unknown as ( + updater: ( + prevState: Readonly> + ) => Record + ) => void ); useEffect(() => { diff --git a/interface/src/app/settings/security/ManageUsers.tsx b/interface/src/app/settings/security/ManageUsers.tsx index 9dec88ee9..5ae9de30a 100644 --- a/interface/src/app/settings/security/ManageUsers.tsx +++ b/interface/src/app/settings/security/ManageUsers.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext, useMemo, useState } from 'react'; +import { memo, useCallback, useContext, useState } from 'react'; import { useBlocker } from 'react-router'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -55,16 +55,14 @@ const ManageUsers = () => { const blocker = useBlocker(changed !== 0); const { LL } = useI18nContext(); - const table_theme = useMemo( - () => - useTheme({ - Table: ` + const table_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -74,7 +72,7 @@ const ManageUsers = () => { border-bottom: 1px solid #565656; } `, - Row: ` + Row: ` .td { padding: 8px; border-top: 1px solid #565656; @@ -87,7 +85,7 @@ const ManageUsers = () => { background-color: #1e1e1e; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(2) { text-align: center; } @@ -95,9 +93,7 @@ const ManageUsers = () => { text-align: right; } ` - }), - [] - ); + }); const noAdminConfigured = () => !data?.users.find((u) => u.admin); @@ -122,11 +118,11 @@ const ManageUsers = () => { setUser({ ...toEdit }); }; - const cancelEditingUser = useCallback(() => { + const cancelEditingUser = () => { setUser(undefined); - }, []); + }; - const doneEditingUser = useCallback(() => { + const doneEditingUser = () => { if (user && data) { const users = [ ...data.users.filter( @@ -138,7 +134,7 @@ const ManageUsers = () => { setUser(undefined); setChanged(changed + 1); } - }, [user, data, updateDataValue, changed]); + }; const closeGenerateToken = useCallback(() => { setGeneratingToken(undefined); diff --git a/interface/src/utils/useRest.ts b/interface/src/utils/useRest.ts index 0d57abff9..791c6e4e0 100644 --- a/interface/src/utils/useRest.ts +++ b/interface/src/utils/useRest.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -54,61 +54,44 @@ export const useRest = ({ read, update }: RestRequestOptions) => { } }, [readData]); - const saveData = useCallback(async () => { + const saveData = async () => { if (!data) return; - // Reset states before saving setRestartNeeded(false); setErrorMessage(undefined); try { await writeData(data as D); - // Only update origData on successful save (dirtyFlags cleared by onSuccess handler) setOrigData(data as D); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === REBOOT_ERROR_MESSAGE) { setRestartNeeded(true); - return; // Early return - save succeeded but needs reboot + return; } - // Restore original data on validation error if (origData) { updateData({ data: origData }); } toast.error(message); setErrorMessage(message); - setDirtyFlags([]); // Clear flags so user can retry + setDirtyFlags([]); } - }, [data, writeData, origData, updateData]); + }; - return useMemo( - () => ({ - loadData, - saveData, - saving: !!saving, - updateDataValue, - data: data as D, - origData: origData as D, - dirtyFlags, - setDirtyFlags, - setOrigData, - blocker, - errorMessage, - restartNeeded - }), - [ - loadData, - saveData, - saving, - updateDataValue, - data, - origData, - dirtyFlags, - blocker, - errorMessage, - restartNeeded - ] - ); + return { + loadData, + saveData, + saving: !!saving, + updateDataValue, + data: data as D, + origData: origData as D, + dirtyFlags, + setDirtyFlags, + setOrigData, + blocker, + errorMessage, + restartNeeded + }; }; From 2cbb5ec5f201de1d17daf1519af75ccfc2b848a6 Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 28 Apr 2026 20:09:22 +0200 Subject: [PATCH 29/33] move restart button from Settings to Version page. only show Factory Reset when in developer mode --- interface/src/app/settings/Settings.tsx | 149 +----------------------- interface/src/app/settings/Version.tsx | 108 +++++++++++++++++ mock-api/restServer.ts | 3 +- 3 files changed, 112 insertions(+), 148 deletions(-) diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index 85d66f39a..7808633d4 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -1,41 +1,21 @@ -import { useContext, useState } from 'react'; -import { toast } from 'react-toastify'; +import { useContext } from 'react'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import BuildIcon from '@mui/icons-material/Build'; -import CancelIcon from '@mui/icons-material/Cancel'; import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import ImportExportIcon from '@mui/icons-material/ImportExport'; import LockIcon from '@mui/icons-material/Lock'; -import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; -import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import TuneIcon from '@mui/icons-material/Tune'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Divider, - List -} from '@mui/material'; +import { List } from '@mui/material'; -import { API } from 'api/app'; - -import { dialogStyle } from 'CustomTheme'; -import { useRequest } from 'alova/client'; -import type { APIcall } from 'app/main/types'; import { SectionContent, useLayoutTitle } from 'components'; import ListMenuItem from 'components/layout/ListMenuItem'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; -import SystemMonitor from '../status/SystemMonitor'; - const Settings = () => { const { LL } = useI18nContext(); const { versions } = useContext(AuthenticatedContext); @@ -46,51 +26,6 @@ const Settings = () => { ? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}` : ''; - const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); - const [confirmRestart, setConfirmRestart] = useState(false); - const [restarting, setRestarting] = useState(); - - const { send: sendAPI } = useRequest((data: APIcall) => API(data), { - immediate: false - }); - - const doFormat = async () => { - await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { - setRestarting(true); - setConfirmFactoryReset(false); - }); - }; - - const doRestart = async () => { - setConfirmRestart(false); - setRestarting(true); - await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( - (error: Error) => { - toast.error(error.message); - } - ); - }; - - const handleFactoryResetClose = () => { - setConfirmFactoryReset(false); - }; - - const handleFactoryResetClick = () => { - setConfirmFactoryReset(true); - }; - - const handleRestartClose = () => { - setConfirmRestart(false); - }; - - const handleRestartClick = () => { - setConfirmRestart(true); - }; - - if (restarting) { - return ; - } - return ( @@ -166,86 +101,6 @@ const Settings = () => { to="downloadUpload" /> - - - {LL.FACTORY_RESET()} - {LL.SYSTEM_FACTORY_TEXT_DIALOG()} - - - - - - - - {LL.RESTART()} - {LL.RESTART_CONFIRM()} - - - - - - - - - - - - ); }; diff --git a/interface/src/app/settings/Version.tsx b/interface/src/app/settings/Version.tsx index 490566587..ceaff599b 100644 --- a/interface/src/app/settings/Version.tsx +++ b/interface/src/app/settings/Version.tsx @@ -7,6 +7,8 @@ import CloseIcon from '@mui/icons-material/Close'; import CheckIcon from '@mui/icons-material/Done'; import DownloadIcon from '@mui/icons-material/GetApp'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; +import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import WarningIcon from '@mui/icons-material/Warning'; import { Box, @@ -15,6 +17,7 @@ import { DialogActions, DialogContent, DialogTitle, + Divider, Grid, IconButton, Table, @@ -416,6 +419,8 @@ const Version = () => { const { me, versions } = useContext(AuthenticatedContext); const [restarting, setRestarting] = useState(false); + const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); + const [confirmRestart, setConfirmRestart] = useState(false); const [openInstallDialog, setOpenInstallDialog] = useState(false); const [partitionVersion, setPartitionVersion] = useState( @@ -515,6 +520,7 @@ const Version = () => { }; const doRestart = async () => { + setConfirmRestart(false); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); @@ -523,6 +529,18 @@ const Version = () => { setRestarting(true); }; + const doFormat = async () => { + await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { + setRestarting(true); + setConfirmFactoryReset(false); + }); + }; + + const handleFactoryResetClose = () => setConfirmFactoryReset(false); + const handleFactoryResetClick = () => setConfirmFactoryReset(true); + const handleRestartClose = () => setConfirmRestart(false); + const handleRestartClick = () => setConfirmRestart(true); + const installFirmwareURL = async (url: string) => { await sendUploadURL(url).catch((error: Error) => { toast.error(error.message); @@ -846,6 +864,96 @@ const Version = () => { )} + + {me.admin && ( + <> + + {LL.FACTORY_RESET()} + {LL.SYSTEM_FACTORY_TEXT_DIALOG()} + + + + + + + + {LL.RESTART()} + {LL.RESTART_CONFIRM()} + + + + + + + {/* */} + + + + {data.developer_mode && ( + + )} + + + )}
); }; diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index 774b92543..1291bc023 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -127,7 +127,7 @@ let system_status = { } ], // partitions: [], - developer_mode: true, + developer_mode: settings.developer_mode, model: '', board: '', // model: 'BBQKees Electronics EMS Gateway E32 V2 (E32 V2.0 P3/2024011)', @@ -4602,6 +4602,7 @@ router .post(EMSESP_SETTINGS_ENDPOINT, async (request: any) => { settings = await request.json(); console.log('application settings saved', settings); + system_status.developer_mode = settings.developer_mode; return status(200); // no restart needed // return status(205); // reboot required }) From 4d3408254ef23b887a356b540d75004ccc738500 Mon Sep 17 00:00:00 2001 From: proddy Date: Wed, 29 Apr 2026 08:57:12 +0200 Subject: [PATCH 30/33] chore: stop tracking .vscode/settings.json Already listed in .gitignore but was tracked, so local edits kept showing up as pending changes. Untrack it so the ignore rule applies. Made-with: Cursor --- .vscode/settings.json | 101 ------------------------------------------ 1 file changed, 101 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 1893589ab..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "search.exclude": { - "**/.yarn": true, - "**/.pnp.*": true - }, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "eslint.validate": [ - "typescript" - ], - "eslint.codeActionsOnSave.rules": null, - "eslint.nodePath": "interface/.yarn/sdks", - "eslint.workingDirectories": ["interface"], - "prettier.prettierPath": "", - "typescript.enablePromptUseWorkspaceTsdk": true, - "files.associations": { - "*.tsx": "typescriptreact", - "*.tcc": "cpp", - "optional": "cpp", - "istream": "cpp", - "ostream": "cpp", - "ratio": "cpp", - "system_error": "cpp", - "array": "cpp", - "functional": "cpp", - "regex": "cpp", - "tuple": "cpp", - "type_traits": "cpp", - "utility": "cpp", - "string": "cpp", - "string_view": "cpp", - "atomic": "cpp", - "bitset": "cpp", - "cctype": "cpp", - "chrono": "cpp", - "clocale": "cpp", - "cmath": "cpp", - "condition_variable": "cpp", - "cstdarg": "cpp", - "cstddef": "cpp", - "cstdint": "cpp", - "cstdio": "cpp", - "cstdlib": "cpp", - "cstring": "cpp", - "ctime": "cpp", - "cwchar": "cpp", - "cwctype": "cpp", - "deque": "cpp", - "list": "cpp", - "unordered_map": "cpp", - "unordered_set": "cpp", - "vector": "cpp", - "exception": "cpp", - "algorithm": "cpp", - "iterator": "cpp", - "map": "cpp", - "memory": "cpp", - "memory_resource": "cpp", - "numeric": "cpp", - "random": "cpp", - "set": "cpp", - "fstream": "cpp", - "initializer_list": "cpp", - "iomanip": "cpp", - "iosfwd": "cpp", - "iostream": "cpp", - "limits": "cpp", - "mutex": "cpp", - "new": "cpp", - "sstream": "cpp", - "stdexcept": "cpp", - "streambuf": "cpp", - "thread": "cpp", - "cinttypes": "cpp", - "typeinfo": "cpp" - }, - "todo-tree.filtering.excludeGlobs": [ - "**/vendor/**", - "**/node_modules/**", - "**/dist/**", - "**/bower_components/**", - "**/build/**", - "**/.vscode/**", - "**/.github/**", - "**/_output/**", - "**/*.min.*", - "**/*.map", - "**/ArduinoJson/**" - ], - "cSpell.enableFiletypes": [ - "ini", - "makefile" - ], - "typescript.preferences.preferTypeOnlyAutoImports": true, - "sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json", - "sonarlint.connectedMode.project": { - "connectionId": "emsesp", - "projectKey": "emsesp_EMS-ESP32" - } - } \ No newline at end of file From fd5a39702b50033b570036cc2b0157f3179c3d37 Mon Sep 17 00:00:00 2001 From: proddy Date: Wed, 29 Apr 2026 08:57:46 +0200 Subject: [PATCH 31/33] update --- interface/pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 91ccc1fac..b50e3120b 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -1151,8 +1151,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.23: - resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} + baseline-browser-mapping@2.10.24: + resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==} engines: {node: '>=6.0.0'} hasBin: true @@ -4279,7 +4279,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.23: {} + baseline-browser-mapping@2.10.24: {} bin-build@3.0.0: dependencies: @@ -4340,7 +4340,7 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.23 + baseline-browser-mapping: 2.10.24 caniuse-lite: 1.0.30001791 electron-to-chromium: 1.5.344 node-releases: 2.0.38 From 41cd49a61c00eb81704aa39a5272bb74b7598474 Mon Sep 17 00:00:00 2001 From: proddy Date: Wed, 29 Apr 2026 08:57:53 +0200 Subject: [PATCH 32/33] remove comment --- interface/src/app/settings/Version.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/interface/src/app/settings/Version.tsx b/interface/src/app/settings/Version.tsx index ceaff599b..373133a0a 100644 --- a/interface/src/app/settings/Version.tsx +++ b/interface/src/app/settings/Version.tsx @@ -921,8 +921,6 @@ const Version = () => { - {/* */} - Date: Wed, 29 Apr 2026 08:58:04 +0200 Subject: [PATCH 33/33] use 3.9.0 as dummy latest dev version --- mock-api/restServer.ts | 2 +- src/web/WebStatusService.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index 1291bc023..22e2c72b3 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -141,7 +141,7 @@ let DEV_VERSION_IS_UPGRADEABLE: boolean; let STABLE_VERSION_IS_UPGRADEABLE: boolean; let THIS_VERSION: string; let LATEST_STABLE_VERSION = '3.8.2'; -let LATEST_DEV_VERSION = '3.8.3-dev.2'; +let LATEST_DEV_VERSION = '3.9.0-dev.1'; // scenarios for testing versioning // let version_test = 0; // on latest stable, or switch to dev diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 43800486e..ee3cadb68 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -363,9 +363,9 @@ void WebStatusService::getVersions(JsonObject root) { stable_out["upgradeable"] = FirmwareVersion("3.8.2") > current_version; JsonObject dev_out = root["dev"].to(); - dev_out["version"] = "3.8.3-dev.2"; + dev_out["version"] = "3.9.0-dev.1"; dev_out["date"] = "2026-04-25"; - dev_out["upgradeable"] = FirmwareVersion("3.8.3-dev.2") > current_version; + dev_out["upgradeable"] = FirmwareVersion("3.9.0-dev.1") > current_version; #endif }