77 Commits

Author SHA1 Message Date
proddy
ee3a5d231a Merge remote-tracking branch 'origin/dev' 2026-05-12 20:15:32 +02:00
Proddy
ddb2390bf7 Merge pull request #3071 from proddy/dev
support filesystem ota
2026-05-12 19:59:58 +02:00
Proddy
91912cf5be Merge branch 'emsesp:dev' into dev 2026-05-12 19:49:18 +02:00
Proddy
4f9851a9fe Merge pull request #3072 from MichaelDvP/dev
add polarity setting for digital_in sensor, #3070
2026-05-12 19:48:27 +02:00
proddy
7854349dbe support filesystem ota 2026-05-10 13:22:00 +02:00
proddy
d1d046f3fd package update 2026-05-10 13:21:47 +02:00
proddy
b988c67c8e update example 2026-05-10 13:21:37 +02:00
proddy
37a94f8e0f auto-formatting 2026-05-10 13:21:28 +02:00
proddy
e8f57b6343 v3.8.1 2026-01-11 19:28:38 +01:00
proddy
a1ab81de66 Merge branch 'dev' 2026-01-11 19:14:11 +01:00
proddy
28135c225b Merge branch 'dev' 2025-12-31 21:26:15 +01:00
proddy
eaa277fef0 v3.7.2 2025-03-22 10:32:03 +01:00
proddy
e418b7d8e7 fix routing in tabs - #2264 2024-11-30 21:31:42 +01:00
proddy
a4e3be6a69 3.7.1 changelog 2024-11-29 10:30:14 +01:00
proddy
a1bc5bb055 3.7.1 2024-11-29 10:30:05 +01:00
proddy
f444ca31e0 Merge remote-tracking branch 'origin/dev' 2024-10-27 11:41:47 +01:00
proddy
1b70b55989 update demo url 2024-08-10 14:58:48 +02:00
proddy
1487f30c43 Merge remote-tracking branch 'origin/dev' for 3.6.5 2024-03-23 17:56:05 +01:00
Proddy
e00eb8e64f 3.6.4 2023-11-26 20:11:36 +01:00
Proddy
f41bb3671c 3.6.4 2023-11-24 07:36:36 +01:00
Proddy
22c75e6df3 3.6.4 2023-11-24 07:36:29 +01:00
proddy
5ab10b7aa6 fixes #1450 2023-11-22 09:48:09 +01:00
Proddy
ee5fd4d0eb 3.6.3 2023-11-21 14:40:47 +01:00
Proddy
46f35bc67c another patch on 3.6.3 2023-11-21 14:40:32 +01:00
Proddy
ec85a7ec24 3.6.3 (refershed) 2023-11-19 21:24:01 +01:00
Proddy
02f2389587 add workflow_dispatch: 2023-11-18 18:51:12 +01:00
Proddy
7f140021aa quick fix - https://github.com/emsesp/EMS-ESP32/pull/1438 2023-11-18 18:47:28 +01:00
Proddy
6796962c1e 3.6.3 2023-11-18 13:35:20 +01:00
Proddy
df6de21cf4 Merge remote-tracking branch 'origin/dev' 2023-11-18 13:35:04 +01:00
Proddy
df9f75a5c9 updated yarn for 3.6.2 2023-10-01 17:42:54 +02:00
Proddy
7bd8710eb6 3.6.2 2023-10-01 17:40:09 +02:00
Proddy
32f2c6d341 Merge remote-tracking branch 'origin/dev' 2023-10-01 17:40:02 +02:00
Proddy
86919c1684 Merge branch 'origin/dev' 2023-09-09 14:12:07 +02:00
Proddy
86e29515e7 build s3 2023-08-15 18:44:24 +02:00
Proddy
46eb4185d7 update with 3.6.0 2023-08-13 14:37:13 +02:00
Proddy
8da6761a48 Merge branch 'dev' 2023-08-13 14:32:41 +02:00
Proddy
9233f0dfcc v3.5.1 - merge with patch 2023-03-11 16:06:05 +01:00
Proddy
292f743b14 Update bug_report.md 2023-02-19 11:18:08 +01:00
Proddy
dd6dfffd57 Delete questions---troubleshooting.md 2023-02-19 10:49:52 +01:00
Proddy
ec705a5307 Delete feature_request.md 2023-02-19 10:49:45 +01:00
Proddy
f45f071710 Create config.yml 2023-02-19 10:49:32 +01:00
Proddy
f3858546de Merge branch 'origin/dev' 2023-02-06 21:58:27 +01:00
Proddy
d0ac0b7804 update with version on dev 2022-10-30 16:58:37 +01:00
Proddy
d8284ec09f Merge pull request #705 from MichaelDvP/main
v3.4.4 Fix for new installations with filesystem not initializing
2022-10-29 10:46:41 +02:00
MichaelDvP
6e982acde8 v3.4.4 Fix for new installations with filesystem not initializing 2022-10-28 10:50:51 +02:00
Proddy
8c94ce99b2 quick fix for filesystem initialization 2022-10-08 09:23:00 +02:00
proddy
fc057d18c9 Merge remote-tracking branch 'origin/dev' 2022-09-18 14:33:23 +02:00
Proddy
18e9b99413 Merge remote-tracking branch 'origin/dev' into main 2022-05-29 16:16:38 +02:00
Proddy
a47e0e8266 update for 3.4.0 2022-05-23 21:20:45 +02:00
Proddy
f412ddc716 Merge remote-tracking branch 'origin/dev' into main 2022-05-23 21:20:36 +02:00
proddy
29110e96e5 Merge remote-tracking branch 'origin/dev' 2022-01-20 10:51:40 +01:00
proddy
b65866217a 3.4.0 2021-11-28 23:03:28 +01:00
proddy
611e3b1243 Merge remote-tracking branch 'origin/dev' 2021-11-28 23:03:15 +01:00
proddy
2ca0a0c634 v3.2.1 merged from dev 2021-08-08 14:46:14 +02:00
proddy
7eb1f061b7 Merge remote-tracking branch 'origin/dev' for 3.2.0 release 2021-08-06 12:06:08 +02:00
proddy
50459a23fe force v16 of nodejs 2021-06-26 11:13:07 +02:00
proddy
5bf53c3389 3.1.1 2021-06-26 11:03:03 +02:00
proddy
4b7aa95be3 Merge remote-tracking branch 'origin/dev' 2021-06-26 11:02:55 +02:00
Proddy
70943f5758 Update pre_release.yml 2021-05-16 15:52:09 +02:00
Proddy
3bc280b817 Delete check_code.yml 2021-05-16 15:51:56 +02:00
Proddy
62b15a5319 Update pre_release.yml 2021-05-16 15:35:06 +02:00
Proddy
8dd18802d6 Update tagged_release.yml 2021-05-16 15:34:45 +02:00
proddy
57a516a83a updated README and images 2021-05-09 15:13:16 +02:00
proddy
a57fdaa4b3 Merge remote-tracking branch 'origin/dev' into main 2021-05-04 12:21:51 +02:00
proddy
4841e42286 Merge remote-tracking branch 'origin/dev' into main 2021-03-30 16:35:18 +02:00
proddy
8c2d2b06ed cleaned up old changelog 2021-03-18 20:59:09 +01:00
proddy
38c8b1b7f0 3.0.0 2021-03-18 20:58:21 +01:00
proddy
6fb5933a02 Merge remote-tracking branch 'origin/dev' into main 2021-03-18 20:58:12 +01:00
proddy
c0944433be remove workspace.code-workspace 2021-03-16 17:41:42 +01:00
Proddy
478e6362c9 Merge pull request #27 from FauthD:main
Add global names to Dallas sensors to avoid ugly <unknown> and other …
2021-03-16 17:39:00 +01:00
fauthd
4d6354db78 Add stuff to gitignore, add vscode workspace 2021-03-16 16:47:09 +01:00
fauthd
beab0f0c77 Add global names to Dallas sensors to avoid ugly <unknown> and other issues in HA 2021-03-16 16:00:23 +01:00
Proddy
c17749bd22 Update README.md 2021-03-14 23:43:33 +01:00
proddy
2bad769c5c build: include assets 2021-03-14 21:20:51 +01:00
proddy
8ad89ca64b move repo 2021-03-14 21:05:15 +01:00
proddy
9244d8daec Semantic Commit Messages 2021-03-14 18:10:57 +01:00
proddy
02d01334b2 update new build 2021-03-14 17:37:18 +01:00
158 changed files with 5532 additions and 5407 deletions

View File

