diff --git a/.github/workflows/dev_release.yml b/.github/workflows/dev_release.yml index e68ac55b4..bb97b1863 100644 --- a/.github/workflows/dev_release.yml +++ b/.github/workflows/dev_release.yml @@ -77,3 +77,23 @@ jobs: files: | CHANGELOG_LATEST.md ./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 }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + VERSION: ${{ steps.build_info.outputs.VERSION }} + run: | + 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 6286e6ea3..93f45ad3d 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 @@ -61,3 +68,23 @@ jobs: files: | CHANGELOG.md ./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 }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + VERSION: ${{ steps.build_info.outputs.VERSION }} + run: | + 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 new file mode 100644 index 000000000..e246f84c2 --- /dev/null +++ b/.github/workflows/update_versions.yml @@ -0,0 +1,40 @@ +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 + 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 + KV_ENV="stable" + else + KV_ENV="dev" + fi + 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 diff --git a/.gitignore b/.gitignore index bb18f41fa..15466ba7c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode/c_cpp_properties.json .vscode/extensions.json .vscode/launch.json +.vscode/settings.json # c++ compiling .clang_complete @@ -63,7 +64,7 @@ words-found-verbose.txt # sonarlint compile_commands.json -# pioarduino + hybrid +# other files managed_components dependencies.lock CMakeLists.txt @@ -75,3 +76,4 @@ pnpm-lock.yaml .cache/ interface/.tsbuildinfo test/test_api/package-lock.json +.clangd 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 diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md index 7427a8c89..92abb9b40 100644 --- a/CHANGELOG_LATEST.md +++ b/CHANGELOG_LATEST.md @@ -2,7 +2,7 @@ For more details go to [emsesp.org](https://emsesp.org/). -## [3.8.2] +## [3.9.0] ## Added @@ -15,6 +15,7 @@ For more details go to [emsesp.org](https://emsesp.org/). - e-mail notification using ReadyMail Client - 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991) - full system backup and restore +- updated version check [#3047](https://github.com/emsesp/EMS-ESP32/issues/3047) ## Fixed @@ -37,3 +38,4 @@ For more details go to [emsesp.org](https://emsesp.org/). - fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017) - move http client from stack to heap - heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021) +- refactored network code into a single class [#3052](https://github.com/emsesp/EMS-ESP32/pull/3052) \ No newline at end of file diff --git a/Makefile b/Makefile index 28c81c2f8..ae75f3d73 100644 --- a/Makefile +++ b/Makefile @@ -113,7 +113,7 @@ CXX := /usr/bin/g++ CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE) CPPFLAGS += -ggdb -g3 -MMD CPPFLAGS += -flto=auto -CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum +CPPFLAGS += -Wall -Wextra -Werror -Wno-switch-enum CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics CPPFLAGS += -Os -DNDEBUG diff --git a/interface/package.json b/interface/package.json index b7108bd2a..019c20056 100644 --- a/interface/package.json +++ b/interface/package.json @@ -1,9 +1,9 @@ { "name": "EMS-ESP", - "version": "3.8.2", + "version": "3.9.0", "description": "EMS-ESP WebUI", "homepage": "https://emsesp.org", - "author": "proddy, emsesp.org", + "author": "emsesp.org", "license": "MIT", "private": true, "type": "module", @@ -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,24 +44,21 @@ "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", - "eslint": "^10.2.1", + "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", "rollup-plugin-visualizer": "^7.0.1", - "terser": "^5.46.1", - "typescript-eslint": "^8.59.0", - "vite": "^8.0.9", + "terser": "^5.46.2", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10", "vite-plugin-imagemin": "^0.6.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 d73652f4f..b19239666 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,15 +66,12 @@ 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) + version: 10.0.1(eslint@10.3.0) '@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) @@ -96,36 +84,33 @@ 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 eslint: - specifier: ^10.2.1 - version: 10.2.1 + specifier: ^10.3.0 + version: 10.3.0 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1) + version: 10.1.8(eslint@10.3.0) prettier: specifier: ^3.8.3 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) + specifier: ^8.59.1 + version: 8.59.1(eslint@10.3.0)(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)) packages: @@ -141,8 +126,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -195,8 +180,8 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -234,11 +219,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==} @@ -634,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'} @@ -650,20 +631,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxc-project/types@0.126.0': - resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} - - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} '@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: @@ -687,103 +660,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==} @@ -1037,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: @@ -1106,8 +1079,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==} @@ -1152,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==} @@ -1162,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'} @@ -1185,8 +1151,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.25: + resolution: {integrity: sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1284,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==} @@ -1472,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'} @@ -1520,8 +1483,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.349: + resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1714,8 +1677,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.2.1: - resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} + eslint@10.3.0: + resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1889,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==} @@ -2497,8 +2456,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2715,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.13: + resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -2875,8 +2834,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 @@ -3128,8 +3087,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 @@ -3194,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 @@ -3242,7 +3201,7 @@ packages: uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-license@3.0.4: @@ -3258,8 +3217,8 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x || 8.x - 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: @@ -3392,7 +3351,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -3401,7 +3360,7 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -3416,7 +3375,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -3428,7 +3387,7 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.2 lru-cache: 5.1.1 @@ -3465,7 +3424,7 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.29.2': + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -3497,7 +3456,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -3505,7 +3464,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -3517,13 +3476,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 @@ -3697,9 +3656,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': dependencies: - eslint: 10.2.1 + eslint: 10.3.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3720,9 +3679,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.2.1)': + '@eslint/js@10.0.1(eslint@10.3.0)': optionalDependencies: - eslint: 10.2.1 + eslint: 10.3.0 '@eslint/object-schema@3.0.5': {} @@ -3858,15 +3817,13 @@ 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 - '@noble/hashes@1.8.0': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3879,31 +3836,23 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@oxc-project/types@0.126.0': {} - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 + '@oxc-project/types@0.127.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.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 @@ -3918,7 +3867,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 @@ -3926,60 +3875,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: @@ -4083,7 +4032,7 @@ snapshots: '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': dependencies: '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 javascript-natural-sort: 0.7.1 @@ -4175,15 +4124,15 @@ 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.3.0)(typescript@6.0.3))(eslint@10.3.0)(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 - eslint: 10.2.1 + '@typescript-eslint/parser': 8.59.1(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 10.3.0 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4191,56 +4140,56 @@ 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.3.0)(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 + eslint: 10.3.0 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.3.0)(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.3.0)(typescript@6.0.3) debug: 4.4.3 - eslint: 10.2.1 + eslint: 10.3.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 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 @@ -4250,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.3.0)(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) - eslint: 10.2.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.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) + eslint: 10.3.0 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): @@ -4272,7 +4221,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 @@ -4308,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 @@ -4334,7 +4279,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.20: {} + baseline-browser-mapping@2.10.25: {} bin-build@3.0.0: dependencies: @@ -4395,9 +4340,9 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001790 - electron-to-chromium: 1.5.343 + baseline-browser-mapping: 2.10.25 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.349 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -4459,7 +4404,7 @@ snapshots: camelcase@2.1.1: {} - caniuse-lite@1.0.30001790: {} + caniuse-lite@1.0.30001791: {} caw@2.0.1: dependencies: @@ -4676,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 @@ -4761,7 +4701,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.343: {} + electron-to-chromium@1.5.349: {} emoji-regex@10.6.0: {} @@ -4907,9 +4847,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.2.1): + eslint-config-prettier@10.1.8(eslint@10.3.0): dependencies: - eslint: 10.2.1 + eslint: 10.3.0 eslint-scope@9.1.2: dependencies: @@ -4922,9 +4862,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.1: + eslint@10.3.0: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 @@ -4934,7 +4874,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 @@ -5136,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 @@ -5720,7 +5654,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} @@ -5917,9 +5851,9 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.10: + postcss@8.5.13: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -6067,35 +6001,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: @@ -6339,7 +6273,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 @@ -6399,13 +6333,13 @@ 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.3.0)(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) - eslint: 10.2.1 + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.3.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.3.0)(typescript@6.0.3) + eslint: 10.3.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6450,7 +6384,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 @@ -6475,11 +6409,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 @@ -6487,20 +6421,20 @@ 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@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 + postcss: 8.5.13 + 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/App.tsx b/interface/src/App.tsx index c92e5f9cd..b2fc2749e 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { ToastContainer, Zoom } from 'react-toastify'; import AppRouting from 'AppRouting'; @@ -46,19 +46,17 @@ const App = memo(() => { const [wasLoaded, setWasLoaded] = useState(false); const [locale, setLocale] = useState('en'); - // Memoize locale initialization to prevent unnecessary re-runs - const initializeLocale = useCallback(async () => { - const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); - const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; - localStorage.setItem('lang', newLocale); - setLocale(newLocale); - await loadLocaleAsync(newLocale); - setWasLoaded(true); - }, []); - useEffect(() => { + const initializeLocale = async () => { + const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); + const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; + localStorage.setItem('lang', newLocale); + setLocale(newLocale); + await loadLocaleAsync(newLocale); + setWasLoaded(true); + }; void initializeLocale(); - }, [initializeLocale]); + }, []); if (!wasLoaded) return null; 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/SignIn.tsx b/interface/src/SignIn.tsx index c3955d286..0b59d9d70 100644 --- a/interface/src/SignIn.tsx +++ b/interface/src/SignIn.tsx @@ -43,7 +43,6 @@ const SignIn = memo(() => { } }); - // Memoize callback to prevent recreation on every render const updateLoginRequestValue = useMemo( () => updateValue((updater) => @@ -65,7 +64,7 @@ const SignIn = memo(() => { }); }, [callSignIn, signInRequest, LL]); - const validateAndSignIn = useCallback(async () => { + const validateAndSignIn = async () => { setProcessing(true); SIGN_IN_REQUEST_VALIDATOR.messages({ required: LL.IS_REQUIRED('%s') @@ -77,7 +76,7 @@ const SignIn = memo(() => { setFieldErrors((error as ValidationError).fieldErrors); setProcessing(false); } - }, [signInRequest, signIn, LL]); + }; const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); diff --git a/interface/src/api/endpoints.ts b/interface/src/api/endpoints.ts index 95d462689..db867385a 100644 --- a/interface/src/api/endpoints.ts +++ b/interface/src/api/endpoints.ts @@ -57,12 +57,3 @@ export const alovaInstance = createAlova({ onSuccess: handleResponse } }); - -export const alovaInstanceGH = createAlova({ - baseURL: - process.env.NODE_ENV === 'development' - ? '/gh' - : 'https://api.github.com/repos/emsesp/EMS-ESP32/releases', - statesHook: ReactHook, - requestAdapter: xhrRequestAdapter() -}); diff --git a/interface/src/api/system.ts b/interface/src/api/system.ts index 1b9d1a37a..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 { alovaInstance, alovaInstanceGH } from './endpoints'; +import { alovaInstance } from './endpoints'; // systemStatus - also used to ping in System Monitor for pinging export const readSystemStatus = () => @@ -13,29 +13,6 @@ 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 - }; - } - }); - const UPLOAD_TIMEOUT = 60000; // 1 minute export const uploadFile = (file: File) => { diff --git a/interface/src/app/main/CustomEntities.tsx b/interface/src/app/main/CustomEntities.tsx index 8359132ec..4e394ba7b 100644 --- a/interface/src/app/main/CustomEntities.tsx +++ b/interface/src/app/main/CustomEntities.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -57,20 +57,18 @@ const CustomEntities = () => { initialData: [] }); - const intervalCallback = useCallback(() => { + useInterval(() => { if (!dialogOpen && !numChanges) { void fetchEntities(); } - }, [dialogOpen, numChanges, fetchEntities]); - - useInterval(intervalCallback); + }); const { send: writeEntities } = useRequest( (data: Entities) => writeCustomEntities(data), { immediate: false } ); - const hasEntityChanged = useCallback((ei: EntityItem) => { + const hasEntityChanged = (ei: EntityItem) => { return ( ei.id !== ei.o_id || ei.ram !== ei.o_ram || @@ -86,21 +84,19 @@ const CustomEntities = () => { ei.deleted !== ei.o_deleted || (ei.value || '') !== (ei.o_value || '') ); - }, []); + }; - const entity_theme = useMemo( - () => - useTheme({ - Table: ` + const entity_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(1) { padding: 8px; } @@ -120,7 +116,7 @@ const CustomEntities = () => { text-align: center; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -129,7 +125,7 @@ const CustomEntities = () => { height: 36px; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -140,11 +136,9 @@ const CustomEntities = () => { background-color: #177ac9; } ` - }), - [] - ); + }); - const saveEntities = useCallback(async () => { + const saveEntities = async () => { await writeEntities({ entities: entities .filter((ei: EntityItem) => !ei.deleted) @@ -173,44 +167,41 @@ const CustomEntities = () => { await fetchEntities(); setNumChanges(0); }); - }, [entities, writeEntities, LL, fetchEntities]); + }; - const editEntityItem = useCallback((ei: EntityItem) => { + const editEntityItem = (ei: EntityItem) => { setCreating(false); setSelectedEntityItem(ei); setDialogOpen(true); - }, []); + }; - const onDialogClose = useCallback(() => { + const onDialogClose = () => { setDialogOpen(false); - }, []); + }; - const onDialogCancel = useCallback(async () => { + const onDialogCancel = async () => { await fetchEntities().then(() => { setNumChanges(0); }); - }, [fetchEntities]); + }; - const onDialogSave = useCallback( - (updatedItem: EntityItem) => { - setDialogOpen(false); - void updateState(readCustomEntities(), (data: EntityItem[]) => { - const new_data = creating - ? [ - ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), - updatedItem - ] - : data.map((ei) => - ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei - ); - setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); - return new_data; - }); - }, - [creating, hasEntityChanged] - ); + const onDialogSave = (updatedItem: EntityItem) => { + setDialogOpen(false); + void updateState(readCustomEntities(), (data: EntityItem[]) => { + const new_data = creating + ? [ + ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), + updatedItem + ] + : data.map((ei) => + ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei + ); + setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); + return new_data; + }); + }; - const onDialogDup = useCallback((item: EntityItem) => { + const onDialogDup = (item: EntityItem) => { setCreating(true); setSelectedEntityItem({ id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), @@ -228,9 +219,9 @@ const CustomEntities = () => { value: item.value }); setDialogOpen(true); - }, []); + }; - const addEntityItem = useCallback(() => { + const addEntityItem = () => { setCreating(true); setSelectedEntityItem({ id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), @@ -248,30 +239,27 @@ const CustomEntities = () => { value: '' }); setDialogOpen(true); - }, []); + }; - const formatValue = useCallback((value: unknown, uom: number) => { + const formatValue = (value: unknown, uom: number) => { return value === undefined ? '' : typeof value === 'number' ? new Intl.NumberFormat().format(value) + (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; - }, []); + }; - const showHex = useCallback((value: number, digit: number) => { + const showHex = (value: number, digit: number) => { return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; - }, []); + }; - const filteredAndSortedEntities = useMemo( - () => - entities - ?.filter((ei: EntityItem) => !ei.deleted) - .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [], - [entities] - ); + const filteredAndSortedEntities = + entities + ?.filter((ei: EntityItem) => !ei.deleted) + .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? []; - const renderEntity = useCallback(() => { + const renderEntity = () => { if (!entities) { return ( @@ -328,17 +316,7 @@ const CustomEntities = () => { )} ); - }, [ - entities, - error, - fetchEntities, - entity_theme, - editEntityItem, - LL, - filteredAndSortedEntities, - showHex, - formatValue - ]); + }; return ( diff --git a/interface/src/app/main/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index b40e4aa25..8ede016b4 100644 --- a/interface/src/app/main/CustomEntitiesDialog.tsx +++ b/interface/src/app/main/CustomEntitiesDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, 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,14 +68,10 @@ const CustomEntitiesDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); useEffect(() => { @@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({ } }, [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); @@ -138,27 +134,21 @@ const CustomEntitiesDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); + }; - const remove = useCallback(() => { - const itemWithDeleted = { ...editItem, deleted: true }; - onSave(itemWithDeleted); - }, [editItem, onSave]); + const remove = () => { + onSave({ ...editItem, deleted: true }); + }; - const dup = useCallback(() => { + const dup = () => { onDup(editItem); - }, [editItem, onDup]); + }; - // Memoize UOM menu items to avoid recreating on every render - const uomMenuItems = useMemo( - () => - DeviceValueUOM_s.map((val, i) => ( - - {val} - - )), - [] - ); + const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( + + {val} + + )); return ( diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index 435c333bb..a127f9a04 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useBlocker, useLocation } from 'react-router'; import { toast } from 'react-toastify'; @@ -171,19 +171,17 @@ const Customizations = () => { ); }; - const entities_theme = useMemo( - () => - useTheme({ - Table: ` + const entities_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(3) { text-align: right; } @@ -194,7 +192,7 @@ const Customizations = () => { text-align: right; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -206,7 +204,7 @@ const Customizations = () => { text-align: center; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -222,7 +220,7 @@ const Customizations = () => { background-color: #177ac9; } `, - Cell: ` + Cell: ` &:nth-of-type(2) { padding: 8px; } @@ -236,9 +234,7 @@ const Customizations = () => { padding-right: 8px; } ` - }), - [] - ); + }); function hasEntityChanged(de: DeviceEntity) { return ( @@ -287,26 +283,23 @@ const Customizations = () => { return value as string; } - const isCommand = useCallback((de: DeviceEntity) => { + const isCommand = (de: DeviceEntity) => { return de.n && de.n[0] === '!'; - }, []); + }; - const formatName = useCallback( - (de: DeviceEntity, withShortname: boolean) => { - let name: string; - if (isCommand(de)) { - name = de.t - ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` - : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; - } else if (de.cn && de.cn !== '') { - name = de.t ? `${de.t} ${de.cn}` : de.cn; - } else { - name = de.t ? `${de.t} ${de.n}` : de.n || ''; - } - return withShortname ? `${name} ${de.id}` : name; - }, - [LL] - ); + const formatName = (de: DeviceEntity, withShortname: boolean) => { + let name: string; + if (isCommand(de)) { + name = de.t + ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` + : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; + } else if (de.cn && de.cn !== '') { + name = de.t ? `${de.t} ${de.cn}` : de.cn; + } else { + name = de.t ? `${de.t} ${de.n}` : de.n || ''; + } + return withShortname ? `${name} ${de.id}` : name; + }; const getMaskNumber = (newMask: string[]) => { let new_mask = 0; @@ -336,33 +329,27 @@ const Customizations = () => { return new_masks; }; - const filter_entity = useCallback( - (de: DeviceEntity) => - (de.m & selectedFilters || !selectedFilters) && - formatName(de, true).toLowerCase().includes(search.toLowerCase()), - [selectedFilters, search, formatName] - ); + const filter_entity = (de: DeviceEntity) => + (de.m & selectedFilters || !selectedFilters) && + formatName(de, true).toLowerCase().includes(search.toLowerCase()); - const maskDisabled = useCallback( - (set: boolean) => { - setDeviceEntities((prev) => - prev.map((de) => { - if (filter_entity(de)) { - const excludeMask = - DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; - return { - ...de, - m: set ? de.m | excludeMask : de.m & ~excludeMask - }; - } - return de; - }) - ); - }, - [filter_entity] - ); + const maskDisabled = (set: boolean) => { + setDeviceEntities((prev) => + prev.map((de) => { + if (filter_entity(de)) { + const excludeMask = + DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; + return { + ...de, + m: set ? de.m | excludeMask : de.m & ~excludeMask + }; + } + return de; + }) + ); + }; - const resetCustomization = useCallback(async () => { + const resetCustomization = async () => { try { await sendResetCustomizations(); toast.info(LL.CUSTOMIZATIONS_RESTART()); @@ -372,30 +359,27 @@ const Customizations = () => { setConfirmReset(false); setRestarting(true); } - }, [sendResetCustomizations, LL]); + }; const onDialogClose = () => { setDialogOpen(false); }; - const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { + const updateDeviceEntity = (updatedItem: DeviceEntity) => { setDeviceEntities( (prev) => prev?.map((de) => de.id === updatedItem.id ? { ...de, ...updatedItem } : de ) ?? [] ); - }, []); + }; - const onDialogSave = useCallback( - (updatedItem: DeviceEntity) => { - setDialogOpen(false); - updateDeviceEntity(updatedItem); - }, - [updateDeviceEntity] - ); + const onDialogSave = (updatedItem: DeviceEntity) => { + setDialogOpen(false); + updateDeviceEntity(updatedItem); + }; - const editDeviceEntity = useCallback((de: DeviceEntity) => { + const editDeviceEntity = (de: DeviceEntity) => { if (de.n === undefined || (de.n && de.n[0] === '!')) { return; } @@ -406,9 +390,9 @@ const Customizations = () => { setSelectedDeviceEntity(de); setDialogOpen(true); - }, []); + }; - const saveCustomization = useCallback(async () => { + const saveCustomization = async () => { if (!devices || !deviceEntities || selectedDevice === -1) { return; } @@ -441,9 +425,9 @@ const Customizations = () => { .finally(() => { setOriginalSettings(deviceEntities); }); - }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); + }; - const renameDevice = useCallback(async () => { + const renameDevice = async () => { await sendDeviceName({ id: selectedDevice, name: selectedDeviceName, @@ -459,14 +443,7 @@ const Customizations = () => { setRename(false); await fetchCoreData(); }); - }, [ - selectedDevice, - selectedDeviceName, - selectedDeviceBrand, - sendDeviceName, - LL, - fetchCoreData - ]); + }; const renderDeviceList = () => ( <> @@ -562,10 +539,7 @@ const Customizations = () => { ); - const filteredEntities = useMemo( - () => deviceEntities.filter((de) => filter_entity(de)), - [deviceEntities, filter_entity] - ); + const filteredEntities = deviceEntities.filter((de) => filter_entity(de)); const renderDeviceData = () => { return ( diff --git a/interface/src/app/main/CustomizationsDialog.tsx b/interface/src/app/main/CustomizationsDialog.tsx index 95615ab56..9e5d68ca9 100644 --- a/interface/src/app/main/CustomizationsDialog.tsx +++ b/interface/src/app/main/CustomizationsDialog.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; @@ -57,23 +57,16 @@ const CustomizationsDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [error, setError] = useState(false); - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); - const isWriteableNumber = useMemo( - () => - typeof editItem.v === 'number' && - editItem.w && - !(editItem.m & DeviceEntityMask.DV_READONLY), - [editItem.v, editItem.w, editItem.m] - ); + const isWriteableNumber = + typeof editItem.v === 'number' && + editItem.w && + !(editItem.m & DeviceEntityMask.DV_READONLY); useEffect(() => { if (open) { @@ -82,16 +75,16 @@ const CustomizationsDialog = ({ } }, [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(() => { + const save = () => { if ( isWriteableNumber && editItem.mi && @@ -102,34 +95,31 @@ const CustomizationsDialog = ({ } else { onSave(editItem); } - }, [isWriteableNumber, editItem, onSave]); + }; - const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { + const updateDeviceEntity = (updatedItem: DeviceEntity) => { setEditItem((prev) => ({ ...prev, m: updatedItem.m })); - }, []); - - const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]); - - const writeableIcon = useMemo( - () => - editItem.w ? ( - - ) : ( - - ), - [editItem.w] - ); + }; return ( - {dialogTitle} + {`${LL.EDIT()} ${LL.ENTITY()}`} - + + ) : ( + + ) + } + /> diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx index b2aff1090..964f68e43 100644 --- a/interface/src/app/main/Dashboard.tsx +++ b/interface/src/app/main/Dashboard.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { memo, useContext, useEffect, useState } from 'react'; import { IconContext } from 'react-icons/lib'; import { Link } from 'react-router'; import { toast } from 'react-toastify'; @@ -77,40 +77,35 @@ const Dashboard = memo(() => { } ); - const deviceValueDialogSave = useCallback( - async (devicevalue: DeviceValue) => { - if (!selectedDashboardItem) { - return; - } - const id = selectedDashboardItem.parentNode.id; // this is the parent ID - await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) - .then(() => { - toast.success(LL.WRITE_CMD_SENT()); - }) - .catch((error: Error) => { - toast.error(error.message); - }) - .finally(() => { - setDeviceValueDialogOpen(false); - setSelectedDashboardItem(undefined); - }); - }, - [selectedDashboardItem, sendDeviceValue, LL] - ); + const deviceValueDialogSave = async (devicevalue: DeviceValue) => { + if (!selectedDashboardItem) { + return; + } + const id = selectedDashboardItem.parentNode.id; // this is the parent ID + await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) + .then(() => { + toast.success(LL.WRITE_CMD_SENT()); + }) + .catch((error: Error) => { + toast.error(error.message); + }) + .finally(() => { + setDeviceValueDialogOpen(false); + setSelectedDashboardItem(undefined); + }); + }; - const dashboard_theme = useMemo( - () => - useTheme({ - Table: ` + const dashboard_theme = useTheme({ + Table: ` --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 28px; } `, - Row: ` + Row: ` cursor: pointer; background-color: #1e1e1e; &:nth-of-type(odd) .td { @@ -120,7 +115,7 @@ const Dashboard = memo(() => { background-color: #177ac9; }, `, - BaseCell: ` + BaseCell: ` &:nth-of-type(2) { text-align: right; } @@ -128,9 +123,7 @@ const Dashboard = memo(() => { text-align: right; } ` - }), - [] - ); + }); const tree = useTree( { nodes: [...data.nodes] }, @@ -164,79 +157,64 @@ const Dashboard = memo(() => { } }); - const nodeIds = useMemo( - () => data.nodes.map((item: DashboardItem) => item.id), - [data.nodes] - ); - useEffect(() => { + const nodeIds = data.nodes.map((item: DashboardItem) => item.id); showAll ? tree.fns.onAddAll(nodeIds) // expand tree : tree.fns.onRemoveAll(); // collapse tree }, [parentNodes]); - const showType = useCallback( - (n?: string, t?: number) => { - // if we have a name show it - if (n) { - return n; + const showType = (n?: string, t?: number) => { + // if we have a name show it + if (n) { + return n; + } + if (t) { + // otherwise pick translation based on type + switch (t) { + case DeviceType.CUSTOM: + return LL.CUSTOM_ENTITIES(0); + case DeviceType.ANALOGSENSOR: + return LL.ANALOG_SENSORS(); + case DeviceType.TEMPERATURESENSOR: + return LL.TEMP_SENSORS(); + case DeviceType.SCHEDULER: + return LL.SCHEDULER(); + default: + break; } - if (t) { - // otherwise pick translation based on type - switch (t) { - case DeviceType.CUSTOM: - return LL.CUSTOM_ENTITIES(0); - case DeviceType.ANALOGSENSOR: - return LL.ANALOG_SENSORS(); - case DeviceType.TEMPERATURESENSOR: - return LL.TEMP_SENSORS(); - case DeviceType.SCHEDULER: - return LL.SCHEDULER(); - default: - break; - } - } - return ''; - }, - [LL] - ); + } + return ''; + }; - const showName = useCallback( - (di: DashboardItem) => { - if (di.id < 100) { - // if its a device (parent node) and has entities - if (di.nodes?.length) { - return ( - - -   {showType(di.n, di.t)} -  ({di.nodes?.length}) - - ); - } + const showName = (di: DashboardItem) => { + if (di.id < 100) { + // if its a device (parent node) and has entities + if (di.nodes?.length) { + return ( + + +   {showType(di.n, di.t)} +  ({di.nodes?.length}) + + ); } - if (di.dv) { - return {di.dv.id.slice(2)}; - } - return null; - }, - [showType] - ); + } + if (di.dv) { + return {di.dv.id.slice(2)}; + } + return null; + }; - const hasMask = useCallback( - (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, - [] - ); + const hasMask = (id: string, mask: number) => + (parseInt(id.slice(0, 2), 16) & mask) === mask; - const editDashboardValue = useCallback( - (di: DashboardItem) => { - if (me.admin && di.dv?.c) { - setSelectedDashboardItem(di); - setDeviceValueDialogOpen(true); - } - }, - [me.admin] - ); + const editDashboardValue = (di: DashboardItem) => { + if (me.admin && di.dv?.c) { + setSelectedDashboardItem(di); + setDeviceValueDialogOpen(true); + } + }; const handleShowAll = ( _event: React.MouseEvent, @@ -248,10 +226,9 @@ const Dashboard = memo(() => { } }; - const hasFavEntities = useMemo( - () => data.nodes.filter((item: DashboardItem) => item.id <= 90).length, - [data.nodes] - ); + const hasFavEntities = data.nodes.filter( + (item: DashboardItem) => item.id <= 90 + ).length; const renderContent = () => { if (!data) { diff --git a/interface/src/app/main/Devices.tsx b/interface/src/app/main/Devices.tsx index f21729b0e..08cd73524 100644 --- a/interface/src/app/main/Devices.tsx +++ b/interface/src/app/main/Devices.tsx @@ -4,7 +4,6 @@ import { useContext, useEffect, useLayoutEffect, - useMemo, useState } from 'react'; import { IconContext } from 'react-icons'; @@ -133,21 +132,19 @@ const Devices = memo(() => { }; }, []); - const leftOffset = useCallback(() => { + const leftOffset = () => { const devicesWindow = document.getElementById('devices-window'); if (!devicesWindow) return 0; const { left, right } = devicesWindow.getBoundingClientRect(); if (!left || !right) return 0; return left + (right - left < 400 ? 0 : 200); - }, []); + }; - const common_theme = useMemo( - () => - useTheme({ - BaseRow: ` + const common_theme = useTheme({ + BaseRow: ` font-size: 14px; `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -155,7 +152,7 @@ const Devices = memo(() => { border-bottom: 1px solid #565656; } `, - Row: ` + Row: ` cursor: pointer; background-color: #1E1E1E; .td { @@ -165,88 +162,78 @@ const Devices = memo(() => { background-color: #177ac9; } ` - }), - [] - ); + }); - const device_theme = useMemo( - () => - useTheme([ - common_theme, - { - BaseRow: ` - font-size: 15px; - .td { - height: 28px; - } - `, - Table: ` - --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; - `, - HeaderRow: ` - .th { - padding: 8px; - `, - Row: ` - &:nth-of-type(odd) .td { - background-color: #303030; - }, - &:hover .td { - background-color: #177ac9; - }, - ` - } - ]), - [common_theme] - ); - - const data_theme = useMemo( - () => - useTheme([ - common_theme, - { - Table: ` - --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; - height: auto; - max-height: 100%; - overflow-y: scroll; - ::-webkit-scrollbar { - display:none; + const device_theme = useTheme([ + common_theme, + { + BaseRow: ` + font-size: 15px; + .td { + height: 28px; } `, - BaseRow: ` - .td { - height: 32px; - } - `, - BaseCell: ` - &:nth-of-type(1) { - border-left: 1px solid #177ac9; - }, - &:nth-of-type(2) { - text-align: right; - }, - &:nth-of-type(3) { - border-right: 1px solid #177ac9; - } - `, - HeaderRow: ` - .th { - border-top: 1px solid #565656; - } - `, - Row: ` - &:nth-of-type(odd) .td { + Table: ` + --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; + `, + HeaderRow: ` + .th { + padding: 8px; + `, + Row: ` + &:nth-of-type(odd) .td { background-color: #303030; - }, - &:hover .td { - background-color: #177ac9; + }, + &:hover .td { + background-color: #177ac9; + }, + ` + } + ]); + + const data_theme = useTheme([ + common_theme, + { + Table: ` + --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; + height: auto; + max-height: 100%; + overflow-y: scroll; + ::-webkit-scrollbar { + display:none; } - ` - } - ]), - [common_theme] - ); + `, + BaseRow: ` + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(1) { + border-left: 1px solid #177ac9; + }, + &:nth-of-type(2) { + text-align: right; + }, + &:nth-of-type(3) { + border-right: 1px solid #177ac9; + } + `, + HeaderRow: ` + .th { + border-top: 1px solid #565656; + } + `, + Row: ` + &:nth-of-type(odd) .td { + background-color: #303030; + }, + &:hover .td { + background-color: #177ac9; + } + ` + } + ]); const getSortIcon = (state: State, sortKey: unknown) => { if (state.sortKey === sortKey && state.reverse) { @@ -345,10 +332,8 @@ const Devices = memo(() => { return sc; }; - const hasMask = useCallback( - (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, - [] - ); + const hasMask = (id: string, mask: number) => + (parseInt(id.slice(0, 2), 16) & mask) === mask; const handleDownloadCsv = () => { const deviceIndex = coreData.devices.findIndex( @@ -607,41 +592,35 @@ const Devices = memo(() => { return; } - const showDeviceValue = useCallback((dv: DeviceValue) => { + const showDeviceValue = (dv: DeviceValue) => { setSelectedDeviceValue(dv); setDeviceValueDialogOpen(true); - }, []); + }; - const renderNameCell = useCallback( - (dv: DeviceValue) => ( - <> - {dv.id.slice(2)}  - {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( - - )} - {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( - - )} - {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( - - )} - - ), - [hasMask] + const renderNameCell = (dv: DeviceValue) => ( + <> + {dv.id.slice(2)}  + {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( + + )} + {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( + + )} + {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( + + )} + ); - const shown_data = useMemo(() => { - if (onlyFav) { - return deviceData.nodes.filter( + const shown_data = onlyFav + ? deviceData.nodes.filter( (dv: DeviceValue) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) + ) + : deviceData.nodes.filter((dv: DeviceValue) => + dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) ); - } - return deviceData.nodes.filter((dv: DeviceValue) => - dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) - ); - }, [deviceData.nodes, onlyFav, search]); const deviceIndex = coreData.devices.findIndex( (d: Device) => d.id === device_select.state.id diff --git a/interface/src/app/main/DevicesDialog.tsx b/interface/src/app/main/DevicesDialog.tsx index fd2770e7d..706c2d608 100644 --- a/interface/src/app/main/DevicesDialog.tsx +++ b/interface/src/app/main/DevicesDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, 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,7 +52,7 @@ const DevicesDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); + const updateFormValue = updateValue(setEditItem); useEffect(() => { if (open) { @@ -61,7 +61,7 @@ const DevicesDialog = ({ } }, [open, selectedItem]); - const save = useCallback(async () => { + const save = async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -69,28 +69,25 @@ const DevicesDialog = ({ } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [validator, editItem, onSave]); + }; - const setUom = useCallback( - (uom?: DeviceValueUOM) => { - if (uom === undefined) { - return; - } - switch (uom) { - case DeviceValueUOM.HOURS: - return LL.HOURS(); - case DeviceValueUOM.MINUTES: - return LL.MINUTES(); - case DeviceValueUOM.SECONDS: - return LL.SECONDS(); - default: - return DeviceValueUOM_s[uom]; - } - }, - [LL] - ); + const setUom = (uom?: DeviceValueUOM) => { + if (uom === undefined) { + return; + } + switch (uom) { + case DeviceValueUOM.HOURS: + return LL.HOURS(); + case DeviceValueUOM.MINUTES: + return LL.MINUTES(); + case DeviceValueUOM.SECONDS: + return LL.SECONDS(); + default: + return DeviceValueUOM_s[uom]; + } + }; - const showHelperText = useCallback((dv: DeviceValue) => { + const showHelperText = (dv: DeviceValue) => { if (dv.h) return dv.h; if (dv.l) return dv.l.join(' | '); if (dv.m !== undefined && dv.x !== undefined) { @@ -101,26 +98,16 @@ const DevicesDialog = ({ ); } return undefined; - }, []); + }; - const isCommand = useMemo( - () => selectedItem.v === '' && selectedItem.c, - [selectedItem.v, selectedItem.c] - ); - - const dialogTitle = useMemo(() => { - if (isCommand) return LL.RUN_COMMAND(); - return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0); - }, [isCommand, writeable, LL]); - - const buttonLabel = useMemo(() => { - return isCommand ? LL.EXECUTE() : LL.UPDATE(); - }, [isCommand, LL]); - - const helperText = useMemo( - () => showHelperText(editItem), - [editItem, showHelperText] - ); + const isCommand = selectedItem.v === '' && selectedItem.c; + const dialogTitle = isCommand + ? LL.RUN_COMMAND() + : writeable + ? LL.CHANGE_VALUE() + : LL.VALUE(0); + const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE(); + const helperText = showHelperText(editItem); const valueLabel = LL.VALUE(0); diff --git a/interface/src/app/main/EntityMaskToggle.tsx b/interface/src/app/main/EntityMaskToggle.tsx index d2304d6f1..e4bbe6176 100644 --- a/interface/src/app/main/EntityMaskToggle.tsx +++ b/interface/src/app/main/EntityMaskToggle.tsx @@ -1,5 +1,3 @@ -import { useCallback, useMemo } from 'react'; - import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import OptionIcon from './OptionIcon'; @@ -11,7 +9,6 @@ interface EntityMaskToggleProps { de: DeviceEntity; } -// Available mask values const MASK_VALUES = [ DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 @@ -20,123 +17,95 @@ const MASK_VALUES = [ DeviceEntityMask.DV_DELETED // 128 ]; -/** - * Converts an array of mask strings to a bitmask number - */ -const getMaskNumber = (newMask: string[]): number => { - return newMask.reduce((mask, entry) => mask | Number(entry), 0); -}; +const getMaskNumber = (newMask: string[]): number => + newMask.reduce((mask, entry) => mask | Number(entry), 0); -/** - * Converts a bitmask number to an array of mask strings - */ -const getMaskString = (mask: number): string[] => { - return MASK_VALUES.filter((value) => (mask & value) === value).map((value) => +const getMaskString = (mask: number): string[] => + MASK_VALUES.filter((value) => (mask & value) === value).map((value) => String(value) ); -}; -/** - * Checks if a specific mask bit is set - */ const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { - const handleChange = useCallback( - (_event: unknown, mask: string[]) => { - // Convert selected masks to a number - const newMask = getMaskNumber(mask); - const updatedDe = { ...de }; + const handleChange = (_event: unknown, mask: string[]) => { + const newMask = getMaskNumber(mask); + const updatedDe = { ...de }; - // Apply business logic for mask interactions - // If entity has no name and is set to readonly, also exclude from web - if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { - updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; - } else { - updatedDe.m = newMask; - } + // If entity has no name and is set to readonly, also exclude from web + if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { + updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; + } else { + updatedDe.m = newMask; + } - // If excluded from web, cannot be favorite - if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { - updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; - } + // If excluded from web, cannot be favorite + if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { + updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; + } - onUpdate(updatedDe); - }, - [de, onUpdate] - ); - - // Memoize mask string value - const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]); - - // Memoize disabled states - const isFavoriteDisabled = useMemo( - () => - hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) || - de.n === undefined, - [de.m, de.n] - ); - - const isReadonlyDisabled = useMemo( - () => - !de.w || - hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE), - [de.w, de.m] - ); - - const isApiMqttExcludeDisabled = useMemo( - () => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED), - [de.n, de.m] - ); - - const isWebExcludeDisabled = useMemo( - () => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED), - [de.n, de.m] - ); - - // Memoize mask flag checks - const isFavoriteSet = useMemo( - () => hasMask(de.m, DeviceEntityMask.DV_FAVORITE), - [de.m] - ); - const isReadonlySet = useMemo( - () => hasMask(de.m, DeviceEntityMask.DV_READONLY), - [de.m] - ); - const isApiMqttExcludeSet = useMemo( - () => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE), - [de.m] - ); - const isWebExcludeSet = useMemo( - () => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE), - [de.m] - ); - const isDeletedSet = useMemo( - () => hasMask(de.m, DeviceEntityMask.DV_DELETED), - [de.m] - ); + onUpdate(updatedDe); + }; return ( - - + + - - + + - - + + - - + + - + ); diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index d68ffa591..ff1fe6a56 100644 --- a/interface/src/app/main/Help.tsx +++ b/interface/src/app/main/Help.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext, useMemo, useState } from 'react'; +import { memo, useContext, useState } from 'react'; import type { ReactElement } from 'react'; import { toast } from 'react-toastify'; @@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps = { bgcolor: '#72caf9' }; +const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 }; + const HelpComponent = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.HELP()); @@ -72,12 +74,7 @@ const HelpComponent = () => { }); const [imgError, setImgError] = useState(false); - const getCustomSupportMethod = useMemo( - () => callAction({ action: 'getCustomSupport' }), - [] - ); - - useRequest(getCustomSupportMethod).onSuccess((event) => { + useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => { if (event?.data && Object.keys(event.data).length !== 0) { const { Support } = event.data as { Support: { img_url?: string; html?: string[] }; @@ -100,47 +97,26 @@ const HelpComponent = () => { toast.error(String(error.error?.message || 'An error occurred')); }); - // Optimize API call memoization - const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); + const helpLinks: HelpLink[] = [ + { + href: 'https://emsesp.org', + icon: , + label: () => LL.HELP_INFORMATION_1() + }, + { + href: 'https://discord.gg/GP9DPSgeJq', + icon: , + label: () => LL.HELP_INFORMATION_2() + }, + { + href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose', + icon: , + label: () => LL.HELP_INFORMATION_3() + } + ]; - const handleDownloadSystemInfo = useCallback(() => { - void sendAPI(apiCall); - }, [sendAPI, apiCall]); - - const handleImageError = useCallback(() => { - setImgError(true); - }, []); - - // Memoize help links to prevent recreation on every render - const helpLinks: HelpLink[] = useMemo( - () => [ - { - href: 'https://emsesp.org', - icon: , - label: () => LL.HELP_INFORMATION_1() - }, - { - href: 'https://discord.gg/GP9DPSgeJq', - icon: , - label: () => LL.HELP_INFORMATION_2() - }, - { - href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose', - icon: , - label: () => LL.HELP_INFORMATION_3() - } - ], - [LL] - ); - - const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]); - - // Memoize image source computation - const imageSrc = useMemo( - () => - imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url, - [imgError, customSupport.img_url] - ); + const imageSrc = + imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url; return ( @@ -157,13 +133,13 @@ const HelpComponent = () => { component="img" referrerPolicy="no-referrer" sx={IMAGE_STYLES} - onError={handleImageError} + onError={() => setImgError(true)} src={imageSrc} /> )} - {isAdmin && ( + {me?.admin && ( {helpLinks.map(({ href, icon, label }) => ( @@ -191,7 +167,7 @@ const HelpComponent = () => { startIcon={} variant="outlined" color="primary" - onClick={handleDownloadSystemInfo} + onClick={() => void sendAPI(SYSTEM_INFO_API)} > {LL.SUPPORT_INFORMATION(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..d68d9b727 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, 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,14 +90,10 @@ const SchedulerDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [scheduleType, setScheduleType] = useState(); - const updateFormValue = useMemo( - () => - updateValue( - setEditItem as unknown as React.Dispatch< - React.SetStateAction> - > - ), - [] + const updateFormValue = updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > ); useEffect(() => { @@ -112,129 +114,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..483d337cc 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, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -53,84 +53,54 @@ const SensorsAnalogDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); - 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 + ) ); - // 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 +127,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 +144,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..714156a85 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, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -50,16 +50,12 @@ const SensorsTemperatureDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [editItem, setEditItem] = useState(selectedItem); - 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(() => { @@ -69,16 +65,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 +79,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 +103,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..0f04aba3f 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'; @@ -24,7 +24,6 @@ import { numberValue, updateValueDirty, useRest } from 'utils'; import { ValidationError, createAPSettingsValidator, validate } from 'validators'; export const isAPEnabled = ({ provision_mode }: APSettingsType) => - provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; // Efficient range function without recursion @@ -63,22 +62,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 +81,7 @@ const APSettings = () => { } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); } - }, [data, saveData]); + }; const content = () => { if (!data) { @@ -108,9 +101,6 @@ const APSettings = () => { onChange={updateFormValue} margin="normal" > - - {LL.AP_PROVIDE_TEXT_1()} - {LL.AP_PROVIDE_TEXT_2()} diff --git a/interface/src/app/settings/ApplicationSettings.tsx b/interface/src/app/settings/ApplicationSettings.tsx index cae0b1e1b..e401862cd 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'; @@ -107,49 +107,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); @@ -158,31 +145,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..7808633d4 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -1,190 +1,108 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useContext } from 'react'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import CancelIcon from '@mui/icons-material/Cancel'; +import BuildIcon from '@mui/icons-material/Build'; import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import ImportExportIcon from '@mui/icons-material/ImportExport'; import LockIcon from '@mui/icons-material/Lock'; -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); useLayoutTitle(LL.SETTINGS(0)); - const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); - const [restarting, setRestarting] = useState(); + const upgradeAvailable = versions?.current?.upgradeable ?? false; + const firmwareText = versions?.current?.version + ? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}` + : ''; - const { send: sendAPI } = useRequest((data: APIcall) => API(data), { - immediate: false - }); + return ( + + + - const doFormat = useCallback(async () => { - await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { - setRestarting(true); - setConfirmFactoryReset(false); - }); - }, [sendAPI]); + - const handleFactoryResetClose = useCallback(() => { - setConfirmFactoryReset(false); - }, []); + - const handleFactoryResetClick = useCallback(() => { - setConfirmFactoryReset(true); - }, []); + - const content = useMemo(() => { - return ( - <> - - + - + - + - + - - - - - - - - - - - {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/Version.tsx b/interface/src/app/settings/Version.tsx new file mode 100644 index 000000000..c29dd33fc --- /dev/null +++ b/interface/src/app/settings/Version.tsx @@ -0,0 +1,958 @@ +import { memo, useContext, useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { toast } from 'react-toastify'; + +import CancelIcon from '@mui/icons-material/Cancel'; +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, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + IconButton, + Table, + TableBody, + TableCell, + TableRow, + Typography +} from '@mui/material'; + +import * as SystemApi from 'api/system'; +import { API, callAction } from 'api/app'; + +import { dialogStyle } from 'CustomTheme'; +import { useRequest } from 'alova/client'; +import type { APIcall } from 'app/main/types'; +import SystemMonitor from 'app/status/SystemMonitor'; +import { + FormLoader, + SectionContent, + SingleUpload, + useLayoutTitle +} from 'components'; +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 +const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/'; +const STABLE_RELNOTES_URL = + 'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md'; +const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/'; +const DEV_RELNOTES_URL = + 'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md'; + +// Types for better type safety +interface PartitionData { + partition: string; + version: string; + install_date?: string; + size: number; +} + +interface VersionData { + emsesp_version: string; + arduino_version: string; + esp_platform: string; + flash_chip_size: number; + psram: boolean; + build_flags?: string; + partition: string; + partitions: PartitionData[]; + developer_mode: boolean; +} + +// Memoized components for better performance +const VersionInfoDialog = memo( + ({ + showVersionInfo, + latestVersion, + latestDevVersion, + partitionVersion, + partition, + currentPartition, + size, + locale, + LL, + onClose + }: { + showVersionInfo: number; + latestVersion: VersionInfo | undefined; + latestDevVersion: VersionInfo | undefined; + partitionVersion: VersionInfo | undefined; + partition: string; + currentPartition: string; + size: number; + locale: string; + LL: TranslationFunctions; + onClose: () => void; + }) => { + if (showVersionInfo === 0) return null; + + const isStable = showVersionInfo === 1; + const isDev = showVersionInfo === 2; + const isPartition = showVersionInfo === 3; + + const version = isStable + ? latestVersion + : isDev + ? latestDevVersion + : partitionVersion; + const relNotesUrl = isStable + ? STABLE_RELNOTES_URL + : isDev + ? DEV_RELNOTES_URL + : ''; + + return ( + + {LL.FIRMWARE_VERSION_INFO()} + + + + + + {LL.VERSION()} + + + {isPartition + ? typeof version === 'string' + ? version + : version?.version + : version?.version} + + + + + {isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()} + + + {partition === currentPartition && LL.ACTIVE() + ' '} + {isStable + ? LL.STABLE() + : isDev + ? LL.DEVELOPMENT() + : 'Partition ' + LL.VERSION()} + + + {isPartition && ( + + + Partition + + + {partition} + + + )} + {isPartition && ( + + + Size + + + {size} KB + + + )} + {version && version.date && ( + + + {isPartition ? 'Install Date' : 'Build Date'} + + + {prettyDateTime(locale, new Date(version.date))} + + + )} + +
+
+ + {!isPartition && ( + + )} + + +
+ ); + } +); + +const InstallDialog = memo( + ({ + openInstallDialog, + fetchDevVersion, + latestVersion, + latestDevVersion, + upgradeImportantMessageType, + downloadOnly, + platform, + LL, + onClose, + onInstall + }: { + openInstallDialog: boolean; + fetchDevVersion: boolean; + latestVersion: VersionInfo | undefined; + latestDevVersion: VersionInfo | undefined; + upgradeImportantMessageType: number; + downloadOnly: boolean; + platform: string; + LL: TranslationFunctions; + onClose: () => void; + onInstall: (url: string) => void; + }) => { + 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}`; + })(); + + return ( + + + {`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`} + + + + {LL.INSTALL_VERSION( + downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(), + fetchDevVersion ? latestDevVersion?.version : latestVersion?.version + )} + + {upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()} + {upgradeImportantMessageType === 1 && ( + <> + {LL.UPGRADE_IMPORTANT_MESSAGES_1()} + + + {LL.DOWNLOAD_SYSTEM_BACKUP()} + + + + )} + + + {LL.ONLINE_HELP()} + + + + + + + {!downloadOnly && ( + + )} + + + ); + } +); + +const InstallPartitionDialog = memo( + ({ + openInstallPartitionDialog, + version, + partition, + LL, + onClose, + onInstall + }: { + openInstallPartitionDialog: boolean; + version: string; + partition: string; + LL: TranslationFunctions; + onClose: () => void; + onInstall: (partition: string) => void; + }) => { + return ( + + + {LL.INSTALL()} {LL.STORED_VERSIONS()} + + + + {LL.INSTALL_VERSION(LL.INSTALL(), version)} + + + + + + + + + ); + } +); + +// Helper function moved outside component +const getPlatform = (data: VersionData): string => { + return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`; +}; + +const Version = () => { + const { LL, locale } = useI18nContext(); + 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( + undefined + ); + const [partition, setPartition] = useState(''); + const [openInstallPartitionDialog, setOpenInstallPartitionDialog] = + useState(false); + + const [fetchDevVersion, setFetchDevVersion] = 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 } + ).onError((error) => { + toast.error(String(error.error?.message || 'An error occurred')); + }); + + const { + data, + send: loadData, + error + } = useRequest(SystemApi.readSystemStatus).onSuccess((event) => { + const systemData = event.data as VersionData; + if (systemData.arduino_version.startsWith('Tasmota')) { + setDownloadOnly(true); + } + }); + + const { send: sendUploadURL } = useRequest( + (url: string) => callAction({ action: 'uploadURL', param: url }), + { immediate: false } + ); + + const { send: sendAPI } = useRequest((data: APIcall) => API(data), { + immediate: false + }); + + const [upgradeImportantMessageType, setUpgradeImportantMessageType] = + useState(0); + + const { send: checkUpgradeImportantMessages } = useRequest( + (version: string) => + callAction({ action: 'upgradeImportantMessages', param: version }), + { + immediate: false + } + ) + .onSuccess((event) => { + const upgradeImportantMessageType_n = ( + event.data as { upgradeImportantMessageType: number } + ).upgradeImportantMessageType; + setUpgradeImportantMessageType(upgradeImportantMessageType_n); + }) + .onError((error) => { + toast.error(String(error.error?.message || 'An error occurred')); + }); + + const platform = data ? getPlatform(data) : ''; + + const otherPartitions = + data?.partitions.filter((p) => p.partition !== data.partition) ?? []; + + 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); + } + }; + + const doRestart = async () => { + setConfirmRestart(false); + await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( + (error: Error) => { + toast.error(error.message); + } + ); + 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); + }); + await doRestart(); + }; + + const installPartitionFirmware = async (partition: string) => { + await sendSetPartition(partition).catch((error: Error) => { + toast.error(error.message); + }); + setRestarting(true); + }; + + const showPartitionDialog = ( + version: string, + partition: string, + install_date: string + ) => { + setOpenInstallPartitionDialog(true); + setPartitionVersion({ version: version, date: install_date }); + setPartition(partition); + }; + + const showFirmwareDialog = (useDevVersion: boolean) => { + setFetchDevVersion(useDevVersion); + const targetVersion = useDevVersion + ? latestDevVersion?.version + : latestVersion?.version; + if (targetVersion) { + void checkUpgradeImportantMessages(targetVersion); + } + setOpenInstallDialog(true); + }; + + const closeInstallDialog = () => setOpenInstallDialog(false); + const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false); + + const handleVersionInfoClose = () => { + setShowVersionInfo(0); + setPartitionVersion(undefined); + setPartition(''); + }; + + useLayoutTitle('EMS-ESP Firmware'); + + 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())} + + + + ); + } + + if (!me.admin) return null; + + const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE(); + + return ( + + ); + }; + + if (restarting) { + return ; + } + + if (!data) { + return ( + + + + ); + } + + return ( + + + + {LL.THIS_VERSION()} + + + + + {LL.VERSION()} + + + + {data.emsesp_version} + {data.build_flags && ( + +   ({data.build_flags}) + + )} + setPartitionVersionInfo(data.partition)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > + + + + + + + {LL.PLATFORM()} + + + + {platform} + +   ( + {data.psram ? ( + + ) : ( + + )} + PSRAM) + + + + + + {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()} + + + + )} + + + {me.admin && ( + <> + + {LL.FACTORY_RESET()} + {LL.SYSTEM_FACTORY_TEXT_DIALOG()} + + + + + + + + {LL.RESTART()} + {LL.RESTART_CONFIRM()} + + + + + + + + + {data.developer_mode && ( + + )} + + + )} + + ); +}; + +export default memo(Version); 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..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,44 +93,36 @@ const ManageUsers = () => { text-align: right; } ` - }), - [] - ); + }); - 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(() => { + const cancelEditingUser = () => { setUser(undefined); - }, []); + }; - const doneEditingUser = useCallback(() => { + const doneEditingUser = () => { if (user && data) { const users = [ ...data.users.filter( @@ -144,26 +134,26 @@ const ManageUsers = () => { setUser(undefined); setChanged(changed + 1); } - }, [user, data, updateDataValue, changed]); + }; const closeGenerateToken = useCallback(() => { 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 +167,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/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 && ( - - -
- ), - [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 ; - } + const activeHighlight = (value: boolean) => + value ? theme.palette.success.main : theme.palette.info.main; + if (!data || !LL) { 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}; + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; 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 ( void; - }) => { - if (showVersionInfo === 0) return null; - - const isStable = showVersionInfo === 1; - const isDev = showVersionInfo === 2; - const isPartition = showVersionInfo === 3; - - const version = isStable - ? latestVersion - : isDev - ? latestDevVersion - : partitionVersion; - const relNotesUrl = isStable - ? STABLE_RELNOTES_URL - : isDev - ? DEV_RELNOTES_URL - : ''; - - return ( - - {LL.FIRMWARE_VERSION_INFO()} - - - - - - {LL.VERSION()} - - - {isPartition - ? typeof version === 'string' - ? version - : version?.name - : version?.name} - - - - - {isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()} - - - {partition === currentPartition && LL.ACTIVE() + ' '} - {isStable - ? LL.STABLE() - : isDev - ? LL.DEVELOPMENT() - : 'Partition ' + LL.VERSION()} - - - {isPartition && ( - - - Partition - - - {partition} - - - )} - {isPartition && ( - - - Size - - - {size} KB - - - )} - {version?.published_at && ( - - - {isPartition ? 'Install Date' : 'Build Date'} - - - {prettyDateTime(locale, new Date(version.published_at))} - - - )} - -
-
- - {!isPartition && ( - - )} - - -
- ); - } -); - -const InstallDialog = memo( - ({ - openInstallDialog, - fetchDevVersion, - latestVersion, - latestDevVersion, - upgradeImportantMessageType, - downloadOnly, - platform, - LL, - onClose, - onInstall - }: { - openInstallDialog: boolean; - fetchDevVersion: boolean; - latestVersion?: VersionInfo; - latestDevVersion?: VersionInfo; - upgradeImportantMessageType: number; - downloadOnly: boolean; - platform: string; - LL: TranslationFunctions; - onClose: () => void; - onInstall: (url: string) => void; - }) => { - const binURL = useMemo(() => { - if (!latestVersion || !latestDevVersion) return ''; - - const version = fetchDevVersion ? latestDevVersion : latestVersion; - const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`; - - return fetchDevVersion - ? `${DEV_URL}${filename}` - : `${STABLE_URL}v${version.name}/${filename}`; - }, [fetchDevVersion, latestVersion, latestDevVersion, platform]); - - return ( - - - {`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`} - - - - {LL.INSTALL_VERSION( - downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(), - fetchDevVersion ? latestDevVersion?.name : latestVersion?.name - )} - - - {upgradeImportantMessageType === 1 && LL.UPGRADE_IMPORTANT_MESSAGES_1()} - {upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()} - - - {LL.ONLINE_HELP()} - - - - - - - {!downloadOnly && ( - - )} - - - ); - } -); - -const InstallPartitionDialog = memo( - ({ - openInstallPartitionDialog, - version, - partition, - LL, - onClose, - onInstall - }: { - openInstallPartitionDialog: boolean; - version: string; - partition: string; - LL: TranslationFunctions; - onClose: () => void; - onInstall: (partition: string) => void; - }) => { - return ( - - - {LL.INSTALL()} {LL.STORED_VERSIONS()} - - - - {LL.INSTALL_VERSION(LL.INSTALL(), version)} - - - - - - - - - ); - } -); - -// Helper function moved outside component -const getPlatform = (data: VersionData): string => { - return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`; -}; - -const Version = () => { - const { LL, locale } = useI18nContext(); - const { me } = useContext(AuthenticatedContext); - - // State management - const [restarting, setRestarting] = useState(false); - const [openInstallDialog, setOpenInstallDialog] = useState(false); - - const [partitionVersion, setPartitionVersion] = useState( - undefined - ); - const [partition, setPartition] = useState(''); - 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 { 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 } - ).onError((error) => { - toast.error(String(error.error?.message || 'An error occurred')); - }); - - const { - data, - send: loadData, - error - } = useRequest(SystemApi.readSystemStatus).onSuccess((event) => { - const systemData = event.data as VersionData; - if (systemData.arduino_version.startsWith('Tasmota')) { - setDownloadOnly(true); - } - setUsingDevVersion(systemData.emsesp_version.includes('dev')); - }); - - const { send: sendUploadURL } = useRequest( - (url: string) => callAction({ action: 'uploadURL', param: url }), - { immediate: false } - ); - - const { data: latestVersion } = useRequest(getStableVersion); - const { data: latestDevVersion } = useRequest(getDevVersion); - - const { send: sendAPI } = useRequest((data: APIcall) => API(data), { - immediate: false - }); - - const [upgradeImportantMessageType, setUpgradeImportantMessageType] = - useState(0); - - const { send: checkUpgradeImportantMessages } = useRequest( - (version: string) => - callAction({ action: 'upgradeImportantMessages', param: version }), - { - immediate: false - } - ) - .onSuccess((event) => { - const upgradeImportantMessageType_n = ( - event.data as { upgradeImportantMessageType: number } - ).upgradeImportantMessageType; - setUpgradeImportantMessageType(upgradeImportantMessageType_n); - }) - .onError((error) => { - 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] - ); - - const setPartitionVersionInfo = useCallback( - (partition: string) => { - setShowVersionInfo(3); - - // search for the partition in the data.partitions array - const partitionData = data?.partitions.find((p) => p.partition === partition); - if (partitionData) { - setPartitionVersion({ - name: partitionData.version, - published_at: partitionData.install_date ?? '' - }); - setPartition(partitionData.partition); - setFirmwareSize(partitionData.size); - } - }, - [data] - ); - - const doRestart = useCallback(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 installPartitionFirmware = useCallback( - async (partition: string) => { - await sendSetPartition(partition).catch((error: Error) => { - toast.error(error.message); - }); - setRestarting(true); - }, - [sendSetPartition] - ); - - const showPartitionDialog = useCallback( - (version: string, partition: string, install_date: string) => { - setOpenInstallPartitionDialog(true); - setPartitionVersion({ name: version, published_at: install_date }); - setPartition(partition); - }, - [] - ); - - const showFirmwareDialog = useCallback( - (useDevVersion: boolean) => { - setFetchDevVersion(useDevVersion); - void checkUpgradeImportantMessages( - useDevVersion ? latestDevVersion?.name : latestVersion?.name - ); - setOpenInstallDialog(true); - }, - [latestDevVersion, latestVersion, fetchDevVersion] - ); - - const closeInstallDialog = useCallback(() => { - setOpenInstallDialog(false); - }, []); - - const closeInstallPartitionDialog = useCallback(() => { - setOpenInstallPartitionDialog(false); - }, []); - - const handleVersionInfoClose = useCallback(() => { - setShowVersionInfo(0); - setPartitionVersion(undefined); - 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 - ? !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; - - return ( - - ); - }, - [ - usingDevVersion, - devUpgradeAvailable, - stableUpgradeAvailable, - me.admin, - LL, - showFirmwareDialog - ] - ); - - const content = useMemo(() => { - if (!data) { - return ; - } - - return ( - <> - - - {LL.THIS_VERSION()} - - - - - {LL.VERSION()} - - - - {data.emsesp_version} - {data.build_flags && ( - -   ({data.build_flags}) - - )} - setPartitionVersionInfo(data.partition)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - - - - - {LL.PLATFORM()} - - - - {platform} - -   ( - {data.psram ? ( - - ) : ( - - )} - PSRAM) - - - - - - {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?.name} - setShowVersionInfo(1)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(false)} - - - - - {LL.DEVELOPMENT()} - - - - {latestDevVersion?.name} - setShowVersionInfo(2)} - aria-label={LL.FIRMWARE_VERSION_INFO()} - > - - - {showButtons(true)} - - - - - ) : ( - - - {LL.INTERNET_CONNECTION_REQUIRED()} - - )} - {me.admin && ( - <> - - - - - {LL.UPLOAD()} - - - - )} - - - ); - }, [ - 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}; -}; - -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..5d52921a6 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'; @@ -18,13 +18,15 @@ 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 handleMenuToggle = useCallback(() => { + const upgradeAvailable = versions?.current?.upgradeable ?? false; + + const handleMenuToggle = () => { setMenuOpen((prev) => !prev); - }, []); + }; return ( <> @@ -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 a0dbc8354..5cdbf8c58 100644 --- a/interface/src/components/layout/LayoutMenuItem.tsx +++ b/interface/src/components/layout/LayoutMenuItem.tsx @@ -1,7 +1,7 @@ -import { memo, useMemo } from 'react'; +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,60 +11,52 @@ interface LayoutMenuItemProps { label: string; to: string; disabled?: boolean; + badge?: boolean; } const LayoutMenuItemComponent = ({ icon: Icon, label, to, - disabled + disabled, + badge }: 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 ( {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/components/routing/BlockNavigation.tsx b/interface/src/components/routing/BlockNavigation.tsx index 74601d785..cb0cb8c35 100644 --- a/interface/src/components/routing/BlockNavigation.tsx +++ b/interface/src/components/routing/BlockNavigation.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import type { Blocker } from 'react-router'; import { @@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react'; const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { 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 ( { ).upgradeImportantMessageType; setUpgradeImportantMessageType(upgradeImportantMessageType_n); if (upgradeImportantMessageType_n === 0) { - onFileSelected(file); + if (file) { + onFileSelected(file); + } } }) .onError((error) => { diff --git a/interface/src/contexts/authentication/Authentication.tsx b/interface/src/contexts/authentication/Authentication.tsx index dba45cad7..af0a0055c 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(() => { @@ -67,15 +87,16 @@ const Authentication: FC = ({ children }) => { void refresh(); }, [refresh]); - // cache object to prevent re-renders const obj = useMemo( () => ({ 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/i18n/cz/index.ts b/interface/src/i18n/cz/index.ts index fbce09041..ef7726653 100644 --- a/interface/src/i18n/cz/index.ts +++ b/interface/src/i18n/cz/index.ts @@ -247,8 +247,7 @@ const cz: Translation = { TIME_ZONE: 'Časová zóna', ACCESS_POINT: 'Přístupový bod', AP_PROVIDE: 'Povolit přístupový bod', - AP_PROVIDE_TEXT_1: 'Vždy', - AP_PROVIDE_TEXT_2: 'Když je WiFi odpojena', + AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené', AP_PROVIDE_TEXT_3: 'Nikdy', AP_PREFERRED_CHANNEL: 'Preferovaný kanál', AP_HIDE_SSID: 'Skrýt SSID', diff --git a/interface/src/i18n/de/index.ts b/interface/src/i18n/de/index.ts index 3a766efb4..ee0988be4 100644 --- a/interface/src/i18n/de/index.ts +++ b/interface/src/i18n/de/index.ts @@ -247,8 +247,7 @@ const de: Translation = { TIME_ZONE: 'Zeitzone', ACCESS_POINT: 'Zugangspunkt', AP_PROVIDE: 'Aktiviere Zugangspunkt', - AP_PROVIDE_TEXT_1: 'Immer', - AP_PROVIDE_TEXT_2: 'Wenn WiFi nicht verbunden', + AP_PROVIDE_TEXT_2: 'Wenn Netzwerkverbindung verloren geht', AP_PROVIDE_TEXT_3: 'Niemals', AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal', AP_HIDE_SSID: 'Verstecke SSID', diff --git a/interface/src/i18n/en/index.ts b/interface/src/i18n/en/index.ts index 610ed0f00..268b0ff64 100644 --- a/interface/src/i18n/en/index.ts +++ b/interface/src/i18n/en/index.ts @@ -247,8 +247,7 @@ const en: Translation = { TIME_ZONE: 'Time Zone', ACCESS_POINT: 'Access Point', AP_PROVIDE: 'Enable Access Point', - AP_PROVIDE_TEXT_1: 'Always', - AP_PROVIDE_TEXT_2: 'When WiFi is disconnected', + AP_PROVIDE_TEXT_2: 'When network connection is lost', AP_PROVIDE_TEXT_3: 'Never', AP_PREFERRED_CHANNEL: 'Preferred Channel', AP_HIDE_SSID: 'Hide SSID', diff --git a/interface/src/i18n/fr/index.ts b/interface/src/i18n/fr/index.ts index f1945d2ab..380d1a705 100644 --- a/interface/src/i18n/fr/index.ts +++ b/interface/src/i18n/fr/index.ts @@ -247,8 +247,7 @@ const fr: Translation = { TIME_ZONE: 'Fuseau horaire', ACCESS_POINT: "Point d'accès", AP_PROVIDE: "Activer le Point d'Accès", - AP_PROVIDE_TEXT_1: 'toujours', - AP_PROVIDE_TEXT_2: 'quand le WiFi est déconnecté', + AP_PROVIDE_TEXT_2: 'quand la connexion réseau est perdue', AP_PROVIDE_TEXT_3: 'jamais', AP_PREFERRED_CHANNEL: 'Canal préféré', AP_HIDE_SSID: 'Cacher le SSID', diff --git a/interface/src/i18n/it/index.ts b/interface/src/i18n/it/index.ts index df8b4de61..93b666c37 100644 --- a/interface/src/i18n/it/index.ts +++ b/interface/src/i18n/it/index.ts @@ -247,8 +247,7 @@ const it: Translation = { TIME_ZONE: 'Fuso orario', ACCESS_POINT: 'Access Point', AP_PROVIDE: 'Abilita Access Point', - AP_PROVIDE_TEXT_1: 'sempre', - AP_PROVIDE_TEXT_2: 'quando WiFi é disconnessa', + AP_PROVIDE_TEXT_2: 'quando la connessione di rete è persa', AP_PROVIDE_TEXT_3: 'mai', AP_PREFERRED_CHANNEL: 'Canale preferito', AP_HIDE_SSID: 'Nascondi SSID', diff --git a/interface/src/i18n/nl/index.ts b/interface/src/i18n/nl/index.ts index 888c033d8..d87348d58 100644 --- a/interface/src/i18n/nl/index.ts +++ b/interface/src/i18n/nl/index.ts @@ -247,8 +247,7 @@ const nl: Translation = { TIME_ZONE: 'Tijdzone', ACCESS_POINT: 'Access Point', AP_PROVIDE: 'Activeer Access Point', - AP_PROVIDE_TEXT_1: 'altijd', - AP_PROVIDE_TEXT_2: 'als WiFi niet is verbonden', + AP_PROVIDE_TEXT_2: 'als netwerk verbinding verloren gaat', AP_PROVIDE_TEXT_3: 'nooit', AP_PREFERRED_CHANNEL: 'Voorkeurskanaal', AP_HIDE_SSID: 'SSID verbergen', diff --git a/interface/src/i18n/no/index.ts b/interface/src/i18n/no/index.ts index 693ef29e8..841d32689 100644 --- a/interface/src/i18n/no/index.ts +++ b/interface/src/i18n/no/index.ts @@ -247,8 +247,7 @@ const no: Translation = { TIME_ZONE: 'Tidssone', ACCESS_POINT: 'Aksesspunkt', AP_PROVIDE: 'Aktiver Aksesspunkt', - AP_PROVIDE_TEXT_1: 'alltid', - AP_PROVIDE_TEXT_2: 'når WiFi er utilgjengelig', + AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen er utilgjengelig', AP_PROVIDE_TEXT_3: 'aldri', AP_PREFERRED_CHANNEL: 'Foretrukket kanal', AP_HIDE_SSID: 'Skjul SSID', diff --git a/interface/src/i18n/pl/index.ts b/interface/src/i18n/pl/index.ts index c6dd004de..24ac4d078 100644 --- a/interface/src/i18n/pl/index.ts +++ b/interface/src/i18n/pl/index.ts @@ -247,7 +247,6 @@ const pl: BaseTranslation = { TIME_ZONE: 'Strefa czasowa', ACCESS_POINT: '{{Punkt|punktu|}} {{dostępowy|dostępowego|}}', AP_PROVIDE: 'Punkt dostępowy', - AP_PROVIDE_TEXT_1: 'zawsze aktywny', AP_PROVIDE_TEXT_2: 'aktywny jeśli brak połączenia z siecią', AP_PROVIDE_TEXT_3: 'nieaktywny', AP_PREFERRED_CHANNEL: 'Preferowany kanał', diff --git a/interface/src/i18n/sk/index.ts b/interface/src/i18n/sk/index.ts index 5e993e998..286eca096 100644 --- a/interface/src/i18n/sk/index.ts +++ b/interface/src/i18n/sk/index.ts @@ -247,8 +247,7 @@ const sk: Translation = { TIME_ZONE: 'Časová zóna', ACCESS_POINT: 'Prístupový bod', AP_PROVIDE: 'Povoliť prístupový bod', - AP_PROVIDE_TEXT_1: 'vždy', - AP_PROVIDE_TEXT_2: 'keď je WiFi odpojená', + AP_PROVIDE_TEXT_2: 'keď je sieťové pripojenie stratené', AP_PROVIDE_TEXT_3: 'nikdy', AP_PREFERRED_CHANNEL: 'Preferovaný kanál', AP_HIDE_SSID: 'Skryť SSID', diff --git a/interface/src/i18n/sv/index.ts b/interface/src/i18n/sv/index.ts index 032eeb026..43bc9e9a3 100644 --- a/interface/src/i18n/sv/index.ts +++ b/interface/src/i18n/sv/index.ts @@ -247,8 +247,7 @@ const sv: Translation = { TIME_ZONE: 'Tidszon', ACCESS_POINT: 'Accesspunkt', AP_PROVIDE: 'Aktivera accesspunkt', - AP_PROVIDE_TEXT_1: 'alltid', - AP_PROVIDE_TEXT_2: 'när WiFi är nedkopplat', + AP_PROVIDE_TEXT_2: 'när nätverksanslutningen är bortkopplad', AP_PROVIDE_TEXT_3: 'aldrig', AP_PREFERRED_CHANNEL: 'Kanal', AP_HIDE_SSID: 'Göm SSID', diff --git a/interface/src/i18n/tr/index.ts b/interface/src/i18n/tr/index.ts index b6caa3238..93867c4fb 100644 --- a/interface/src/i18n/tr/index.ts +++ b/interface/src/i18n/tr/index.ts @@ -247,8 +247,7 @@ const tr: Translation = { TIME_ZONE: 'Saat dilimi', ACCESS_POINT: 'Erişim Noktası', AP_PROVIDE: 'Erişim noktasını çalıştır', - AP_PROVIDE_TEXT_1: 'her zaman', - AP_PROVIDE_TEXT_2: 'Kablosuz bağlantı kesildiğinde', + AP_PROVIDE_TEXT_2: 'Ağ bağlantısı kesildiğinde', AP_PROVIDE_TEXT_3: 'asla', AP_PREFERRED_CHANNEL: 'Tercih edilen kanal', AP_HIDE_SSID: 'SSID yi gizle', diff --git a/interface/src/types/ap.ts b/interface/src/types/ap.ts index de5fe64ad..5094b0dbb 100644 --- a/interface/src/types/ap.ts +++ b/interface/src/types/ap.ts @@ -1,5 +1,4 @@ export enum APProvisionMode { - AP_MODE_ALWAYS = 0, AP_MODE_DISCONNECTED = 1, AP_NEVER = 2 } 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/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}"`, 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 + }; }; diff --git a/interface/vite.config.ts b/interface/vite.config.ts index ff34966b7..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: { @@ -229,8 +232,7 @@ export default defineConfig( changeOrigin: true, secure: false }, - '/rest': 'http://localhost:3080', - '/gh': 'http://localhost:3080' + '/rest': 'http://localhost:3080' } }, build: { diff --git a/lib/uuid-syslog/src/syslog.cpp b/lib/uuid-syslog/src/syslog.cpp index 4e0eac774..0ebdaebeb 100644 --- a/lib/uuid-syslog/src/syslog.cpp +++ b/lib/uuid-syslog/src/syslog.cpp @@ -231,7 +231,7 @@ SyslogService::QueuedLogMessage::QueuedLogMessage(unsigned long id, std::shared_ : id_(id) , content_(std::move(content)) { // Added for EMS-ESP - if (time_good_ || emsesp::EMSESP::system_.network_connected()) { + if (time_good_ || emsesp::EMSESP::network_.network_connected()) { #if UUID_SYSLOG_HAVE_GETTIMEOFDAY if (gettimeofday(&time_, nullptr) != 0) { time_.tv_sec = (time_t)-1; diff --git a/lib_standalone/ESP32React.h b/lib_standalone/ESP32React.h index cb5644db6..f2026c479 100644 --- a/lib_standalone/ESP32React.h +++ b/lib_standalone/ESP32React.h @@ -12,6 +12,7 @@ #include "SecuritySettingsService.h" #include "StatefulService.h" #include "Network.h" +// #include "IPAddress.h" #include @@ -21,7 +22,6 @@ #define NTP_SETTINGS_FILE "/config/ntpSettings.json" #define EMSESP_SETTINGS_FILE "/config/emsespSettings.json" -#define AP_MODE_ALWAYS 0 class DummySettings { public: // SYSTEM @@ -49,6 +49,22 @@ class DummySettings { uint16_t keepAlive = 60; bool cleanSession = false; uint8_t entity_format = 1; + String CORSOrigin = "*"; + uint8_t tx_power = 0; + String bssid = ""; + String localIP = ""; + String gatewayIP = ""; + String subnetMask = ""; + bool staticIPConfig = false; + String dnsIP1 = ""; + String dnsIP2 = ""; + bool enableMDNS = true; + bool enableCORS = false; + uint8_t channel = 1; + bool ssid_hidden = false; + uint8_t max_clients = 4; + bool ssidHidden = false; + uint8_t maxClients = 4; uint16_t publish_time_boiler = 10; uint16_t publish_time_thermostat = 10; @@ -59,21 +75,10 @@ class DummySettings { uint16_t publish_time_heartbeat = 60; uint32_t publish_time_water = 0; - String hostname = "ems-esp"; - String jwtSecret = "ems-esp"; - String ssid = "ems-esp"; - String password = "ems-esp"; - String bssid = ""; - String localIP = ""; - String gatewayIP = ""; - String subnetMask = ""; - bool staticIPConfig = false; - String dnsIP1 = ""; - String dnsIP2 = ""; - bool enableMDNS = true; - bool enableCORS = false; - String CORSOrigin = "*"; - uint8_t tx_power = 0; + String hostname = "ems-esp"; + String jwtSecret = "ems-esp"; + String ssid = "ems-esp"; + String password = "ems-esp"; // AP uint8_t provisionMode = 0; diff --git a/mock-api/package.json b/mock-api/package.json index dea8d1846..fec2473af 100644 --- a/mock-api/package.json +++ b/mock-api/package.json @@ -1,6 +1,6 @@ { "name": "mock-api", - "version": "3.8.2", + "version": "3.9.0", "description": "mock api for EMS-ESP", "author": "proddy, emsesp.org", "license": "MIT", @@ -15,5 +15,5 @@ "itty-router": "^5.0.23", "prettier": "^3.8.3" }, - "packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820" + "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8" } diff --git a/mock-api/pnpm-lock.yaml b/mock-api/pnpm-lock.yaml index d1f155ffa..1ddedb1b1 100644 --- a/mock-api/pnpm-lock.yaml +++ b/mock-api/pnpm-lock.yaml @@ -46,8 +46,8 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -185,7 +185,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -197,14 +197,14 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} - '@babel/parser@7.29.2': + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -212,7 +212,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -249,7 +249,7 @@ snapshots: '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': dependencies: '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 javascript-natural-sort: 0.7.1 diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index 1e3a7afae..f062fb3ff 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 GH_ENDPOINT_ROOT = '/gh/'; // for mock GitHub API for version checking // HTTP HEADERS for msgpack const headers = { @@ -128,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)', @@ -142,13 +141,13 @@ 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 +// 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) { @@ -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; @@ -403,48 +402,74 @@ function upgradeImportantMessages(version: string) { // see if its a filename with a .bin extension if (version.endsWith('.bin')) { - upgradeImportantMessageType_n = 1; // 1 means 3.9 and factory reset required + upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning factory reset required } else if (version.endsWith('.md')) { - upgradeImportantMessageType_n = 0; + upgradeImportantMessageType_n = 0; // use default 0, no message } else { // this is a version string like "3.9.0" - upgradeImportantMessageType_n = 2; + // upgradeImportantMessageType_n = 2; // make it 2, for testing, meaning a major version upgrade + upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning a factory reset is required + } - console.log('upgradeImportantMessageType: ' + upgradeImportantMessageType_n); + console.log('upgradeImportantMessageType: version=' + version + ' type=' + upgradeImportantMessageType_n); 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 +// 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 currentUpgradeable = + !MOCK_OFFLINE && + (isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE); - 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 + const data: { + current: { + version: string; + type: 'stable' | 'dev'; + date: string; + upgradeable: boolean; }; - } else { - console.log('requesting ems-esp version (' + THIS_VERSION + ')'); - data = { - emsesp_version: THIS_VERSION + 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', + upgradeable: currentUpgradeable + } + }; + + if (!MOCK_OFFLINE) { + data.stable = { + version: LATEST_STABLE_VERSION, + date: '2026-04-25', + upgradeable: STABLE_VERSION_IS_UPGRADEABLE + }; + 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; } @@ -4579,6 +4604,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 }) @@ -5172,13 +5198,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); @@ -5233,27 +5255,6 @@ router return status(404); // not found }); -// Mock GitHub API -// https://api.github.com/repos/emsesp/EMS-ESP32/releases - -router - .get(GH_ENDPOINT_ROOT + '/tags/latest', () => { - 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; - }); - // const logger: ResponseHandler = (response, request) => { // console.log( // response.status, diff --git a/platformio.ini b/platformio.ini index acb518d9b..5abde677e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -91,7 +91,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/mobizt/ReadyMail.git @ 0.4.0 https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3 ; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8 diff --git a/project-words.txt b/project-words.txt index 7a998c037..0ffcdb986 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1330,4 +1330,8 @@ zyxwvutsrqponmlkjihgfedcba öffnen česky živanović -MWDT \ No newline at end of file +MWDT +juststopped +handshaked +startm +netifs diff --git a/sonar-project.properties b/sonar-project.properties index 563719c80..d1d03a7d6 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization=emsesp sonar.projectKey=emsesp_EMS-ESP32 sonar.projectName=EMS-ESP32 -sonar.projectVersion=3.8.2 +sonar.projectVersion=3.9.0 sonar.sources=./src sonar.cfamily.compile-commands=bw-output/compile_commands.json sonar.sourceEncoding=UTF-8 diff --git a/src/ESP32React/APSettingsService.cpp b/src/ESP32React/APSettingsService.cpp index a68037738..05d02a110 100644 --- a/src/ESP32React/APSettingsService.cpp +++ b/src/ESP32React/APSettingsService.cpp @@ -4,110 +4,17 @@ APSettingsService::APSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager) : _httpEndpoint(APSettings::read, APSettings::update, this, server, AP_SETTINGS_SERVICE_PATH, securityManager) - , _fsPersistence(APSettings::read, APSettings::update, this, fs, AP_SETTINGS_FILE) - , _dnsServer(nullptr) - , _lastManaged(0) - , _reconfigureAp(false) - , _connected(0) { - addUpdateHandler([this] { reconfigureAP(); }, false); + , _fsPersistence(APSettings::read, APSettings::update, this, fs, AP_SETTINGS_FILE) { } void APSettingsService::begin() { _fsPersistence.readFromFS(); - // disabled for delayed start, first try station mode - // reconfigureAP(); -} - -void APSettingsService::reconfigureAP() { - _lastManaged = uuid::get_uptime() - MANAGE_NETWORK_DELAY; - _reconfigureAp = true; -} - -void APSettingsService::loop() { - const uint8_t was_connected = _connected; - if (WiFi.isConnected()) { - _connected |= 1U; - } else { - _connected &= ~1U; - } - if (ETH.connected()) { - _connected |= 2U; - } else { - _connected &= ~2U; - } - // wait 10 sec before starting AP - if (was_connected && !_connected) { - _lastManaged = uuid::get_uptime(); - } - const unsigned long currentMillis = uuid::get_uptime(); - if ((currentMillis - _lastManaged) >= MANAGE_NETWORK_DELAY) { - _lastManaged = currentMillis; - manageAP(); - } - - if (_dnsServer) { - handleDNS(); - } -} - -void APSettingsService::manageAP() { - const WiFiMode_t currentWiFiMode = WiFi.getMode(); - if (_state.provisionMode == AP_MODE_ALWAYS || (_state.provisionMode == AP_MODE_DISCONNECTED && !_connected)) { - if (_reconfigureAp || currentWiFiMode == WIFI_OFF || currentWiFiMode == WIFI_STA) { - startAP(); - } - } else if ((currentWiFiMode == WIFI_AP || currentWiFiMode == WIFI_AP_STA) && _connected && (_reconfigureAp || !WiFi.softAPgetStationNum())) { - stopAP(); - } - _reconfigureAp = false; -} - -void APSettingsService::startAP() { - WiFi.softAPenableIPv6(); // force IPV6, same as for WiFi - fixes https://github.com/emsesp/EMS-ESP32/issues/1922 - WiFi.softAPConfig(_state.localIP, _state.gatewayIP, _state.subnetMask); - esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_AP), WIFI_BW_HT20); - WiFi.softAP(_state.ssid.c_str(), _state.password.c_str(), _state.channel, _state.ssidHidden, _state.maxClients); -#if CONFIG_IDF_TARGET_ESP32C3 - WiFi.setTxPower(WIFI_POWER_8_5dBm); // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi -#endif - if (!_dnsServer) { - const IPAddress apIp = WiFi.softAPIP(); - char ipStr[16]; - snprintf(ipStr, sizeof(ipStr), "%u.%u.%u.%u", apIp[0], apIp[1], apIp[2], apIp[3]); - emsesp::EMSESP::logger().info("Starting Access Point with captive portal on %s", ipStr); - _dnsServer = new DNSServer; - _dnsServer->start(DNS_PORT, "*", apIp); - } -} - -void APSettingsService::stopAP() { - if (_dnsServer) { - emsesp::EMSESP::logger().info("Stopping Access Point"); - _dnsServer->stop(); - delete _dnsServer; - _dnsServer = nullptr; - } - WiFi.softAPdisconnect(true); -} - -void APSettingsService::handleDNS() { - if (_dnsServer) { - _dnsServer->processNextRequest(); - } } APNetworkStatus APSettingsService::getAPNetworkStatus() { - const WiFiMode_t currentWiFiMode = WiFi.getMode(); - const bool apActive = (currentWiFiMode == WIFI_AP || currentWiFiMode == WIFI_AP_STA); - - if (apActive && _state.provisionMode != AP_MODE_ALWAYS && WiFi.status() == WL_CONNECTED) { - return APNetworkStatus::LINGERING; - } - - return apActive ? APNetworkStatus::ACTIVE : APNetworkStatus::INACTIVE; + return emsesp::EMSESP::network_.ap_connected() ? APNetworkStatus::ACTIVE : APNetworkStatus::INACTIVE; } - void APSettings::read(const APSettings & settings, JsonObject root) { root["provision_mode"] = settings.provisionMode; root["ssid"] = settings.ssid; @@ -125,12 +32,11 @@ StateUpdateResult APSettings::update(JsonObject root, APSettings & settings) { newSettings.provisionMode = static_cast(root["provision_mode"] | FACTORY_AP_PROVISION_MODE); switch (settings.provisionMode) { - case AP_MODE_ALWAYS: case AP_MODE_DISCONNECTED: case AP_MODE_NEVER: break; default: - newSettings.provisionMode = AP_MODE_ALWAYS; + newSettings.provisionMode = AP_MODE_DISCONNECTED; } newSettings.ssid = root["ssid"] | FACTORY_AP_SSID; @@ -148,5 +54,10 @@ StateUpdateResult APSettings::update(JsonObject root, APSettings & settings) { } settings = newSettings; + + // if the AP mode has changed, force a disconnect and reconnect + if (settings.provisionMode != newSettings.provisionMode) { + emsesp::EMSESP::network_.reconnect(); + } return StateUpdateResult::CHANGED; } diff --git a/src/ESP32React/APSettingsService.h b/src/ESP32React/APSettingsService.h index 6051c5cc5..6c280875b 100644 --- a/src/ESP32React/APSettingsService.h +++ b/src/ESP32React/APSettingsService.h @@ -5,9 +5,6 @@ #include "FSPersistence.h" #include "JsonUtils.h" -#include -#include - #ifndef FACTORY_AP_PROVISION_MODE #define FACTORY_AP_PROVISION_MODE AP_MODE_DISCONNECTED #endif @@ -47,14 +44,10 @@ #define AP_SETTINGS_FILE "/config/apSettings.json" #define AP_SETTINGS_SERVICE_PATH "/rest/apSettings" -#define AP_MODE_ALWAYS 0 #define AP_MODE_DISCONNECTED 1 #define AP_MODE_NEVER 2 -#define MANAGE_NETWORK_DELAY 10000 -#define DNS_PORT 53 - -enum APNetworkStatus { ACTIVE = 0, INACTIVE, LINGERING }; +enum APNetworkStatus { ACTIVE = 0, INACTIVE }; class APSettings { public: @@ -84,26 +77,11 @@ class APSettingsService : public StatefulService { APSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager); void begin(); - void loop(); APNetworkStatus getAPNetworkStatus(); private: HttpEndpoint _httpEndpoint; FSPersistence _fsPersistence; - - // for the captive portal - DNSServer * _dnsServer; - - // for the management delay loop - volatile unsigned long _lastManaged; - volatile bool _reconfigureAp; - volatile uint8_t _connected; - - void reconfigureAP(); - void manageAP(); - void startAP(); - void stopAP(); - void handleDNS(); }; #endif diff --git a/src/ESP32React/APStatus.cpp b/src/ESP32React/APStatus.cpp index c0baa482d..09d8c39b0 100644 --- a/src/ESP32React/APStatus.cpp +++ b/src/ESP32React/APStatus.cpp @@ -1,5 +1,7 @@ #include "APStatus.h" +#include + APStatus::APStatus(AsyncWebServer * server, SecurityManager * securityManager, APSettingsService * apSettingsService) : _apSettingsService(apSettingsService) { securityManager->addEndpoint(server, AP_STATUS_SERVICE_PATH, AuthenticationPredicates::IS_AUTHENTICATED, [this](AsyncWebServerRequest * request) { @@ -12,9 +14,9 @@ void APStatus::apStatus(AsyncWebServerRequest * request) { JsonObject root = response->getRoot(); root["status"] = _apSettingsService->getAPNetworkStatus(); - root["ip_address"] = WiFi.softAPIP().toString(); - root["mac_address"] = WiFi.softAPmacAddress(); - root["station_num"] = WiFi.softAPgetStationNum(); + root["ip_address"] = emsesp::EMSESP::network_.getLocalIP(); + root["mac_address"] = emsesp::EMSESP::network_.getMacAddress(); + root["station_num"] = emsesp::EMSESP::network_.getStationNum(); response->setLength(); request->send(response); diff --git a/src/ESP32React/APStatus.h b/src/ESP32React/APStatus.h index 23ee79305..1105a742a 100644 --- a/src/ESP32React/APStatus.h +++ b/src/ESP32React/APStatus.h @@ -1,12 +1,7 @@ #ifndef APStatus_h #define APStatus_h -#include -#include - #include -#include -#include #include "SecurityManager.h" #include "APSettingsService.h" diff --git a/src/ESP32React/ESP32React.cpp b/src/ESP32React/ESP32React.cpp index 4cba7ee79..79a3fb843 100644 --- a/src/ESP32React/ESP32React.cpp +++ b/src/ESP32React/ESP32React.cpp @@ -101,8 +101,6 @@ void ESP32React::begin() { } void ESP32React::loop() { - _networkSettingsService.loop(); - _apSettingsService.loop(); _mqttSettingsService.loop(); _ntpSettingsService.loop(); } diff --git a/src/ESP32React/ESP32React.h b/src/ESP32React/ESP32React.h index cbcea487e..17c849595 100644 --- a/src/ESP32React/ESP32React.h +++ b/src/ESP32React/ESP32React.h @@ -54,19 +54,6 @@ class ESP32React { return _mqttSettingsService.getMqttClient(); } - // - // special functions needed outside scope - // - - // true if AP is active - bool apStatus() { - return _apSettingsService.getAPNetworkStatus() == APNetworkStatus::ACTIVE; - } - - uint16_t getWifiReconnects() { - return _networkSettingsService.getWifiReconnects(); - } - private: AsyncWebServer * _server; SecuritySettingsService _securitySettingsService; diff --git a/src/ESP32React/MqttSettingsService.cpp b/src/ESP32React/MqttSettingsService.cpp index 7049c5713..3f53e8027 100644 --- a/src/ESP32React/MqttSettingsService.cpp +++ b/src/ESP32React/MqttSettingsService.cpp @@ -79,7 +79,7 @@ void MqttSettingsService::startClient() { } void MqttSettingsService::loop() { - if (_state.enabled && _mqttClient && _mqttClient->connected() && !emsesp::EMSESP::system_.network_connected()) { + if (_state.enabled && _mqttClient && _mqttClient->connected() && !emsesp::EMSESP::network_.network_connected()) { // emsesp::EMSESP::logger().info("Network connection dropped, stopping MQTT client"); _mqttClient->disconnect(true); } @@ -154,7 +154,7 @@ bool MqttSettingsService::configureMqtt() { } // only connect if WiFi is connected and MQTT is enabled - if (_state.enabled && emsesp::EMSESP::system_.network_connected() && !_state.host.isEmpty()) { + if (_state.enabled && emsesp::EMSESP::network_.network_connected() && !_state.host.isEmpty()) { // create the Last Will Testament topic (LWT) with the base prefixed. It has to be static because the client destroys the reference static char will_topic[FACTORY_MQTT_MAX_TOPIC_LENGTH]; if (_state.base.isEmpty()) { diff --git a/src/ESP32React/NTPSettingsService.cpp b/src/ESP32React/NTPSettingsService.cpp index 4faebb1b9..6040c21c8 100644 --- a/src/ESP32React/NTPSettingsService.cpp +++ b/src/ESP32React/NTPSettingsService.cpp @@ -19,8 +19,8 @@ void NTPSettingsService::begin() { } void NTPSettingsService::loop() { - if (_connected != emsesp::EMSESP::system_.network_connected()) { - _connected = emsesp::EMSESP::system_.network_connected(); + if (_connected != emsesp::EMSESP::network_.network_connected()) { + _connected = emsesp::EMSESP::network_.network_connected(); configureNTP(); } } diff --git a/src/ESP32React/NetworkSettingsService.cpp b/src/ESP32React/NetworkSettingsService.cpp index 29b12b19a..af80fe049 100644 --- a/src/ESP32React/NetworkSettingsService.cpp +++ b/src/ESP32React/NetworkSettingsService.cpp @@ -4,484 +4,13 @@ NetworkSettingsService::NetworkSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager) : _httpEndpoint(NetworkSettings::read, NetworkSettings::update, this, server, NETWORK_SETTINGS_SERVICE_PATH, securityManager) - , _fsPersistence(NetworkSettings::read, NetworkSettings::update, this, fs, NETWORK_SETTINGS_FILE) - , _lastConnectionAttempt(0) - , _stopping(false) { - addUpdateHandler([this] { reconfigureWiFiConnection(); }, false); - // Eth is also bound to the WifiGeneric event handler - // Network.onEvent([this](arduino_event_id_t event, arduino_event_info_t info) { WiFiEvent(event, info); }); -} - -static bool formatBssid(const String & bssid, uint8_t (&mac)[6]) { - uint tmp[6]; - if (bssid.isEmpty() || sscanf(bssid.c_str(), "%X:%X:%X:%X:%X:%X", &tmp[0], &tmp[1], &tmp[2], &tmp[3], &tmp[4], &tmp[5]) != 6) { - return false; - } - for (uint8_t i = 0; i < 6; i++) { - mac[i] = static_cast(tmp[i]); - } - return true; + , _fsPersistence(NetworkSettings::read, NetworkSettings::update, this, fs, NETWORK_SETTINGS_FILE) { } void NetworkSettingsService::begin() { - // We want the device to come up in opmode=0 (WIFI_OFF), when erasing the flash this is not the default. - // If needed, we save opmode=0 before disabling persistence so the device boots with WiFi disabled in the future. - if (WiFi.getMode() != WIFI_OFF) { - WiFi.mode(WIFI_OFF); - } - - // Disable WiFi config persistance and auto reconnect - WiFi.persistent(false); - WiFi.setAutoReconnect(false); - - WiFi.mode(WIFI_STA); - WiFi.mode(WIFI_OFF); - - // scan settings give connect issues since arduino 2.0.14 and arduino 3.x.x with some wifi systems - // WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN - // WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set - _fsPersistence.readFromFS(); } -void NetworkSettingsService::reconfigureWiFiConnection() { - // do not disconnect for switching to eth, restart is needed - if (WiFi.isConnected() && _state.ssid.length() == 0) { - return; - } - - // disconnect and de-configure wifi - if (WiFi.disconnect(true)) { - _stopping = true; - } -} - -void NetworkSettingsService::loop() { - unsigned long currentMillis = millis(); - if (!_lastConnectionAttempt || static_cast(currentMillis - _lastConnectionAttempt) >= WIFI_RECONNECTION_DELAY) { - _lastConnectionAttempt = currentMillis; - manageSTA(); - } - static uint8_t connect = 0; - enum uint8_t { - CONNECT_IDLE = 0, - CONNECT_WAIT_ETH, - CONNECT_WAIT_IP4, - CONNECT_WAIT_ETH_IP4, - CONNECT_WAIT_IP6, - CONNECT_WAIT_ETH_IP6, - CONNECT_ETH_ACTIVE, - CONNECT_WIFI_ACTIVE - }; - switch (connect) { - default: - connect = CONNECT_IDLE; - break; - case CONNECT_IDLE: - if (ETH.started() && _state.ssid.length() == 0) { - emsesp::EMSESP::logger().info("ETH started"); - ETH.setHostname(emsesp::EMSESP::system_.hostname().c_str()); - ETH.enableIPv6(true); - if (_state.staticIPConfig) { - ETH.config(_state.localIP, _state.gatewayIP, _state.subnetMask, _state.dnsIP1, _state.dnsIP2); - } - connect = CONNECT_WAIT_ETH; - } - if (WiFi.isConnected()) { - emsesp::EMSESP::logger().info("Wifi connected"); - if (_state.tx_power == 0) { - setWiFiPowerOnRSSI(); - } - mDNS_start(); - emsesp::EMSESP::system_.has_ipv6(true); - connect = CONNECT_WAIT_IP4; - } - break; - case CONNECT_WAIT_ETH: - if (ETH.connected()) { - emsesp::EMSESP::logger().info("ETH connected"); - emsesp::EMSESP::system_.ethernet_connected(true); - mDNS_start(); - emsesp::EMSESP::system_.has_ipv6(true); - connect = CONNECT_WAIT_ETH_IP4; - } - break; - case CONNECT_WAIT_ETH_IP4: - if (ETH.hasIP()) { - emsesp::EMSESP::logger().info("ETH IPv4: %s", ETH.localIP().toString().c_str()); - connect = CONNECT_WAIT_ETH_IP6; - } - if (!ETH.connected()) { - connect = CONNECT_ETH_ACTIVE; - } - break; - case CONNECT_WAIT_ETH_IP6: - if (ETH.hasLinkLocalIPv6() && ETH.hasGlobalIPv6()) { - emsesp::EMSESP::system_.has_ipv6(true); - connect = CONNECT_ETH_ACTIVE; - } - if (!ETH.connected()) { - connect = CONNECT_ETH_ACTIVE; - } - break; - case CONNECT_ETH_ACTIVE: - if (!ETH.connected()) { - emsesp::EMSESP::logger().info("ETH disconnected"); - emsesp::EMSESP::system_.ethernet_connected(false); - emsesp::EMSESP::system_.has_ipv6(false); - connect = CONNECT_IDLE; - } - break; - case CONNECT_WAIT_IP4: - if (!WiFi.localIP().toString().isEmpty()) { - emsesp::EMSESP::logger().info("Wifi IPv4: %s", WiFi.localIP().toString().c_str()); - connect = CONNECT_WAIT_IP6; - } - if (!WiFi.isConnected()) { - connect = CONNECT_ETH_ACTIVE; - } - break; - case CONNECT_WAIT_IP6: - if (WiFi.linkLocalIPv6().toString() != "::" && WiFi.globalIPv6().toString() != "::") { - emsesp::EMSESP::logger().info("Wifi IPv6: %s, %s", WiFi.linkLocalIPv6().toString().c_str(), WiFi.globalIPv6().toString().c_str()); - emsesp::EMSESP::system_.has_ipv6(true); - connect = CONNECT_WIFI_ACTIVE; - } - if (!WiFi.isConnected()) { - connect = CONNECT_WIFI_ACTIVE; - } - break; - case CONNECT_WIFI_ACTIVE: - if (!WiFi.isConnected()) { - emsesp::EMSESP::logger().info("WiFi disconnected"); - if (_stopping) { - _lastConnectionAttempt = 0; - _stopping = false; - } - emsesp::EMSESP::system_.has_ipv6(false); - connect = CONNECT_IDLE; - } - break; - } -} - -void NetworkSettingsService::manageSTA() { - // Abort if already connected, or if we have no SSID - if (WiFi.isConnected() || _state.ssid.length() == 0) { - return; - } - - // Connect or reconnect as required - if ((WiFi.getMode() & WIFI_STA) == 0) { - WiFi.setHostname(_state.hostname.c_str()); // updates shared default_hostname buffer - WiFi.enableSTA(true); // creates the STA netif - WiFi.STA.setHostname(_state.hostname.c_str()); // pushes to esp_netif_set_hostname - WiFi.enableIPv6(true); - if (_state.staticIPConfig) { - WiFi.config(_state.localIP, _state.gatewayIP, _state.subnetMask, _state.dnsIP1, _state.dnsIP2); // configure for static IP - } - - // www.esp32.com/viewtopic.php?t=12055 - if (_state.bandwidth20) { - esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT20); - } else { - esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT40); - } - if (_state.nosleep) { - WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE - } - - // attempt to connect to the network - uint8_t bssid[6]; - if (formatBssid(_state.bssid, bssid)) { - WiFi.begin(_state.ssid.c_str(), _state.password.c_str(), 0, bssid); - } else { - WiFi.begin(_state.ssid.c_str(), _state.password.c_str()); - } - -#ifdef BOARD_C3_MINI_V1 - // always hardcode Tx power for Wemos CS Mini v1 - // v1 needs this value, see https://github.com/emsesp/EMS-ESP32/pull/620#discussion_r993173979 - // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi - WiFi.setTxPower(WIFI_POWER_8_5dBm); -#else - if (_state.tx_power != 0) { - // if not set to Auto (0) set the Tx power now - if (!WiFi.setTxPower(static_cast(_state.tx_power))) { - emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power"); - } - } -#endif - } else { // not connected but STA-mode active => disconnect - reconfigureWiFiConnection(); - } -} - -// set the TxPower based on the RSSI (signal strength), picking the lowest value -// code is based of RSSI (signal strength) and copied from Tasmota's WiFiSetTXpowerBasedOnRssi() which is copied ESPEasy's ESPEasyWifi.SetWiFiTXpower() function -void NetworkSettingsService::setWiFiPowerOnRSSI() { - // Range ESP32 : 2dBm - 20dBm - // 802.11b - wifi1 - // 802.11a - wifi2 - // 802.11g - wifi3 - // 802.11n - wifi4 - // 802.11ac - wifi5 - // 802.11ax - wifi6 - - int max_tx_pwr = MAX_TX_PWR_DBM_n; // assume wifi4 - int threshold = WIFI_SENSITIVITY_n + 120; // Margin in dBm * 10 on top of threshold - - // Assume AP sends with max set by ETSI standard. - // 2.4 GHz: 100 mWatt (20 dBm) - // US and some other countries allow 1000 mW (30 dBm) - int rssi = WiFi.RSSI() * 10; - int newrssi = rssi - 200; // We cannot send with over 20 dBm, thus it makes no sense to force higher TX power all the time. - - int min_tx_pwr = 0; - if (newrssi < threshold) { - min_tx_pwr = threshold - newrssi; - } - if (min_tx_pwr > max_tx_pwr) { - min_tx_pwr = max_tx_pwr; - } - - // from WiFIGeneric.h use: - // WIFI_POWER_19_5dBm = 78,// 19.5dBm - // WIFI_POWER_19dBm = 76,// 19dBm - // WIFI_POWER_18_5dBm = 74,// 18.5dBm - // WIFI_POWER_17dBm = 68,// 17dBm - // WIFI_POWER_15dBm = 60,// 15dBm - // WIFI_POWER_13dBm = 52,// 13dBm - // WIFI_POWER_11dBm = 44,// 11dBm - // WIFI_POWER_8_5dBm = 34,// 8.5dBm - // WIFI_POWER_7dBm = 28,// 7dBm - // WIFI_POWER_5dBm = 20,// 5dBm - // WIFI_POWER_2dBm = 8,// 2dBm - // WIFI_POWER_MINUS_1dBm = -4// -1dBm - wifi_power_t p = WIFI_POWER_2dBm; - if (min_tx_pwr > 185) - p = WIFI_POWER_19_5dBm; - else if (min_tx_pwr > 170) - p = WIFI_POWER_18_5dBm; - else if (min_tx_pwr > 150) - p = WIFI_POWER_17dBm; - else if (min_tx_pwr > 130) - p = WIFI_POWER_15dBm; - else if (min_tx_pwr > 110) - p = WIFI_POWER_13dBm; - else if (min_tx_pwr > 85) - p = WIFI_POWER_11dBm; - else if (min_tx_pwr > 70) - p = WIFI_POWER_8_5dBm; - else if (min_tx_pwr > 50) - p = WIFI_POWER_7dBm; - else if (min_tx_pwr > 20) - p = WIFI_POWER_5dBm; - -#if defined(EMSESP_DEBUG) - // emsesp::EMSESP::logger().debug("Recommended WiFi Tx Power (set_power %d, new power %d, rssi %d, threshold %d)", min_tx_pwr / 10, p, rssi, threshold); -#endif - - if (!WiFi.setTxPower(p)) { - emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power"); - } -} - -// start the multicast UDP service so EMS-ESP is discoverable via .local -void NetworkSettingsService::mDNS_start() const { -#ifndef EMSESP_STANDALONE - MDNS.end(); - - if (_state.enableMDNS) { - if (!MDNS.begin(emsesp::EMSESP::system_.hostname().c_str())) { - emsesp::EMSESP::logger().warning("Failed to start mDNS Responder service"); - return; - } - - std::string address_s = emsesp::EMSESP::system_.hostname() + ".local"; - - MDNS.addService("http", "tcp", 80); // add our web server and rest API - MDNS.addService("telnet", "tcp", 23); // add our telnet console - - // MDNS.addServiceTxt("http", "tcp", "version", EMSESP_APP_VERSION); - MDNS.addServiceTxt("http", "tcp", "address", address_s.c_str()); - - emsesp::EMSESP::logger().info("Starting mDNS Responder service"); - } -#endif -} - -const char * NetworkSettingsService::disconnectReason(uint8_t code) { -#ifndef EMSESP_STANDALONE - switch (code) { - case WIFI_REASON_UNSPECIFIED: // = 1, - return "unspecified"; - case WIFI_REASON_AUTH_EXPIRE: // = 2, - return "auth expire"; - case WIFI_REASON_AUTH_LEAVE: // = 3, - return "auth leave"; - case WIFI_REASON_ASSOC_EXPIRE: // = 4, - return "assoc expired"; - case WIFI_REASON_ASSOC_TOOMANY: // = 5, - return "assoc too many"; - case WIFI_REASON_NOT_AUTHED: // = 6, - return "not authenticated"; - case WIFI_REASON_NOT_ASSOCED: // = 7, - return "not assoc"; - case WIFI_REASON_ASSOC_LEAVE: // = 8, - return "assoc leave"; - case WIFI_REASON_ASSOC_NOT_AUTHED: // = 9, - return "assoc not authed"; - case WIFI_REASON_DISASSOC_PWRCAP_BAD: // = 10, - return "disassoc powerCAP bad"; - case WIFI_REASON_DISASSOC_SUPCHAN_BAD: // = 11, - return "disassoc supchan bad"; - case WIFI_REASON_IE_INVALID: // = 13, - return "IE invalid"; - case WIFI_REASON_MIC_FAILURE: // = 14, - return "MIC failure"; - case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: // = 15, - return "4way handshake timeout"; - case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: // = 16, - return "group key-update timeout"; - case WIFI_REASON_IE_IN_4WAY_DIFFERS: // = 17, - return "IE in 4way differs"; - case WIFI_REASON_GROUP_CIPHER_INVALID: // = 18, - return "group cipher invalid"; - case WIFI_REASON_PAIRWISE_CIPHER_INVALID: // = 19, - return "pairwise cipher invalid"; - case WIFI_REASON_AKMP_INVALID: // = 20, - return "AKMP invalid"; - case WIFI_REASON_UNSUPP_RSN_IE_VERSION: // = 21, - return "unsupported RSN_IE version"; - case WIFI_REASON_INVALID_RSN_IE_CAP: // = 22, - return "invalid RSN_IE_CAP"; - case WIFI_REASON_802_1X_AUTH_FAILED: // = 23, - return "802 X1 auth failed"; - case WIFI_REASON_CIPHER_SUITE_REJECTED: // = 24, - return "cipher suite rejected"; - case WIFI_REASON_BEACON_TIMEOUT: // = 200, - return "beacon timeout"; - case WIFI_REASON_NO_AP_FOUND: // = 201, - return "no AP found"; - case WIFI_REASON_AUTH_FAIL: // = 202, - return "auth fail"; - case WIFI_REASON_ASSOC_FAIL: // = 203, - return "assoc fail"; - case WIFI_REASON_HANDSHAKE_TIMEOUT: // = 204, - return "handshake timeout"; - case WIFI_REASON_CONNECTION_FAIL: // 205, - return "connection fail"; - case WIFI_REASON_AP_TSF_RESET: // 206, - return "AP tsf reset"; - case WIFI_REASON_ROAMING: // 207, - return "roaming"; - case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: // 208, - return "assoc comeback time too long"; - case WIFI_REASON_SA_QUERY_TIMEOUT: // 209, - return "sa query timeout"; - default: - return "unknown"; - } -#endif - - return ""; -} - -// handles both WiFI and Ethernet -void NetworkSettingsService::WiFiEvent(arduino_event_id_t event, arduino_event_info_t info) { -#ifndef EMSESP_STANDALONE - - switch (event) { - case ARDUINO_EVENT_WIFI_STA_STOP: - if (_stopping) { - _lastConnectionAttempt = 0; - _stopping = false; - } - break; - - case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: - connectcount_ = connectcount_ + 1; // count the number of WiFi reconnects - emsesp::EMSESP::logger().warning("WiFi disconnected (#%d). Reason: %s (%d)", - connectcount_, - disconnectReason(info.wifi_sta_disconnected.reason), - info.wifi_sta_disconnected.reason); // IDF 4.0 - emsesp::EMSESP::system_.has_ipv6(false); - - - break; - - case ARDUINO_EVENT_WIFI_STA_GOT_IP: - char result[10]; - emsesp::EMSESP::logger().info("WiFi connected (Local IP=%s, hostname=%s, TxPower=%s dBm)", - WiFi.localIP().toString().c_str(), - WiFi.getHostname(), - emsesp::Helpers::render_value(result, ((double)(WiFi.getTxPower()) / 4), 1)); - mDNS_start(); - break; - - case ARDUINO_EVENT_ETH_START: - // apply hostname FIRST so DHCP DISCOVER carries the correct name - ETH.setHostname(emsesp::EMSESP::system_.hostname().c_str()); - if (_state.staticIPConfig) { - ETH.config(_state.localIP, _state.gatewayIP, _state.subnetMask, _state.dnsIP1, _state.dnsIP2); - } - break; - - case ARDUINO_EVENT_ETH_GOT_IP: - // prevent double calls to mDNS - 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); - mDNS_start(); - } - break; - - case ARDUINO_EVENT_ETH_DISCONNECTED: - emsesp::EMSESP::logger().warning("Ethernet disconnected. Reason: %s (%d)", - disconnectReason(info.wifi_sta_disconnected.reason), - info.wifi_sta_disconnected.reason); - emsesp::EMSESP::system_.ethernet_connected(false); - emsesp::EMSESP::system_.has_ipv6(false); - break; - - case ARDUINO_EVENT_ETH_STOP: - emsesp::EMSESP::logger().info("Ethernet stopped"); - emsesp::EMSESP::system_.ethernet_connected(false); - emsesp::EMSESP::system_.has_ipv6(false); - break; - - case ARDUINO_EVENT_WIFI_STA_CONNECTED: - // Set the TxPower after the connection is established, if we're using TxPower = 0 (Auto) - if (_state.tx_power == 0) { - setWiFiPowerOnRSSI(); - } - break; - - case ARDUINO_EVENT_ETH_CONNECTED: - break; - - // IPv6 specific - WiFi/Eth - case ARDUINO_EVENT_WIFI_STA_GOT_IP6: - case ARDUINO_EVENT_ETH_GOT_IP6: { - auto ip6 = IPAddress(IPv6, (uint8_t *)info.got_ip6.ip6_info.ip.addr, 0).toString(); - const char * link = event == ARDUINO_EVENT_ETH_GOT_IP6 ? "Eth" : "WiFi"; - if (ip6.startsWith("fe80")) { - emsesp::EMSESP::logger().info("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()); - } else { - emsesp::EMSESP::logger().info("IPv6 (%s) global: %s", link, ip6.c_str()); - } - emsesp::EMSESP::system_.has_ipv6(true); - } break; - - default: - break; - } -#endif -} - void NetworkSettings::read(NetworkSettings & settings, JsonObject root) { // connection settings root["ssid"] = settings.ssid; @@ -544,6 +73,7 @@ StateUpdateResult NetworkSettings::update(JsonObject root, NetworkSettings & set } // see if we need to inform the user of a restart + // if tx power, enableCORS, CORSOrigin, ssid changes, we need to restart if (tx_power != settings.tx_power || enableCORS != settings.enableCORS || CORSOrigin != settings.CORSOrigin || (ssid != settings.ssid && settings.ssid.isEmpty())) { return StateUpdateResult::CHANGED_RESTART; // tell WebUI that a restart is needed diff --git a/src/ESP32React/NetworkSettingsService.h b/src/ESP32React/NetworkSettingsService.h index 4635c5181..c9903a681 100644 --- a/src/ESP32React/NetworkSettingsService.h +++ b/src/ESP32React/NetworkSettingsService.h @@ -8,15 +8,10 @@ #ifndef EMSESP_STANDALONE #include -#include -#include -#include -#include #endif #define NETWORK_SETTINGS_FILE "/config/networkSettings.json" #define NETWORK_SETTINGS_SERVICE_PATH "/rest/networkSettings" -#define WIFI_RECONNECTION_DELAY (1000 * 3) #ifndef FACTORY_WIFI_SSID #define FACTORY_WIFI_SSID "" @@ -30,37 +25,6 @@ #define FACTORY_WIFI_HOSTNAME "" #endif -// copied from Tasmota -#if CONFIG_IDF_TARGET_ESP32S2 -#define MAX_TX_PWR_DBM_11b 195 -#define MAX_TX_PWR_DBM_54g 150 -#define MAX_TX_PWR_DBM_n 130 -#define WIFI_SENSITIVITY_11b -880 -#define WIFI_SENSITIVITY_54g -750 -#define WIFI_SENSITIVITY_n -720 -#elif CONFIG_IDF_TARGET_ESP32S3 -#define MAX_TX_PWR_DBM_11b 210 -#define MAX_TX_PWR_DBM_54g 190 -#define MAX_TX_PWR_DBM_n 185 -#define WIFI_SENSITIVITY_11b -880 -#define WIFI_SENSITIVITY_54g -760 -#define WIFI_SENSITIVITY_n -720 -#elif CONFIG_IDF_TARGET_ESP32C2 || CONFIG_IDF_TARGET_ESP32C3 -#define MAX_TX_PWR_DBM_11b 210 -#define MAX_TX_PWR_DBM_54g 190 -#define MAX_TX_PWR_DBM_n 185 -#define WIFI_SENSITIVITY_11b -880 -#define WIFI_SENSITIVITY_54g -760 -#define WIFI_SENSITIVITY_n -730 -#else -#define MAX_TX_PWR_DBM_11b 195 -#define MAX_TX_PWR_DBM_54g 160 -#define MAX_TX_PWR_DBM_n 140 -#define WIFI_SENSITIVITY_11b -880 -#define WIFI_SENSITIVITY_54g -750 -#define WIFI_SENSITIVITY_n -700 -#endif - class NetworkSettings { public: // core wifi configuration @@ -92,27 +56,10 @@ class NetworkSettingsService : public StatefulService { NetworkSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager); void begin(); - void loop(); - - uint16_t getWifiReconnects() const { - return connectcount_; - } private: HttpEndpoint _httpEndpoint; FSPersistence _fsPersistence; - - volatile unsigned long _lastConnectionAttempt; - volatile bool _stopping; - - volatile uint16_t connectcount_ = 0; // number of wifi reconnects - - void WiFiEvent(arduino_event_id_t event, arduino_event_info_t info); - void mDNS_start() const; - const char * disconnectReason(uint8_t code); - void reconfigureWiFiConnection(); - void manageSTA(); - void setWiFiPowerOnRSSI(); }; #endif diff --git a/src/ESP32React/NetworkStatus.cpp b/src/ESP32React/NetworkStatus.cpp index 9003be9b2..cbfbe5152 100644 --- a/src/ESP32React/NetworkStatus.cpp +++ b/src/ESP32React/NetworkStatus.cpp @@ -16,11 +16,10 @@ void NetworkStatus::networkStatus(AsyncWebServerRequest * request) { auto * response = new AsyncJsonResponse(false); JsonObject root = response->getRoot(); - bool ethernet_connected = emsesp::EMSESP::system_.ethernet_connected(); - wl_status_t wifi_status = WiFi.status(); + wl_status_t wifi_status = WiFi.status(); // see if Ethernet is connected - if (ethernet_connected) { + if (emsesp::EMSESP::network_.ethernet_connected()) { root["status"] = 10; // custom code #10 - ETHERNET_STATUS_CONNECTED root["hostname"] = ETH.getHostname(); } else { @@ -29,7 +28,7 @@ void NetworkStatus::networkStatus(AsyncWebServerRequest * request) { } // for both connections show ethernet - if (ethernet_connected) { + if (emsesp::EMSESP::network_.ethernet_connected()) { // Ethernet root["local_ip"] = ETH.localIP().toString(); root["local_ipv6"] = ETH.linkLocalIPv6().toString(); @@ -52,7 +51,7 @@ void NetworkStatus::networkStatus(AsyncWebServerRequest * request) { root["ssid"] = WiFi.SSID(); root["bssid"] = WiFi.BSSIDstr(); root["channel"] = WiFi.channel(); - root["reconnect_count"] = emsesp::EMSESP::esp32React.getWifiReconnects(); + root["reconnect_count"] = emsesp::EMSESP::network_.getWifiReconnects(); root["subnet_mask"] = WiFi.subnetMask().toString(); if (WiFi.gatewayIP() != INADDR_NONE) { 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/console.cpp b/src/core/console.cpp index c84adaf2d..eb2d5146a 100644 --- a/src/core/console.cpp +++ b/src/core/console.cpp @@ -202,7 +202,7 @@ static void setup_commands(std::shared_ptr const & commands) { commands->add_command(ShellContext::MAIN, CommandFlags::ADMIN, string_vector{F_(wifi), F_(reconnect)}, - [](Shell & shell, const std::vector & arguments) { EMSESP::system_.wifi_reconnect(); }); + [](Shell & shell, const std::vector & arguments) { EMSESP::network_.reconnect(); }); // // SET commands @@ -250,12 +250,12 @@ static void setup_commands(std::shared_ptr const & commands) { shell.enter_password(F_(new_password_prompt2), [password1](Shell & shell, bool completed, const std::string & password2) { if (completed) { if (password1 == password2) { - EMSESP::esp32React.getNetworkSettingsService()->updateWithoutPropagation([&](NetworkSettings & networkSettings) { + EMSESP::esp32React.getNetworkSettingsService()->update([&](NetworkSettings & networkSettings) { networkSettings.password = password2.c_str(); return StateUpdateResult::CHANGED; }); shell.println("WiFi password updated. Reconnecting..."); - EMSESP::system_.wifi_reconnect(); + EMSESP::network_.reconnect(); } else { shell.println("Passwords do not match"); } @@ -277,6 +277,7 @@ static void setup_commands(std::shared_ptr const & commands) { networkSettings.hostname = arguments.front().c_str(); return StateUpdateResult::CHANGED; }); + EMSESP::network_.reconnect(); }); commands->add_command(ShellContext::MAIN, @@ -284,12 +285,14 @@ static void setup_commands(std::shared_ptr const & commands) { string_vector{F_(set), F_(wifi), F_(ssid)}, {F_(name_mandatory)}, [](Shell & shell, const std::vector & arguments) { - EMSESP::esp32React.getNetworkSettingsService()->updateWithoutPropagation([&](NetworkSettings & networkSettings) { + shell.println("The network connection will be reset..."); + Shell::loop_all(); + delay(1000); // wait a second + EMSESP::esp32React.getNetworkSettingsService()->update([&](NetworkSettings & networkSettings) { networkSettings.ssid = arguments.front().c_str(); return StateUpdateResult::CHANGED; }); - shell.println("WiFi ssid updated. Reconnecting..."); - EMSESP::system_.wifi_reconnect(); + EMSESP::network_.reconnect(); }); diff --git a/src/core/emsesp.cpp b/src/core/emsesp.cpp index a0fa75bc0..330210591 100644 --- a/src/core/emsesp.cpp +++ b/src/core/emsesp.cpp @@ -86,6 +86,7 @@ TxService EMSESP::txservice_; // outgoing Telegram Tx handler Mqtt EMSESP::mqtt_; // mqtt handler Modbus * EMSESP::modbus_ = nullptr; // modbus handler System EMSESP::system_; // core system services +Network EMSESP::network_; // network services TemperatureSensor EMSESP::temperaturesensor_; // Temperature sensors AnalogSensor EMSESP::analogsensor_; // Analog sensors Shower EMSESP::shower_; // Shower logic @@ -1721,13 +1722,13 @@ void EMSESP::start() { // start web log service. now we can start capturing logs to the web log webLogService.begin(); - // loads core system services settings (network, mqtt, ap, ntp etc) + // loads core system services settings (mqtt, ap, ntp etc) esp32React.begin(); #ifndef EMSESP_STANDALONE if (factory_settings) { LOG_WARNING("No settings found on filesystem. Using factory settings."); - // make sure OTAdata is updated with core3 format + // make sure OTAdata is updated with core3 (v3.9.0) format esp_ota_set_boot_partition(esp_ota_get_running_partition()); } #endif @@ -1777,6 +1778,9 @@ void EMSESP::start() { webSettingsService.begin(); // load EMS-ESP Application settings + // start network services. This will initialise WiFi or Ethernet depending on the settings. + network_.begin(); + // perform any system upgrades if (!factory_settings) { if (system_.check_upgrade()) { @@ -1813,10 +1817,10 @@ void EMSESP::start() { analogsensor_.start(factory_settings); // Analog external sensors // start web services + LOG_INFO("Starting Web Server"); webLogService.start(); // apply settings to weblog service webModulesService.begin(); // setup the external library modules webServer.begin(); // start the web server - LOG_INFO("Starting Web Server"); } void EMSESP::start_serial_console() { @@ -1847,6 +1851,9 @@ void EMSESP::shell_prompt() { void EMSESP::loop() { uuid::loop(); // store system uptime + // handle network + network_.loop(); + // handles LED and checks system health, and syslog service if (system_.loop()) { return; // LED flashing is active, skip the rest of the loop @@ -1867,6 +1874,7 @@ void EMSESP::loop() { } // loop through the services + webStatusService.loop(); // periodic refresh of cached versions.json rxservice_.loop(); // process any incoming Rx telegrams shower_.loop(); // check for shower on/off temperaturesensor_.loop(); // read sensor temperatures diff --git a/src/core/emsesp.h b/src/core/emsesp.h index 416c1bea7..cd9bd62c4 100644 --- a/src/core/emsesp.h +++ b/src/core/emsesp.h @@ -63,6 +63,7 @@ #include "mqtt.h" #include "modbus.h" #include "system.h" +#include "network.h" #include "temperaturesensor.h" #include "analogsensor.h" #include "console.h" @@ -228,6 +229,7 @@ class EMSESP { static Mqtt mqtt_; static Modbus * modbus_; static System system_; + static Network network_; static TemperatureSensor temperaturesensor_; static AnalogSensor analogsensor_; static Shower shower_; 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/locale_common.h b/src/core/locale_common.h index d5b88c789..dc54f45fd 100644 --- a/src/core/locale_common.h +++ b/src/core/locale_common.h @@ -60,6 +60,7 @@ MAKE_WORD(mqtt) MAKE_WORD(gpio) MAKE_WORD(modbus) MAKE_WORD(emsesp) +MAKE_WORD(network) MAKE_WORD(connected) MAKE_WORD(disconnected) MAKE_WORD(passwd) diff --git a/src/core/mqtt.cpp b/src/core/mqtt.cpp index 7c63923f3..bb501cafb 100644 --- a/src/core/mqtt.cpp +++ b/src/core/mqtt.cpp @@ -563,7 +563,7 @@ void Mqtt::ha_status() { // create the HA sensors - must match the MQTT payload keys in the heartbeat topic // Note we don't use camelCase as it would change the HA entity_id and impact historic data #ifndef EMSESP_STANDALONE - if (!EMSESP::system_.ethernet_connected() || WiFi.isConnected()) { + if (EMSESP::network_.wifi_connected()) { publish_system_ha_sensor_config(DeviceValueType::INT8, "RSSI", "rssi", DeviceValueUOM::DBM); publish_system_ha_sensor_config(DeviceValueType::INT8, "Signal", "wifistrength", DeviceValueUOM::PERCENT); } @@ -585,7 +585,7 @@ void Mqtt::ha_status() { publish_system_ha_sensor_config(DeviceValueType::INT8, "CPU temperature", "temperature", DeviceValueUOM::DEGREES); #endif - if (!EMSESP::system_.ethernet_connected()) { + if (!EMSESP::network_.ethernet_connected()) { publish_system_ha_sensor_config(DeviceValueType::INT16, "WiFi reconnects", "wifireconnects", DeviceValueUOM::NONE); } // This one comes from the info MQTT topic - and handled in the publish_ha_sensor_config function diff --git a/src/core/network.cpp b/src/core/network.cpp new file mode 100644 index 000000000..46045c211 --- /dev/null +++ b/src/core/network.cpp @@ -0,0 +1,679 @@ +/* + * EMS-ESP - https://github.com/emsesp/EMS-ESP + * Copyright 2020-2025 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 "network.h" + +#include "emsesp.h" + +namespace emsesp { + +uuid::log::Logger Network::logger_{F_(network), uuid::log::Facility::KERN}; + +void Network::begin() { +#ifndef EMSESP_STANDALONE + // pull Network settings and store locally on stack + EMSESP::esp32React.getNetworkSettingsService()->read([&](auto & settings) { + enableMDNS_ = settings.enableMDNS; + staticIPConfig_ = settings.staticIPConfig; + localIP_ = settings.localIP; + gatewayIP_ = settings.gatewayIP; + subnetMask_ = settings.subnetMask; + dnsIP1_ = settings.dnsIP1; + dnsIP2_ = settings.dnsIP2; + hostname_ = settings.hostname; + ssid_ = settings.ssid; + password_ = settings.password; + bandwidth20_ = settings.bandwidth20; + nosleep_ = settings.nosleep; + tx_power_ = settings.tx_power; + bssid_ = settings.bssid; + }); + + // read Ethernet settings + EMSESP::webSettingsService.read([&](WebSettings & settings) { + phy_type_ = settings.phy_type; + eth_power_ = settings.eth_power; + eth_phy_addr_ = settings.eth_phy_addr; + eth_clock_mode_ = settings.eth_clock_mode; + }); + + // get Access Point settings + EMSESP::esp32React.getAPSettingsService()->read([&](APSettings & settings) { + ap_provisionMode_ = settings.provisionMode; + ap_ssid_ = settings.ssid; + ap_password_ = settings.password; + ap_channel_ = settings.channel; + ap_ssid_hidden_ = settings.ssidHidden; + ap_max_clients_ = settings.maxClients; + ap_localIP_ = settings.localIP; + ap_gatewayIP_ = settings.gatewayIP; + ap_subnetMask_ = settings.subnetMask; + }); + + // Initialise WiFi - we only do this once, when the network service is started. + // We want the device to come up in opmode=0 (WIFI_OFF), which is not the default after a flash erase. + // Persistence is true by default, so this WiFi.mode() call writes opmode=0 to NVS for future boots. + WiFi.mode(WIFI_OFF); + + // From here on, mode changes stay in RAM only and don't touch NVS + WiFi.persistent(false); + WiFi.setAutoReconnect(false); + WiFi.mode(WIFI_STA); + + // scan settings give connect issues since arduino 2.0.14 and arduino 3.x.x with some wifi systems + // WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN + // WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set + + // capture the WIFI_REASON_* code on every STA disconnect event so check_connection() can + // log a meaningful reason when its periodic poll notices we're no longer associated. + // Also release the connect-pending guard so the next loop tick can issue a fresh WiFi.begin() + WiFi.onEvent( + [this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) { + last_disconnect_reason_ = info.wifi_sta_disconnected.reason; + wifi_connect_pending_ = false; + }, + ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + + // clear the saved reason and the connect-pending guard on a fresh STA association + WiFi.onEvent( + [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { + last_disconnect_reason_ = 0; + wifi_connect_pending_ = false; + }, + ARDUINO_EVENT_WIFI_STA_GOT_IP); +#endif +} + +// format the BSSID (MAC address) of the network interface +bool Network::formatBSSID(const String & bssid, uint8_t (&mac)[6]) { +#ifndef EMSESP_STANDALONE + uint tmp[6]; + if (bssid.isEmpty() || sscanf(bssid.c_str(), "%X:%X:%X:%X:%X:%X", &tmp[0], &tmp[1], &tmp[2], &tmp[3], &tmp[4], &tmp[5]) != 6) { + return false; + } + for (uint8_t i = 0; i < 6; i++) { + mac[i] = static_cast(tmp[i]); + } +#endif + return true; +} + +// get the local IP address of the network interface +std::string Network::getLocalIP() const { + switch (network_iface_) { +#ifndef EMSESP_STANDALONE + case NetIface::AP: + return WiFi.softAPIP().toString().c_str(); + case NetIface::WIFI: + return WiFi.localIP().toString().c_str(); + case NetIface::ETHERNET: + return ETH.localIP().toString().c_str(); + case NetIface::NONE: +#endif + default: + return ""; + } +} + +// get the MAC address of the network interface +std::string Network::getMacAddress() const { + switch (network_iface_) { +#ifndef EMSESP_STANDALONE + case NetIface::AP: + return WiFi.softAPmacAddress().c_str(); + case NetIface::WIFI: + return WiFi.macAddress().c_str(); + case NetIface::ETHERNET: + return ETH.macAddress().c_str(); + case NetIface::NONE: +#endif + default: + return ""; + } +} + +// get the number of stations connected to the AP +uint8_t Network::getStationNum() const { +#ifndef EMSESP_STANDALONE + return network_iface_ == NetIface::AP ? WiFi.softAPgetStationNum() : 0; +#else + return 0; +#endif +} + +// disconnect all WiFi, Eth and AP +// so we can starts searching again to reconnect +void Network::reconnect() { + LOG_DEBUG("Disconnecting all networks"); + +#ifndef EMSESP_STANDALONE + // disconnect WiFi + if (wifi_connected()) { + WiFi.disconnect(true); + } + + // disconnect AP + if (WiFi.getMode() & WIFI_AP) { + stopAP(); + } +#endif + + // reset network state + network_ip_ = 0; + network_iface_ = NetIface::NONE; + has_ipv6_ = false; + juststopped_ = true; + wifi_connect_pending_ = false; + last_disconnect_reason_ = 0; + + // reload the network settings, as this could be called from the console + begin(); +} + +// network loop, looking for new and disconnecting networks +void Network::loop() { +#ifndef EMSESP_STANDALONE + // if we already have a Wifi or Ethernet connection then re-check every NETWORK_RECONNECTION_DELAY_LONG, otherwise NETWORK_RECONNECTION_DELAY_SHORT + const unsigned long currentMillis = millis(); + const uint32_t reconnectDelay = + (network_iface_ == NetIface::WIFI || network_iface_ == NetIface::ETHERNET) ? NETWORK_RECONNECTION_DELAY_LONG : NETWORK_RECONNECTION_DELAY_SHORT; + if (!lastConnectionAttempt_ || static_cast(currentMillis - lastConnectionAttempt_) >= reconnectDelay) { + lastConnectionAttempt_ = currentMillis; + + // manage network interfaces + startAP(); // Captive Portal (AP) + startWIFI(); // WiFi + startEthernet(); // Ethernet + + // already have a connection: verify it's still alive + // or trigger if the WiFi handshaked failed on the WiFi.begin() call + if (network_ip_ != 0 || last_disconnect_reason_ != 0) { + checkConnection(); + } + findNetworks(); // detect new connections + } + + // process DNS requests for the captive portal while the soft-AP is up + if (ap_dnsServer_) { + ap_dnsServer_->processNextRequest(); + } +#endif +} + +// Re-validate the currently active connection +// if a netif is no longer up or has lost its IP (cable unplugged, AP gone, DHCP lease lost, ...) we drop our state so +// find_networks() can pick up a new one +void Network::checkConnection() { +#ifndef EMSESP_STANDALONE + bool still_up = false; + for (esp_netif_t * netif = esp_netif_next_unsafe(NULL); netif != NULL; netif = esp_netif_next_unsafe(netif)) { + if (iface_from_desc(esp_netif_get_desc(netif)) != network_iface_) { + continue; + } + esp_netif_ip_info_t ip_info = {}; + if (esp_netif_is_netif_up(netif) && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr == network_ip_) { + still_up = true; + } + break; // only one active netif per kind in our world (sta / eth / ap) + } + + if (!still_up) { + if (network_iface_ == NetIface::WIFI) { + uint8_t reason = last_disconnect_reason_; + if (reason == 0) { + reason = WIFI_REASON_UNSPECIFIED; // event hasn't fired yet (or was cleared); avoid logging "0" + } + LOG_INFO("WiFi connection lost (reason %u: %s)", reason, disconnectReason(reason)); + } else { + LOG_INFO("Ethernet connection lost"); + } + reconnect(); + } +#endif +} + +// set the WiFi TxPower based on the RSSI (signal strength), picking the lowest value +// code is based of RSSI (signal strength) and copied from Tasmota's WiFiSetTXpowerBasedOnRssi() which is copied ESPEasy's ESPEasyWifi.SetWiFiTXpower() function +// Range ESP32 : 2dBm - 20dBm +// 802.11b - wifi1 +// 802.11a - wifi2 +// 802.11g - wifi3 +// 802.11n - wifi4 +// 802.11ac - wifi5 +// 802.11ax - wifi6 +// tx_power is the Tx power to set, 0 for auto +void Network::setWiFiPower(uint8_t tx_power) { +#ifndef EMSESP_STANDALONE + if (tx_power != 0) { + if (!WiFi.setTxPower(static_cast(tx_power))) { + emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power"); + } + return; + } + + int max_tx_pwr = MAX_TX_PWR_DBM_n; // assume wifi4 + int threshold = WIFI_SENSITIVITY_n + 120; // Margin in dBm * 10 on top of threshold + + // Assume AP sends with max set by ETSI standard. + // 2.4 GHz: 100 mWatt (20 dBm) + // US and some other countries allow 1000 mW (30 dBm) + int rssi = WiFi.RSSI() * 10; + int newrssi = rssi - 200; // We cannot send with over 20 dBm, thus it makes no sense to force higher TX power all the time. + + int min_tx_pwr = 0; + if (newrssi < threshold) { + min_tx_pwr = threshold - newrssi; + } + if (min_tx_pwr > max_tx_pwr) { + min_tx_pwr = max_tx_pwr; + } + + // from WiFIGeneric.h use: + wifi_power_t p = WIFI_POWER_2dBm; + if (min_tx_pwr > 185) + p = WIFI_POWER_19_5dBm; + else if (min_tx_pwr > 170) + p = WIFI_POWER_18_5dBm; + else if (min_tx_pwr > 150) + p = WIFI_POWER_17dBm; + else if (min_tx_pwr > 130) + p = WIFI_POWER_15dBm; + else if (min_tx_pwr > 110) + p = WIFI_POWER_13dBm; + else if (min_tx_pwr > 85) + p = WIFI_POWER_11dBm; + else if (min_tx_pwr > 70) + p = WIFI_POWER_8_5dBm; + else if (min_tx_pwr > 50) + p = WIFI_POWER_7dBm; + else if (min_tx_pwr > 20) + p = WIFI_POWER_5dBm; + +#if defined(EMSESP_DEBUG) + // emsesp::EMSESP::logger().debug("Recommended WiFi Tx Power (set_power %d, new power %d, rssi %d, threshold %d)", min_tx_pwr / 10, p, rssi, threshold); +#endif + + if (!WiFi.setTxPower(p)) { + emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power"); + } +#endif +} + +// start the multicast UDP service so EMS-ESP is discoverable via .local +void Network::startmDNS() const { +#ifndef EMSESP_STANDALONE + MDNS.end(); + + if (!enableMDNS_) { + return; + } + + if (!MDNS.begin(emsesp::EMSESP::system_.hostname().c_str())) { + emsesp::EMSESP::logger().warning("Failed to start mDNS Responder service"); + return; + } + + std::string address_s = emsesp::EMSESP::system_.hostname() + ".local"; + + MDNS.addService("http", "tcp", 80); // add our web server and rest API + MDNS.addService("telnet", "tcp", 23); // add our telnet console + MDNS.addServiceTxt("http", "tcp", "address", address_s.c_str()); + + emsesp::EMSESP::logger().info("Starting mDNS Responder service"); +#endif +} + +// WiFi disconnect reason code to string +const char * Network::disconnectReason(uint8_t code) { +#ifndef EMSESP_STANDALONE + switch (code) { + case WIFI_REASON_UNSPECIFIED: // = 1, + return "unspecified"; + case WIFI_REASON_AUTH_EXPIRE: // = 2, + return "auth expire"; + case WIFI_REASON_AUTH_LEAVE: // = 3, + return "auth leave"; + case WIFI_REASON_ASSOC_EXPIRE: // = 4, + return "assoc expired"; + case WIFI_REASON_ASSOC_TOOMANY: // = 5, + return "assoc too many"; + case WIFI_REASON_NOT_AUTHED: // = 6, + return "not authenticated"; + case WIFI_REASON_NOT_ASSOCED: // = 7, + return "not assoc"; + case WIFI_REASON_ASSOC_LEAVE: // = 8, + return "assoc leave"; + case WIFI_REASON_ASSOC_NOT_AUTHED: // = 9, + return "assoc not authed"; + case WIFI_REASON_DISASSOC_PWRCAP_BAD: // = 10, + return "disassoc powerCAP bad"; + case WIFI_REASON_DISASSOC_SUPCHAN_BAD: // = 11, + return "disassoc supchan bad"; + case WIFI_REASON_IE_INVALID: // = 13, + return "IE invalid"; + case WIFI_REASON_MIC_FAILURE: // = 14, + return "MIC failure"; + case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: // = 15, + return "4way handshake timeout"; + case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: // = 16, + return "group key-update timeout"; + case WIFI_REASON_IE_IN_4WAY_DIFFERS: // = 17, + return "IE in 4way differs"; + case WIFI_REASON_GROUP_CIPHER_INVALID: // = 18, + return "group cipher invalid"; + case WIFI_REASON_PAIRWISE_CIPHER_INVALID: // = 19, + return "pairwise cipher invalid"; + case WIFI_REASON_AKMP_INVALID: // = 20, + return "AKMP invalid"; + case WIFI_REASON_UNSUPP_RSN_IE_VERSION: // = 21, + return "unsupported RSN_IE version"; + case WIFI_REASON_INVALID_RSN_IE_CAP: // = 22, + return "invalid RSN_IE_CAP"; + case WIFI_REASON_802_1X_AUTH_FAILED: // = 23, + return "802 X1 auth failed"; + case WIFI_REASON_CIPHER_SUITE_REJECTED: // = 24, + return "cipher suite rejected"; + case WIFI_REASON_BEACON_TIMEOUT: // = 200, + return "beacon timeout"; + case WIFI_REASON_NO_AP_FOUND: // = 201, + return "no AP found"; + case WIFI_REASON_AUTH_FAIL: // = 202, + return "auth fail"; + case WIFI_REASON_ASSOC_FAIL: // = 203, + return "assoc fail"; + case WIFI_REASON_HANDSHAKE_TIMEOUT: // = 204, + return "handshake timeout"; + case WIFI_REASON_CONNECTION_FAIL: // 205, + return "connection fail"; + case WIFI_REASON_AP_TSF_RESET: // 206, + return "AP tsf reset"; + case WIFI_REASON_ROAMING: // 207, + return "roaming"; + case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: // 208, + return "assoc comeback time too long"; + case WIFI_REASON_SA_QUERY_TIMEOUT: // 209, + return "sa query timeout"; + default: + return "unknown"; + } +#endif + + return ""; +} + +// WiFi management +void Network::startWIFI() { +#ifndef EMSESP_STANDALONE + // Abort if already connected, or if we have no SSID or another Wifi.begin() is already in progress + if (WiFi.isConnected() || ssid_.length() == 0 || wifi_connect_pending_) { + return; + } + + WiFi.setHostname(hostname_.c_str()); // updates shared default_hostname buffer + WiFi.enableSTA(true); // creates the STA netif + WiFi.STA.setHostname(hostname_.c_str()); // pushes to esp_netif_set_hostname + WiFi.enableIPv6(true); + if (staticIPConfig_) { + WiFi.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); // configure for static IP + } + + // www.esp32.com/viewtopic.php?t=12055 + if (bandwidth20_) { + esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT20); + } else { + esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT40); + } + if (nosleep_) { + WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE + } + + // attempt to connect to the network + uint8_t bssid[6]; + wl_status_t status; + wifi_connect_pending_ = true; // set before begin() so the event handlers can race-clear it safely + + if (formatBSSID(bssid_, bssid)) { + status = WiFi.begin(ssid_.c_str(), password_.c_str(), 0, bssid); + } else { + status = WiFi.begin(ssid_.c_str(), password_.c_str()); + } + if (status == WL_CONNECT_FAILED) { + wifi_connect_pending_ = false; // begin() didn't actually start anything, allow next tick to retry + LOG_ERROR("WiFi connection failed (code %d)", status); + } + +#ifdef BOARD_C3_MINI_V1 + // always hardcode Tx power for Wemos CS Mini v1 + // v1 needs this value, see https://github.com/emsesp/EMS-ESP32/pull/620#discussion_r993173979 + // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi + WiFi.setTxPower(WIFI_POWER_8_5dBm); +#else + setWiFiPower(tx_power_); +#endif + +#endif +} + +// Ethernet management +// Brings up the ETH driver / netif exactly once. After ETH.begin() returns true the driver +// continues to run autonomously (link negotiation, DHCP, etc); the loop must NOT call ETH.begin() +// again on every iteration because that thrashes the netif and can prevent DHCP from completing +void Network::startEthernet() { +#if CONFIG_IDF_TARGET_ESP32 + // already up and running, nothing to do + if (eth_started_) { + return; + } + +#ifndef EMSESP_STANDALONE + + // no ethernet present or wifi takes precedence + if (phy_type_ == PHY_type::PHY_TYPE_NONE || (ssid_.length() > 0)) { + return; + } + + // configure Ethernet + int mdc = 23; // Pin# of the I²C clock signal for the Ethernet PHY - hardcoded + int mdio = 18; // Pin# of the I²C IO signal for the Ethernet PHY - hardcoded + uint8_t phy_addr = eth_phy_addr_; // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) + int8_t power = eth_power_; // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) + eth_phy_type_t type = (phy_type_ == PHY_type::PHY_TYPE_LAN8720) ? ETH_PHY_LAN8720 + : (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_TLK110 + : ETH_PHY_RTL8201; // Type of the Ethernet PHY (LAN8720 or TLK110) + // clock mode: + // ETH_CLOCK_GPIO0_IN = 0 RMII clock input to GPIO0 + // ETH_CLOCK_GPIO0_OUT = 1 RMII clock output from GPIO0 + // ETH_CLOCK_GPIO16_OUT = 2 RMII clock output from GPIO16 + // ETH_CLOCK_GPIO17_OUT = 3 RMII clock output from GPIO17, for 50hz inverted clock + auto clock_mode = (eth_clock_mode_t)eth_clock_mode_; + + // reset power and add a delay as ETH doesn't not always start up correctly after a warm boot + if (eth_power_ != -1) { + pinMode(eth_power_, OUTPUT); + digitalWrite(eth_power_, LOW); + delay(500); + digitalWrite(eth_power_, HIGH); + } + + if (ETH.begin(type, phy_addr, mdc, mdio, power, clock_mode)) { + eth_started_ = true; // mark up; do not re-enter this block until reboot / explicit teardown + ETH.setHostname(hostname_.c_str()); // Push hostname to the ETH netif immediately after it's created + ETH.enableIPv6(true); + if (staticIPConfig_) { + ETH.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); + } + } else { + LOG_ERROR("Failed to start Ethernet"); + } +#endif +#endif +} + +// check if the network is connected and set network_ip_ / network_iface_ / has_ipv6_ +// Iterates over every esp-netif that exists, prioritizing Ethernet > WiFi > AP +bool Network::findNetworks() { +#ifndef EMSESP_STANDALONE + + // exit if already have a connection, unless in AP mode + // when in AP mode, it will always try and connect to the WiFi + if (network_ip_ != 0 && !(WiFi.getMode() & WIFI_AP)) { + // const esp_ip4_addr_t ip4 = {.addr = network_ip_}; + // LOG_DEBUG("Network already connected via IPv4: " IPSTR, IP2STR(&ip4)); + return true; + } + + struct NetInfo { + esp_ip4_addr_t ip; + esp_ip6_addr_t ip6; + char desc[8]; + bool has_ipv6; + } info = {}; + + // Preference order: ETHERNET > WIFI (STA) > AP + auto iface_priority = [](NetIface iface) -> uint8_t { + switch (iface) { + case NetIface::ETHERNET: + return 3; + case NetIface::WIFI: + return 2; + case NetIface::AP: + return 1; + case NetIface::NONE: + default: + return 0; + } + }; + + NetIface best_iface = NetIface::NONE; + for (esp_netif_t * netif = esp_netif_next_unsafe(NULL); netif != NULL; netif = esp_netif_next_unsafe(netif)) { + const char * desc = esp_netif_get_desc(netif); + bool is_up = esp_netif_is_netif_up(netif); + esp_netif_ip_info_t ip_info = {}; + esp_err_t err = esp_netif_get_ip_info(netif, &ip_info); + + if (!is_up || err != ESP_OK || ip_info.ip.addr == 0) { + continue; + } + + const NetIface candidate = iface_from_desc(desc); + if (iface_priority(candidate) <= iface_priority(best_iface)) { + continue; // already have something at least as good + } + + info.ip = ip_info.ip; + if (desc) { + strlcpy(info.desc, desc, sizeof(info.desc)); + } + info.has_ipv6 = (esp_netif_get_ip6_linklocal(netif, &info.ip6) == ESP_OK); + best_iface = candidate; + + if (best_iface == NetIface::ETHERNET) { + break; // top priority, can't be beaten by anything later in the list + } + } + + auto previous_iface = NetIface::NONE; + + // if we have a connection and it's a new one, set it up + if (best_iface != NetIface::NONE && best_iface != previous_iface) { + previous_iface = network_iface_; // save the previous interface for comparison next time + network_ip_ = info.ip.addr; + network_iface_ = iface_from_desc(info.desc); // "sta"/"ap"/"eth*" + has_ipv6_ = info.has_ipv6; + connect_retry_ = 0; + + LOG_INFO("Network connected via %s (IP: " IPSTR ")", + network_iface_ == NetIface::ETHERNET ? "Ethernet" + : network_iface_ == NetIface::WIFI ? "WiFi" + : network_iface_ == NetIface::AP ? "AP" + : "unknown", + IP2STR(&info.ip)); + + // if we have a Eth or Wifi connection and the AP is running, stop it + if (network_iface_ != NetIface::AP && WiFi.getMode() & WIFI_AP) { + stopAP(); + } + + // count the number of restarts (for Wifi and Eth) + if (juststopped_) { + juststopped_ = false; + connectcount_++; + LOG_DEBUG("Network re-connection count %d", connectcount_); + } + + // start mDNS for any real network interface (skip the SoftAP since the captive portal handles its own DNS) + if (enableMDNS_ && network_iface_ != NetIface::AP && network_iface_ != NetIface::NONE) { + startmDNS(); + } + + return true; // we have a network connection + } + + // fallback + network_ip_ = 0; + network_iface_ = NetIface::NONE; + has_ipv6_ = false; + connect_retry_++; + LOG_DEBUG("No active network interfaces found yet, re-connection count %d", connect_retry_); +#endif + return false; // no connection found yet +} + +// access point (soft-AP) and the captive portal +void Network::startAP() { +#ifndef EMSESP_STANDALONE + // Only start AP as a fallback if the Network has failed + if (connect_retry_ < MAX_NETWORK_RECONNECTION_ATTEMPTS) { + return; + } + + // don't start the soft-AP if it is disabled, or Ethernet has taken over or we have a real WiFi connection or it's already running + if (ap_provisionMode_ == AP_MODE_NEVER || network_connected() || WiFi.getMode() & WIFI_AP) { + return; + } + + WiFi.softAPenableIPv6(); // force IPv6, same as for STA - fixes https://github.com/emsesp/EMS-ESP32/issues/1922 + WiFi.softAPConfig(ap_localIP_, ap_gatewayIP_, ap_subnetMask_); + esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_AP), WIFI_BW_HT20); + WiFi.softAP(ap_ssid_.c_str(), ap_password_.c_str(), ap_channel_, ap_ssid_hidden_, ap_max_clients_); +#if CONFIG_IDF_TARGET_ESP32C3 + WiFi.setTxPower(WIFI_POWER_8_5dBm); // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi +#endif + const IPAddress apIp = WiFi.softAPIP(); + LOG_INFO("Starting Access Point with captive portal on %u.%u.%u.%u", apIp[0], apIp[1], apIp[2], apIp[3]); + + // start DNS server for Captive Portal + ap_dnsServer_ = new DNSServer; + ap_dnsServer_->start(DNS_PORT, "*", apIp); +#endif +} + +// stop AP +void Network::stopAP() { + LOG_INFO("Stopping Access Point"); +#ifndef EMSESP_STANDALONE + WiFi.softAPdisconnect(true); + if (ap_dnsServer_) { + ap_dnsServer_->stop(); + delete ap_dnsServer_; + ap_dnsServer_ = nullptr; + } +#endif +} + +} // namespace emsesp diff --git a/src/core/network.h b/src/core/network.h new file mode 100644 index 000000000..577f3f5a4 --- /dev/null +++ b/src/core/network.h @@ -0,0 +1,225 @@ +/* + * EMS-ESP - https://github.com/emsesp/EMS-ESP + * Copyright 2020-2025 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_NETWORK_H_ +#define EMSESP_NETWORK_H_ + +#ifndef EMSESP_STANDALONE +#include +#include +#include +#include +#include +#include +#endif + +#include + +#include +#include + +namespace emsesp { + +#define NETWORK_RECONNECTION_DELAY_SHORT 3000 // 3 seconds +#define NETWORK_RECONNECTION_DELAY_LONG 60000 // 60 seconds + +#define MAX_NETWORK_RECONNECTION_ATTEMPTS 3 // maximum number of network reconnection attempts + +#define DNS_PORT 53 + +// copied from Tasmota +#if CONFIG_IDF_TARGET_ESP32S2 +#define MAX_TX_PWR_DBM_11b 195 +#define MAX_TX_PWR_DBM_54g 150 +#define MAX_TX_PWR_DBM_n 130 +#define WIFI_SENSITIVITY_11b -880 +#define WIFI_SENSITIVITY_54g -750 +#define WIFI_SENSITIVITY_n -720 +#elif CONFIG_IDF_TARGET_ESP32S3 +#define MAX_TX_PWR_DBM_11b 210 +#define MAX_TX_PWR_DBM_54g 190 +#define MAX_TX_PWR_DBM_n 185 +#define WIFI_SENSITIVITY_11b -880 +#define WIFI_SENSITIVITY_54g -760 +#define WIFI_SENSITIVITY_n -720 +#elif CONFIG_IDF_TARGET_ESP32C2 || CONFIG_IDF_TARGET_ESP32C3 +#define MAX_TX_PWR_DBM_11b 210 +#define MAX_TX_PWR_DBM_54g 190 +#define MAX_TX_PWR_DBM_n 185 +#define WIFI_SENSITIVITY_11b -880 +#define WIFI_SENSITIVITY_54g -760 +#define WIFI_SENSITIVITY_n -730 +#else +#define MAX_TX_PWR_DBM_11b 195 +#define MAX_TX_PWR_DBM_54g 160 +#define MAX_TX_PWR_DBM_n 140 +#define WIFI_SENSITIVITY_11b -880 +#define WIFI_SENSITIVITY_54g -750 +#define WIFI_SENSITIVITY_n -700 +#endif + +// which physical interface we are currently using for the active network connection. +// Mapped from the esp-netif description string returned by esp_netif_get_desc(): "sta" -> WIFI, +// "ap" -> AP, "eth"/"eth1"/"eth2"/... (arduino-esp32 v3.x suffixes ETH netifs because it supports +// multiple ETH instances) -> ETHERNET. Anything else stays as NONE. +enum class NetIface : uint8_t { + NONE = 0, + WIFI, + ETHERNET, + AP, +}; + +class Network { + public: + void begin(); + void loop(); + + uint16_t getWifiReconnects() const { + return connectcount_; + } + + uint32_t network_ip() const { + return network_ip_; + } + + NetIface network_iface() const { + return network_iface_; + } + + bool ethernet_connected() const { + return network_iface_ == NetIface::ETHERNET && network_ip_ != 0; + } + + bool wifi_connected() const { + return network_iface_ == NetIface::WIFI && network_ip_ != 0; + } + + bool ap_connected() const { + return network_iface_ == NetIface::AP && network_ip_ != 0; + } + + bool network_connected() const { + return ethernet_connected() || wifi_connected(); // don't include AP here + } + + bool has_ipv6() const { + return has_ipv6_ && (network_iface_ == NetIface::WIFI || network_iface_ == NetIface::ETHERNET); + } + + uint16_t getWifiReconnects() { + return connectcount_; + } + + std::string getLocalIP() const; + std::string getMacAddress() const; + uint8_t getStationNum() const; + + void reconnect(); + + // map a netif description string (from esp_netif_get_desc) to a NetIface + static NetIface iface_from_desc(const char * desc) { + if (!desc) { + return NetIface::NONE; + } + if (strcmp(desc, "sta") == 0) { + return NetIface::WIFI; + } + if (strcmp(desc, "ap") == 0) { + return NetIface::AP; + } + // any "eth*" (eth, eth0, eth1, ...) is treated as Ethernet + if (strncmp(desc, "eth", 3) == 0) { + return NetIface::ETHERNET; + } + return NetIface::NONE; + } + + private: + static uuid::log::Logger logger_; + + bool findNetworks(); + void checkConnection(); + void startmDNS() const; + bool formatBSSID(const String & bssid, uint8_t (&mac)[6]); + void startAP(); + void startWIFI(); + void startEthernet(); + void setWiFiPower(uint8_t tx_power); + const char * disconnectReason(uint8_t code); + void stopAP(); + +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-private-field" +#endif + + unsigned long lastConnectionAttempt_ = 0; + uint16_t connectcount_ = 0; // number of network reconnects + uint32_t network_ip_ = 0; + NetIface network_iface_ = NetIface::NONE; + bool has_ipv6_ = false; + bool juststopped_ = false; + bool eth_started_ = false; // true after ETH.begin() has succeeded once; prevents repeated re-init while DHCP is still running + volatile uint8_t last_disconnect_reason_ = 0; + uint16_t connect_retry_ = 0; // number of network re-connection attempts + volatile bool wifi_connect_pending_ = false; + + // Network and AP settings + bool enableMDNS_; + bool staticIPConfig_; + IPAddress localIP_; + IPAddress gatewayIP_; + IPAddress subnetMask_; + IPAddress dnsIP1_; + IPAddress dnsIP2_; + String hostname_; + String ssid_; + String password_; + bool bandwidth20_; + bool nosleep_; + uint8_t tx_power_; + String bssid_; + uint8_t phy_type_; + int8_t eth_power_; + uint8_t eth_phy_addr_; + uint8_t eth_clock_mode_; + + // AP settings + uint8_t ap_provisionMode_; + String ap_ssid_; + String ap_password_; + uint8_t ap_channel_; + bool ap_ssid_hidden_; + uint8_t ap_max_clients_; + IPAddress ap_localIP_; + IPAddress ap_gatewayIP_; + IPAddress ap_subnetMask_; + +#if defined(__clang__) +#pragma clang diagnostic pop +#endif + +// for the captive portal in AP mode +#ifndef EMSESP_STANDALONE + DNSServer * ap_dnsServer_; +#endif +}; + +} // namespace emsesp + +#endif diff --git a/src/core/system.cpp b/src/core/system.cpp index b39ce5e15..b420e13b1 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -17,6 +17,7 @@ */ #include "system.h" +#include "network.h" #include "emsesp.h" // for send_raw_telegram() command #ifndef EMSESP_STANDALONE @@ -31,7 +32,7 @@ #include #include -#include "EMSESP_Version.h" +#include "firmwareVersion.h" #if defined(EMSESP_TEST) #include "../test/test.h" @@ -485,7 +486,7 @@ void System::set_partition_install_date() { snprintf(c, sizeof(c), "d_%s", current_partition); time_t d = EMSESP::nvs_.getULong(c, 0); if (d < 1500000000L) { - LOG_DEBUG("Setting the install date in partition %s", current_partition); + LOG_DEBUG("Setting the NTP install date in partition %s", current_partition); auto t = time(nullptr) - uuid::get_uptime_sec(); EMSESP::nvs_.putULong(c, t); } @@ -586,15 +587,6 @@ void System::system_restart(const char * partitionname) { #endif } -// saves all settings -void System::wifi_reconnect() { - EMSESP::esp32React.getNetworkSettingsService()->read( - [](NetworkSettings & networkSettings) { LOG_INFO("WiFi reconnecting to SSID '%s'...", networkSettings.ssid.c_str()); }); - delay(500); // wait - EMSESP::webSettingsService.save(); // save local settings - EMSESP::esp32React.getNetworkSettingsService()->callUpdateHandlers(); // in case we've changed ssid or password -} - void System::syslog_init() { EMSESP::webSettingsService.read([&](WebSettings & settings) { syslog_enabled_ = settings.syslog_enabled; @@ -673,14 +665,9 @@ void System::store_settings(WebSettings & settings) { bool_dashboard_ = settings.bool_dashboard; enum_format_ = settings.enum_format; readonly_mode_ = settings.readonly_mode; - - phy_type_ = settings.phy_type; - eth_power_ = settings.eth_power; - eth_phy_addr_ = settings.eth_phy_addr; - eth_clock_mode_ = settings.eth_clock_mode; - locale_ = settings.locale; developer_mode_ = settings.developer_mode; + // start services if (settings.modbus_enabled) { if (EMSESP::modbus_ == nullptr) { @@ -736,9 +723,10 @@ void System::start() { commands_init(); // console & api commands led_init(); // init LED button_init(); // button - network_init(); // network - uart_init(); // start UART - syslog_init(); // start syslog + + last_system_check_ = 0; // force the LED to go from fast flash to pulse + uart_init(); // start UART + syslog_init(); // start syslog } // button single click @@ -756,9 +744,10 @@ void System::button_OnClick(PButton & b) { // button double click void System::button_OnDblClick(PButton & b) { LOG_NOTICE("Button pressed - double click - wifi reconnect to AP"); +#ifndef EMSESP_STANDALONE // set AP mode to always so will join AP if wifi ssid fails to connect EMSESP::esp32React.getAPSettingsService()->update([&](APSettings & apSettings) { - apSettings.provisionMode = AP_MODE_ALWAYS; + apSettings.provisionMode = AP_MODE_DISCONNECTED; return StateUpdateResult::CHANGED; }); // remove SSID from network settings @@ -766,7 +755,8 @@ void System::button_OnDblClick(PButton & b) { networkSettings.ssid = ""; return StateUpdateResult::CHANGED; }); - EMSESP::esp32React.getNetworkSettingsService()->callUpdateHandlers(); // in case we've changed ssid or password + EMSESP::network_.reconnect(); // reconnect to the network +#endif } // LED flash every 100ms @@ -904,7 +894,8 @@ bool System::loop() { // this is only done once when the connection is established void System::send_info_mqtt() { static uint8_t _connection = 0; - uint8_t connection = (ethernet_connected() ? 1 : 0) + ((WiFi.status() == WL_CONNECTED) ? 2 : 0) + (ntp_connected_ ? 4 : 0) + (has_ipv6_ ? 8 : 0); + uint8_t connection = (EMSESP::network_.ethernet_connected() ? 1 : 0) + (EMSESP::network_.wifi_connected() ? 2 : 0) + (ntp_connected_ ? 4 : 0) + + (EMSESP::network_.has_ipv6() ? 8 : 0); // check if connection status has changed if (!Mqtt::connected() || connection == _connection) { return; @@ -924,7 +915,7 @@ void System::send_info_mqtt() { } #ifndef EMSESP_STANDALONE - if (EMSESP::system_.ethernet_connected()) { + if (EMSESP::network_.ethernet_connected()) { doc["network"] = "ethernet"; doc["hostname"] = ETH.getHostname(); /* @@ -937,7 +928,7 @@ void System::send_info_mqtt() { } */ - } else if (WiFi.status() == WL_CONNECTED) { + } else if (EMSESP::network_.wifi_connected()) { doc["network"] = "wifi"; doc["hostname"] = WiFi.getHostname(); doc["SSID"] = WiFi.SSID(); @@ -997,19 +988,24 @@ void System::heartbeat_json(JsonObject output) { #ifndef EMSESP_STANDALONE output["freemem"] = getHeapMem(); output["max_alloc"] = getMaxAllocMem(); +#endif #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_) { + if (!EMSESP::network_.ethernet_connected()) { int8_t rssi = WiFi.RSSI(); output["rssi"] = rssi; output["wifistrength"] = wifi_quality(rssi); - output["wifireconnects"] = EMSESP::esp32React.getWifiReconnects(); + output["wifireconnects"] = EMSESP::network_.getWifiReconnects(); } #endif + + // see if there is a newer version available + if (EMSESP::webStatusService.versions_cache_valid()) { + output["upgradeable"] = EMSESP::webStatusService.current_upgradeable(); + } } // send periodic MQTT message with system information @@ -1023,49 +1019,6 @@ void System::send_heartbeat() { Mqtt::queue_publish(F_(heartbeat), json); // send to MQTT with retain off. This will add to MQTT queue. } -// initializes network -void System::network_init() { - last_system_check_ = 0; // force the LED to go from fast flash to pulse - -#if CONFIG_IDF_TARGET_ESP32 - bool disableEth; - EMSESP::esp32React.getNetworkSettingsService()->read([&](NetworkSettings & settings) { disableEth = settings.ssid.length() > 0; }); - - // no ethernet present or disabled - if (phy_type_ == PHY_type::PHY_TYPE_NONE || disableEth) { - return; - } // no ethernet present - - // configure Ethernet - int mdc = 23; // Pin# of the I²C clock signal for the Ethernet PHY - hardcoded - int mdio = 18; // Pin# of the I²C IO signal for the Ethernet PHY - hardcoded - uint8_t phy_addr = eth_phy_addr_; // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) - int8_t power = eth_power_; // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) - eth_phy_type_t type = (phy_type_ == PHY_type::PHY_TYPE_LAN8720) ? ETH_PHY_LAN8720 - : (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_TLK110 - : ETH_PHY_RTL8201; // Type of the Ethernet PHY (LAN8720 or TLK110) - // clock mode: - // ETH_CLOCK_GPIO0_IN = 0 RMII clock input to GPIO0 - // ETH_CLOCK_GPIO0_OUT = 1 RMII clock output from GPIO0 - // ETH_CLOCK_GPIO16_OUT = 2 RMII clock output from GPIO16 - // ETH_CLOCK_GPIO17_OUT = 3 RMII clock output from GPIO17, for 50hz inverted clock - auto clock_mode = (eth_clock_mode_t)eth_clock_mode_; - - // reset power and add a delay as ETH doesn't not always start up correctly after a warm boot - if (eth_power_ != -1) { - pinMode(eth_power_, OUTPUT); - digitalWrite(eth_power_, LOW); - delay(500); - digitalWrite(eth_power_, HIGH); - } - eth_present_ = ETH.begin(type, phy_addr, mdc, mdio, power, clock_mode); - if (eth_present_) { - // Push hostname to the ETH netif immediately after it's created - ETH.setHostname(hostname_.c_str()); - } -#endif -} - // check health of system, done every 5 seconds void System::system_check() { uint32_t current_uptime = uuid::get_uptime(); @@ -1084,7 +1037,7 @@ void System::system_check() { #endif // check if we have a valid network connection - if (!ethernet_connected() && (WiFi.status() != WL_CONNECTED)) { + if (!EMSESP::network_.network_connected()) { healthcheck_ |= HEALTHCHECK_NO_NETWORK; } else { healthcheck_ &= ~HEALTHCHECK_NO_NETWORK; @@ -1402,7 +1355,7 @@ void System::show_system(uuid::console::Shell & shell) { } // show Ethernet if connected - if (ethernet_connected_) { + if (EMSESP::network_.ethernet_connected()) { shell.println(); shell.printfln(" Ethernet Status: connected"); shell.printfln(" Ethernet MAC address: %s", ETH.macAddress().c_str()); @@ -1622,8 +1575,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()); @@ -1694,6 +1647,21 @@ bool System::check_upgrade() { } return changed; }); + EMSESP::network_.reconnect(); + } + + // changes going to v3.9 from an earlier version + if (settings_version.major() == 3 && settings_version.minor() < 9) { +#ifndef EMSESP_STANDALONE + EMSESP::esp32React.getAPSettingsService()->update([&](APSettings & apSettings) { + if (apSettings.provisionMode == 0) { + apSettings.provisionMode = AP_MODE_DISCONNECTED; // AP_MODE_ALWAYS has been removed + LOG_INFO("Upgrade: Setting AP provision mode to auto"); + return StateUpdateResult::CHANGED; + } + return StateUpdateResult::UNCHANGED; + }); +#endif } // changes to application settings @@ -2348,7 +2316,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; @@ -2379,14 +2346,14 @@ String System::get_ip_or_hostname() { #ifndef EMSESP_STANDALONE EMSESP::esp32React.getNetworkSettingsService()->read([&](NetworkSettings & settings) { if (settings.enableMDNS) { - if (EMSESP::system_.ethernet_connected()) { + if (EMSESP::network_.ethernet_connected()) { result = ETH.getHostname(); } else if (WiFi.status() == WL_CONNECTED) { result = WiFi.getHostname(); } } else { // no DNS, use the IP - if (EMSESP::system_.ethernet_connected()) { + if (EMSESP::network_.ethernet_connected()) { result = ETH.localIP().toString(); } else if (WiFi.status() == WL_CONNECTED) { result = WiFi.localIP().toString(); @@ -2470,7 +2437,7 @@ bool System::command_info(const char * value, const int8_t id, JsonObject output // Network Status node = output["network"].to(); #ifndef EMSESP_STANDALONE - if (EMSESP::system_.ethernet_connected()) { + if (EMSESP::network_.ethernet_connected()) { node["network"] = "Ethernet"; node["hostname"] = ETH.getHostname(); // node["MAC"] = ETH.macAddress(); @@ -2484,7 +2451,7 @@ bool System::command_info(const char * value, const int8_t id, JsonObject output node["network"] = "WiFi"; node["hostname"] = WiFi.getHostname(); node["RSSI"] = WiFi.RSSI(); - node["WIFIReconnects"] = EMSESP::esp32React.getWifiReconnects(); + node["WIFIReconnects"] = EMSESP::network_.getWifiReconnects(); // node["MAC"] = WiFi.macAddress(); // node["IPv4 address"] = uuid::printable_to_string(WiFi.localIP()) + "/" + uuid::printable_to_string(WiFi.subnetMask()); // node["IPv4 gateway"] = uuid::printable_to_string(WiFi.gatewayIP()); diff --git a/src/core/system.h b/src/core/system.h index 52f6b0e5c..35a86b580 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -141,7 +141,6 @@ class System { static bool uploadFirmwareURL(const char * url = nullptr); void led_init(); - void network_init(); void button_init(); void commands_init(); void uart_init(); @@ -260,33 +259,9 @@ class System { hostname_ = hostname; } - bool ethernet_connected() { - return ethernet_connected_; - } - - void ethernet_connected(bool b) { - ethernet_connected_ = b; - } - - void has_ipv6(bool b) { - has_ipv6_ = b; - } - - bool has_ipv6() { - return has_ipv6_; - } - void ntp_connected(bool b); bool ntp_connected(); - bool network_connected() { -#ifndef EMSESP_STANDALONE - return (ethernet_connected() || WiFi.isConnected()); -#else - return true; -#endif - } - void fahrenheit(bool b) { fahrenheit_ = b; } @@ -312,8 +287,6 @@ class System { void show_system(uuid::console::Shell & shell); void show_users(uuid::console::Shell & shell); - void wifi_reconnect(); - static std::string languages_string(); uint32_t FStotal() { @@ -442,15 +415,11 @@ class System { uint8_t healthcheck_ = HEALTHCHECK_NO_NETWORK | HEALTHCHECK_NO_BUS; // start with all flags set, no wifi and no ems bus connection uint32_t last_system_check_ = 0; - bool upload_isrunning_ = false; // true if we're in the middle of a OTA firmware upload - bool ethernet_connected_ = false; - bool has_ipv6_ = false; + bool upload_isrunning_ = false; // true if we're in the middle of a OTA firmware upload bool ntp_connected_ = false; uint32_t ntp_last_check_ = 0; - bool eth_present_ = false; - // EMS-ESP settings std::string hostname_; String locale_; @@ -482,17 +451,10 @@ class System { uint8_t modbus_max_clients_; uint32_t modbus_timeout_; bool developer_mode_; - - // ethernet - uint8_t phy_type_; - int8_t eth_power_; - uint8_t eth_phy_addr_; - uint8_t eth_clock_mode_; - - uint32_t fstotal_; - uint32_t psram_; - uint32_t appused_; - uint32_t appfree_; + uint32_t fstotal_; + uint32_t psram_; + uint32_t appused_; + uint32_t appfree_; #if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2 temperature_sensor_handle_t temperature_handle_ = NULL; 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 0aea80b67..1f2b49104 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.9.0-dev.0" +#define EMSESP_APP_VERSION "3.9.0-dev.1" 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()); diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index d97c4d4e8..d12ddc226 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -20,6 +20,7 @@ #ifndef EMSESP_STANDALONE #include +#include #endif namespace emsesp { @@ -79,9 +80,9 @@ void WebStatusService::systemStatus(AsyncWebServerRequest * request) { } #endif - root["ap_status"] = EMSESP::esp32React.apStatus(); + root["ap_status"] = EMSESP::network_.ap_connected(); - if (EMSESP::system_.ethernet_connected()) { + if (EMSESP::network_.ethernet_connected()) { root["network_status"] = 10; // custom code #10 - ETHERNET_STATUS_CONNECTED root["wifi_rssi"] = 0; } else { @@ -205,11 +206,11 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json) bool is_admin = AuthenticationPredicates::IS_ADMIN(authentication); // call action command - bool ok = false; + bool ok = true; std::string action = json["action"]; - if (action == "checkUpgrade") { - ok = checkUpgrade(root, param); // param could be empty, if so only send back version + if (action == "getVersions") { + getVersions(root); } else if (action == "setPartition") { ok = EMSESP::system_.set_partition(param.c_str()); } else if (action == "export") { @@ -224,10 +225,8 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json) ok = setSystemStatus(param.c_str()); } else if (action == "resetMQTT" && is_admin) { EMSESP::mqtt_.reset_mqtt(); - ok = true; } else if (action == "upgradeImportantMessages") { root["upgradeImportantMessageType"] = upgradeImportantMessages(param); - ok = true; } #if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY) @@ -262,7 +261,7 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) { // it's a filename with a .bin or .md extension, try and extract the version from it // e.g. EMS-ESP-3_8_2-dev_13-ESP32-16MB+.bin -> major=3 minor=8 patch=2 - version::EMSESP_Version latest_version; + FirmwareVersion latest_version; if ((version.find(".bin") != std::string::npos) || (version.find(".md") != std::string::npos)) { std::string filename = version; auto pos = filename.find("EMS-ESP-"); @@ -283,18 +282,18 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) { std::string major_version = filename.substr(pos, underscore1 - pos); std::string minor_version = filename.substr(underscore1 + 1, underscore2 - underscore1 - 1); std::string patch_version = filename.substr(underscore2 + 1, dash - underscore2 - 1); - latest_version = version::EMSESP_Version(major_version + "." + minor_version + "." + patch_version); + latest_version = FirmwareVersion(major_version + "." + minor_version + "." + patch_version); } else { // if it's .json file exit if (version.find(".json") != std::string::npos) { return 0; } else { // treat it like a version string like "3.9.0" - latest_version = version::EMSESP_Version(version); + latest_version = FirmwareVersion(version); } } - version::EMSESP_Version current_version(current_version_s); // get current version + FirmwareVersion current_version(current_version_s); // get current version if ((current_version.major() <= 3 && current_version.minor() <= 8) && (latest_version.major() == 3 && latest_version.minor() == 9)) { return 1; // if moving from below 3.8.x to 3.9.x return 1 @@ -311,46 +310,166 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) { return 0; // if it's not a valid version upgrade return 0 } -// action = checkUpgrade -// versions holds the latest development version and stable version in one string, comma separated -bool WebStatusService::checkUpgrade(JsonObject root, std::string & version) { - if (!version.empty()) { - version::EMSESP_Version current_version(current_version_s); - version::EMSESP_Version latest_dev_version(version.substr(0, version.find(','))); - version::EMSESP_Version latest_stable_version(version.substr(version.find(',') + 1)); +// action = getVersions +// returns the device's current version for dev and stable +// The remote fetch runs from the main loop task via WebStatusService::loop() so that we never block the AsyncTCP callback +void WebStatusService::getVersions(JsonObject root) { + FirmwareVersion current_version(current_version_s); + bool is_dev = current_version.prerelease().find("dev") != std::string::npos; - bool dev_upgradeable = latest_dev_version > current_version; - bool stable_upgradeable = latest_stable_version > current_version; + JsonObject current = root["current"].to(); + current["version"] = current_version_s; + current["type"] = is_dev ? "dev" : "stable"; + current["date"] = ""; + current["upgradeable"] = current_upgradeable(); // false if cache not valid yet -#if defined(EMSESP_DEBUG) - // look for dev in the name to determine if we're using a dev release - bool using_dev_version = !current_version.prerelease().find("dev"); - EMSESP::logger() - .debug("Checking version upgrade. This version=%d.%d.%d-%s (%s),latest dev=%d.%d.%d-%s (%s upgradeable),latest stable=%d.%d.%d-%s (%s upgradeable)", - current_version.major(), - current_version.minor(), - current_version.patch(), - current_version.prerelease().c_str(), - using_dev_version ? "Dev" : "Stable", - latest_dev_version.major(), - latest_dev_version.minor(), - latest_dev_version.patch(), - latest_dev_version.prerelease().c_str(), - dev_upgradeable ? "is" : "is not", - latest_stable_version.major(), - latest_stable_version.minor(), - latest_stable_version.patch(), - latest_stable_version.prerelease().c_str(), - stable_upgradeable ? "is" : "is not"); -#endif - - root["dev_upgradeable"] = dev_upgradeable; - root["stable_upgradeable"] = stable_upgradeable; +#ifndef EMSESP_STANDALONE + // pull the install_date for the running partition (if known) + const esp_partition_t * running = esp_ota_get_running_partition(); + if (running != nullptr) { + const auto & info = EMSESP::system_.partition_info_; + auto it = info.find(running->label); + if (it != info.end() && it->second.install_date > 0) { + char time_string[25]; + time_t d = it->second.install_date; + strftime(time_string, sizeof(time_string), "%FT%T", localtime(&d)); + current["date"] = time_string; + } } - root["emsesp_version"] = current_version_s; // always send back current version + if (!versions_cache_valid_) { + // no successful fetch yet (no network, fetch pending, or parse error) + return; + } + // copies a cached entry into root[key] + auto add_section = [&](const char * key, const VersionInfo & info) { + if (info.version.empty()) { + return; + } + JsonObject out = root[key].to(); + 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"] = FirmwareVersion("3.8.2") > current_version; + + JsonObject dev_out = root["dev"].to(); + dev_out["version"] = "3.9.0-dev.1"; + dev_out["date"] = "2026-04-25"; + dev_out["upgradeable"] = FirmwareVersion("3.9.0-dev.1") > current_version; +#endif +} + +// periodic refresh (1 hour) of the cached versions.json +// runs on the main loop task, which has a much bigger stack than AsyncTCP needed for https +void WebStatusService::loop() { +#ifndef EMSESP_STANDALONE + // need a network + if (!EMSESP::network_.network_connected()) { + return; + } + + // TODO handle a network re-connect to fetch the values again (set versions_next_fetch_ms_ to 1) + + // 0 = idle, nothing scheduled + if (versions_next_fetch_ms_ == 0) { + return; + } + + // not time yet (signed difference handles uint32 wrap) + if ((int32_t)(uuid::get_uptime() - versions_next_fetch_ms_) < 0) { + return; + } + + bool ok = refresh_versions_cache(); + uint32_t next = uuid::get_uptime() + (ok ? VERSIONS_REFRESH_INTERVAL_MS : VERSIONS_RETRY_INTERVAL_MS); + if (next == 0) { + next = 1; + } + versions_next_fetch_ms_ = next; +#endif +} + +// runs on the main loop task — never call this from an AsyncWebServer handler +bool WebStatusService::refresh_versions_cache() { +#ifdef EMSESP_STANDALONE + return false; +#else + HTTPClient http; + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setTimeout(5000); + http.useHTTP10(true); + + if (!http.begin(EMSESP_VERSIONS_URL)) { +#if defined(EMSESP_DEBUG) + EMSESP::logger().debug("versions.json: failed to start HTTPS request"); +#endif + return false; + } + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { +#if defined(EMSESP_DEBUG) + EMSESP::logger().debug("versions.json: HTTP %d", httpCode); +#endif + http.end(); + return false; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, http.getStream()); + http.end(); + if (err) { +#if defined(EMSESP_DEBUG) + EMSESP::logger().debug("versions.json: parse error"); +#endif + return false; + } + + FirmwareVersion current_version(current_version_s); + + auto read_section = [&doc, ¤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() && FirmwareVersion(out.version) > current_version; + }; + + read_section("stable", versions_stable_); + read_section("dev", versions_dev_); + + versions_cache_valid_ = true; +#if defined(EMSESP_DEBUG) + EMSESP::logger().debug("versions.json: refreshed (stable=%s dev=%s), current=%s", + versions_stable_.version.c_str(), + versions_dev_.version.c_str(), + current_version_s.c_str()); +#endif return true; +#endif +} + +// returns if current dev/stable is upgradeable +bool WebStatusService::current_upgradeable() const { + if (!versions_cache_valid_) { + return false; + } + FirmwareVersion current_version(current_version_s); + bool is_dev = current_version.prerelease().find("dev") != std::string::npos; + return is_dev ? versions_dev_.upgradeable : versions_stable_.upgradeable; } // action = allvalues diff --git a/src/web/WebStatusService.h b/src/web/WebStatusService.h index 6d3f59f1a..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,6 +21,22 @@ class WebStatusService { return current_version_s; } + // called from EMSESP::loop() to refresh the cached versions.json from emsesp.org + // so that the web request handler never has to do blocking HTTPS on the small AsyncTCP stack + void loop(); + + // true once we've had at least one successful versions.json fetch + bool versions_cache_valid() const { + return versions_cache_valid_; + } + + // refresh the versions.json cache + void schedule_versions_refresh() { + versions_next_fetch_ms_ = 1; + } + + bool current_upgradeable() const; // true if a newer version is available + // make action function public so we can test in the debug and standalone mode #ifndef EMSESP_STANDALONE protected: @@ -30,7 +48,7 @@ class WebStatusService { SecurityManager * _securityManager; // actions - bool checkUpgrade(JsonObject root, std::string & latest_version); + void getVersions(JsonObject root); bool exportData(JsonObject root, std::string & type); bool getCustomSupport(JsonObject root); bool uploadURL(const char * url); @@ -39,6 +57,22 @@ class WebStatusService { uint8_t upgradeImportantMessages(std::string & version); std::string current_version_s = EMSESP_APP_VERSION; + + // cached emsesp.org/versions.json. Refreshed from the main loop task, which has more stack. + struct VersionInfo { + std::string version; + std::string date; + bool upgradeable = false; + }; + VersionInfo versions_stable_; + VersionInfo versions_dev_; + bool versions_cache_valid_ = false; // true once we've had at least one successful fetch + uint32_t versions_next_fetch_ms_ = 0; // uuid::get_uptime() of the next attempt; 0 = idle + + bool refresh_versions_cache(); // does the actual HTTPS fetch + parse, returns true on success + + static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success + static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after failure }; } // namespace emsesp 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"