@@ -77,23 +77,3 @@ jobs:
files: | files: |
CHANGELOG_LATEST.md CHANGELOG_LATEST.md
./build/firmware/*.* ./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

View File

@@ -31,13 +31,6 @@ jobs:
- name: Enable Corepack - name: Enable Corepack
run: corepack enable pnpm 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 - name: Install PlatformIO
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
@@ -68,23 +61,3 @@ jobs:
files: | files: |
CHANGELOG.md CHANGELOG.md
./build/firmware/*.* ./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

View File

@@ -1,40 +0,0 @@
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

5
.gitignore vendored
View File

@@ -2,7 +2,6 @@
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/extensions.json .vscode/extensions.json
.vscode/launch.json .vscode/launch.json
.vscode/settings.json
# c++ compiling # c++ compiling
.clang_complete .clang_complete
@@ -64,7 +63,7 @@ words-found-verbose.txt
# sonarlint # sonarlint
compile_commands.json compile_commands.json
# other files # pioarduino + hybrid
managed_components managed_components
dependencies.lock dependencies.lock
CMakeLists.txt CMakeLists.txt
@@ -76,5 +75,3 @@ pnpm-lock.yaml
.cache/ .cache/
interface/.tsbuildinfo interface/.tsbuildinfo
test/test_api/package-lock.json test/test_api/package-lock.json
.clangd
mklittlefs

101
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,101 @@
{
"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"
}
}

View File

@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.2] 12 May 2026
## Added
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
- full system backup and restore
- auto-logic to set ht3/ems+ tx-mode
- polariity for digital_in sensors [#3070](https://github.com/emsesp/EMS-ESP32/discussions/3070)
## Fixed
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
- missing translations [#3015](https://github.com/emsesp/EMS-ESP32/issues/3015)
- custom entities check fetch length
- modbus initialization [#3064](https://github.com/emsesp/EMS-ESP32/issues/3064)
## Changed
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
- 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)
- check and read 0x470 as summer2_typeids[0] only if received [#2686](https://github.com/emsesp/EMS-ESP32/issues/2686), [#3055](https://github.com/emsesp/EMS-ESP32/issues/3055)
- default bus-id: gateway1(0x49), tx-mode: auto
## [3.8.1] 11 January 2026 ## [3.8.1] 11 January 2026
## Added ## Added
@@ -33,7 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- board profile `CUSTOM` can only be selected in developer mode - board profile `CUSTOM` can only be selected in developer mode
- mqtt sends round values without decimals (`28` instead of `28.0`) - mqtt sends round values without decimals (`28` instead of `28.0`)
## [3.8.0] 31 December 2025 ## [3.8.0] 31 December 2025
## Added ## Added

View File

@@ -2,45 +2,12 @@
For more details go to [emsesp.org](https://emsesp.org/). For more details go to [emsesp.org](https://emsesp.org/).
## [3.9.0] ## [3.8.3]
## Added ## Added
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- 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)
- auto-logic to set ht3/ems+ tx-mode
- polarity for digital_in sensors [#3070](https://github.com/emsesp/EMS-ESP32/discussions/3070)
## Fixed ## Fixed
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
- missing translations [#3015](https://github.com/emsesp/EMS-ESP32/issues/3015)
- custom entities check fetch length
- modbus initialization [#3064](https://github.com/emsesp/EMS-ESP32/issues/3064)
## Changed ## Changed
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
- use tasmota core 2026.03.30
- secure mqtt uses ESP_SSLClient
- 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)
- check and read 0x470 as summer2_typeids[0] only if received [#2686](https://github.com/emsesp/EMS-ESP32/issues/2686), [#3055](https://github.com/emsesp/EMS-ESP32/issues/3055)
- default bus-id: gateway1(0x49), tx-mode: auto

View File

@@ -113,7 +113,7 @@ CXX := /usr/bin/g++
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE) CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
CPPFLAGS += -ggdb -g3 -MMD CPPFLAGS += -ggdb -g3 -MMD
CPPFLAGS += -flto=auto CPPFLAGS += -flto=auto
CPPFLAGS += -Wall -Wextra -Werror -Wno-switch-enum CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
CPPFLAGS += -Os -DNDEBUG CPPFLAGS += -Os -DNDEBUG

View File

@@ -5,7 +5,7 @@
}, },
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DNO_TLS_SUPPORT", "-DTASMOTA_SDK",
"-DARDUINO_LOLIN_C3_MINI", "-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1" "-DARDUINO_USB_CDC_ON_BOOT=1"

View File

@@ -6,7 +6,7 @@
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DBOARD_HAS_PSRAM", "-DBOARD_HAS_PSRAM",
"-DNO_TLS_SUPPORT", "-DTASMOTA_SDK",
"-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0" "-DARDUINO_USB_MODE=0"
], ],

View File

@@ -21,7 +21,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS", "name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "32MB", "flash_size": "32MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,

View File

@@ -1,7 +1,7 @@
{ {
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": "-DNO_TLS_SUPPORT", "extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L", "f_cpu": "240000000L",
"f_flash": "40000000L", "f_flash": "40000000L",
"flash_mode": "dio", "flash_mode": "dio",
@@ -19,7 +19,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS", "name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "16MB", "flash_size": "16MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,

View File

@@ -19,7 +19,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS", "name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "16MB", "flash_size": "16MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,

View File

@@ -1,7 +1,7 @@
{ {
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": "-DNO_TLS_SUPPORT", "extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L", "f_cpu": "240000000L",
"f_flash": "40000000L", "f_flash": "40000000L",
"flash_mode": "dio", "flash_mode": "dio",

View File

@@ -2,7 +2,6 @@
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DNO_TLS_SUPPORT",
"-DARDUINO_XIAO_ESP32C6", "-DARDUINO_XIAO_ESP32C6",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1" "-DARDUINO_USB_CDC_ON_BOOT=1"

View File

@@ -1,6 +1,6 @@
{ {
"type": "systembackup", "type": "systembackup",
"version": "3.9.0", "version": "3.8.2",
"systembackup": [ "systembackup": [
{ {
"type": "settings", "type": "settings",
@@ -8,7 +8,7 @@
"ssid": "", "ssid": "",
"bssid": "", "bssid": "",
"password": "", "password": "",
"hostname": "ems-esp2", "hostname": "ems-esp",
"static_ip_config": false, "static_ip_config": false,
"bandwidth20": false, "bandwidth20": false,
"nosleep": true, "nosleep": true,
@@ -82,12 +82,12 @@
] ]
}, },
"Settings": { "Settings": {
"version": "3.9.0", "version": "3.8.2-dev.22",
"board_profile": "E32V2_2", "board_profile": "E32V2_2",
"platform": "ESP32", "platform": "ESP32",
"locale": "en", "locale": "en",
"tx_mode": 1, "tx_mode": 5,
"ems_bus_id": 11, "ems_bus_id": 73,
"syslog_enabled": false, "syslog_enabled": false,
"syslog_level": 3, "syslog_level": 3,
"trace_raw": false, "trace_raw": false,
@@ -131,16 +131,7 @@
"modbus_port": 502, "modbus_port": 502,
"modbus_max_clients": 10, "modbus_max_clients": 10,
"modbus_timeout": 300, "modbus_timeout": 300,
"developer_mode": false, "developer_mode": false
"email_enabled": false,
"email_security": 2,
"email_server": "smtp.example.net",
"email_port": 587,
"email_login": "",
"email_pass": "",
"email_sender": "ems-esp@example.net",
"email_recp": "",
"email_subject": "ems-esp notification"
} }
}, },
{ {
@@ -152,6 +143,14 @@
{ {
"type": "customizations", "type": "customizations",
"Customizations": { "Customizations": {
"ts": [
{
"id": "28_1767_7B13_2502",
"name": "gateway_temperature",
"offset": 0,
"is_system": true
}
],
"as": [ "as": [
{ {
"gpio": 39, "gpio": 39,

View File

@@ -1,9 +1,9 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.9.0", "version": "3.8.2",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "emsesp.org", "author": "proddy, emsesp.org",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -28,11 +28,14 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1", "@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1", "@mui/material": "^9.0.1",
"@preact/compat": "^18.3.2",
"@table-library/react-table-library": "4.1.15", "@table-library/react-table-library": "4.1.15",
"alova": "^3.5.1", "alova": "3.5.1",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"etag": "^1.8.1", "etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.2", "mime-types": "^3.0.2",
"preact": "^10.29.1", "preact": "^10.29.1",
"react": "^19.2.6", "react": "^19.2.6",
@@ -44,12 +47,15 @@
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.7.0", "@types/node": "^25.7.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"axe-core": "^4.11.4",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -58,7 +64,8 @@
"terser": "^5.47.1", "terser": "^5.47.1",
"typescript-eslint": "^8.59.3", "typescript-eslint": "^8.59.3",
"vite": "^8.0.12", "vite": "^8.0.12",
"vite-plugin-imagemin": "^0.6.1" "vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^6.1.1"
}, },
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800" "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
} }

403
interface/pnpm-lock.yaml generated
View File

@@ -23,11 +23,14 @@ importers:
'@mui/material': '@mui/material':
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@preact/compat':
specifier: ^18.3.2
version: 18.3.2(preact@10.29.1)
'@table-library/react-table-library': '@table-library/react-table-library':
specifier: 4.1.15 specifier: 4.1.15
version: 4.1.15(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 4.1.15(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
alova: alova:
specifier: ^3.5.1 specifier: 3.5.1
version: 3.5.1 version: 3.5.1
async-validator: async-validator:
specifier: ^4.2.5 specifier: ^4.2.5
@@ -35,9 +38,15 @@ importers:
etag: etag:
specifier: ^1.8.1 specifier: ^1.8.1
version: 1.8.1 version: 1.8.1
formidable:
specifier: ^3.5.4
version: 3.5.4
jwt-decode: jwt-decode:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
magic-string:
specifier: ^0.30.21
version: 0.30.21
mime-types: mime-types:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2 version: 3.0.2
@@ -66,12 +75,15 @@ importers:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3 version: 6.0.3
devDependencies: devDependencies:
'@babel/core':
specifier: ^7.29.0
version: 7.29.0
'@eslint/js': '@eslint/js':
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(eslint@10.3.0) version: 10.0.1(eslint@10.3.0)
'@preact/preset-vite': '@preact/preset-vite':
specifier: ^2.10.5 specifier: ^2.10.5
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1)) version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))
'@trivago/prettier-plugin-sort-imports': '@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(prettier@3.8.3) version: 6.0.2(prettier@3.8.3)
@@ -84,6 +96,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
axe-core:
specifier: ^4.11.4
version: 4.11.4
concurrently: concurrently:
specifier: ^9.2.1 specifier: ^9.2.1
version: 9.2.1 version: 9.2.1
@@ -107,10 +122,13 @@ importers:
version: 8.59.3(eslint@10.3.0)(typescript@6.0.3) version: 8.59.3(eslint@10.3.0)(typescript@6.0.3)
vite: vite:
specifier: ^8.0.12 specifier: ^8.0.12
version: 8.0.12(@types/node@25.7.0)(terser@5.47.1) version: 8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)
vite-plugin-imagemin: vite-plugin-imagemin:
specifier: ^0.6.1 specifier: ^0.6.1
version: 0.6.1(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1)) version: 0.6.1(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))
packages: packages:
@@ -282,12 +300,168 @@ packages:
'@emotion/weak-memoize@0.4.0': '@emotion/weak-memoize@0.4.0':
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.14.54': '@esbuild/linux-loong64@0.14.54':
resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==} resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==}
engines: {node: '>=12'} engines: {node: '>=12'}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.1': '@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -463,6 +637,10 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -478,9 +656,17 @@ packages:
'@oxc-project/types@0.129.0': '@oxc-project/types@0.129.0':
resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@popperjs/core@2.11.8': '@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} 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': '@preact/preset-vite@2.10.5':
resolution: {integrity: sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==} resolution: {integrity: sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==}
peerDependencies: peerDependencies:
@@ -972,6 +1158,9 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'} engines: {node: '>=8'}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
async-validator@4.2.5: async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
@@ -979,6 +1168,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
axe-core@4.11.4:
resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
engines: {node: '>=4'}
babel-plugin-macros@3.1.0: babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'} engines: {node: '>=10', npm: '>=6'}
@@ -1285,6 +1478,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dir-glob@3.0.1: dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1489,6 +1685,11 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
hasBin: true hasBin: true
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1694,6 +1895,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
formidable@3.5.4:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
from2@2.3.0: from2@2.3.0:
resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==}
@@ -1784,6 +1989,9 @@ packages:
resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==}
engines: {node: '>=8'} engines: {node: '>=8'}
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
gopd@1.2.0: gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2971,6 +3179,16 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
hasBin: true
peerDependencies:
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3059,6 +3277,11 @@ packages:
peerDependencies: peerDependencies:
vite: 5.x || 6.x || 7.x || 8.x vite: 5.x || 6.x || 7.x || 8.x
vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies:
vite: '*'
vite@8.0.12: vite@8.0.12:
resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -3417,9 +3640,87 @@ snapshots:
'@emotion/weak-memoize@0.4.0': {} '@emotion/weak-memoize@0.4.0': {}
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.14.54': '@esbuild/linux-loong64@0.14.54':
optional: true optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)':
dependencies: dependencies:
eslint: 10.3.0 eslint: 10.3.0
@@ -3588,6 +3889,8 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true optional: true
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -3602,21 +3905,29 @@ snapshots:
'@oxc-project/types@0.129.0': {} '@oxc-project/types@0.129.0': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
'@popperjs/core@2.11.8': {} '@popperjs/core@2.11.8': {}
'@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.59.0)(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1))': '@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.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx': 7.28.6(@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) '@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.12(@types/node@25.7.0)(terser@5.47.1)) '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))
'@rollup/pluginutils': 5.3.0(rollup@4.59.0) '@rollup/pluginutils': 5.3.0(rollup@4.59.0)
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0)
debug: 4.4.3 debug: 4.4.3
magic-string: 0.30.21 magic-string: 0.30.21
picocolors: 1.1.1 picocolors: 1.1.1
vite: 8.0.12(@types/node@25.7.0)(terser@5.47.1) vite: 8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)
vite-prerender-plugin: 0.5.13(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1)) vite-prerender-plugin: 0.5.13(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))
zimmerframe: 1.1.4 zimmerframe: 1.1.4
transitivePeerDependencies: transitivePeerDependencies:
- preact - preact
@@ -3631,7 +3942,7 @@ snapshots:
'@prefresh/utils@1.2.1': {} '@prefresh/utils@1.2.1': {}
'@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1))': '@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@prefresh/babel-plugin': 0.5.3 '@prefresh/babel-plugin': 0.5.3
@@ -3639,7 +3950,7 @@ snapshots:
'@prefresh/utils': 1.2.1 '@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1 '@rollup/pluginutils': 4.2.1
preact: 10.29.1 preact: 10.29.1
vite: 8.0.12(@types/node@25.7.0)(terser@5.47.1) vite: 8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -4024,12 +4335,16 @@ snapshots:
array-union@2.1.0: {} array-union@2.1.0: {}
asap@2.0.6: {}
async-validator@4.2.5: {} async-validator@4.2.5: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axe-core@4.11.4: {}
babel-plugin-macros@3.1.0: babel-plugin-macros@3.1.0:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
@@ -4388,6 +4703,11 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
wrappy: 1.0.2
dir-glob@3.0.1: dir-glob@3.0.1:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
@@ -4578,6 +4898,36 @@ snapshots:
esbuild-windows-64: 0.14.54 esbuild-windows-64: 0.14.54
esbuild-windows-arm64: 0.14.54 esbuild-windows-arm64: 0.14.54
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
optional: true
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@1.0.5: {} escape-string-regexp@1.0.5: {}
@@ -4813,6 +5163,12 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 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: from2@2.3.0:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -4915,6 +5271,8 @@ snapshots:
merge2: 1.4.1 merge2: 1.4.1
slash: 3.0.0 slash: 3.0.0
globrex@0.1.2: {}
gopd@1.2.0: {} gopd@1.2.0: {}
got@7.1.0: got@7.1.0:
@@ -6048,6 +6406,10 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
tsconfck@3.1.6(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
tslib@2.8.1: {} tslib@2.8.1: {}
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
@@ -6121,7 +6483,7 @@ snapshots:
spdx-correct: 3.2.0 spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1 spdx-expression-parse: 3.0.1
vite-plugin-imagemin@0.6.1(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1)): vite-plugin-imagemin@0.6.1(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)):
dependencies: dependencies:
'@types/imagemin': 7.0.1 '@types/imagemin': 7.0.1
'@types/imagemin-gifsicle': 7.0.4 '@types/imagemin-gifsicle': 7.0.4
@@ -6146,11 +6508,11 @@ snapshots:
imagemin-webp: 6.1.0 imagemin-webp: 6.1.0
jpegtran-bin: 6.0.1 jpegtran-bin: 6.0.1
pathe: 0.2.0 pathe: 0.2.0
vite: 8.0.12(@types/node@25.7.0)(terser@5.47.1) vite: 8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite-prerender-plugin@0.5.13(vite@8.0.12(@types/node@25.7.0)(terser@5.47.1)): vite-prerender-plugin@0.5.13(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)):
dependencies: dependencies:
kolorist: 1.8.0 kolorist: 1.8.0
magic-string: 0.30.21 magic-string: 0.30.21
@@ -6158,9 +6520,19 @@ snapshots:
simple-code-frame: 1.3.0 simple-code-frame: 1.3.0
source-map: 0.7.6 source-map: 0.7.6
stack-trace: 1.0.0 stack-trace: 1.0.0
vite: 8.0.12(@types/node@25.7.0)(terser@5.47.1) vite: 8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)
vite@8.0.12(@types/node@25.7.0)(terser@5.47.1): vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@6.0.3)
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@8.0.12(@types/node@25.7.0)(esbuild@0.27.4)(terser@5.47.1):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -6169,6 +6541,7 @@ snapshots:
tinyglobby: 0.2.16 tinyglobby: 0.2.16
optionalDependencies: optionalDependencies:
'@types/node': 25.7.0 '@types/node': 25.7.0
esbuild: 0.27.4
fsevents: 2.3.3 fsevents: 2.3.3
terser: 5.47.1 terser: 5.47.1

View File

@@ -1,11 +1,3 @@
allowBuilds:
cwebp-bin: true
esbuild: true
gifsicle: true
jpegtran-bin: true
mozjpeg: true
optipng-bin: true
pngquant-bin: true
onlyBuiltDependencies: onlyBuiltDependencies:
- cwebp-bin - cwebp-bin
- esbuild - esbuild

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify'; import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting'; import AppRouting from 'AppRouting';
@@ -46,18 +46,20 @@ const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en'); const [locale, setLocale] = useState<Locales>('en');
useEffect(() => { // Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = async () => { const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale); localStorage.setItem('lang', newLocale);
setLocale(newLocale); setLocale(newLocale);
await loadLocaleAsync(newLocale); await loadLocaleAsync(newLocale);
setWasLoaded(true); setWasLoaded(true);
};
void initializeLocale();
}, []); }, []);
useEffect(() => {
void initializeLocale();
}, [initializeLocale]);
if (!wasLoaded) return null; if (!wasLoaded) return null;
return ( return (

View File

@@ -16,7 +16,6 @@ import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings'; import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings'; import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings'; import Settings from 'app/settings/Settings';
import Version from 'app/settings/Version';
import Network from 'app/settings/network/Network'; import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security'; import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus'; import APStatus from 'app/status/APStatus';
@@ -27,6 +26,7 @@ import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus'; import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status'; import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog'; import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components'; import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -49,11 +49,11 @@ const AuthenticatedRouting = memo(() => {
<Route path="/status/ntp" element={<NTPStatus />} /> <Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} /> <Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} /> <Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
{me.admin && ( {me.admin && (
<> <>
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/settings/version" element={<Version />} />
<Route path="/settings/application" element={<ApplicationSettings />} /> <Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} /> <Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} /> <Route path="/settings/ntp" element={<NTPSettings />} />

View File

@@ -43,6 +43,7 @@ const SignIn = memo(() => {
} }
}); });
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo( const updateLoginRequestValue = useMemo(
() => () =>
updateValue((updater) => updateValue((updater) =>
@@ -64,7 +65,7 @@ const SignIn = memo(() => {
}); });
}, [callSignIn, signInRequest, LL]); }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = async () => { const validateAndSignIn = useCallback(async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({ SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s') required: LL.IS_REQUIRED('%s')
@@ -76,7 +77,7 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false); setProcessing(false);
} }
}; }, [signInRequest, signIn, LL]);
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);

View File

@@ -57,3 +57,12 @@ export const alovaInstance = createAlova({
onSuccess: handleResponse 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()
});

View File

@@ -1,6 +1,6 @@
import type { LogSettings, SystemStatus } from 'types'; import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance } from './endpoints'; import { alovaInstance, alovaInstanceGH } from './endpoints';
// systemStatus - also used to ping in System Monitor for pinging // systemStatus - also used to ping in System Monitor for pinging
export const readSystemStatus = () => export const readSystemStatus = () =>
@@ -13,6 +13,29 @@ export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data); alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log'); 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 const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => { export const uploadFile = (file: File) => {

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -57,18 +57,20 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
useInterval(() => { const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}); }, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
const hasEntityChanged = (ei: EntityItem) => { const hasEntityChanged = useCallback((ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -84,9 +86,11 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted || ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '') (ei.value || '') !== (ei.o_value || '')
); );
}; }, []);
const entity_theme = useTheme({ const entity_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`, `,
@@ -136,9 +140,11 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}); }),
[]
);
const saveEntities = async () => { const saveEntities = useCallback(async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -167,25 +173,26 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}; }, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = (ei: EntityItem) => { const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogCancel = async () => { const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => { const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => { void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating const new_data = creating
@@ -199,9 +206,11 @@ const CustomEntities = () => {
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data; return new_data;
}); });
}; },
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => { const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -219,9 +228,9 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const addEntityItem = () => { const addEntityItem = useCallback(() => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,27 +248,30 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const formatValue = (value: unknown, uom: number) => { const formatValue = useCallback((value: unknown, uom: number) => {
return value === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + ? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}; }, []);
const showHex = (value: number, digit: number) => { const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}; }, []);
const filteredAndSortedEntities = const filteredAndSortedEntities = useMemo(
() =>
entities entities
?.filter((ei: EntityItem) => !ei.deleted) ?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? []; .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = () => { const renderEntity = useCallback(() => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -316,7 +328,17 @@ const CustomEntities = () => {
)} )}
</Table> </Table>
); );
}; }, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -68,10 +68,14 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem); const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue( const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch< setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>> React.SetStateAction<Record<string, unknown>>
> >
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -101,16 +105,16 @@ const CustomEntitiesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -134,21 +138,27 @@ const CustomEntitiesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const remove = () => { const remove = useCallback(() => {
onSave({ ...editItem, deleted: true }); const itemWithDeleted = { ...editItem, deleted: true };
}; onSave(itemWithDeleted);
}, [editItem, onSave]);
const dup = () => { const dup = useCallback(() => {
onDup(editItem); onDup(editItem);
}; }, [editItem, onDup]);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( // Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}> <MenuItem key={val} value={i}>
{val} {val}
</MenuItem> </MenuItem>
)); )),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -171,7 +171,9 @@ const Customizations = () => {
); );
}; };
const entities_theme = useTheme({ const entities_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
@@ -234,7 +236,9 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}); }),
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -283,11 +287,12 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = (de: DeviceEntity) => { const isCommand = useCallback((de: DeviceEntity) => {
return de.n && de.n[0] === '!'; return de.n && de.n[0] === '!';
}; }, []);
const formatName = (de: DeviceEntity, withShortname: boolean) => { const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string; let name: string;
if (isCommand(de)) { if (isCommand(de)) {
name = de.t name = de.t
@@ -299,7 +304,9 @@ const Customizations = () => {
name = de.t ? `${de.t} ${de.n}` : de.n || ''; name = de.t ? `${de.t} ${de.n}` : de.n || '';
} }
return withShortname ? `${name} ${de.id}` : name; return withShortname ? `${name} ${de.id}` : name;
}; },
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -329,11 +336,15 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = (de: DeviceEntity) => const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()); formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => { const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) => setDeviceEntities((prev) =>
prev.map((de) => { prev.map((de) => {
if (filter_entity(de)) { if (filter_entity(de)) {
@@ -347,9 +358,11 @@ const Customizations = () => {
return de; return de;
}) })
); );
}; },
[filter_entity]
);
const resetCustomization = async () => { const resetCustomization = useCallback(async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -359,27 +372,30 @@ const Customizations = () => {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true); setRestarting(true);
} }
}; }, [sendResetCustomizations, LL]);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
(prev) => (prev) =>
prev?.map((de) => prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? [] ) ?? []
); );
}; }, []);
const onDialogSave = (updatedItem: DeviceEntity) => { const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false); setDialogOpen(false);
updateDeviceEntity(updatedItem); updateDeviceEntity(updatedItem);
}; },
[updateDeviceEntity]
);
const editDeviceEntity = (de: DeviceEntity) => { const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
return; return;
} }
@@ -390,9 +406,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const saveCustomization = async () => { const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return; return;
} }
@@ -425,9 +441,9 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
}; }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
const renameDevice = async () => { const renameDevice = useCallback(async () => {
await sendDeviceName({ await sendDeviceName({
id: selectedDevice, id: selectedDevice,
name: selectedDeviceName, name: selectedDeviceName,
@@ -443,7 +459,14 @@ const Customizations = () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}; }, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -539,7 +562,10 @@ const Customizations = () => {
</> </>
); );
const filteredEntities = deviceEntities.filter((de) => filter_entity(de)); const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => { const renderDeviceData = () => {
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -57,16 +57,23 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue( const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch< setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>> React.SetStateAction<Record<string, unknown>>
> >
),
[]
); );
const isWriteableNumber = const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' && typeof editItem.v === 'number' &&
editItem.w && editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY); !(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -75,16 +82,16 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = () => { const save = useCallback(() => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
@@ -95,31 +102,34 @@ const CustomizationsDialog = ({
} else { } else {
onSave(editItem); onSave(editItem);
} }
}; }, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}; }, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue <LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`} label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n} value={editItem.n}
/> />
<LabelValue <LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}> <Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />

View File

@@ -1,4 +1,4 @@
import { memo, useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IconContext } from 'react-icons/lib'; import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -77,7 +77,8 @@ const Dashboard = memo(() => {
} }
); );
const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const deviceValueDialogSave = useCallback(
async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) { if (!selectedDashboardItem) {
return; return;
} }
@@ -93,9 +94,13 @@ const Dashboard = memo(() => {
setDeviceValueDialogOpen(false); setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined); setSelectedDashboardItem(undefined);
}); });
}; },
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useTheme({ const dashboard_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`, `,
@@ -123,7 +128,9 @@ const Dashboard = memo(() => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: [...data.nodes] },
@@ -157,14 +164,19 @@ const Dashboard = memo(() => {
} }
}); });
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => { useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll showAll
? tree.fns.onAddAll(nodeIds) // expand tree ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
const showType = (n?: string, t?: number) => { const showType = useCallback(
(n?: string, t?: number) => {
// if we have a name show it // if we have a name show it
if (n) { if (n) {
return n; return n;
@@ -185,9 +197,12 @@ const Dashboard = memo(() => {
} }
} }
return ''; return '';
}; },
[LL]
);
const showName = (di: DashboardItem) => { const showName = useCallback(
(di: DashboardItem) => {
if (di.id < 100) { if (di.id < 100) {
// if its a device (parent node) and has entities // if its a device (parent node) and has entities
if (di.nodes?.length) { if (di.nodes?.length) {
@@ -204,17 +219,24 @@ const Dashboard = memo(() => {
return <span>{di.dv.id.slice(2)}</span>; return <span>{di.dv.id.slice(2)}</span>;
} }
return null; return null;
}; },
[showType]
);
const hasMask = (id: string, mask: number) => const hasMask = useCallback(
(parseInt(id.slice(0, 2), 16) & mask) === mask; (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const editDashboardValue = (di: DashboardItem) => { const editDashboardValue = useCallback(
(di: DashboardItem) => {
if (me.admin && di.dv?.c) { if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di); setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
} }
}; },
[me.admin]
);
const handleShowAll = ( const handleShowAll = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
@@ -226,9 +248,10 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = data.nodes.filter( const hasFavEntities = useMemo(
(item: DashboardItem) => item.id <= 90 () => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
).length; [data.nodes]
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {

View File

@@ -4,6 +4,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
@@ -132,15 +133,17 @@ const Devices = memo(() => {
}; };
}, []); }, []);
const leftOffset = () => { const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0; if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect(); const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0; if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}; }, []);
const common_theme = useTheme({ const common_theme = useMemo(
() =>
useTheme({
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
@@ -162,9 +165,13 @@ const Devices = memo(() => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}); }),
[]
);
const device_theme = useTheme([ const device_theme = useMemo(
() =>
useTheme([
common_theme, common_theme,
{ {
BaseRow: ` BaseRow: `
@@ -189,9 +196,13 @@ const Devices = memo(() => {
}, },
` `
} }
]); ]),
[common_theme]
);
const data_theme = useTheme([ const data_theme = useMemo(
() =>
useTheme([
common_theme, common_theme,
{ {
Table: ` Table: `
@@ -233,7 +244,9 @@ const Devices = memo(() => {
} }
` `
} }
]); ]),
[common_theme]
);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -332,8 +345,10 @@ const Devices = memo(() => {
return sc; return sc;
}; };
const hasMask = (id: string, mask: number) => const hasMask = useCallback(
(parseInt(id.slice(0, 2), 16) & mask) === mask; (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
@@ -592,12 +607,13 @@ const Devices = memo(() => {
return; return;
} }
const showDeviceValue = (dv: DeviceValue) => { const showDeviceValue = useCallback((dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}; }, []);
const renderNameCell = (dv: DeviceValue) => ( const renderNameCell = useCallback(
(dv: DeviceValue) => (
<> <>
{dv.id.slice(2)}&nbsp; {dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
@@ -610,17 +626,22 @@ const Devices = memo(() => {
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)} )}
</> </>
),
[hasMask]
); );
const shown_data = onlyFav const shown_data = useMemo(() => {
? deviceData.nodes.filter( if (onlyFav) {
return deviceData.nodes.filter(
(dv: DeviceValue) => (dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
) );
: deviceData.nodes.filter((dv: DeviceValue) => }
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
); );
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -52,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -61,7 +61,7 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -69,9 +69,10 @@ const DevicesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const setUom = (uom?: DeviceValueUOM) => { const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) { if (uom === undefined) {
return; return;
} }
@@ -85,9 +86,11 @@ const DevicesDialog = ({
default: default:
return DeviceValueUOM_s[uom]; return DeviceValueUOM_s[uom];
} }
}; },
[LL]
);
const showHelperText = (dv: DeviceValue) => { const showHelperText = useCallback((dv: DeviceValue) => {
if (dv.h) return dv.h; if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | '); if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) { if (dv.m !== undefined && dv.x !== undefined) {
@@ -98,16 +101,26 @@ const DevicesDialog = ({
); );
} }
return undefined; return undefined;
}; }, []);
const isCommand = selectedItem.v === '' && selectedItem.c; const isCommand = useMemo(
const dialogTitle = isCommand () => selectedItem.v === '' && selectedItem.c,
? LL.RUN_COMMAND() [selectedItem.v, selectedItem.c]
: writeable );
? LL.CHANGE_VALUE()
: LL.VALUE(0); const dialogTitle = useMemo(() => {
const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE(); if (isCommand) return LL.RUN_COMMAND();
const helperText = showHelperText(editItem); 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 valueLabel = LL.VALUE(0); const valueLabel = LL.VALUE(0);

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
@@ -9,6 +11,7 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
// Available mask values
const MASK_VALUES = [ const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -17,21 +20,35 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128 DeviceEntityMask.DV_DELETED // 128
]; ];
const getMaskNumber = (newMask: string[]): number => /**
newMask.reduce((mask, entry) => mask | Number(entry), 0); * 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 getMaskString = (mask: number): string[] => /**
MASK_VALUES.filter((value) => (mask & value) === value).map((value) => * 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) =>
String(value) String(value)
); );
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = (_event: unknown, mask: string[]) => { const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask); const newMask = getMaskNumber(mask);
const updatedDe = { ...de }; const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web // If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
@@ -45,67 +62,81 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
} }
onUpdate(updatedDe); 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]
);
return ( return (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(de.m)} value={maskStringValue}
onChange={handleChange} onChange={handleChange}
> >
<ToggleButton <ToggleButton value="8" disabled={isFavoriteDisabled}>
value="8" <OptionIcon type="favorite" isSet={isFavoriteSet} />
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="4" disabled={isReadonlyDisabled}>
value="4" <OptionIcon type="readonly" isSet={isReadonlySet} />
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
value="2" <OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="1" disabled={isWebExcludeDisabled}>
value="1" <OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon <OptionIcon type="deleted" isSet={isDeletedSet} />
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );

View File

@@ -1,4 +1,4 @@
import { memo, useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -60,8 +60,6 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9' bgcolor: '#72caf9'
}; };
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => { const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
@@ -74,7 +72,12 @@ const HelpComponent = () => {
}); });
const [imgError, setImgError] = useState<boolean>(false); const [imgError, setImgError] = useState<boolean>(false);
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => { const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) { if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as { const { Support } = event.data as {
Support: { img_url?: string; html?: string[] }; Support: { img_url?: string; html?: string[] };
@@ -97,7 +100,20 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
const helpLinks: HelpLink[] = [ // Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
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', href: 'https://emsesp.org',
icon: <MenuBookIcon />, icon: <MenuBookIcon />,
@@ -113,10 +129,18 @@ const HelpComponent = () => {
icon: <GitHubIcon />, icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3() label: () => LL.HELP_INFORMATION_3()
} }
]; ],
[LL]
);
const imageSrc = const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return ( return (
<SectionContent> <SectionContent>
@@ -133,13 +157,13 @@ const HelpComponent = () => {
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={IMAGE_STYLES} sx={IMAGE_STYLES}
onError={() => setImgError(true)} onError={handleImageError}
src={imageSrc} src={imageSrc}
/> />
</Stack> </Stack>
)} )}
{me?.admin && ( {isAdmin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <ListItem key={href}>
@@ -167,7 +191,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => void sendAPI(SYSTEM_INFO_API)} onClick={handleDownloadSystemInfo}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -190,6 +214,7 @@ const HelpComponent = () => {
); );
}; };
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent); const Help = memo(HelpComponent);
export default Help; export default Help;

View File

@@ -1,4 +1,4 @@
import { memo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -69,7 +69,9 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme({ const modules_theme = useTheme(
useMemo(
() => ({
Table: ` Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`, `,
@@ -109,13 +111,16 @@ const Modules = () => {
background-color: #303030; background-color: #303030;
} }
` `
}); }),
[]
)
);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const updateModuleItem = (updatedItem: ModuleItem) => { const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => { void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) => const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -123,25 +128,28 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length); setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data; return new_data;
}); });
}; }, []);
const onDialogSave = (updatedItem: ModuleItem) => { const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false); setDialogOpen(false);
updateModuleItem(updatedItem); updateModuleItem(updatedItem);
}; },
[updateModuleItem]
);
const editModuleItem = (mi: ModuleItem) => { const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const onCancel = async () => { const onCancel = useCallback(async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchModules]);
const saveModules = async () => { const saveModules = useCallback(async () => {
try { try {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
@@ -159,9 +167,9 @@ const Modules = () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
} }
}; }, [modules, updateModules, LL, fetchModules]);
const renderContent = () => { const content = useMemo(() => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -254,12 +262,22 @@ const Modules = () => {
</Box> </Box>
</> </>
); );
}; }, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()} {content}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,10 +37,14 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue( const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch< setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>> React.SetStateAction<Record<string, unknown>>
> >
),
[]
); );
// Sync form state when dialog opens or selected item changes // Sync form state when dialog opens or selected item changes
@@ -50,13 +54,18 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = () => { const handleSave = useCallback(() => {
onSave(editItem); onSave(editItem);
}; }, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -132,7 +132,7 @@ const Scheduler = () => {
} }
); );
const hasScheduleChanged = (si: ScheduleItem) => { const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return ( return (
si.id !== si.o_id || si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') || (si.name || '') !== (si.o_name || '') ||
@@ -143,13 +143,15 @@ const Scheduler = () => {
si.cmd !== si.o_cmd || si.cmd !== si.o_cmd ||
si.value !== si.o_value si.value !== si.o_value
); );
}; }, []);
useInterval(() => { const intervalCallback = useCallback(() => {
if (numChanges === 0) { if (numChanges === 0) {
void fetchSchedule(); void fetchSchedule();
} }
}, INTERVAL_DELAY); }, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { const formatter = new Intl.DateTimeFormat(locale, {
@@ -167,7 +169,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme); const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = async () => { const saveSchedule = useCallback(async () => {
try { try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
@@ -190,28 +192,29 @@ const Scheduler = () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
} }
}; }, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = (si: ScheduleItem) => { const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) { if (si.o_name === undefined) {
si.o_name = si.name; si.o_name = si.name;
} }
}; }, []);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogCancel = async () => { const onDialogCancel = useCallback(async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchSchedule]);
const onDialogSave = (updatedItem: ScheduleItem) => { const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => { void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating const new_data = creating
@@ -224,9 +227,11 @@ const Scheduler = () => {
return new_data; return new_data;
}); });
}; },
[creating, hasScheduleChanged]
);
const addScheduleItem = () => { const addScheduleItem = useCallback(() => {
setCreating(true); setCreating(true);
const newItem: ScheduleItem = { const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -234,13 +239,18 @@ const Scheduler = () => {
}; };
setSelectedScheduleItem(newItem); setSelectedScheduleItem(newItem);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const filteredAndSortedSchedule = schedule const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted) .filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags); .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = (si: ScheduleItem, flag: number) => { const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2; const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag; const isActive = (si.flags & flag) === flag;
@@ -254,9 +264,11 @@ const Scheduler = () => {
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
</> </>
); );
}; },
[dow]
);
const scheduleType = (si: ScheduleItem) => { const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags]; const label = scheduleTypeLabels[si.flags];
return ( return (
@@ -266,9 +278,9 @@ const Scheduler = () => {
</Typography> </Typography>
</Box> </Box>
); );
}; }, []);
const renderSchedule = () => { const renderSchedule = useCallback(() => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -331,7 +343,17 @@ const Scheduler = () => {
)} )}
</Table> </Table>
); );
}; }, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -60,12 +60,6 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT ScheduleFlag.SCHEDULE_SAT
] as const; ] 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 { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -90,10 +84,14 @@ const SchedulerDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue( const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch< setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>> React.SetStateAction<Record<string, unknown>>
> >
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -114,7 +112,9 @@ const SchedulerDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = async (itemToSave: ScheduleItem) => { // Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, itemToSave); await validate(validator, itemToSave);
@@ -122,21 +122,36 @@ const SchedulerDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; },
[validator, onSave]
);
const save = async () => { const save = useCallback(async () => {
await handleSave(editItem); await handleSave(editItem);
}; }, [editItem, handleSave]);
const saveandactivate = async () => { const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true }); await handleSave({ ...editItem, active: true });
}; }, [editItem, handleSave]);
const remove = () => { const remove = useCallback(() => {
onSave({ ...editItem, deleted: true }); onSave({ ...editItem, deleted: true });
}; }, [editItem, onSave]);
const DayOfWeekButton = (flag: number) => { // 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 dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag; const isSelected = (editItem.flags & flag) === flag;
return ( return (
@@ -147,21 +162,21 @@ const SchedulerDialog = ({
{dow[dayIndex]} {dow[dayIndex]}
</Typography> </Typography>
); );
}; },
[editItem.flags, dow]
);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const handleScheduleTypeChange = ( const handleScheduleTypeChange = useCallback(
_event: React.SyntheticEvent<HTMLElement>, (_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
flag: ScheduleFlag | null
) => {
if (flag !== null) { if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag); setScheduleType(flag);
@@ -170,39 +185,56 @@ const SchedulerDialog = ({
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag; const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
} }
}; },
[]
);
const handleDOWChange = ( const handleDOWChange = useCallback(
_event: React.SyntheticEvent<HTMLElement>, (_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
flags: string[]
) => {
const newFlags = const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags); getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags })); setEditItem((prev) => ({ ...prev, flags: newFlags }));
}; },
[getFlagDOWnumber]
);
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY; // Memoize derived values
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER; const isDaySchedule = useMemo(
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE; () => scheduleType === ScheduleFlag.SCHEDULE_DAY,
const needsTimeField = isDaySchedule || isTimerSchedule; [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 = getFlagDOWstring(editItem.flags); const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = needsTimeField const timeFieldValue = useMemo(() => {
? editItem.time === '' if (needsTimeField) {
? DEFAULT_TIME return editItem.time === '' ? DEFAULT_TIME : editItem.time;
: editItem.time }
: editItem.time === DEFAULT_TIME return editItem.time === DEFAULT_TIME ? '' : editItem.time;
? '' }, [editItem.time, needsTimeField]);
: editItem.time;
const timeFieldLabel = (() => { const timeFieldLabel = useMemo(() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1); return LL.TIME(1);
})(); }, [scheduleType, LL]);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useContext, useRef, useState } from 'react'; import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -158,16 +158,18 @@ const Sensors = () => {
} }
); );
useInterval(() => { const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}); }, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = useCallback((state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -175,7 +177,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />; return <KeyboardArrowUpOutlinedIcon />;
} }
return <UnfoldMoreOutlinedIcon />; return <UnfoldMoreOutlinedIcon />;
}; }, []);
const analog_sort = useSort( const analog_sort = useSort(
{ nodes: sensorData.as }, { nodes: sensorData.as },
@@ -232,7 +234,8 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => { const formatDurationMin = useCallback(
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE; const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY); const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
@@ -249,9 +252,12 @@ const Sensors = () => {
parts.push(LL.NUM_MINUTES({ num: minutes })); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return parts.join(' '); return parts.join(' ');
}; },
[LL]
);
const formatValue = (value: unknown, uom: DeviceValueUOM) => { const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) { if (value === undefined) {
return ''; return '';
} }
@@ -280,22 +286,28 @@ const Sensors = () => {
default: default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
} }
}; },
[formatDurationMin, LL]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => { const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) { if (me.admin) {
ts.o_n = ts.n; ts.o_n = ts.n;
setSelectedTemperatureSensor(ts); setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true); setTemperatureDialogOpen(true);
} }
}; },
[me.admin]
);
const onTemperatureDialogClose = () => { const onTemperatureDialogClose = useCallback(() => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}; }, [fetchSensorData]);
const onTemperatureDialogSave = async (ts: TemperatureSensor) => { const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ await sendTemperatureSensor({
id: ts.id, id: ts.id,
name: ts.n, name: ts.n,
@@ -313,23 +325,28 @@ const Sensors = () => {
setSelectedTemperatureSensor(undefined); setSelectedTemperatureSensor(undefined);
void fetchSensorData(); void fetchSensorData();
}); });
}; },
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = (as: AnalogSensor) => { const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) { if (me.admin) {
setCreating(false); setCreating(false);
as.o_n = as.n; as.o_n = as.n;
setSelectedAnalogSensor(as); setSelectedAnalogSensor(as);
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
} }
}; },
[me.admin]
);
const onAnalogDialogClose = () => { const onAnalogDialogClose = useCallback(() => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}; }, [fetchSensorData]);
const addAnalogSensor = () => { const addAnalogSensor = useCallback(() => {
if (firstAvailableGPIO.current === undefined) { if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO()); toast.error(LL.NO_GPIO());
return; return;
@@ -349,9 +366,10 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}; }, []);
const onAnalogDialogSave = async (as: AnalogSensor) => { const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({ await sendAnalogSensor({
id: as.id, id: as.id,
gpio: as.g, gpio: as.g,
@@ -374,9 +392,12 @@ const Sensors = () => {
setSelectedAnalogSensor(undefined); setSelectedAnalogSensor(undefined);
void fetchSensorData(); void fetchSensorData();
}); });
}; },
[sendAnalogSensor, LL, fetchSensorData]
);
const RenderAnalogSensors = ( const RenderAnalogSensors = useMemo(
() => (
<Table <Table
data={{ nodes: sensorData.as }} data={{ nodes: sensorData.as }}
theme={analog_theme} theme={analog_theme}
@@ -422,7 +443,9 @@ const Sensors = () => {
fullWidth fullWidth
style={HEADER_BUTTON_STYLE_END} style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')} endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })} onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
> >
{LL.VALUE(0)} {LL.VALUE(0)}
</Button> </Button>
@@ -455,9 +478,20 @@ const Sensors = () => {
</> </>
)} )}
</Table> </Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
); );
const RenderTemperatureSensors = ( const RenderTemperatureSensors = useMemo(
() => (
<Table <Table
data={{ nodes: sensorData.ts }} data={{ nodes: sensorData.ts }}
theme={temperature_theme} theme={temperature_theme}
@@ -510,6 +544,16 @@ const Sensors = () => {
</> </>
)} )}
</Table> </Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -53,38 +53,62 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue((updater) => const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem( setEditItem(
(prev) => (prev) =>
updater( updater(
prev as unknown as Record<string, unknown> prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor ) as unknown as AnalogSensor
) )
),
[setEditItem]
); );
const isCounterOrRate = // Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE || editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
const isCounter = [editItem.t]
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
const isFreqType = [editItem.t]
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2; );
const isPWM = 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_0 ||
editItem.t === AnalogType.PWM_1 || editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2; editItem.t === AnalogType.PWM_2,
const isDACOutGPIO = [editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT && editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26); (editItem.g === 25 || editItem.g === 26),
const isDigitalOutGPIO = [editItem.t, editItem.g]
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26; );
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({ // Memoize menu items to avoid recreation on each render
name: val, const analogTypeMenuItems = useMemo(
value: i + 1 () =>
})) AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => ( .map(({ name, value }) => (
<MenuItem <MenuItem
@@ -94,13 +118,19 @@ const SensorsAnalogDialog = ({
> >
{name} {name}
</MenuItem> </MenuItem>
)); )),
[disabledTypeList]
);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}> <MenuItem key={val} value={i}>
{val} {val}
</MenuItem> </MenuItem>
)); )),
[]
);
const analogGPIOMenuItems = () => const analogGPIOMenuItems = () =>
// add selectedItem.g to the list // add selectedItem.g to the list
@@ -127,16 +157,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -144,13 +174,17 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const remove = () => { const remove = useCallback(() => {
onSave({ ...editItem, d: true }); onSave({ ...editItem, d: true });
}; }, [editItem, onSave]);
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`; const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -50,12 +50,16 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue( const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as ( setEditItem as unknown as (
updater: ( updater: (
prevState: Readonly<Record<string, unknown>> prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown> ) => Record<string, unknown>
) => void ) => void
),
[setEditItem]
); );
useEffect(() => { useEffect(() => {
@@ -65,13 +69,16 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = (_event: React.SyntheticEvent, reason?: string) => { const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -79,11 +86,29 @@ const SensorsTemperatureDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); 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: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -103,23 +128,12 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={numberValue(editItem.o)} value={offsetValue}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={{ slotProps={slotProps}
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -1,4 +1,4 @@
import { memo, useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import { import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE()); useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = () => { const handleSignOut = useCallback(() => {
signOut(true); signOut(true);
}; }, [signOut]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -43,15 +43,6 @@ export interface Settings {
modbus_port: number; modbus_port: number;
modbus_max_clients: number; modbus_max_clients: number;
modbus_timeout: number; modbus_timeout: number;
email_enabled: boolean;
email_security: number;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean; developer_mode: boolean;
} }

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -24,6 +24,7 @@ import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createAPSettingsValidator, validate } from 'validators'; import { ValidationError, createAPSettingsValidator, validate } from 'validators';
export const isAPEnabled = ({ provision_mode }: APSettingsType) => export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion // Efficient range function without recursion
@@ -62,16 +63,22 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty( const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const apEnabled = data ? isAPEnabled(data) : false; // Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
const validateAndSubmit = async () => { // Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
@@ -81,7 +88,7 @@ const APSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [data, saveData]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -101,6 +108,9 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
{LL.AP_PROVIDE_TEXT_1()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}> <MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()} {LL.AP_PROVIDE_TEXT_2()}
</MenuItem> </MenuItem>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -28,7 +28,6 @@ import {
FormLoader, FormLoader,
MessageBox, MessageBox,
SectionContent, SectionContent,
ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
@@ -107,36 +106,49 @@ const ApplicationSettings = () => {
}); });
}); });
const SecondsInputProps = { // Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}; }),
[LL]
);
const MinutesInputProps = { const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}; }),
[LL]
);
const HoursInputProps = { const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}; }),
[LL]
);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => { const updateBoardProfile = useCallback(
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => { await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message); toast.error(error.message);
}); });
}; },
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = async () => { const validateAndSubmit = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
@@ -145,9 +157,10 @@ const ApplicationSettings = () => {
} finally { } finally {
await saveData(); await saveData();
} }
}; }, [data, saveData]);
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => { const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value; const boardProfile = event.target.value;
updateFormValue(event); updateFormValue(event);
if (boardProfile === 'CUSTOM') { if (boardProfile === 'CUSTOM') {
@@ -158,29 +171,17 @@ const ApplicationSettings = () => {
} else { } else {
void updateBoardProfile(boardProfile); void updateBoardProfile(boardProfile);
} }
}; },
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => { const restart = useCallback(async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}; }, [validateAndSubmit, doRestart]);
const sendmail = async () => { // Memoize board profile select items to prevent recreation
await sendAPI({ const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
device: 'system',
cmd: 'sendmail',
data: 'Email notification test successful!',
id: 0
})
.then(() => {
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
})
.catch((error: Error) => {
toast.error(error.message);
});
};
const boardProfileItems = boardProfileSelectItems();
const content = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {
@@ -350,148 +351,6 @@ const ApplicationSettings = () => {
</Grid> </Grid>
</Grid> </Grid>
)} )}
<Typography color="secondary">eMail</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.email_enabled}
onChange={updateFormValue}
name="email_enabled"
disabled={!hardwareData.psram}
/>
}
label={
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
Enable eMail notification
{!hardwareData.psram && (
<Typography variant="caption">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/>
{data.email_enabled && (
<>
<Grid
container
spacing={2}
direction="row"
sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_server"
label="SMTP Server"
variant="outlined"
value={data.email_server}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
sx={{ width: '12ch' }}
name="email_port"
variant="outlined"
label="Port"
value={numberValue(data.email_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
sx={{ width: '20ch' }}
name="email_security"
label={LL.SECURITY(0)}
value={data.email_security}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>SSL</MenuItem>
<MenuItem value={2}>StartTLS</MenuItem>
</TextField>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_login"
label="Login"
variant="outlined"
value={data.email_login}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
name="email_pass"
label="Password"
variant="outlined"
value={data.email_pass}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_sender"
label="From"
variant="outlined"
value={data.email_sender}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_recp"
label="To"
variant="outlined"
value={data.email_recp}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_subject"
label="Subject"
variant="outlined"
value={data.email_subject}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<Button
sx={{ mt: 3 }}
variant="outlined"
color="primary"
disabled={dirtyFlags.length !== 0}
onClick={sendmail}
>
Send test email
</Button>
</Grid>
</Grid>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()} {LL.SENSORS()}
</Typography> </Typography>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
try { try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,33 +65,16 @@ const DownloadUpload = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
setRestarting(false); setRestarting(false);
} }
}; }, [sendAPI]);
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = () => { const handleCloseBackupDialog = useCallback(() => {
setConfirmBackup(false); setConfirmBackup(false);
}; }, []);
const handleDownload = (type: string) => () => { const renderBackupDialog = useMemo(
void sendExportData(type); () => (
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Dialog <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmBackup} open={confirmBackup}
@@ -115,13 +98,40 @@ const DownloadUpload = () => {
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={handleDownload('systembackup')} onClick={() => handleDownload('systembackup')()}
color="primary" color="primary"
> >
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
),
[confirmBackup, handleCloseBackupDialog, LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{renderBackupDialog}
<Typography sx={{ pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = () => { const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' }) void callAction({ action: 'resetMQTT' })
.then(() => { .then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful'); toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,20 +65,29 @@ const MqttSettings = () => {
.catch((error) => { .catch((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
}; }, []);
const updateFormValue = updateValueDirty( const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const SecondsInputProps = { const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}; }),
[LL]
);
const validateAndSubmit = async () => { const emptyFieldErrors = useMemo(() => ({}), []);
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -87,9 +96,10 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [data, saveData]);
const publishIntervalFields = [ const publishIntervalFields = useMemo(
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{ {
@@ -102,7 +112,9 @@ const MqttSettings = () => {
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), 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_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
]; ],
[LL]
);
if (!data) { if (!data) {
return ( return (
@@ -142,7 +154,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -154,7 +166,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -166,7 +178,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -207,7 +219,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -426,7 +438,7 @@ const MqttSettings = () => {
<Grid key={field.name}> <Grid key={field.name}>
{field.validated ? ( {field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name={field.name} name={field.name}
label={field.label} label={field.label}
slotProps={{ slotProps={{

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -61,11 +61,14 @@ const NTPSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems(); const timeZoneItems = useTimeZoneSelectItems();
const selectedTzValue = data // Memoized selected timezone value
? selectedTimeZone(data.tz_label, data.tz_format) const selectedTzValue = useMemo(
: undefined; () => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>(''); const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
@@ -79,22 +82,32 @@ const NTPSettings = () => {
} }
); );
const updateFormValue = updateValueDirty( // Memoize updateFormValue to prevent recreation on every render
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => // Memoize updateLocalTime handler
setLocalTime(event.target.value); const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const openSetTime = () => { // Memoize openSetTime handler
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}; }, []);
const configureTime = async () => { // Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -107,11 +120,13 @@ const NTPSettings = () => {
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}; }, [localTime, updateTime, LL, loadData]);
const handleCloseSetTime = () => setSettingTime(false); // Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
const validateAndSubmit = async () => { // Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -120,18 +135,23 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [data, saveData]);
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => { // Memoize timezone change handler
const changeTimeZone = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings, ...settings,
tz_label: event.target.value, tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value] tz_format: TIME_ZONES[event.target.value]
})); }));
updateFormValue(event); updateFormValue(event);
}; },
[updateFormValue]
);
const renderContent = () => { // Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
@@ -216,12 +236,26 @@ const NTPSettings = () => {
)} )}
</> </>
); );
}; }, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()} {renderContent}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}> <Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle> <DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>

View File

@@ -1,43 +1,67 @@
import { useContext } from 'react'; import { useCallback, useMemo, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build'; import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { List } from '@mui/material'; import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
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 { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const upgradeAvailable = versions?.current?.upgradeable ?? false; const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const firmwareText = versions?.current?.version const [restarting, setRestarting] = useState<boolean>();
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
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 ( return (
<SectionContent> <>
<List> <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
<ListMenuItem <ListMenuItem
icon={TuneIcon} icon={TuneIcon}
bgcolor="#134ba2" bgcolor="#134ba2"
@@ -101,8 +125,66 @@ const Settings = () => {
to="downloadUpload" to="downloadUpload"
/> />
</List> </List>
</SectionContent>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
}}
>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
); );
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
}; };
export default Settings; export default Settings;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = { export const TIME_ZONES: Record<string, string> = {
@@ -470,16 +472,26 @@ export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}> <MenuItem key={label} value={label}>
{label} {label}
</MenuItem> </MenuItem>
)); ));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return precomputedTimeZoneItems; return precomputedTimeZoneItems;
} }

View File

@@ -1,958 +0,0 @@
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 (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.version
: version?.version}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version && version.date && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.date))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
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 (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// 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<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const latestVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.stable
? { version: versions.stable.version, date: versions.stable.date }
: undefined,
[versions?.stable]
);
const latestDevVersion = useMemo<VersionInfo | undefined>(
() =>
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<number>(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 (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={isUpdateAvailable ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
{isUpdateAvailable && (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
)}
</Button>
);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.version}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.version}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.version || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
{me.admin && (
<>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleRestartClose}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleRestartClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap',
gap: 1
}}
>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="error"
>
{LL.RESTART()}
</Button>
{data.developer_mode && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
)}
</Box>
</>
)}
</SectionContent>
);
};
export default memo(Version);

View File

@@ -1,4 +1,4 @@
import { memo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { import {
Navigate, Navigate,
Route, Route,
@@ -40,20 +40,26 @@ const Network = () => {
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = (network: WiFiNetwork) => { const selectNetwork = useCallback(
(network: WiFiNetwork) => {
setSelectedNetwork(network); setSelectedNetwork(network);
void navigate('/settings/network/settings'); void navigate('/settings/network/settings');
}; },
[navigate]
);
const deselectNetwork = () => { const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}; }, []);
const contextValue = { const contextValue = useMemo(
() => ({
...(selectedNetwork && { selectedNetwork }), ...(selectedNetwork && { selectedNetwork }),
selectNetwork, selectNetwork,
deselectNetwork deselectNetwork
}; }),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return ( return (
<WiFiConnectionContext.Provider value={contextValue}> <WiFiConnectionContext.Provider value={contextValue}>

View File

@@ -89,7 +89,7 @@ const NetworkSettings = () => {
static_ip_config: false, static_ip_config: false,
bandwidth20: false, bandwidth20: false,
tx_power: 0, tx_power: 0,
nosleep: true, nosleep: false,
enableMDNS: true, enableMDNS: true,
enableCORS: false, enableCORS: false,
CORSOrigin: '*' CORSOrigin: '*'
@@ -121,19 +121,19 @@ const NetworkSettings = () => {
deselectNetwork(); deselectNetwork();
}, [data, saveData, deselectNetwork]); }, [data, saveData, deselectNetwork]);
const setCancel = async () => { const setCancel = useCallback(async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}; }, [deselectNetwork, loadData]);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="ssid" name="ssid"
label="SSID" label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.ssid} value={data.ssid}

View File

@@ -1,4 +1,4 @@
import { memo, useRef, useState } from 'react'; import { memo, useCallback, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = () => { const renderNetworkScanner = useCallback(() => {
if (!networkList) { if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />; return <FormLoader errorMessage={errorMessage || ''} />;
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}; }, [networkList, errorMessage]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { memo, useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,7 +63,8 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => ( const renderNetwork = useCallback(
(network: WiFiNetwork) => (
<ListItem <ListItem
key={network.bssid} key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)} onClick={() => wifiConnectionContext.selectNetwork(network)}
@@ -88,6 +89,8 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
</Badge> </Badge>
</ListItemIcon> </ListItemIcon>
</ListItem> </ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,7 +55,9 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useTheme({ const table_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`, `,
@@ -93,36 +95,44 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const noAdminConfigured = () => !data?.users.find((u) => u.admin); const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = (toRemove: UserType) => { const removeUser = useCallback(
(toRemove: UserType) => {
if (!data) return; if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username); const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users }); updateDataValue({ ...data, users });
setChanged(changed + 1); setChanged(changed + 1);
}; },
[data, updateDataValue, changed]
);
const createUser = () => { const createUser = useCallback(() => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}; }, []);
const editUser = (toEdit: UserType) => { const editUser = useCallback((toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}; }, []);
const cancelEditingUser = () => { const cancelEditingUser = useCallback(() => {
setUser(undefined); setUser(undefined);
}; }, []);
const doneEditingUser = () => { const doneEditingUser = useCallback(() => {
if (user && data) { if (user && data) {
const users = [ const users = [
...data.users.filter( ...data.users.filter(
@@ -134,26 +144,26 @@ const ManageUsers = () => {
setUser(undefined); setUser(undefined);
setChanged(changed + 1); setChanged(changed + 1);
} }
}; }, [user, data, updateDataValue, changed]);
const closeGenerateToken = useCallback(() => { const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined); setGeneratingToken(undefined);
}, []); }, []);
const generateTokenForUser = (username: string) => { const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}; }, []);
const onSubmit = async () => { const onSubmit = useCallback(async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}; }, [saveData, authenticatedContext]);
const onCancelSubmit = async () => { const onCancelSubmit = useCallback(async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}; }, [loadData]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -167,10 +177,15 @@ const ManageUsers = () => {
admin: boolean; admin: boolean;
} }
const user_table = data.users.map((u) => ({ // add id to the type, needed for the table
const user_table = useMemo(
() =>
data.users.map((u) => ({
...u, ...u,
id: u.username id: u.username
})) as UserType2[]; })) as UserType2[],
[data.users]
);
return ( return (
<> <>

View File

@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
@@ -15,7 +15,9 @@ const Security = () => {
const location = useLocation(); const location = useLocation();
const matchedRoutes = matchRoutes( const matchedRoutes = useMemo(
() =>
matchRoutes(
[ [
{ {
path: '/settings/security/settings', path: '/settings/security/settings',
@@ -24,6 +26,8 @@ const Security = () => {
{ path: '/settings/security/users', element: <SecuritySettings /> } { path: '/settings/security/users', element: <SecuritySettings /> }
], ],
location location
),
[location]
); );
const routerTab = matchedRoutes?.[0]?.route.path || false; const routerTab = matchedRoutes?.[0]?.route.path || false;

View File

@@ -79,7 +79,7 @@ const SecuritySettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} /> <MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
} }
}, [open]); }, [open]);
const validateAndDone = async () => { const validateAndDone = useCallback(async () => {
if (user) { if (user) {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
} }
}; }, [user, validator, onDoneEditing]);
return ( return (
<Dialog <Dialog

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -34,7 +36,9 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({ const stats_theme = tableTheme(
useMemo(
() => ({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`, `,
@@ -70,15 +74,21 @@ const SystemActivity = () => {
text-align: center; text-align: center;
} }
` `
}); }),
[]
)
);
const showName = (id: number) => { const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] = const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES']; id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name](); return LL.STATUS_NAMES[name]();
}; },
[LL]
);
const showQuality = (stat: Stat) => { const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
@@ -90,18 +100,14 @@ const SystemActivity = () => {
} else { } else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
} }
}; }, []);
const content = useMemo(() => {
if (!data) { if (!data) {
return ( return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<SectionContent>
<Table <Table
data={{ nodes: data.stats }} data={{ nodes: data.stats }}
theme={stats_theme} theme={stats_theme}
@@ -130,8 +136,10 @@ const SystemActivity = () => {
</> </>
)} )}
</Table> </Table>
</SectionContent>
); );
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; export default SystemActivity;

View File

@@ -1,4 +1,4 @@
import { type FC, memo } from 'react'; import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,15 +127,16 @@ const MqttStatus = () => {
void loadData(); void loadData();
}); });
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || ''; const errorMessage = error?.message || '';
const mqttStatusText = !data const mqttStatusText = useMemo(() => {
? '' if (!data) return '';
: !data.enabled if (!data.enabled) return LL.NOT_ENABLED();
? LL.NOT_ENABLED() return data.connected
: data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})` ? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`; : `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) { if (!data) {
return ( return (

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -65,16 +67,12 @@ const NTPStatus = () => {
} }
}; };
const content = useMemo(() => {
if (!data) { if (!data) {
return ( return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -123,8 +121,10 @@ const NTPStatus = () => {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
</SectionContent>
); );
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
}; };
export default NTPStatus; export default NTPStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite'; import GiteIcon from '@mui/icons-material/Gite';
@@ -122,12 +124,9 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
const content = useMemo(() => {
if (!data) { if (!data) {
return ( return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL); const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
@@ -135,7 +134,6 @@ const NetworkStatus = () => {
const qualityColor = networkQualityHighlight(data, theme); const qualityColor = networkQualityHighlight(data, theme);
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -229,8 +227,10 @@ const NetworkStatus = () => {
</> </>
)} )}
</List> </List>
</SectionContent>
); );
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;

View File

@@ -1,16 +1,25 @@
import { useContext } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev'; import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory'; import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router'; import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi'; import WifiIcon from '@mui/icons-material/Wifi';
import { import {
Avatar, Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
@@ -18,10 +27,12 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { busConnectionStatus } from 'app/main/types'; import { type APIcall, busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components'; import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -30,6 +41,9 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time'; import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = ( const formatDurationSec = (
@@ -58,7 +72,24 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const { data, send: loadData, error } = useRequest(readSystemStatus); const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
useInterval(() => { useInterval(() => {
void loadData(); void loadData();
@@ -66,8 +97,10 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
const busStatus = (() => { // Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown'; if (!data) return 'EMS state unknown';
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -78,10 +111,12 @@ const SystemStatus = () => {
default: default:
return 'EMS state unknown'; return 'EMS state unknown';
} }
})(); }, [data?.bus_status, data?.bus_uptime, LL]);
const systemStatus = (() => { // Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
if (!data) return '??'; if (!data) return '??';
switch (data.status) { switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
@@ -94,12 +129,14 @@ const SystemStatus = () => {
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0)); return LL.GPIO_OF(LL.FAILED(0));
default: default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK'; return 'OK';
} }
})(); }, [data?.status, LL]);
const busStatusHighlight = (() => { const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -110,10 +147,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
})(); }, [data?.bus_status, theme.palette]);
const ntpStatus = (() => { const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
@@ -126,10 +164,11 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
})(); }, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = (() => { const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main; if (!data) return theme.palette.error.main;
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -140,10 +179,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
})(); }, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = (() => { const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -158,10 +198,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
})(); }, [data?.network_status, theme.palette]);
const networkStatus = (() => { const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1); return LL.INACTIVE(1);
@@ -182,22 +223,114 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
})(); }, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) => const activeHighlight = useCallback(
value ? theme.palette.success.main : theme.palette.info.main; (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
if (!data || !LL) { [theme.palette]
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
); );
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
),
[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 <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
return ( return (
<SectionContent> <>
<List> <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
to="version"
/>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}> <Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
@@ -206,8 +339,18 @@ const SystemStatus = () => {
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))} primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`} secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/> />
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
)}
</ListItem> </ListItem>
<ListMenuItem <ListMenuItem
@@ -215,7 +358,7 @@ const SystemStatus = () => {
icon={MemoryIcon} icon={MemoryIcon}
bgcolor="#68374d" bgcolor="#68374d"
label={LL.HARDWARE()} label={LL.HARDWARE()}
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`} text={freeMemoryText}
to="/status/hardwarestatus" to="/status/hardwarestatus"
/> />
@@ -230,11 +373,7 @@ const SystemStatus = () => {
<ListMenuItem <ListMenuItem
disabled={!me.admin} disabled={!me.admin}
icon={ icon={networkIcon}
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight} bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)} label={LL.NETWORK(1)}
text={networkStatus} text={networkStatus}
@@ -246,7 +385,7 @@ const SystemStatus = () => {
icon={DeviceHubIcon} icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)} bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT" label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)} text={mqttStatusText}
to="/status/mqtt" to="/status/mqtt"
/> />
@@ -264,7 +403,7 @@ const SystemStatus = () => {
icon={SettingsInputAntennaIcon} icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)} bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)} label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)} text={apStatusText}
to="/status/ap" to="/status/ap"
/> />
@@ -277,8 +416,34 @@ const SystemStatus = () => {
to="/status/log" to="/status/log"
/> />
</List> </List>
</SectionContent>
{renderRestartDialog}
</>
); );
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
}; };
export default SystemStatus; export default SystemStatus;

View File

@@ -1,4 +1,11 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -178,7 +185,8 @@ const SystemLog = () => {
}; };
}, [data]); // Recalculate when data changes (in case layout shifts) }, [data]); // Recalculate when data changes (in case layout shifts)
const handleLogMessage = (message: { data: string }) => { // Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data; const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry; const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => { setLogEntries((log) => {
@@ -192,7 +200,7 @@ const SystemLog = () => {
const newLog = [...log, logentry]; const newLog = [...log, logentry];
return newLog; return newLog;
}); });
}; }, []);
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
@@ -203,7 +211,7 @@ const SystemLog = () => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const onDownload = () => { const onDownload = useCallback(() => {
const result = logEntries const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n'); .join('\n');
@@ -217,11 +225,11 @@ const SystemLog = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}; }, [logEntries]);
const saveSettings = async () => { const saveSettings = useCallback(async () => {
await saveData(); await saveData();
}; }, [saveData]);
// handle scrolling - optimized to only scroll when needed // handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -238,7 +246,7 @@ const SystemLog = () => {
} }
}, [logEntries.length, autoscroll]); }, [logEntries.length, autoscroll]);
const sendReadCommand = () => { const sendReadCommand = useCallback(() => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -249,7 +257,7 @@ const SystemLog = () => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
} }
}; }, [readValue, readOpen, send]);
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
@@ -57,9 +57,10 @@ const SystemMonitor = () => {
void send(); void send();
}, 1000); // check every 1 second }, 1000); // check every 1 second
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status; const status = data?.status;
const statusMessage = const message =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE() ? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
@@ -70,18 +71,25 @@ const SystemMonitor = () => {
? 'Upload Failed' ? 'Upload Failed'
: LL.RESTARTING_POST(); : LL.RESTARTING_POST();
const isUploading = const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progressValue = const progress =
isUploading && status uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0; : 0;
const onCancel = async () => { return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined); setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/'; document.location.href = '/';
}; }, [setSystemStatus]);
return ( return (
<Box <Box

View File

@@ -0,0 +1,931 @@
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
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 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 { getDevVersion, getStableVersion } from 'api/system';
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 { 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;
}
interface UpgradeCheckData {
emsesp_version: string;
dev_upgradeable: boolean;
stable_upgradeable: boolean;
}
interface VersionInfo {
name: string;
published_at?: string;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
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 (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.name
: version?.name}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version?.published_at && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.published_at))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
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 (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// 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<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(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<number>(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 (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
</Button>
);
},
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">
{LL.STORED_VERSIONS()}
</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.name || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
</>
);
}, [
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 ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default memo(Version);

View File

@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, memo } from 'react'; import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
@@ -38,18 +38,19 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level]; const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level]; const palettePath = LEVEL_PALETTE_PATHS[level];
const [paletteKeyName, shade] = palettePath.split('.') as [ const [key, shade] = palettePath.split('.') as [
keyof typeof theme.palette, keyof typeof theme.palette,
string string
]; ];
const paletteKey = theme.palette[paletteKeyName] as unknown as Record< const paletteKey = theme.palette[key] as unknown as Record<string, string>;
string,
string
>;
const backgroundColor = paletteKey[shade]; const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
return ( return (
<Box <Box
{...rest} {...rest}

View File

@@ -1,4 +1,4 @@
import { memo, useContext } from 'react'; import { memo, useCallback, useContext, useMemo } from 'react';
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
@@ -44,14 +44,27 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => { const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext); const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
target async ({ target }) => {
}) => {
const loc = target.value as Locales; const loc = target.value as Locales;
localStorage.setItem('lang', loc); localStorage.setItem('lang', loc);
await loadLocaleAsync(loc); await loadLocaleAsync(loc);
setLocale(loc); setLocale(loc);
}; },
[setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return ( return (
<TextField <TextField
@@ -63,12 +76,7 @@ const LanguageSelector = () => {
size="small" size="small"
select select
> >
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( {menuItems}
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
</TextField> </TextField>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { memo, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = () => { const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev); setShowPassword((prev) => !prev);
}; }, []);
return ( return (
<ValidatedTextField <ValidatedTextField

View File

@@ -18,6 +18,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME); const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation(); const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => { const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev); setMobileOpen((prev) => !prev);
}, []); }, []);
@@ -27,6 +28,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false); setMobileOpen(false);
}, [pathname]); }, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]); const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,11 +39,14 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pathnames = location.pathname.split('/').filter((x) => x); const pathnames = useMemo(
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = () => { const handleBackClick = useCallback(() => {
void navigate('/' + pathnames[0]); void navigate('/' + pathnames[0]);
}; }, [navigate, pathnames]);
return ( return (
<AppBar position="fixed" sx={appBarStyles}> <AppBar position="fixed" sx={appBarStyles}>

View File

@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
@@ -24,7 +24,9 @@ interface LayoutDrawerProps {
} }
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
const drawer = ( // Memoize drawer content to prevent unnecessary re-renders
const drawer = useMemo(
() => (
<> <>
<Toolbar disableGutters> <Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
@@ -36,6 +38,8 @@ const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
<Divider /> <Divider />
<LayoutMenu /> <LayoutMenu />
</> </>
),
[]
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useContext, useState } from 'react'; import { memo, useCallback, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -18,15 +18,13 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => { const LayoutMenuComponent = () => {
const { me, versions } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const upgradeAvailable = versions?.current?.upgradeable ?? false; const handleMenuToggle = useCallback(() => {
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev); setMenuOpen((prev) => !prev);
}; }, []);
return ( return (
<> <>
@@ -107,7 +105,6 @@ const LayoutMenuComponent = () => {
label={LL.SETTINGS(0)} label={LL.SETTINGS(0)}
disabled={!me.admin} disabled={!me.admin}
to="/settings" to="/settings"
badge={upgradeAvailable}
/> />
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} /> <LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider /> <Divider />

View File

@@ -1,7 +1,7 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material'; import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -11,21 +11,21 @@ interface LayoutMenuItemProps {
label: string; label: string;
to: string; to: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const LayoutMenuItemComponent = ({ const LayoutMenuItemComponent = ({
icon: Icon, icon: Icon,
label, label,
to, to,
disabled, disabled
badge
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = routeMatches(to, pathname); const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
const buttonStyles: SxProps<Theme> = { // Memoize dynamic styles based on selected state
const buttonStyles: SxProps<Theme> = useMemo(
() => ({
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
borderRadius: '8px', borderRadius: '8px',
@@ -43,20 +43,28 @@ const LayoutMenuItemComponent = ({
backgroundColor: '#90caf9', backgroundColor: '#90caf9',
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
} }
}; }),
[selected]
);
const iconStyles: SxProps<Theme> = { const iconStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#9e9e9e', color: selected ? '#90caf9' : '#9e9e9e',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.1)' : 'scale(1)', transform: selected ? 'scale(1.1)' : 'scale(1)',
transitionProperty: 'color, transform' transitionProperty: 'color, transform'
}; }),
[selected]
);
const textStyles: SxProps<Theme> = { const textStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#f5f5f5', color: selected ? '#90caf9' : '#f5f5f5',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transitionProperty: 'color, font-weight' transitionProperty: 'color, font-weight'
}; }),
[selected]
);
return ( return (
<ListItemButton <ListItemButton
@@ -70,20 +78,6 @@ const LayoutMenuItemComponent = ({
<Icon /> <Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText> <ListItemText sx={textStyles}>{label}</ListItemText>
{badge && (
<Box
aria-label="update available"
sx={{
width: 8,
height: 8,
ml: 1,
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
flexShrink: 0
}}
/>
)}
</ListItemButton> </ListItemButton>
); );
}; };

View File

@@ -5,7 +5,6 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { import {
Avatar, Avatar,
Box,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemButton, ListItemButton,
@@ -21,7 +20,6 @@ interface ListMenuItemProps {
text: string; text: string;
to?: string; to?: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@@ -30,40 +28,15 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle' verticalAlign: 'middle'
}; };
const Badge = () => (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
);
const RenderIcon = memo( const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => ( ({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
<Icon /> <Icon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText primary={label} secondary={text} />
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</> </>
) )
); );
@@ -74,8 +47,7 @@ const LayoutMenuItem = ({
label, label,
text, text,
to, to,
disabled, disabled
badge
}: ListMenuItemProps) => ( }: ListMenuItemProps) => (
<> <>
{to && !disabled ? ( {to && !disabled ? (
@@ -93,7 +65,6 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@@ -104,7 +75,6 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItem> </ListItem>
)} )}

View File

@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useCallback } from 'react';
import type { Blocker } from 'react-router'; import type { Blocker } from 'react-router';
import { import {
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const handleReset = () => { const handleReset = useCallback(() => {
blocker.reset?.(); blocker.reset?.();
}; }, [blocker]);
const handleProceed = () => { const handleProceed = useCallback(() => {
blocker.proceed?.(); blocker.proceed?.();
}; }, [blocker]);
return ( return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}> <Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>

View File

@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -16,9 +16,12 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (_event: unknown, path: string) => { const handleTabChange = useCallback(
(_event: unknown, path: string) => {
void navigate(path); void navigate(path);
}; },
[navigate]
);
return ( return (
<Tabs <Tabs

View File

@@ -91,10 +91,8 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
).upgradeImportantMessageType; ).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n); setUpgradeImportantMessageType(upgradeImportantMessageType_n);
if (upgradeImportantMessageType_n === 0) { if (upgradeImportantMessageType_n === 0) {
if (file) {
onFileSelected(file); onFileSelected(file);
} }
}
}) })
.onError((error) => { .onError((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));

View File

@@ -3,7 +3,6 @@ import type { FC } from 'react';
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { callAction } from 'api/app';
import { ACCESS_TOKEN } from 'api/endpoints'; import { ACCESS_TOKEN } from 'api/endpoints';
import * as AuthenticationApi from 'components/routing/authentication'; import * as AuthenticationApi from 'components/routing/authentication';
@@ -11,7 +10,7 @@ import { useRequest } from 'alova/client';
import { LoadingSpinner } from 'components'; import { LoadingSpinner } from 'components';
import { verifyAuthorization } from 'components/routing/authentication'; import { verifyAuthorization } from 'components/routing/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { Me, VersionsResponse } from 'types'; import type { Me } from 'types';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context'; import { AuthenticationContext } from './context';
@@ -21,34 +20,17 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const [initialized, setInitialized] = useState<boolean>(false); const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>(); const [me, setMe] = useState<Me>();
const [versions, setVersions] = useState<VersionsResponse>();
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), { const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
immediate: false 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) => { const signIn = (accessToken: string) => {
try { try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken); AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken); const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe); setMe(decodedMe);
toast.success(LL.LOGGED_IN({ name: decodedMe.username })); toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
void refreshVersions();
} catch { } catch {
setMe(undefined); setMe(undefined);
throw new Error('Failed to parse JWT'); throw new Error('Failed to parse JWT');
@@ -58,7 +40,6 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const signOut = (doRedirect: boolean) => { const signOut = (doRedirect: boolean) => {
AuthenticationApi.clearAccessToken(); AuthenticationApi.clearAccessToken();
setMe(undefined); setMe(undefined);
setVersions(undefined);
if (doRedirect) { if (doRedirect) {
redirect('/'); redirect('/');
} }
@@ -68,9 +49,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
await sendVerifyAuthorization() await sendVerifyAuthorization()
.then(async () => { .then(() => {
setMe(AuthenticationApi.decodeMeJWT(accessToken)); setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true); setInitialized(true);
}) })
.catch(() => { .catch(() => {
@@ -87,16 +67,15 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
// cache object to prevent re-renders
const obj = useMemo( const obj = useMemo(
() => ({ () => ({
signIn, signIn,
signOut, signOut,
refresh, refresh,
refreshVersions, ...(me && { me })
...(me && { me }),
...(versions && { versions })
}), }),
[signIn, signOut, me, refresh, refreshVersions, versions] [signIn, signOut, me, refresh]
); );
if (initialized) { if (initialized) {

View File

@@ -1,14 +1,12 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { Me, VersionsResponse } from 'types'; import type { Me } from 'types';
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
refresh: () => Promise<void>; refresh: () => Promise<void>;
signIn: (accessToken: string) => void; signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void; signOut: (redirect: boolean) => void;
me?: Me; me?: Me;
versions?: VersionsResponse;
refreshVersions: () => Promise<void>;
} }
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue; const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;

View File

@@ -247,7 +247,8 @@ const cz: Translation = {
TIME_ZONE: 'Časová zóna', TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Přístupový bod', ACCESS_POINT: 'Přístupový bod',
AP_PROVIDE: 'Povolit přístupový bod', AP_PROVIDE: 'Povolit přístupový bod',
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené', AP_PROVIDE_TEXT_1: 'Vždy',
AP_PROVIDE_TEXT_2: 'Když je WiFi odpojena',
AP_PROVIDE_TEXT_3: 'Nikdy', AP_PROVIDE_TEXT_3: 'Nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál', AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skrýt SSID', AP_HIDE_SSID: 'Skrýt SSID',
@@ -261,6 +262,7 @@ const cz: Translation = {
SCAN_AGAIN: 'Skenovat znovu', SCAN_AGAIN: 'Skenovat znovu',
NETWORK_SCANNER: 'Síťový skener', NETWORK_SCANNER: 'Síťový skener',
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě', NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
NETWORK_BLANK_SSID: 'ponechte prázdné pro deaktivaci WiFi a povolení ETH',
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID', NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
TX_POWER: 'Vysílací výkon', TX_POWER: 'Vysílací výkon',
HOSTNAME: 'Název hostitele', HOSTNAME: 'Název hostitele',
@@ -362,8 +364,8 @@ const cz: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy', UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.', UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.', UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?', WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?'
TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán'
}; };
export default cz; export default cz;

View File

@@ -247,7 +247,8 @@ const de: Translation = {
TIME_ZONE: 'Zeitzone', TIME_ZONE: 'Zeitzone',
ACCESS_POINT: 'Zugangspunkt', ACCESS_POINT: 'Zugangspunkt',
AP_PROVIDE: 'Aktiviere Zugangspunkt', AP_PROVIDE: 'Aktiviere Zugangspunkt',
AP_PROVIDE_TEXT_2: 'Wenn Netzwerkverbindung verloren geht', AP_PROVIDE_TEXT_1: 'Immer',
AP_PROVIDE_TEXT_2: 'Wenn WiFi nicht verbunden',
AP_PROVIDE_TEXT_3: 'Niemals', AP_PROVIDE_TEXT_3: 'Niemals',
AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal', AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal',
AP_HIDE_SSID: 'Verstecke SSID', AP_HIDE_SSID: 'Verstecke SSID',
@@ -261,6 +262,7 @@ const de: Translation = {
SCAN_AGAIN: 'Erneute Suche', SCAN_AGAIN: 'Erneute Suche',
NETWORK_SCANNER: 'Netzwerksuche', NETWORK_SCANNER: 'Netzwerksuche',
NETWORK_NO_WIFI: 'Keine WiFi-Netzwerke gefunden', NETWORK_NO_WIFI: 'Keine WiFi-Netzwerke gefunden',
NETWORK_BLANK_SSID: 'Freilassen, um WiFi zu deaktivieren und ETH zu aktivieren.',
NETWORK_BLANK_BSSID: 'Freilassen, um nur SSID für die Verbindung zu nutzen.', NETWORK_BLANK_BSSID: 'Freilassen, um nur SSID für die Verbindung zu nutzen.',
TX_POWER: 'Tx Leistung', TX_POWER: 'Tx Leistung',
HOSTNAME: 'Hostname', HOSTNAME: 'Hostname',
@@ -362,8 +364,8 @@ const de: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Wichtige Nachrichten aktualisieren', UPGRADE_IMPORTANT_MESSAGES: 'Wichtige Nachrichten aktualisieren',
UPGRADE_IMPORTANT_MESSAGES_1: 'Für diese Aktualisierung ist ein Werksreset erforderlich. Stellen Sie sicher, dass Sie zuerst eine Systemsicherung herunterladen, bevor Sie fortfahren, und laden Sie diese Datei dann nach der Installation der neuen Version hoch.', UPGRADE_IMPORTANT_MESSAGES_1: 'Für diese Aktualisierung ist ein Werksreset erforderlich. Stellen Sie sicher, dass Sie zuerst eine Systemsicherung herunterladen, bevor Sie fortfahren, und laden Sie diese Datei dann nach der Installation der neuen Version hoch.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Sie aktualisieren auf eine neue Hauptversion. Stellen Sie sicher, dass Sie den ChangeLog für alle wichtigen Änderungen gelesen haben.', UPGRADE_IMPORTANT_MESSAGES_2: 'Sie aktualisieren auf eine neue Hauptversion. Stellen Sie sicher, dass Sie den ChangeLog für alle wichtigen Änderungen gelesen haben.',
WARNING_SYSTEM_BACKUP: 'Dies wird eine Sicherung Ihrer vollständigen Systemkonfiguration und Einstellungen erstellen. Alle Passwörter werden in dieser Sicherungsdatei lesbar sein. Seien Sie vorsichtig beim Teilen! Möchten Sie fortfahren?', WARNING_SYSTEM_BACKUP: 'Dies wird eine Sicherung Ihrer vollständigen Systemkonfiguration und Einstellungen erstellen. Alle Passwörter werden in dieser Sicherungsdatei lesbar sein. Seien Sie vorsichtig beim Teilen! Möchten Sie fortfahren?'
TEST_EMAIL_SUCCESSFUL: 'Test email erfolgreich gesendet'
}; };
export default de; export default de;

View File

@@ -247,7 +247,8 @@ const en: Translation = {
TIME_ZONE: 'Time Zone', TIME_ZONE: 'Time Zone',
ACCESS_POINT: 'Access Point', ACCESS_POINT: 'Access Point',
AP_PROVIDE: 'Enable Access Point', AP_PROVIDE: 'Enable Access Point',
AP_PROVIDE_TEXT_2: 'When network connection is lost', AP_PROVIDE_TEXT_1: 'Always',
AP_PROVIDE_TEXT_2: 'When WiFi is disconnected',
AP_PROVIDE_TEXT_3: 'Never', AP_PROVIDE_TEXT_3: 'Never',
AP_PREFERRED_CHANNEL: 'Preferred Channel', AP_PREFERRED_CHANNEL: 'Preferred Channel',
AP_HIDE_SSID: 'Hide SSID', AP_HIDE_SSID: 'Hide SSID',
@@ -261,6 +262,7 @@ const en: Translation = {
SCAN_AGAIN: 'Scan again', SCAN_AGAIN: 'Scan again',
NETWORK_SCANNER: 'Network Scanner', NETWORK_SCANNER: 'Network Scanner',
NETWORK_NO_WIFI: 'No WiFi networks found', NETWORK_NO_WIFI: 'No WiFi networks found',
NETWORK_BLANK_SSID: 'leave blank to disable WiFi and enable ETH',
NETWORK_BLANK_BSSID: 'leave blank to use only SSID', NETWORK_BLANK_BSSID: 'leave blank to use only SSID',
TX_POWER: 'Tx Power', TX_POWER: 'Tx Power',
HOSTNAME: 'Hostname', HOSTNAME: 'Hostname',
@@ -362,8 +364,8 @@ const en: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Important Messages', UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Important Messages',
UPGRADE_IMPORTANT_MESSAGES_1: 'This upgrade requires a factory reset. Make sure you first download a System Backup before continuing, and then upload this file after the new version is installed.', UPGRADE_IMPORTANT_MESSAGES_1: 'This upgrade requires a factory reset. Make sure you first download a System Backup before continuing, and then upload this file after the new version is installed.',
UPGRADE_IMPORTANT_MESSAGES_2: 'You are upgrading to a new major version. Make sure you have read the ChangeLog for any breaking changes.', UPGRADE_IMPORTANT_MESSAGES_2: 'You are upgrading to a new major version. Make sure you have read the ChangeLog for any breaking changes.',
WARNING_SYSTEM_BACKUP: 'This will create a backup of your full system configuration and settings. All passwords will be readable in the backup file. Be careful with sharing! Do you want to continue?', WARNING_SYSTEM_BACKUP: 'This will create a backup of your full system configuration and settings. All passwords will be readable in the backup file. Be careful with sharing! Do you want to continue?'
TEST_EMAIL_SUCCESSFUL: 'Test email sent successfully'
}; };
export default en; export default en;

View File

@@ -247,7 +247,8 @@ const fr: Translation = {
TIME_ZONE: 'Fuseau horaire', TIME_ZONE: 'Fuseau horaire',
ACCESS_POINT: "Point d'accès", ACCESS_POINT: "Point d'accès",
AP_PROVIDE: "Activer le Point d'Accès", AP_PROVIDE: "Activer le Point d'Accès",
AP_PROVIDE_TEXT_2: 'quand la connexion réseau est perdue', AP_PROVIDE_TEXT_1: 'toujours',
AP_PROVIDE_TEXT_2: 'quand le WiFi est déconnecté',
AP_PROVIDE_TEXT_3: 'jamais', AP_PROVIDE_TEXT_3: 'jamais',
AP_PREFERRED_CHANNEL: 'Canal préféré', AP_PREFERRED_CHANNEL: 'Canal préféré',
AP_HIDE_SSID: 'Cacher le SSID', AP_HIDE_SSID: 'Cacher le SSID',
@@ -261,6 +262,7 @@ const fr: Translation = {
SCAN_AGAIN: 'Rescanner', SCAN_AGAIN: 'Rescanner',
NETWORK_SCANNER: 'Scan réseau', NETWORK_SCANNER: 'Scan réseau',
NETWORK_NO_WIFI: 'Pas de réseau WiFi trouvé', NETWORK_NO_WIFI: 'Pas de réseau WiFi trouvé',
NETWORK_BLANK_SSID: 'laisser vide pour désactiver le WiFi',
NETWORK_BLANK_BSSID: 'laisser vide pour utiliser uniquement le SSID', NETWORK_BLANK_BSSID: 'laisser vide pour utiliser uniquement le SSID',
TX_POWER: 'Puissance Tx', TX_POWER: 'Puissance Tx',
HOSTNAME: "Nom d'hôte", HOSTNAME: "Nom d'hôte",
@@ -362,8 +364,8 @@ const fr: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Mettre à jour les messages importants', UPGRADE_IMPORTANT_MESSAGES: 'Mettre à jour les messages importants',
UPGRADE_IMPORTANT_MESSAGES_1: 'Cette mise à jour nécessite une réinitialisation de fabrique. Assurez-vous de télécharger une sauvegarde système avant de continuer, et de la charger après l\'installation de la nouvelle version.', UPGRADE_IMPORTANT_MESSAGES_1: 'Cette mise à jour nécessite une réinitialisation de fabrique. Assurez-vous de télécharger une sauvegarde système avant de continuer, et de la charger après l\'installation de la nouvelle version.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Vous mettez à jour vers une nouvelle version majeure. Assurez-vous de lire le ChangeLog pour tout changement important.', UPGRADE_IMPORTANT_MESSAGES_2: 'Vous mettez à jour vers une nouvelle version majeure. Assurez-vous de lire le ChangeLog pour tout changement important.',
WARNING_SYSTEM_BACKUP: 'Cela créera une sauvegarde de votre configuration et paramètres complets. Tous les mots de passe seront lisibles dans le fichier de sauvegarde. Soyez prudent avec le partage ! Voulez-vous continuer ?', WARNING_SYSTEM_BACKUP: 'Cela créera une sauvegarde de votre configuration et paramètres complets. Tous les mots de passe seront lisibles dans le fichier de sauvegarde. Soyez prudent avec le partage ! Voulez-vous continuer ?'
TEST_EMAIL_SUCCESSFUL: 'Test email envoyé avec succès'
}; };
export default fr; export default fr;

View File

@@ -247,7 +247,8 @@ const it: Translation = {
TIME_ZONE: 'Fuso orario', TIME_ZONE: 'Fuso orario',
ACCESS_POINT: 'Access Point', ACCESS_POINT: 'Access Point',
AP_PROVIDE: 'Abilita Access Point', AP_PROVIDE: 'Abilita Access Point',
AP_PROVIDE_TEXT_2: 'quando la connessione di rete è persa', AP_PROVIDE_TEXT_1: 'sempre',
AP_PROVIDE_TEXT_2: 'quando WiFi é disconnessa',
AP_PROVIDE_TEXT_3: 'mai', AP_PROVIDE_TEXT_3: 'mai',
AP_PREFERRED_CHANNEL: 'Canale preferito', AP_PREFERRED_CHANNEL: 'Canale preferito',
AP_HIDE_SSID: 'Nascondi SSID', AP_HIDE_SSID: 'Nascondi SSID',
@@ -261,6 +262,7 @@ const it: Translation = {
SCAN_AGAIN: 'Scansiona ancora', SCAN_AGAIN: 'Scansiona ancora',
NETWORK_SCANNER: 'Scansione Rete', NETWORK_SCANNER: 'Scansione Rete',
NETWORK_NO_WIFI: 'Nessuana rete WiFi trovata', NETWORK_NO_WIFI: 'Nessuana rete WiFi trovata',
NETWORK_BLANK_SSID: 'lasciare vuoto per disattivare WiFi',
NETWORK_BLANK_BSSID: 'lasciare vuoto per usare solo SSID', NETWORK_BLANK_BSSID: 'lasciare vuoto per usare solo SSID',
TX_POWER: 'Potenza Tx', TX_POWER: 'Potenza Tx',
HOSTNAME: 'Nome ospite', HOSTNAME: 'Nome ospite',
@@ -362,8 +364,8 @@ const it: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Aggiorna Messaggi Importanti', UPGRADE_IMPORTANT_MESSAGES: 'Aggiorna Messaggi Importanti',
UPGRADE_IMPORTANT_MESSAGES_1: 'Questa aggiornamento richiede un ripristino di fabbrica. Assicurati di prima scaricare un backup del sistema prima di continuare, e poi caricare questo file dopo l\'installazione della nuova versione.', UPGRADE_IMPORTANT_MESSAGES_1: 'Questa aggiornamento richiede un ripristino di fabbrica. Assicurati di prima scaricare un backup del sistema prima di continuare, e poi caricare questo file dopo l\'installazione della nuova versione.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Stai aggiornando a una nuova versione principale. Assicurati di aver letto il ChangeLog per qualsiasi cambiamento importante.', UPGRADE_IMPORTANT_MESSAGES_2: 'Stai aggiornando a una nuova versione principale. Assicurati di aver letto il ChangeLog per qualsiasi cambiamento importante.',
WARNING_SYSTEM_BACKUP: 'Questo creerà un backup delle tue configurazioni e impostazioni complete. Tutte le password saranno leggibili nel file di backup. Sei sicuro di voler continuare?', WARNING_SYSTEM_BACKUP: 'Questo creerà un backup delle tue configurazioni e impostazioni complete. Tutte le password saranno leggibili nel file di backup. Sei sicuro di voler continuare?'
TEST_EMAIL_SUCCESSFUL: 'Test email inviata con successo'
}; };
export default it; export default it;

View File

@@ -247,7 +247,8 @@ const nl: Translation = {
TIME_ZONE: 'Tijdzone', TIME_ZONE: 'Tijdzone',
ACCESS_POINT: 'Access Point', ACCESS_POINT: 'Access Point',
AP_PROVIDE: 'Activeer Access Point', AP_PROVIDE: 'Activeer Access Point',
AP_PROVIDE_TEXT_2: 'als netwerk verbinding verloren gaat', AP_PROVIDE_TEXT_1: 'altijd',
AP_PROVIDE_TEXT_2: 'als WiFi niet is verbonden',
AP_PROVIDE_TEXT_3: 'nooit', AP_PROVIDE_TEXT_3: 'nooit',
AP_PREFERRED_CHANNEL: 'Voorkeurskanaal', AP_PREFERRED_CHANNEL: 'Voorkeurskanaal',
AP_HIDE_SSID: 'SSID verbergen', AP_HIDE_SSID: 'SSID verbergen',
@@ -261,6 +262,7 @@ const nl: Translation = {
SCAN_AGAIN: 'Opnieuw scannen', SCAN_AGAIN: 'Opnieuw scannen',
NETWORK_SCANNER: 'Netwerk Scannen', NETWORK_SCANNER: 'Netwerk Scannen',
NETWORK_NO_WIFI: 'Geen WiFi netwerken gevonden', NETWORK_NO_WIFI: 'Geen WiFi netwerken gevonden',
NETWORK_BLANK_SSID: 'laat leeg om WiFi uit te schakelen',
NETWORK_BLANK_BSSID: 'laat leeg om alleen SSID te bebruiken', NETWORK_BLANK_BSSID: 'laat leeg om alleen SSID te bebruiken',
TX_POWER: 'Tx Vermogen', TX_POWER: 'Tx Vermogen',
HOSTNAME: 'Hostnaam', HOSTNAME: 'Hostnaam',
@@ -362,8 +364,8 @@ const nl: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Belangrijke Berichten', UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Belangrijke Berichten',
UPGRADE_IMPORTANT_MESSAGES_1: 'Deze upgrade vereist een fabrieksinstelling. Zorg ervoor dat u eerst een Systeem Backup download voordat u doorgaat, en upload deze file na de installatie van de nieuwe versie.', UPGRADE_IMPORTANT_MESSAGES_1: 'Deze upgrade vereist een fabrieksinstelling. Zorg ervoor dat u eerst een Systeem Backup download voordat u doorgaat, en upload deze file na de installatie van de nieuwe versie.',
UPGRADE_IMPORTANT_MESSAGES_2: 'U updatet naar een nieuwe grote versie. Zorg ervoor dat u de ChangeLog hebt gelezen voor alle brekende wijzigingen.', UPGRADE_IMPORTANT_MESSAGES_2: 'U updatet naar een nieuwe grote versie. Zorg ervoor dat u de ChangeLog hebt gelezen voor alle brekende wijzigingen.',
WARNING_SYSTEM_BACKUP: 'Dit zal een back-up van uw volledige systeemconfiguratie en instellingen maken. Alle wachtwoorden zijn leesbaar in het back-upbestand. Wees voorzichtig bij delen! Wilt u doorgaan?', WARNING_SYSTEM_BACKUP: 'Dit zal een back-up van uw volledige systeemconfiguratie en instellingen maken. Alle wachtwoorden zijn leesbaar in het back-upbestand. Wees voorzichtig bij delen! Wilt u doorgaan?'
TEST_EMAIL_SUCCESSFUL: 'Test email verzonden succesvol'
}; };
export default nl; export default nl;

View File

@@ -247,7 +247,8 @@ const no: Translation = {
TIME_ZONE: 'Tidssone', TIME_ZONE: 'Tidssone',
ACCESS_POINT: 'Aksesspunkt', ACCESS_POINT: 'Aksesspunkt',
AP_PROVIDE: 'Aktiver Aksesspunkt', AP_PROVIDE: 'Aktiver Aksesspunkt',
AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen er utilgjengelig', AP_PROVIDE_TEXT_1: 'alltid',
AP_PROVIDE_TEXT_2: 'når WiFi er utilgjengelig',
AP_PROVIDE_TEXT_3: 'aldri', AP_PROVIDE_TEXT_3: 'aldri',
AP_PREFERRED_CHANNEL: 'Foretrukket kanal', AP_PREFERRED_CHANNEL: 'Foretrukket kanal',
AP_HIDE_SSID: 'Skjul SSID', AP_HIDE_SSID: 'Skjul SSID',
@@ -261,6 +262,7 @@ const no: Translation = {
SCAN_AGAIN: 'Søk igjen', SCAN_AGAIN: 'Søk igjen',
NETWORK_SCANNER: 'Nettverk Scanner', NETWORK_SCANNER: 'Nettverk Scanner',
NETWORK_NO_WIFI: 'Ingen trådløse nett funnet', NETWORK_NO_WIFI: 'Ingen trådløse nett funnet',
NETWORK_BLANK_SSID: 'la feltet være blankt for å deaktivisere trådløst nettverk',
NETWORK_BLANK_BSSID: 'la feltet være blankt for å bruke kun SSID', NETWORK_BLANK_BSSID: 'la feltet være blankt for å bruke kun SSID',
TX_POWER: 'Tx Effekt', TX_POWER: 'Tx Effekt',
HOSTNAME: 'Hostname', HOSTNAME: 'Hostname',
@@ -362,8 +364,8 @@ const no: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Oppdater viktige meldinger', UPGRADE_IMPORTANT_MESSAGES: 'Oppdater viktige meldinger',
UPGRADE_IMPORTANT_MESSAGES_1: 'Denne oppdateringen krever en fabriksinstilling. Sørg for at du først lastet ned en System Backup før du fortsetter, og last denne filen etter at den nye versjonen er installert.', UPGRADE_IMPORTANT_MESSAGES_1: 'Denne oppdateringen krever en fabriksinstilling. Sørg for at du først lastet ned en System Backup før du fortsetter, og last denne filen etter at den nye versjonen er installert.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Du oppdaterer til en ny hovedversjon. Sørg for at du har lest ChangeLog for eventuelle bruddende endringer.', UPGRADE_IMPORTANT_MESSAGES_2: 'Du oppdaterer til en ny hovedversjon. Sørg for at du har lest ChangeLog for eventuelle bruddende endringer.',
WARNING_SYSTEM_BACKUP: 'Dette vil lage en sikkerhetskopi av din fullstendige systemkonfigurasjon og innstillinger. Alle passord vil være lesbare i sikkerhetskopien. Vær forsiktig med deling! Vil du fortsette?', WARNING_SYSTEM_BACKUP: 'Dette vil lage en sikkerhetskopi av din fullstendige systemkonfigurasjon og innstillinger. Alle passord vil være lesbare i sikkerhetskopien. Vær forsiktig med deling! Vil du fortsette?'
TEST_EMAIL_SUCCESSFUL: 'Test email sendt suksessfullt'
}; };
export default no; export default no;

View File

@@ -247,6 +247,7 @@ const pl: BaseTranslation = {
TIME_ZONE: 'Strefa czasowa', TIME_ZONE: 'Strefa czasowa',
ACCESS_POINT: '{{Punkt|punktu|}} {{dostępowy|dostępowego|}}', ACCESS_POINT: '{{Punkt|punktu|}} {{dostępowy|dostępowego|}}',
AP_PROVIDE: 'Punkt dostępowy', 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_2: 'aktywny jeśli brak połączenia z siecią',
AP_PROVIDE_TEXT_3: 'nieaktywny', AP_PROVIDE_TEXT_3: 'nieaktywny',
AP_PREFERRED_CHANNEL: 'Preferowany kanał', AP_PREFERRED_CHANNEL: 'Preferowany kanał',
@@ -261,6 +262,7 @@ const pl: BaseTranslation = {
SCAN_AGAIN: 'Skanuj ponownie', SCAN_AGAIN: 'Skanuj ponownie',
NETWORK_SCANNER: 'Skaner sieci WiFi', NETWORK_SCANNER: 'Skaner sieci WiFi',
NETWORK_NO_WIFI: 'Brak sieci WiFi w zasięgu', NETWORK_NO_WIFI: 'Brak sieci WiFi w zasięgu',
NETWORK_BLANK_SSID: 'pozostaw puste aby wyłączyć WiFi i włączyć ETH',
NETWORK_BLANK_BSSID: 'pozostaw puste aby używać tylko SSID', NETWORK_BLANK_BSSID: 'pozostaw puste aby używać tylko SSID',
TX_POWER: 'Moc nadawania', TX_POWER: 'Moc nadawania',
HOSTNAME: 'Nazwa w sieci', HOSTNAME: 'Nazwa w sieci',
@@ -362,8 +364,8 @@ const pl: BaseTranslation = {
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizuj ważne wiadomości', UPGRADE_IMPORTANT_MESSAGES: 'Aktualizuj ważne wiadomości',
UPGRADE_IMPORTANT_MESSAGES_1: 'Ta aktualizacja wymaga resetu fabrycznego. Upewnij się, że najpierw pobierzesz kopię zapasową systemu przed kontynuowaniem, a następnie przesuń tę plik po zainstalowaniu nowej wersji.', UPGRADE_IMPORTANT_MESSAGES_1: 'Ta aktualizacja wymaga resetu fabrycznego. Upewnij się, że najpierw pobierzesz kopię zapasową systemu przed kontynuowaniem, a następnie przesuń tę plik po zainstalowaniu nowej wersji.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujesz się do nowej głównej wersji. Upewnij się, że przeczytałeś ChangeLog dla wszelkich istotnych zmian.', UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujesz się do nowej głównej wersji. Upewnij się, że przeczytałeś ChangeLog dla wszelkich istotnych zmian.',
WARNING_SYSTEM_BACKUP: 'To spowoduje utworzenie kopii zapasowej całej konfiguracji i ustawień systemu. Wszystkie hasła będą widoczne w pliku kopii zapasowej. Bądź ostrożny przy udostępnianiu! Chcesz kontynuować?', WARNING_SYSTEM_BACKUP: 'To spowoduje utworzenie kopii zapasowej całej konfiguracji i ustawień systemu. Wszystkie hasła będą widoczne w pliku kopii zapasowej. Bądź ostrożny przy udostępnianiu! Chcesz kontynuować?'
TEST_EMAIL_SUCCESSFUL: 'Test email wysłany pomyślnie'
}; };
export default pl; export default pl;

View File

@@ -247,7 +247,8 @@ const sk: Translation = {
TIME_ZONE: 'Časová zóna', TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Prístupový bod', ACCESS_POINT: 'Prístupový bod',
AP_PROVIDE: 'Povoliť prístupový bod', AP_PROVIDE: 'Povoliť prístupový bod',
AP_PROVIDE_TEXT_2: 'keď je sieťové pripojenie stratené', AP_PROVIDE_TEXT_1: 'vždy',
AP_PROVIDE_TEXT_2: 'keď je WiFi odpojená',
AP_PROVIDE_TEXT_3: 'nikdy', AP_PROVIDE_TEXT_3: 'nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál', AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skryť SSID', AP_HIDE_SSID: 'Skryť SSID',
@@ -261,6 +262,7 @@ const sk: Translation = {
SCAN_AGAIN: 'Skenovať znova', SCAN_AGAIN: 'Skenovať znova',
NETWORK_SCANNER: 'Sieťový skener', NETWORK_SCANNER: 'Sieťový skener',
NETWORK_NO_WIFI: 'WiFi siete nenájdené', NETWORK_NO_WIFI: 'WiFi siete nenájdené',
NETWORK_BLANK_SSID: 'nechajte prázdne, ak chcete zakázať WiFi a povoliť ETH',
NETWORK_BLANK_BSSID: 'ponechajte prázdne, ak chcete používať iba SSID', NETWORK_BLANK_BSSID: 'ponechajte prázdne, ak chcete používať iba SSID',
TX_POWER: 'Tx výkon', TX_POWER: 'Tx výkon',
HOSTNAME: 'Hostname', HOSTNAME: 'Hostname',
@@ -362,8 +364,8 @@ const sk: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovať dôležité správy', UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovať dôležité správy',
UPGRADE_IMPORTANT_MESSAGES_1: 'Táto aktualizácia vyžaduje reštart základných nastavení. Uistite sa, že najprv stiahnete systémovú zálohu pred pokračovaním, a potom nahrajte tento súbor po instalácii novej verzie.', UPGRADE_IMPORTANT_MESSAGES_1: 'Táto aktualizácia vyžaduje reštart základných nastavení. Uistite sa, že najprv stiahnete systémovú zálohu pred pokračovaním, a potom nahrajte tento súbor po instalácii novej verzie.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete sa na novú hlavnú verziu. Uistite sa, že ste prečítali ChangeLog pre akékoľvek dôležité zmeny.', UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete sa na novú hlavnú verziu. Uistite sa, že ste prečítali ChangeLog pre akékoľvek dôležité zmeny.',
WARNING_SYSTEM_BACKUP: 'Toto vytvorí zálohu všetkých vašich celých systémových konfigurácií a nastavení. Všetky hesla budú čitateľné v zálohovom súbore. Buďte opatrní pri zdieľaní! Chcete pokračovať?', WARNING_SYSTEM_BACKUP: 'Toto vytvorí zálohu všetkých vašich celých systémových konfigurácií a nastavení. Všetky hesla budú čitateľné v zálohovom súbore. Buďte opatrní pri zdieľaní! Chcete pokračovať?'
TEST_EMAIL_SUCCESSFUL: 'Test email bol úspešne odoslaný'
}; };
export default sk; export default sk;

View File

@@ -247,7 +247,8 @@ const sv: Translation = {
TIME_ZONE: 'Tidszon', TIME_ZONE: 'Tidszon',
ACCESS_POINT: 'Accesspunkt', ACCESS_POINT: 'Accesspunkt',
AP_PROVIDE: 'Aktivera accesspunkt', AP_PROVIDE: 'Aktivera accesspunkt',
AP_PROVIDE_TEXT_2: 'när nätverksanslutningen är bortkopplad', AP_PROVIDE_TEXT_1: 'alltid',
AP_PROVIDE_TEXT_2: 'när WiFi är nedkopplat',
AP_PROVIDE_TEXT_3: 'aldrig', AP_PROVIDE_TEXT_3: 'aldrig',
AP_PREFERRED_CHANNEL: 'Kanal', AP_PREFERRED_CHANNEL: 'Kanal',
AP_HIDE_SSID: 'Göm SSID', AP_HIDE_SSID: 'Göm SSID',
@@ -261,6 +262,7 @@ const sv: Translation = {
SCAN_AGAIN: 'Sök igen', SCAN_AGAIN: 'Sök igen',
NETWORK_SCANNER: 'Hittade nätverk', NETWORK_SCANNER: 'Hittade nätverk',
NETWORK_NO_WIFI: 'Inga WiFi-nätverk hittades', NETWORK_NO_WIFI: 'Inga WiFi-nätverk hittades',
NETWORK_BLANK_SSID: 'lämna blankt för att inaktivera WiFi',
NETWORK_BLANK_BSSID: 'lämna blankt för att bara använda SSID', NETWORK_BLANK_BSSID: 'lämna blankt för att bara använda SSID',
TX_POWER: 'Tx effekt', TX_POWER: 'Tx effekt',
HOSTNAME: 'Värdnamn', HOSTNAME: 'Värdnamn',
@@ -362,8 +364,8 @@ const sv: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Uppdatera viktiga meddelanden', UPGRADE_IMPORTANT_MESSAGES: 'Uppdatera viktiga meddelanden',
UPGRADE_IMPORTANT_MESSAGES_1: 'Denna uppdatering kräver en fabriksåterställning. Se till att du först laddar ned en System Backup innan du fortsätter, och ladda upp denna fil efter att den nya versionen är installerad.', UPGRADE_IMPORTANT_MESSAGES_1: 'Denna uppdatering kräver en fabriksåterställning. Se till att du först laddar ned en System Backup innan du fortsätter, och ladda upp denna fil efter att den nya versionen är installerad.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Du uppdaterar till en ny huvudversion. Se till att du har läst ChangeLog för eventuella brkande ändringar.', UPGRADE_IMPORTANT_MESSAGES_2: 'Du uppdaterar till en ny huvudversion. Se till att du har läst ChangeLog för eventuella brkande ändringar.',
WARNING_SYSTEM_BACKUP: 'Detta kommer att skapa en säkerhetskopia av din fullständiga systemkonfiguration och inställningar. Alla lösenord kommer att vara läsbara i säkerhetskopien. Var försiktig med att dela! Vill du fortsätta?', WARNING_SYSTEM_BACKUP: 'Detta kommer att skapa en säkerhetskopia av din fullständiga systemkonfiguration och inställningar. Alla lösenord kommer att vara läsbara i säkerhetskopien. Var försiktig med att dela! Vill du fortsätta?'
TEST_EMAIL_SUCCESSFUL: 'Test email skickad lyckades'
}; };
export default sv; export default sv;

View File

@@ -247,7 +247,8 @@ const tr: Translation = {
TIME_ZONE: 'Saat dilimi', TIME_ZONE: 'Saat dilimi',
ACCESS_POINT: 'Erişim Noktası', ACCESS_POINT: 'Erişim Noktası',
AP_PROVIDE: 'Erişim noktasını çalıştır', AP_PROVIDE: 'Erişim noktasını çalıştır',
AP_PROVIDE_TEXT_2: 'Ağ bağlantısı kesildiğinde', AP_PROVIDE_TEXT_1: 'her zaman',
AP_PROVIDE_TEXT_2: 'Kablosuz bağlantı kesildiğinde',
AP_PROVIDE_TEXT_3: 'asla', AP_PROVIDE_TEXT_3: 'asla',
AP_PREFERRED_CHANNEL: 'Tercih edilen kanal', AP_PREFERRED_CHANNEL: 'Tercih edilen kanal',
AP_HIDE_SSID: 'SSID yi gizle', AP_HIDE_SSID: 'SSID yi gizle',
@@ -261,6 +262,7 @@ const tr: Translation = {
SCAN_AGAIN: 'Tekrar tara', SCAN_AGAIN: 'Tekrar tara',
NETWORK_SCANNER: 'Ağ Tarayıcısı', NETWORK_SCANNER: 'Ağ Tarayıcısı',
NETWORK_NO_WIFI: 'Hiçbir Kablosuz Ağ bulunamadı', NETWORK_NO_WIFI: 'Hiçbir Kablosuz Ağ bulunamadı',
NETWORK_BLANK_SSID: 'Kablosuz ağı devre dışı bırakmak için boş bırakın',
NETWORK_BLANK_BSSID: 'sadece SSID kullanmak için boş bırakın', NETWORK_BLANK_BSSID: 'sadece SSID kullanmak için boş bırakın',
TX_POWER: 'Aktarım gücü', TX_POWER: 'Aktarım gücü',
HOSTNAME: 'Ana Makine Adı', HOSTNAME: 'Ana Makine Adı',
@@ -362,8 +364,8 @@ const tr: Translation = {
UPGRADE_IMPORTANT_MESSAGES: 'Önemli Mesajları Güncelle', UPGRADE_IMPORTANT_MESSAGES: 'Önemli Mesajları Güncelle',
UPGRADE_IMPORTANT_MESSAGES_1: 'Bu güncelleme továrnı ayarlarını gerektirir. Yapılandırmanızı ve ayarlarınızı önce yedekleyin ve ardından yeni sürüm yüklendikten sonra yükleyin.', UPGRADE_IMPORTANT_MESSAGES_1: 'Bu güncelleme továrnı ayarlarını gerektirir. Yapılandırmanızı ve ayarlarınızı önce yedekleyin ve ardından yeni sürüm yüklendikten sonra yükleyin.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Yeni bir büyük sürüme yükselteceksiniz. Değişiklikleri ChangeLogı okuduğunuzdan emin olun.', UPGRADE_IMPORTANT_MESSAGES_2: 'Yeni bir büyük sürüme yükselteceksiniz. Değişiklikleri ChangeLogı okuduğunuzdan emin olun.',
WARNING_SYSTEM_BACKUP: 'Bu, sistem yapılandırmanızı ve ayarlarınızın bir yedeklemesi oluşturacaktır. Tüm şifreler yedekleme dosyasında okunabilir olacaktır. Paylaşırken dikkatli olun! Devam etmek istediğinize emin misiniz?', WARNING_SYSTEM_BACKUP: 'Bu, sistem yapılandırmanızı ve ayarlarınızın bir yedeklemesi oluşturacaktır. Tüm şifreler yedekleme dosyasında okunabilir olacaktır. Paylaşırken dikkatli olun! Devam etmek istediğinize emin misiniz?'
TEST_EMAIL_SUCCESSFUL: 'Test email başarıyla gönderildi'
}; };
export default tr; export default tr;

View File

@@ -1,4 +1,5 @@
export enum APProvisionMode { export enum APProvisionMode {
AP_MODE_ALWAYS = 0,
AP_MODE_DISCONNECTED = 1, AP_MODE_DISCONNECTED = 1,
AP_NEVER = 2 AP_NEVER = 2
} }

View File

@@ -7,4 +7,3 @@ export * from './ntp';
export * from './security'; export * from './security';
export * from './signin'; export * from './signin';
export * from './system'; export * from './system';
export * from './versions';

View File

@@ -1,23 +0,0 @@
// 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;
}

View File

@@ -1,27 +1,34 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
export const usePersistState = <T>( export const usePersistState = <T>(
initial_value: T, initial_value: T,
id: string id: string
): [T, (new_state: T) => void] => { ): [T, (new_state: T) => void] => {
const [state, setState] = useState<T>(() => { // Set initial value - only computed once on mount
const _initial_value = useMemo(() => {
try { try {
const stored = localStorage.getItem(`state:${id}`); const local_storage_value_str = localStorage.getItem(`state:${id}`);
if (stored) { // If there is a value stored in localStorage, use that
return JSON.parse(stored) as T; if (local_storage_value_str) {
return JSON.parse(local_storage_value_str) as T;
} }
} catch (error) { } catch (error) {
// If parsing fails, fall back to initial_value
console.warn( console.warn(
`Failed to parse localStorage value for key "state:${id}"`, `Failed to parse localStorage value for key "state:${id}"`,
error error
); );
} }
// Otherwise use initial_value that was passed to the function
return initial_value; return initial_value;
}); }, [id]); // initial_value intentionally omitted - only read on first mount
const [state, setState] = useState(_initial_value);
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem(`state:${id}`, JSON.stringify(state)); const state_str = JSON.stringify(state);
localStorage.setItem(`state:${id}`, state_str);
} catch (error) { } catch (error) {
console.warn( console.warn(
`Failed to save state to localStorage for key "state:${id}"`, `Failed to save state to localStorage for key "state:${id}"`,

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -54,33 +54,37 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
} }
}, [readData]); }, [readData]);
const saveData = async () => { const saveData = useCallback(async () => {
if (!data) return; if (!data) return;
// Reset states before saving
setRestartNeeded(false); setRestartNeeded(false);
setErrorMessage(undefined); setErrorMessage(undefined);
try { try {
await writeData(data as D); await writeData(data as D);
// Only update origData on successful save (dirtyFlags cleared by onSuccess handler)
setOrigData(data as D); setOrigData(data as D);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (message === REBOOT_ERROR_MESSAGE) { if (message === REBOOT_ERROR_MESSAGE) {
setRestartNeeded(true); setRestartNeeded(true);
return; return; // Early return - save succeeded but needs reboot
} }
// Restore original data on validation error
if (origData) { if (origData) {
updateData({ data: origData }); updateData({ data: origData });
} }
toast.error(message); toast.error(message);
setErrorMessage(message); setErrorMessage(message);
setDirtyFlags([]); setDirtyFlags([]); // Clear flags so user can retry
} }
}; }, [data, writeData, origData, updateData]);
return { return useMemo(
() => ({
loadData, loadData,
saveData, saveData,
saving: !!saving, saving: !!saving,
@@ -93,5 +97,18 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
blocker, blocker,
errorMessage, errorMessage,
restartNeeded restartNeeded
}; }),
[
loadData,
saveData,
saving,
updateDataValue,
data,
origData,
dirtyFlags,
blocker,
errorMessage,
restartNeeded
]
);
}; };

View File

@@ -6,6 +6,9 @@ import { Plugin, PluginOption, defineConfig } from 'vite';
import viteImagemin from 'vite-plugin-imagemin'; import viteImagemin from 'vite-plugin-imagemin';
import zlib from 'zlib'; import zlib from 'zlib';
// @ts-expect-error - mock server doesn't have type declarations
import mockServer from '../mock-api/mockServer.js';
// Constants // Constants
const KB_DIVISOR = 1024; const KB_DIVISOR = 1024;
const REPEAT_CHAR = '='; const REPEAT_CHAR = '=';
@@ -97,10 +100,6 @@ const createPreactPlugin = (devToolsEnabled: boolean) =>
// Patch preact/compat to export stub React 19 APIs (use, useOptimistic) so that // 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. // 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 => ({ const preactCompatPatchPlugin = (): Plugin => ({
name: 'preact-compat-react19-patch', name: 'preact-compat-react19-patch',
transform(code, id) { transform(code, id) {
@@ -211,11 +210,9 @@ const imageOptimizationPlugin = {
}; };
export default defineConfig( export default defineConfig(
async ({ command, mode }: { command: string; mode: string }) => { ({ command, mode }: { command: string; mode: string }) => {
if (command === 'serve') { if (command === 'serve') {
console.log(`Preparing for standalone build with server, mode=${mode}`); 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 { return {
plugins: [...createBasePlugins(true, true), mockServer()], plugins: [...createBasePlugins(true, true), mockServer()],
resolve: { resolve: {
@@ -232,7 +229,8 @@ export default defineConfig(
changeOrigin: true, changeOrigin: true,
secure: false secure: false
}, },
'/rest': 'http://localhost:3080' '/rest': 'http://localhost:3080',
'/gh': 'http://localhost:3080'
} }
}, },
build: { build: {

View File

@@ -49,10 +49,6 @@ the LICENSE file.
#define EMC_CLIENTID_LENGTH 23 + 1 #define EMC_CLIENTID_LENGTH 23 + 1
#endif #endif
#ifdef EMSESP_MQTT_STACKSIZE
#define EMC_TASK_STACK_SIZE EMSESP_MQTT_STACKSIZE
#endif
#ifndef EMC_TASK_STACK_SIZE #ifndef EMC_TASK_STACK_SIZE
#define EMC_TASK_STACK_SIZE 5120 #define EMC_TASK_STACK_SIZE 5120
#endif #endif
@@ -77,3 +73,7 @@ the LICENSE file.
#define EMC_SIZE_POOL_ELEMENTS 128 #define EMC_SIZE_POOL_ELEMENTS 128
#endif #endif
#endif #endif
#ifndef TASMOTA_SDK
#define EMC_CLIENT_SECURE
#endif

View File

@@ -62,19 +62,14 @@ MqttClient::MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint
_xSemaphore = xSemaphoreCreateMutex(); _xSemaphore = xSemaphoreCreateMutex();
EMC_SEMAPHORE_GIVE(); // release before first use EMC_SEMAPHORE_GIVE(); // release before first use
if (_useInternalTask == espMqttClientTypes::UseInternalTask::YES) { if (_useInternalTask == espMqttClientTypes::UseInternalTask::YES) {
if (core > 1) {
xTaskCreate((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle);
} else {
xTaskCreatePinnedToCore((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle, core); xTaskCreatePinnedToCore((TaskFunction_t)_loop, "mqttclient", EMC_TASK_STACK_SIZE, this, priority, &_taskHandle, core);
} }
}
#else #else
(void) useInternalTask; (void) useInternalTask;
(void) priority; (void) priority;
(void) core; (void) core;
#endif #endif
_clientId = _generatedClientId; _clientId = _generatedClientId;
_core = core;
} }
MqttClient::~MqttClient() { MqttClient::~MqttClient() {

View File

@@ -69,16 +69,6 @@ class MqttClient {
const char* getClientId() const; const char* getClientId() const;
size_t queueSize(); // No const because of mutex size_t queueSize(); // No const because of mutex
void loop(); void loop();
uint32_t stack() {
#ifndef EMSESP_STANDALONE
return uxTaskGetStackHighWaterMark(_taskHandle);
#else
return 0;
#endif
}
uint8_t core() {
return _core;
}
protected: protected:
explicit MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1); explicit MqttClient(espMqttClientTypes::UseInternalTask useInternalTask, uint8_t priority = 1, uint8_t core = 1);
@@ -108,7 +98,6 @@ class MqttClient {
uint8_t _willQos; uint8_t _willQos;
bool _willRetain; bool _willRetain;
uint32_t _timeout; uint32_t _timeout;
uint8_t _core;
// state is protected to allow state changes by the transport system, defined in child classes // state is protected to allow state changes by the transport system, defined in child classes
// eg. to allow AsyncTCP // eg. to allow AsyncTCP

Some files were not shown because too many files have changed in this diff Show More