78 Commits

Author SHA1 Message Date
Proddy
9cad606a55 Merge pull request #3058 from proddy/core3
small adjustments
2026-05-04 08:23:28 +02:00
proddy
6587ddcead increase timeout to allow slower networks to connect 2026-05-04 08:22:39 +02:00
proddy
ad05eec952 fix standalone and tests 2026-05-04 08:15:27 +02:00
Proddy
847fa4f46c Merge pull request #3056 from MichaelDvP/core3
"Send Testmail" button (not translated) #3050
2026-05-03 23:04:43 +02:00
Proddy
c5897b7ee1 Merge pull request #3057 from proddy/core3
updates to Network code
2026-05-03 23:04:19 +02:00
proddy
99ef4c0c18 updates 2026-05-03 23:03:22 +02:00
proddy
cc118adec6 text changes 2026-05-03 22:55:25 +02:00
proddy
ca94e37495 use a state machine for cycling between Eth-Wifi-AP 2026-05-03 21:59:28 +02:00
proddy
e2bd721c3e remove empty SSID check 2026-05-03 17:16:38 +02:00
proddy
033ce24fb7 udpate 2026-05-03 15:21:26 +02:00
proddy
eab7cdd7b5 updates 2026-05-03 15:19:49 +02:00
proddy
666ba41f67 package update 2026-05-03 14:24:36 +02:00
MichaelDvP
2579450eae "Send Testmail" button (not translated) #3050 2026-05-03 14:24:03 +02:00
MichaelDvP
87a3ca8393 nosleep default on after network selector 2026-05-03 14:22:08 +02:00
proddy
9ff4be41f7 getWifiReconnects -> getNetworkReconnects 2026-05-03 08:43:28 +02:00
Proddy
da3ed6cd3a Merge pull request #3054 from proddy/core3
add back version check
2026-05-02 11:04:15 +02:00
proddy
23519a8a90 add back version check 2026-05-02 11:03:33 +02:00
Proddy
242708358e Merge pull request #3052 from proddy/core3
refactor networking into a single class
2026-05-02 10:51:37 +02:00
proddy
3cc3c74e5a update versions 2026-05-02 10:44:06 +02:00
proddy
cb4cb39396 update 2026-05-02 10:43:36 +02:00
proddy
363799c9c6 fix connect spelling 2026-05-02 09:49:34 +02:00
proddy
132f83aa79 update dictionary 2026-05-02 09:49:21 +02:00
proddy
f998714225 add missing #endif 2026-05-02 09:49:09 +02:00
proddy
323fc1bb99 remove comments 2026-05-02 09:48:55 +02:00
proddy
3062d3f0e3 remove Divider 2026-05-02 09:48:44 +02:00
proddy
8f37bb7623 package update 2026-05-02 09:48:31 +02:00
proddy
a57ed90756 use new network code 2026-05-02 09:48:19 +02:00
proddy
eaf8332d16 Merge remote-tracking branch 'origin/dev' into core3 2026-05-02 09:35:31 +02:00
proddy
522286ff74 remove double wifi lost message 2026-05-02 09:11:19 +02:00
proddy
747047556e fix lint warnings on osx 2026-05-01 17:58:22 +02:00
proddy
df3d75c702 ignore .vscode/settings.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-01 17:44:41 +02:00
proddy
e40beeadd4 performance updates 2026-05-01 17:04:46 +02:00
proddy
751f10603d upgrade if AP provision mode is AP_MODE_ALWAYS 2026-05-01 17:03:56 +02:00
proddy
751c540cb3 refactor network code 2026-05-01 08:07:05 +02:00
proddy
d7bbc329bb use 3.9.0 as dummy latest dev version 2026-04-29 08:58:04 +02:00
proddy
41cd49a61c remove comment 2026-04-29 08:57:53 +02:00
proddy
fd5a39702b update 2026-04-29 08:57:46 +02:00
proddy
4d3408254e chore: stop tracking .vscode/settings.json
Already listed in .gitignore but was tracked, so local edits kept
showing up as pending changes. Untrack it so the ignore rule applies.

Made-with: Cursor
2026-04-29 08:57:12 +02:00
proddy
2cbb5ec5f2 move restart button from Settings to Version page. only show Factory Reset when in developer mode 2026-04-28 20:09:22 +02:00
proddy
3b765b308e remove unused useMemo 2026-04-28 17:31:50 +02:00
proddy
53ac82520e DeserializationError is enum 2026-04-28 16:28:11 +02:00
proddy
381fcf4080 ESP32Async/ESPAsyncWebServer @ 3.11.0 2026-04-28 16:27:59 +02:00
proddy
a3f0faf022 package update 2026-04-28 16:27:46 +02:00
proddy
b3a8737a71 move Version from status to settings 2026-04-28 16:27:39 +02:00
proddy
6e76bcc9af show badge if there is an update available, which is cached 2026-04-27 18:12:05 +02:00
proddy
6473c55317 don't force an update on each request 2026-04-27 18:11:48 +02:00
proddy
1a880f14a0 Remove useMemo/useCallback across the web UI 2026-04-27 13:24:07 +02:00
proddy
e39af36589 fix lint errors 2026-04-27 13:23:39 +02:00
proddy
c5b262af8a dont update cloudflare KV for forks 2026-04-27 11:34:03 +02:00
proddy
43ec5c1925 move mockserver to standalone section only 2026-04-27 11:30:40 +02:00
proddy
5e260f0239 refactoring 2026-04-27 11:09:51 +02:00
proddy
ab67f97b40 3.8.2-dev.20 2026-04-27 11:09:34 +02:00
proddy
9ac35e2e14 fetch emsesp firmware versions after IP connected 2026-04-27 11:09:24 +02:00
proddy
7c6259dddd tidy up comments 2026-04-27 11:08:52 +02:00
proddy
1cff1abc33 package update 2026-04-27 11:08:22 +02:00
proddy
d834d46586 rename EMSESP_Version to firmwareVersion 2026-04-27 11:08:13 +02:00
proddy
1107e1bdf3 package update 2026-04-26 16:10:35 +02:00
proddy
3a11327e7e https://github.com/emsesp/EMS-ESP32/discussions/3044 2026-04-26 16:10:30 +02:00
proddy
74062bab57 update tests 2026-04-26 16:07:45 +02:00
proddy
6802336b6b remove old code 2026-04-26 16:07:29 +02:00
proddy
a9db134d3a version updates 2026-04-26 13:24:40 +02:00
proddy
ee7be1d907 add 2026-04-26 12:20:48 +02:00
proddy
5ecda88457 inlclude full date/time 2026-04-25 21:14:16 +02:00
proddy
7056c446fa use emsesp.org/versions.json 2026-04-25 20:55:10 +02:00
proddy
147c09ae64 automatically update versions in Cloudflare KV store 2026-04-25 11:42:26 +02:00
proddy
112adf9eb0 add vscode 2026-04-25 11:19:39 +02:00
Proddy
469d412951 Merge pull request #3045 from MichaelDvP/dev
fix legegram length, #2969
2026-04-24 17:14:58 +02:00
MichaelDvP
6edbac86e2 fix legegram length, #2969 2026-04-24 14:46:53 +02:00
Proddy
0e08334132 Merge pull request #3043 from MichaelDvP/core3
sync Core3
2026-04-22 21:44:19 +02:00
MichaelDvP
3d51acf9e7 Merge branch 'core3' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 16:51:25 +02:00
MichaelDvP
fd6ea5ed7e Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into core3 2026-04-22 16:48:39 +02:00
proddy
db2be70d66 chore: update generated files for v3.8.2-dev.18 2026-04-22 14:22:25 +00:00
Proddy
c36f231990 Merge pull request #3042 from proddy/dev
minor updates
2026-04-22 16:10:20 +02:00
proddy
26102121e1 async-validator fixes 2026-04-22 16:07:56 +02:00
proddy
8e64c6303e package update 2026-04-22 15:43:58 +02:00
proddy
74c76eb90b remove YIELD 2026-04-22 15:43:29 +02:00
proddy
daffdcf58e https://github.com/emsesp/EMS-ESP32/issues/2686 2026-04-22 15:43:20 +02:00
Proddy
4bc4fa903f Merge pull request #3040 from MichaelDvP/dev
version checks prelease
2026-04-22 15:11:02 +02:00
128 changed files with 4750 additions and 4986 deletions

View File

@@ -77,3 +77,23 @@ 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

@@ -27,10 +27,17 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- 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
@@ -61,3 +68,23 @@ 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

40
.github/workflows/update_versions.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: 'Update versions'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-version:
name: 'Update versions in Cloudflare KV store'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get and Send EMS-ESP version to Cloudflare
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
KV_ENV="stable"
else
KV_ENV="dev"
fi
JSON_DATA=$(jq -n \
--arg version "$version" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "KV_ENV: $KV_ENV"
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo

4
.gitignore vendored
View File

@@ -2,6 +2,7 @@
.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
@@ -63,7 +64,7 @@ words-found-verbose.txt
# sonarlint # sonarlint
compile_commands.json compile_commands.json
# pioarduino + hybrid # other files
managed_components managed_components
dependencies.lock dependencies.lock
CMakeLists.txt CMakeLists.txt
@@ -75,3 +76,4 @@ pnpm-lock.yaml
.cache/ .cache/
interface/.tsbuildinfo interface/.tsbuildinfo
test/test_api/package-lock.json test/test_api/package-lock.json
.clangd

101
.vscode/settings.json vendored
View File

@@ -1,101 +0,0 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript"
],
"eslint.codeActionsOnSave.rules": null,
"eslint.nodePath": "interface/.yarn/sdks",
"eslint.workingDirectories": ["interface"],
"prettier.prettierPath": "",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.tsx": "typescriptreact",
"*.tcc": "cpp",
"optional": "cpp",
"istream": "cpp",
"ostream": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"array": "cpp",
"functional": "cpp",
"regex": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"string": "cpp",
"string_view": "cpp",
"atomic": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"random": "cpp",
"set": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"todo-tree.filtering.excludeGlobs": [
"**/vendor/**",
"**/node_modules/**",
"**/dist/**",
"**/bower_components/**",
"**/build/**",
"**/.vscode/**",
"**/.github/**",
"**/_output/**",
"**/*.min.*",
"**/*.map",
"**/ArduinoJson/**"
],
"cSpell.enableFiletypes": [
"ini",
"makefile"
],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
"sonarlint.connectedMode.project": {
"connectionId": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}
}

View File

@@ -2,7 +2,7 @@
For more details go to [emsesp.org](https://emsesp.org/). For more details go to [emsesp.org](https://emsesp.org/).
## [3.8.2] ## [3.9.0]
## Added ## Added
@@ -15,6 +15,7 @@ For more details go to [emsesp.org](https://emsesp.org/).
- e-mail notification using ReadyMail Client - e-mail notification using ReadyMail Client
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991) - 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
- full system backup and restore - full system backup and restore
- updated version check [#3047](https://github.com/emsesp/EMS-ESP32/issues/3047)
## Fixed ## Fixed
@@ -37,3 +38,4 @@ For more details go to [emsesp.org](https://emsesp.org/).
- fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017) - fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017)
- move http client from stack to heap - move http client from stack to heap
- heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021) - 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)

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 -Wswitch-enum CPPFLAGS += -Wall -Wextra -Werror -Wno-switch-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

@@ -171,6 +171,7 @@ telegram_type_id,name,is_fetched
0x0468,HPSet, 0x0468,HPSet,
0x0469,HPSet, 0x0469,HPSet,
0x046A,HPSet, 0x046A,HPSet,
0x0470,RC300Summer2,
0x0471,RC300Summer2, 0x0471,RC300Summer2,
0x0472,RC300Summer2, 0x0472,RC300Summer2,
0x0473,RC300Summer2, 0x0473,RC300Summer2,
@@ -178,7 +179,6 @@ telegram_type_id,name,is_fetched
0x0475,RC300Summer2, 0x0475,RC300Summer2,
0x0476,RC300Summer2, 0x0476,RC300Summer2,
0x0477,RC300Summer2, 0x0477,RC300Summer2,
0x0478,RC300Summer2,
0x047B,HP2, 0x047B,HP2,
0x0484,HPSilentMode,fetched 0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched 0x0485,HpCooling,fetched
1 telegram_type_id name is_fetched
171 0x0468 HPSet
172 0x0469 HPSet
173 0x046A HPSet
174 0x0470 RC300Summer2
175 0x0471 RC300Summer2
176 0x0472 RC300Summer2
177 0x0473 RC300Summer2
179 0x0475 RC300Summer2
180 0x0476 RC300Summer2
181 0x0477 RC300Summer2
0x0478 RC300Summer2
182 0x047B HP2
183 0x0484 HPSilentMode fetched
184 0x0485 HpCooling fetched

View File

@@ -1,9 +1,9 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.8.2", "version": "3.9.0",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "proddy, emsesp.org", "author": "emsesp.org",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -28,14 +28,11 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0", "@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@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.5", "react": "^19.2.5",
@@ -47,24 +44,21 @@
"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.6.0", "@types/node": "^25.6.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.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^10.2.1", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.46.1", "terser": "^5.46.2",
"typescript-eslint": "^8.59.0", "typescript-eslint": "^8.59.1",
"vite": "^8.0.9", "vite": "^8.0.10",
"vite-plugin-imagemin": "^0.6.1" "vite-plugin-imagemin": "^0.6.1"
}, },
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820" "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
} }

538
interface/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,6 +16,7 @@ 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';
@@ -26,7 +27,6 @@ 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,7 +43,6 @@ const SignIn = memo(() => {
} }
}); });
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo( const updateLoginRequestValue = useMemo(
() => () =>
updateValue((updater) => updateValue((updater) =>
@@ -65,7 +64,7 @@ const SignIn = memo(() => {
}); });
}, [callSignIn, signInRequest, LL]); }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = useCallback(async () => { const validateAndSignIn = 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')
@@ -77,7 +76,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,12 +57,3 @@ 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, alovaInstanceGH } from './endpoints'; import { alovaInstance } 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,29 +13,6 @@ 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 { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -57,20 +57,18 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
const intervalCallback = useCallback(() => { useInterval(() => {
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 = useCallback((ei: EntityItem) => { const hasEntityChanged = (ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -86,21 +84,19 @@ 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 = useMemo( const entity_theme = useTheme({
() => Table: `
useTheme({
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;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(1) { &:nth-of-type(1) {
padding: 8px; padding: 8px;
} }
@@ -120,7 +116,7 @@ const CustomEntities = () => {
text-align: center; text-align: center;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -129,7 +125,7 @@ const CustomEntities = () => {
height: 36px; height: 36px;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -140,11 +136,9 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const saveEntities = useCallback(async () => { const saveEntities = async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -173,44 +167,41 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}, [entities, writeEntities, LL, fetchEntities]); };
const editEntityItem = useCallback((ei: EntityItem) => { const editEntityItem = (ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchEntities]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: EntityItem) => {
(updatedItem: EntityItem) => { setDialogOpen(false);
setDialogOpen(false); void updateState(readCustomEntities(), (data: EntityItem[]) => {
void updateState(readCustomEntities(), (data: EntityItem[]) => { const new_data = creating
const new_data = creating ? [
? [ ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), updatedItem
updatedItem ]
] : data.map((ei) =>
: data.map((ei) => ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei );
); 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 = useCallback((item: EntityItem) => { const onDialogDup = (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),
@@ -228,9 +219,9 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const addEntityItem = useCallback(() => { const addEntityItem = () => {
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),
@@ -248,30 +239,27 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const formatValue = useCallback((value: unknown, uom: number) => { const formatValue = (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 = useCallback((value: number, digit: number) => { const showHex = (value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []); };
const filteredAndSortedEntities = useMemo( const filteredAndSortedEntities =
() => 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 = useCallback(() => { const renderEntity = () => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -328,17 +316,7 @@ 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 { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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,14 +68,10 @@ 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 = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -138,27 +134,21 @@ const CustomEntitiesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
const itemWithDeleted = { ...editItem, deleted: true }; onSave({ ...editItem, deleted: true });
onSave(itemWithDeleted); };
}, [editItem, onSave]);
const dup = useCallback(() => { const dup = () => {
onDup(editItem); onDup(editItem);
}, [editItem, onDup]); };
// Memoize UOM menu items to avoid recreating on every render const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
const uomMenuItems = useMemo( <MenuItem key={val} value={i}>
() => {val}
DeviceValueUOM_s.map((val, i) => ( </MenuItem>
<MenuItem key={val} value={i}> ));
{val}
</MenuItem>
)),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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,19 +171,17 @@ const Customizations = () => {
); );
}; };
const entities_theme = useMemo( const entities_theme = useTheme({
() => Table: `
useTheme({
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);
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(3) { &:nth-of-type(3) {
text-align: right; text-align: right;
} }
@@ -194,7 +192,7 @@ const Customizations = () => {
text-align: right; text-align: right;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -206,7 +204,7 @@ const Customizations = () => {
text-align: center; text-align: center;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -222,7 +220,7 @@ const Customizations = () => {
background-color: #177ac9; background-color: #177ac9;
} }
`, `,
Cell: ` Cell: `
&:nth-of-type(2) { &:nth-of-type(2) {
padding: 8px; padding: 8px;
} }
@@ -236,9 +234,7 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}), });
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -287,26 +283,23 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = useCallback((de: DeviceEntity) => { const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!'; return de.n && de.n[0] === '!';
}, []); };
const formatName = useCallback( const formatName = (de: DeviceEntity, withShortname: boolean) => {
(de: DeviceEntity, withShortname: boolean) => { let name: string;
let name: string; if (isCommand(de)) {
if (isCommand(de)) { name = de.t
name = de.t ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; } else if (de.cn && de.cn !== '') {
} else if (de.cn && de.cn !== '') { name = de.t ? `${de.t} ${de.cn}` : de.cn;
name = de.t ? `${de.t} ${de.cn}` : de.cn; } else {
} else { 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;
@@ -336,33 +329,27 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = useCallback( const filter_entity = (de: DeviceEntity) =>
(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 = useCallback( const maskDisabled = (set: boolean) => {
(set: boolean) => { setDeviceEntities((prev) =>
setDeviceEntities((prev) => prev.map((de) => {
prev.map((de) => { if (filter_entity(de)) {
if (filter_entity(de)) { const excludeMask =
const excludeMask = DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; return {
return { ...de,
...de, m: set ? de.m | excludeMask : de.m & ~excludeMask
m: set ? de.m | excludeMask : de.m & ~excludeMask };
}; }
} return de;
return de; })
}) );
); };
},
[filter_entity]
);
const resetCustomization = useCallback(async () => { const resetCustomization = async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -372,30 +359,27 @@ const Customizations = () => {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true); setRestarting(true);
} }
}, [sendResetCustomizations, LL]); };
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (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 = useCallback( const onDialogSave = (updatedItem: DeviceEntity) => {
(updatedItem: DeviceEntity) => { setDialogOpen(false);
setDialogOpen(false); updateDeviceEntity(updatedItem);
updateDeviceEntity(updatedItem); };
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = (de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
return; return;
} }
@@ -406,9 +390,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const saveCustomization = useCallback(async () => { const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return; return;
} }
@@ -441,9 +425,9 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); };
const renameDevice = useCallback(async () => { const renameDevice = async () => {
await sendDeviceName({ await sendDeviceName({
id: selectedDevice, id: selectedDevice,
name: selectedDeviceName, name: selectedDeviceName,
@@ -459,14 +443,7 @@ const Customizations = () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}, [ };
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -562,10 +539,7 @@ const Customizations = () => {
</> </>
); );
const filteredEntities = useMemo( const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => { const renderDeviceData = () => {
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -57,23 +57,16 @@ 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 = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
const isWriteableNumber = useMemo( const isWriteableNumber =
() => 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) {
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(() => { const save = () => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
} else { } else {
onSave(editItem); onSave(editItem);
} }
}, [isWriteableNumber, editItem, onSave]); };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (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>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</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 label={LL.WRITEABLE()} value={writeableIcon} /> <LabelValue
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, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { memo, useContext, useEffect, 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,40 +77,35 @@ const Dashboard = memo(() => {
} }
); );
const deviceValueDialogSave = useCallback( const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
async (devicevalue: DeviceValue) => { if (!selectedDashboardItem) {
if (!selectedDashboardItem) { return;
return; }
} const id = selectedDashboardItem.parentNode.id; // this is the parent ID
const id = selectedDashboardItem.parentNode.id; // this is the parent ID await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) .then(() => {
.then(() => { toast.success(LL.WRITE_CMD_SENT());
toast.success(LL.WRITE_CMD_SENT()); })
}) .catch((error: Error) => {
.catch((error: Error) => { toast.error(error.message);
toast.error(error.message); })
}) .finally(() => {
.finally(() => { setDeviceValueDialogOpen(false);
setDeviceValueDialogOpen(false); setSelectedDashboardItem(undefined);
setSelectedDashboardItem(undefined); });
}); };
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useMemo( const dashboard_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 28px; height: 28px;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1e1e1e; background-color: #1e1e1e;
&:nth-of-type(odd) .td { &:nth-of-type(odd) .td {
@@ -120,7 +115,7 @@ const Dashboard = memo(() => {
background-color: #177ac9; background-color: #177ac9;
}, },
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: right; text-align: right;
} }
@@ -128,9 +123,7 @@ const Dashboard = memo(() => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: [...data.nodes] },
@@ -164,79 +157,64 @@ 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 = useCallback( const showType = (n?: string, t?: number) => {
(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; }
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
} }
if (t) { }
// otherwise pick translation based on type return '';
switch (t) { };
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
}
return '';
},
[LL]
);
const showName = useCallback( const showName = (di: DashboardItem) => {
(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) { return (
return ( <span style={{ fontSize: '15px' }}>
<span style={{ fontSize: '15px' }}> <DeviceIcon type_id={di.t ?? 0} />
<DeviceIcon type_id={di.t ?? 0} /> &nbsp;&nbsp;{showType(di.n, di.t)}
&nbsp;&nbsp;{showType(di.n, di.t)} <span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span> </span>
</span> );
);
}
} }
if (di.dv) { }
return <span>{di.dv.id.slice(2)}</span>; if (di.dv) {
} return <span>{di.dv.id.slice(2)}</span>;
return null; }
}, return null;
[showType] };
);
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const editDashboardValue = useCallback( const editDashboardValue = (di: DashboardItem) => {
(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>,
@@ -248,10 +226,9 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = useMemo( const hasFavEntities = data.nodes.filter(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length, (item: DashboardItem) => item.id <= 90
[data.nodes] ).length;
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {

View File

@@ -4,7 +4,6 @@ import {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
@@ -133,21 +132,19 @@ const Devices = memo(() => {
}; };
}, []); }, []);
const leftOffset = useCallback(() => { const leftOffset = () => {
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 = useMemo( const common_theme = useTheme({
() => BaseRow: `
useTheme({
BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -155,7 +152,7 @@ const Devices = memo(() => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1E1E1E; background-color: #1E1E1E;
.td { .td {
@@ -165,88 +162,78 @@ const Devices = memo(() => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const device_theme = useMemo( const device_theme = useTheme([
() => common_theme,
useTheme([ {
common_theme, BaseRow: `
{ font-size: 15px;
BaseRow: ` .td {
font-size: 15px; height: 28px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
`, `,
BaseRow: ` Table: `
.td { --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
height: 32px; `,
} HeaderRow: `
`, .th {
BaseCell: ` padding: 8px;
&:nth-of-type(1) { `,
border-left: 1px solid #177ac9; Row: `
}, &:nth-of-type(odd) .td {
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030; background-color: #303030;
}, },
&:hover .td { &:hover .td {
background-color: #177ac9; background-color: #177ac9;
},
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
` `,
} BaseRow: `
]), .td {
[common_theme] height: 32px;
); }
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -345,10 +332,8 @@ const Devices = memo(() => {
return sc; return sc;
}; };
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
@@ -607,41 +592,35 @@ const Devices = memo(() => {
return; return;
} }
const showDeviceValue = useCallback((dv: DeviceValue) => { const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}, []); };
const renderNameCell = useCallback( const renderNameCell = (dv: DeviceValue) => (
(dv: DeviceValue) => ( <>
<> {dv.id.slice(2)}&nbsp;
{dv.id.slice(2)}&nbsp; {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( <StarIcon color="primary" sx={{ fontSize: 12 }} />
<StarIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( <EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} </>
</>
),
[hasMask]
); );
const shown_data = useMemo(() => { const shown_data = onlyFav
if (onlyFav) { ? deviceData.nodes.filter(
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) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
); );
}
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex( 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 { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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 = useMemo(() => updateValue(setEditItem), [setEditItem]); const updateFormValue = updateValue(setEditItem);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -61,7 +61,7 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -69,28 +69,25 @@ const DevicesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const setUom = useCallback( const setUom = (uom?: DeviceValueUOM) => {
(uom?: DeviceValueUOM) => { if (uom === undefined) {
if (uom === undefined) { return;
return; }
} switch (uom) {
switch (uom) { case DeviceValueUOM.HOURS:
case DeviceValueUOM.HOURS: return LL.HOURS();
return LL.HOURS(); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.MINUTES: return LL.MINUTES();
return LL.MINUTES(); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.SECONDS: return LL.SECONDS();
return LL.SECONDS(); default:
default: return DeviceValueUOM_s[uom];
return DeviceValueUOM_s[uom]; }
} };
},
[LL]
);
const showHelperText = useCallback((dv: DeviceValue) => { const showHelperText = (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) {
@@ -101,26 +98,16 @@ const DevicesDialog = ({
); );
} }
return undefined; return undefined;
}, []); };
const isCommand = useMemo( const isCommand = selectedItem.v === '' && selectedItem.c;
() => selectedItem.v === '' && selectedItem.c, const dialogTitle = isCommand
[selectedItem.v, selectedItem.c] ? LL.RUN_COMMAND()
); : writeable
? LL.CHANGE_VALUE()
const dialogTitle = useMemo(() => { : LL.VALUE(0);
if (isCommand) return LL.RUN_COMMAND(); const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0); const helperText = showHelperText(editItem);
}, [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,5 +1,3 @@
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';
@@ -11,7 +9,6 @@ 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
@@ -20,123 +17,95 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128 DeviceEntityMask.DV_DELETED // 128
]; ];
/** const getMaskNumber = (newMask: string[]): number =>
* Converts an array of mask strings to a bitmask number newMask.reduce((mask, entry) => mask | Number(entry), 0);
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/** const getMaskString = (mask: number): string[] =>
* Converts a bitmask number to an array of mask strings MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
*/
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 = useCallback( const handleChange = (_event: unknown, mask: string[]) => {
(_event: unknown, mask: string[]) => { const newMask = getMaskNumber(mask);
// Convert selected masks to a number const updatedDe = { ...de };
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions // If entity has no name and is set to readonly, also exclude from web
// If 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; } else {
} else { updatedDe.m = newMask;
updatedDe.m = newMask; }
}
// If excluded from web, cannot be favorite // If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
} }
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={maskStringValue} value={getMaskString(de.m)}
onChange={handleChange} onChange={handleChange}
> >
<ToggleButton value="8" disabled={isFavoriteDisabled}> <ToggleButton
<OptionIcon type="favorite" isSet={isFavoriteSet} /> value="8"
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 value="4" disabled={isReadonlyDisabled}> <ToggleButton
<OptionIcon type="readonly" isSet={isReadonlySet} /> value="4"
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 value="2" disabled={isApiMqttExcludeDisabled}> <ToggleButton
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} /> value="2"
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 value="1" disabled={isWebExcludeDisabled}> <ToggleButton
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} /> value="1"
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 type="deleted" isSet={isDeletedSet} /> <OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { memo, useContext, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -60,6 +60,8 @@ 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());
@@ -72,12 +74,7 @@ const HelpComponent = () => {
}); });
const [imgError, setImgError] = useState<boolean>(false); const [imgError, setImgError] = useState<boolean>(false);
const getCustomSupportMethod = useMemo( useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
() => 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[] };
@@ -100,47 +97,26 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
// Optimize API call memoization const helpLinks: HelpLink[] = [
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); {
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
];
const handleDownloadSystemInfo = useCallback(() => { const imageSrc =
void sendAPI(apiCall); imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return ( return (
<SectionContent> <SectionContent>
@@ -157,13 +133,13 @@ const HelpComponent = () => {
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={IMAGE_STYLES} sx={IMAGE_STYLES}
onError={handleImageError} onError={() => setImgError(true)}
src={imageSrc} src={imageSrc}
/> />
</Stack> </Stack>
)} )}
{isAdmin && ( {me?.admin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <ListItem key={href}>
@@ -191,7 +167,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={handleDownloadSystemInfo} onClick={() => void sendAPI(SYSTEM_INFO_API)}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -214,7 +190,6 @@ 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, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -69,58 +69,53 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme( const modules_theme = useTheme({
useMemo( Table: `
() => ({ --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
Table: ` `,
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; BaseRow: `
`, font-size: 14px;
BaseRow: ` .td {
font-size: 14px; height: 32px;
.td { }
height: 32px; `,
} BaseCell: `
`, &:nth-of-type(1) {
BaseCell: ` text-align: center;
&:nth-of-type(1) { }
text-align: center; `,
} HeaderRow: `
`, text-transform: uppercase;
HeaderRow: ` background-color: black;
text-transform: uppercase; color: #90CAF9;
background-color: black; .th {
color: #90CAF9; border-bottom: 1px solid #565656;
.th { height: 36px;
border-bottom: 1px solid #565656; }
height: 36px; `,
} Row: `
`, background-color: #1e1e1e;
Row: ` position: relative;
background-color: #1e1e1e; cursor: pointer;
position: relative; .td {
cursor: pointer; border-top: 1px solid #565656;
.td { border-bottom: 1px solid #565656;
border-top: 1px solid #565656; }
border-bottom: 1px solid #565656; &:hover .td {
} border-top: 1px solid #177ac9;
&:hover .td { border-bottom: 1px solid #177ac9;
border-top: 1px solid #177ac9; }
border-bottom: 1px solid #177ac9; &:nth-of-type(odd) .td {
} background-color: #303030;
&:nth-of-type(odd) .td { }
background-color: #303030; `
} });
`
}),
[]
)
);
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const updateModuleItem = useCallback((updatedItem: ModuleItem) => { const updateModuleItem = (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
@@ -128,28 +123,25 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length); setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data; return new_data;
}); });
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ModuleItem) => {
(updatedItem: ModuleItem) => { setDialogOpen(false);
setDialogOpen(false); updateModuleItem(updatedItem);
updateModuleItem(updatedItem); };
},
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => { const editModuleItem = (mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onCancel = useCallback(async () => { const onCancel = async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchModules]); };
const saveModules = useCallback(async () => { const saveModules = async () => {
try { try {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
@@ -167,9 +159,9 @@ const Modules = () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
} }
}, [modules, updateModules, LL, fetchModules]); };
const content = useMemo(() => { const renderContent = () => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -262,22 +254,12 @@ 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}
{content} {renderContent()}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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,14 +37,10 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
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
@@ -54,18 +50,13 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = useCallback(() => { const handleSave = () => {
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>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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 = useCallback((si: ScheduleItem) => { const hasScheduleChanged = (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,15 +143,13 @@ const Scheduler = () => {
si.cmd !== si.o_cmd || si.cmd !== si.o_cmd ||
si.value !== si.o_value si.value !== si.o_value
); );
}, []); };
const intervalCallback = useCallback(() => { useInterval(() => {
if (numChanges === 0) { if (numChanges === 0) {
void fetchSchedule(); void fetchSchedule();
} }
}, [numChanges, fetchSchedule]); }, INTERVAL_DELAY);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { const formatter = new Intl.DateTimeFormat(locale, {
@@ -169,7 +167,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme); const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => { const saveSchedule = async () => {
try { try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
@@ -192,46 +190,43 @@ const Scheduler = () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
} }
}, [LL, schedule, updateSchedule, fetchSchedule]); };
const editScheduleItem = useCallback((si: ScheduleItem) => { const editScheduleItem = (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 = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchSchedule]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ScheduleItem) => {
(updatedItem: ScheduleItem) => { setDialogOpen(false);
setDialogOpen(false); void updateState(readSchedule(), (data: ScheduleItem[]) => {
void updateState(readSchedule(), (data: ScheduleItem[]) => { const new_data = creating
const new_data = creating ? [...data, updatedItem]
? [...data, updatedItem] : data.map((si) =>
: data.map((si) => si.id === updatedItem.id ? { ...si, ...updatedItem } : si
si.id === updatedItem.id ? { ...si, ...updatedItem } : si );
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data; return new_data;
}); });
}, };
[creating, hasScheduleChanged]
);
const addScheduleItem = useCallback(() => { const addScheduleItem = () => {
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),
@@ -239,36 +234,29 @@ const Scheduler = () => {
}; };
setSelectedScheduleItem(newItem); setSelectedScheduleItem(newItem);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const filteredAndSortedSchedule = useMemo( const filteredAndSortedSchedule = schedule
() => .filter((si: ScheduleItem) => !si.deleted)
schedule .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = useCallback( const dayBox = (si: ScheduleItem, flag: number) => {
(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;
return ( return (
<> <>
<Box> <Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}> <Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]} {dow[dayIndex]}
</Typography> </Typography>
</Box> </Box>
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
</> </>
); );
}, };
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => { const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags]; const label = scheduleTypeLabels[si.flags];
return ( return (
@@ -278,9 +266,9 @@ const Scheduler = () => {
</Typography> </Typography>
</Box> </Box>
); );
}, []); };
const renderSchedule = useCallback(() => { const renderSchedule = () => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -343,17 +331,7 @@ 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 { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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,6 +60,12 @@ 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;
@@ -84,14 +90,10 @@ const SchedulerDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -112,129 +114,95 @@ const SchedulerDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
// Helper function to handle save operations const handleSave = async (itemToSave: ScheduleItem) => {
const handleSave = useCallback( try {
async (itemToSave: ScheduleItem) => { setFieldErrors(undefined);
try { await validate(validator, itemToSave);
setFieldErrors(undefined); onSave(itemToSave);
await validate(validator, itemToSave); } catch (error) {
onSave(itemToSave); setFieldErrors((error as ValidationError).fieldErrors);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const save = useCallback(async () => {
await handleSave(editItem);
}, [editItem, handleSave]);
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
} }
return editItem.time === DEFAULT_TIME ? '' : editItem.time; };
}, [editItem.time, needsTimeField]);
const timeFieldLabel = useMemo(() => { const save = async () => {
await handleSave(editItem);
};
const saveandactivate = async () => {
await handleSave({ ...editItem, active: true });
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const DayOfWeekButton = (flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleScheduleTypeChange = (
_event: React.SyntheticEvent<HTMLElement>,
flag: ScheduleFlag | null
) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
};
const handleDOWChange = (
_event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
};
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
const timeFieldValue = needsTimeField
? editItem.time === ''
? DEFAULT_TIME
: editItem.time
: editItem.time === DEFAULT_TIME
? ''
: editItem.time;
const timeFieldLabel = (() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_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 { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useContext, 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,18 +158,16 @@ const Sensors = () => {
} }
); );
const intervalCallback = useCallback(() => { useInterval(() => {
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 = useCallback((state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -177,7 +175,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 },
@@ -234,119 +232,104 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback( const formatDurationMin = (duration_min: number) => {
(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; const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const parts: string[] = []; const parts: string[] = [];
if (days > 0) { if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days })); parts.push(LL.NUM_DAYS({ num: days }));
} }
if (hours > 0) { if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours })); parts.push(LL.NUM_HOURS({ num: hours }));
} }
if (minutes > 0) { if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes })); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return parts.join(' '); return parts.join(' ');
}, };
[LL]
);
const formatValue = useCallback( const formatValue = (value: unknown, uom: DeviceValueUOM) => {
(value: unknown, uom: DeviceValueUOM) => { if (value === undefined) {
if (value === undefined) { return '';
return ''; }
} if (typeof value !== 'number') {
if (typeof value !== 'number') { return value as string;
return value as string; }
} switch (uom) {
switch (uom) { case DeviceValueUOM.HOURS:
case DeviceValueUOM.HOURS: return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.MINUTES: return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.SECONDS: return LL.NUM_SECONDS({ num: value });
return LL.NUM_SECONDS({ num: value }); case DeviceValueUOM.NONE:
case DeviceValueUOM.NONE: return new Intl.NumberFormat().format(value);
return new Intl.NumberFormat().format(value); case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.FAHRENHEIT:
case DeviceValueUOM.FAHRENHEIT: return (
return ( new Intl.NumberFormat(undefined, {
new Intl.NumberFormat(undefined, { minimumFractionDigits: 1
minimumFractionDigits: 1 }).format(value) +
}).format(value) + ' ' +
' ' + DeviceValueUOM_s[uom]
DeviceValueUOM_s[uom] );
); default:
default: return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; }
} };
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = useCallback( const updateTemperatureSensor = (ts: TemperatureSensor) => {
(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 = useCallback(() => { const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const onTemperatureDialogSave = useCallback( const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
async (ts: TemperatureSensor) => { await sendTemperatureSensor({
await sendTemperatureSensor({ id: ts.id,
id: ts.id, name: ts.n,
name: ts.n, offset: ts.o,
offset: ts.o, is_system: ts.s
is_system: ts.s })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
.then(() => { .catch(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1))); toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
}) })
.catch(() => { .finally(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); setTemperatureDialogOpen(false);
}) setSelectedTemperatureSensor(undefined);
.finally(() => { void fetchSensorData();
setTemperatureDialogOpen(false); });
setSelectedTemperatureSensor(undefined); };
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = useCallback( const updateAnalogSensor = (as: AnalogSensor) => {
(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 = useCallback(() => { const onAnalogDialogClose = () => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const addAnalogSensor = useCallback(() => { const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) { if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO()); toast.error(LL.NO_GPIO());
return; return;
@@ -366,194 +349,167 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}, []); };
const onAnalogDialogSave = useCallback( const onAnalogDialogSave = async (as: AnalogSensor) => {
async (as: AnalogSensor) => { await sendAnalogSensor({
await sendAnalogSensor({ id: as.id,
id: as.id, gpio: as.g,
gpio: as.g, name: as.n,
name: as.n, offset: as.o,
offset: as.o, factor: as.f,
factor: as.f, uom: as.u,
uom: as.u, type: as.t,
type: as.t, deleted: as.d,
deleted: as.d, is_system: as.s
is_system: as.s })
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
}) })
.then(() => { .catch(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
}) })
.catch(() => { .finally(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); setAnalogDialogOpen(false);
}) setSelectedAnalogSensor(undefined);
.finally(() => { void fetchSensorData();
setAnalogDialogOpen(false); });
setSelectedAnalogSensor(undefined); };
void fetchSensorData();
}); const RenderAnalogSensors = (
}, <Table
[sendAnalogSensor, LL, fetchSensorData] data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
); );
const RenderAnalogSensors = useMemo( const RenderTemperatureSensors = (
() => ( <Table
<Table data={{ nodes: sensorData.ts }}
data={{ nodes: sensorData.as }} theme={temperature_theme}
theme={analog_theme} sort={temperature_sort}
sort={analog_sort} layout={{ custom: true }}
layout={{ custom: true }} >
> {(tableList: TemperatureSensor[]) => (
{(tableList: AnalogSensor[]) => ( <>
<> <Header>
<Header> <HeaderRow>
<HeaderRow> <HeaderCell resize>
<HeaderCell stiff> <Button
<Button fullWidth
fullWidth style={HEADER_BUTTON_STYLE}
style={HEADER_BUTTON_STYLE} endIcon={getSortIcon(temperature_sort.state, 'NAME')}
endIcon={getSortIcon(analog_sort.state, 'GPIO')} onClick={() =>
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })} temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
> }
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
> >
<Cell stiff>{as.g}</Cell> {LL.NAME(0)}
<Cell>{as.n}</Cell> </Button>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell> </HeaderCell>
{(as.t === AnalogType.DIGITAL_OUT && <HeaderCell stiff>
as.g !== GPIO_25 && <Button
as.g !== GPIO_26) || fullWidth
as.t === AnalogType.DIGITAL_IN || style={HEADER_BUTTON_STYLE_END}
as.t === AnalogType.PULSE ? ( endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell> onClick={() =>
) : ( temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
<Cell stiff>{formatValue(as.v, as.u)}</Cell> }
)}
</Row>
))}
</Body>
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
> >
<Cell>{ts.n}</Cell> {LL.VALUE(0)}
<Cell>{formatValue(ts.t, ts.u)}</Cell> </Button>
</Row> </HeaderCell>
))} </HeaderRow>
</Body> </Header>
</> <Body>
)} {tableList.map((ts: TemperatureSensor) => (
</Table> <Row
), style={{ color: ts.s ? 'grey' : 'inherit' }}
[ key={ts.id}
temperature_sort, item={ts}
temperature_theme, onClick={() => updateTemperatureSensor(ts)}
getSortIcon, >
sensorData.ts, <Cell>{ts.n}</Cell>
LL, <Cell>{formatValue(ts.t, ts.u)}</Cell>
updateTemperatureSensor, </Row>
formatValue ))}
] </Body>
</>
)}
</Table>
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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,84 +53,54 @@ 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 = useMemo( const updateFormValue = updateValue((updater) =>
() => setEditItem(
updateValue((updater) => (prev) =>
setEditItem( updater(
(prev) => prev as unknown as Record<string, unknown>
updater( ) as unknown as AnalogSensor
prev as unknown as Record<string, unknown> )
) as unknown as AnalogSensor
)
),
[setEditItem]
); );
// Memoize helper functions to check sensor type conditions const isCounterOrRate =
const isCounterOrRate = useMemo( editItem.t === AnalogType.COUNTER ||
() => editItem.t === AnalogType.RATE ||
editItem.t === AnalogType.COUNTER || (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
editItem.t === AnalogType.RATE || const isCounter =
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), editItem.t === AnalogType.COUNTER ||
[editItem.t] (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
); const isFreqType =
const isCounter = useMemo( editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
() => const isPWM =
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.PWM_0 ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), editItem.t === AnalogType.PWM_1 ||
[editItem.t] editItem.t === AnalogType.PWM_2;
); const isDACOutGPIO =
const isFreqType = useMemo( editItem.t === AnalogType.DIGITAL_OUT &&
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2, (editItem.g === 25 || editItem.g === 26);
[editItem.t] const isDigitalOutGPIO =
); editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
const analogTypeMenuItems = useMemo( name: val,
() => 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
key={name} key={name}
value={value} value={value}
disabled={disabledTypeList?.includes(value)} disabled={disabledTypeList?.includes(value)}
> >
{name} {name}
</MenuItem> </MenuItem>
)), ));
[disabledTypeList]
);
const uomMenuItems = useMemo( const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
() => <MenuItem key={val} value={i}>
DeviceValueUOM_s.map((val, i) => ( {val}
<MenuItem key={val} value={i}> </MenuItem>
{val} ));
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () => const analogGPIOMenuItems = () =>
// add selectedItem.g to the list // add selectedItem.g to the list
@@ -157,16 +127,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -174,17 +144,13 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
onSave({ ...editItem, d: true }); onSave({ ...editItem, d: true });
}, [editItem, onSave]); };
const dialogTitle = useMemo( const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
() =>
`${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 { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, 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,16 +50,12 @@ 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 = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as (
updateValue( updater: (
setEditItem as unknown as ( prevState: Readonly<Record<string, unknown>>
updater: ( ) => Record<string, unknown>
prevState: Readonly<Record<string, unknown>> ) => void
) => Record<string, unknown>
) => void
),
[setEditItem]
); );
useEffect(() => { useEffect(() => {
@@ -69,16 +65,13 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
(_event: React.SyntheticEvent, reason?: string) => { if (reason !== 'backdropClick') {
if (reason !== 'backdropClick') { onClose();
onClose(); }
} };
},
[onClose]
);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -86,29 +79,11 @@ 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>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</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}
@@ -128,12 +103,23 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={offsetValue} value={numberValue(editItem.o)}
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, useCallback, useContext } from 'react'; import { memo, 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 = useCallback(() => { const handleSignOut = () => {
signOut(true); signOut(true);
}, [signOut]); };
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { 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,7 +24,6 @@ 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
@@ -63,22 +62,16 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() => origData as unknown as Record<string, unknown>,
updateValueDirty( dirtyFlags,
origData as unknown as Record<string, unknown>, setDirtyFlags,
dirtyFlags, updateDataValue as (value: unknown) => void
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize AP enabled state const apEnabled = data ? isAPEnabled(data) : false;
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
@@ -88,7 +81,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) {
@@ -108,9 +101,6 @@ 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 { useCallback, useMemo, useState } from 'react'; import { 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';
@@ -107,49 +107,36 @@ const ApplicationSettings = () => {
}); });
}); });
// Memoized input props to prevent recreation on every render const SecondsInputProps = {
const SecondsInputProps = useMemo( endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
() => ({ };
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo( const MinutesInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> };
}),
[LL]
);
const HoursInputProps = useMemo( const HoursInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> };
}),
[LL]
);
const doRestart = useCallback(async () => { const doRestart = 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 = useCallback( const updateBoardProfile = async (board_profile: string) => {
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 = useCallback(async () => { const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
@@ -158,31 +145,42 @@ const ApplicationSettings = () => {
} finally { } finally {
await saveData(); await saveData();
} }
}, [data, saveData]); };
const changeBoardProfile = useCallback( const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => { const boardProfile = event.target.value;
const boardProfile = event.target.value; updateFormValue(event);
updateFormValue(event); if (boardProfile === 'CUSTOM') {
if (boardProfile === 'CUSTOM') { updateDataValue({
updateDataValue({ ...data,
...data, board_profile: boardProfile
board_profile: boardProfile });
}); } else {
} else { void updateBoardProfile(boardProfile);
void updateBoardProfile(boardProfile); }
} };
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = useCallback(async () => { const restart = async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}, [validateAndSubmit, doRestart]); };
// Memoize board profile select items to prevent recreation const sendmail = async () => {
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []); await sendAPI({
device: 'system',
cmd: 'sendmail',
data: 'testmail',
id: 0
})
.then(() => {
toast.success(LL.SUCCESSFUL());
})
.catch((error: Error) => {
toast.error(error.message);
});
};
const boardProfileItems = boardProfileSelectItems();
const content = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {
@@ -379,8 +377,7 @@ const ApplicationSettings = () => {
container container
spacing={2} spacing={2}
direction="row" direction="row"
justifyContent="flex-start" sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
alignItems="flex-start"
> >
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
@@ -406,7 +403,10 @@ const ApplicationSettings = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid size={4} mt={!data.email_ssl && !data.email_starttls ? 0 : 3}> <Grid
size={4}
sx={{ mt: !data.email_ssl && !data.email_starttls ? 0 : 3 }}
>
{!data.email_starttls && ( {!data.email_starttls && (
<BlockFormControlLabel <BlockFormControlLabel
sx={{ width: '12ch' }} sx={{ width: '12ch' }}
@@ -499,6 +499,17 @@ const ApplicationSettings = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid>
<Button
sx={{ mt: 3 }}
variant="outlined"
color="primary"
disabled={dirtyFlags.length !== 0}
onClick={sendmail}
>
Send Testmail
</Button>
</Grid>
</Grid> </Grid>
</> </>
)} )}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { 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 = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
try { try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,16 +65,33 @@ 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 = useCallback(() => { const handleCloseBackupDialog = () => {
setConfirmBackup(false); setConfirmBackup(false);
}, []); };
const renderBackupDialog = useMemo( const handleDownload = (type: string) => () => {
() => ( 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}
@@ -98,40 +115,13 @@ 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 { useCallback, useMemo, useState } from 'react'; import { 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 = useCallback(() => { const sendResetMQTT = () => {
void callAction({ action: 'resetMQTT' }) void callAction({ action: 'resetMQTT' })
.then(() => { .then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful'); toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,29 +65,20 @@ 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 = useMemo( const updateFormValue = updateValueDirty(
() => origData as unknown as Record<string, unknown>,
updateValueDirty( dirtyFlags,
origData as unknown as Record<string, unknown>, setDirtyFlags,
dirtyFlags, updateDataValue as (value: unknown) => void
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const SecondsInputProps = useMemo( const SecondsInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> };
}),
[LL]
);
const emptyFieldErrors = useMemo(() => ({}), []); const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -96,25 +87,22 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const publishIntervalFields = useMemo( const publishIntervalFields = [
() => [ { 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 }, {
{ name: 'publish_time_thermostat',
name: 'publish_time_thermostat', label: LL.MQTT_INT_THERMOSTATS(),
label: LL.MQTT_INT_THERMOSTATS(), validated: false
validated: false },
}, { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false }, { 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 (
@@ -154,7 +142,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -166,7 +154,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -178,7 +166,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -219,7 +207,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -438,7 +426,7 @@ const MqttSettings = () => {
<Grid key={field.name}> <Grid key={field.name}>
{field.validated ? ( {field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name={field.name} name={field.name}
label={field.label} label={field.label}
slotProps={{ slotProps={{

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { 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,14 +61,11 @@ 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();
// Memoized selected timezone value const selectedTzValue = data
const selectedTzValue = useMemo( ? selectedTimeZone(data.tz_label, data.tz_format)
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined), : 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);
@@ -82,32 +79,22 @@ const NTPSettings = () => {
} }
); );
// Memoize updateFormValue to prevent recreation on every render const updateFormValue = updateValueDirty(
const updateFormValue = useMemo( origData as unknown as Record<string, unknown>,
() => dirtyFlags,
updateValueDirty( setDirtyFlags,
origData as unknown as Record<string, unknown>, updateDataValue as (value: unknown) => void
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize updateLocalTime handler const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
const updateLocalTime = useCallback( setLocalTime(event.target.value);
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
// Memoize openSetTime handler const openSetTime = () => {
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}, []); };
// Memoize configureTime handler const configureTime = async () => {
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -120,13 +107,11 @@ const NTPSettings = () => {
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}, [localTime, updateTime, LL, loadData]); };
// Memoize close dialog handler const handleCloseSetTime = () => setSettingTime(false);
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
// Memoize validate and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -135,23 +120,18 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
// Memoize timezone change handler const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
const changeTimeZone = useCallback( void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
(event: React.ChangeEvent<HTMLInputElement>) => { ...settings,
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ tz_label: event.target.value,
...settings, tz_format: TIME_ZONES[event.target.value]
tz_label: event.target.value, }));
tz_format: TIME_ZONES[event.target.value] updateFormValue(event);
})); };
updateFormValue(event);
},
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders const renderContent = () => {
const renderContent = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
@@ -236,26 +216,12 @@ 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,190 +1,108 @@
import { useCallback, useMemo, useState } from 'react'; import { useContext } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import BuildIcon from '@mui/icons-material/Build';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import 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 { import { List } from '@mui/material';
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 [confirmFactoryReset, setConfirmFactoryReset] = useState(false); const upgradeAvailable = versions?.current?.upgradeable ?? false;
const [restarting, setRestarting] = useState<boolean>(); const firmwareText = versions?.current?.version
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { return (
immediate: false <SectionContent>
}); <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
const doFormat = useCallback(async () => { <ListMenuItem
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { icon={TuneIcon}
setRestarting(true); bgcolor="#134ba2"
setConfirmFactoryReset(false); label={LL.APPLICATION()}
}); text={LL.APPLICATION_SETTINGS_1()}
}, [sendAPI]); to="application"
/>
const handleFactoryResetClose = useCallback(() => { <ListMenuItem
setConfirmFactoryReset(false); icon={SettingsEthernetIcon}
}, []); bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
const handleFactoryResetClick = useCallback(() => { <ListMenuItem
setConfirmFactoryReset(true); icon={SettingsInputAntennaIcon}
}, []); bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
const content = useMemo(() => { <ListMenuItem
return ( icon={AccessTimeIcon}
<> bgcolor="#c5572c"
<List> label="NTP"
<ListMenuItem text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
icon={TuneIcon} to="ntp"
bgcolor="#134ba2" />
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem <ListMenuItem
icon={SettingsEthernetIcon} icon={DeviceHubIcon}
bgcolor="#40828f" bgcolor="#68374d"
label={LL.NETWORK(0)} label="MQTT"
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))} text={LL.CONFIGURE('MQTT')}
to="network" to="mqtt"
/> />
<ListMenuItem <ListMenuItem
icon={SettingsInputAntennaIcon} icon={LockIcon}
bgcolor="#5f9a5f" label={LL.SECURITY(0)}
label={LL.ACCESS_POINT(0)} text={LL.SECURITY_1()}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))} to="security"
to="ap" />
/>
<ListMenuItem <ListMenuItem
icon={AccessTimeIcon} icon={ViewModuleIcon}
bgcolor="#c5572c" bgcolor="#efc34b"
label="NTP" label={LL.MODULES()}
text={LL.CONFIGURE(LL.LOCAL_TIME(1))} text={LL.MODULES_1()}
to="ntp" to="modules"
/> />
<ListMenuItem <ListMenuItem
icon={DeviceHubIcon} icon={ImportExportIcon}
bgcolor="#68374d" bgcolor="#5d89f7"
label="MQTT" label={LL.DOWNLOAD_UPLOAD()}
text={LL.CONFIGURE('MQTT')} text={LL.DOWNLOAD_UPLOAD_1()}
to="mqtt" to="downloadUpload"
/> />
</List>
<ListMenuItem </SectionContent>
icon={LockIcon} );
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
<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,5 +1,3 @@
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> = {
@@ -472,26 +470,16 @@ 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

@@ -0,0 +1,958 @@
import { memo, useContext, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import type { VersionInfo } from 'types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
partitionVersion: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<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, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { import {
Navigate, Navigate,
Route, Route,
@@ -40,26 +40,20 @@ const Network = () => {
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback( const selectNetwork = (network: WiFiNetwork) => {
(network: WiFiNetwork) => { setSelectedNetwork(network);
setSelectedNetwork(network); void navigate('/settings/network/settings');
void navigate('/settings/network/settings'); };
},
[navigate]
);
const deselectNetwork = useCallback(() => { const deselectNetwork = () => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}, []); };
const contextValue = useMemo( const contextValue = {
() => ({ ...(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: false, nosleep: true,
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 = useCallback(async () => { const setCancel = async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}, [deselectNetwork, loadData]); };
const doRestart = useCallback(async () => { const doRestart = 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 (' + LL.NETWORK_BLANK_SSID() + ')'} label="SSID"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.ssid} value={data.ssid}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react'; import { memo, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = useCallback(() => { const renderNetworkScanner = () => {
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, useCallback, useContext } from 'react'; import { memo, 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,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = useCallback( const renderNetwork = (network: WiFiNetwork) => (
(network: WiFiNetwork) => ( <ListItem
<ListItem key={network.bssid}
key={network.bssid} onClick={() => wifiConnectionContext.selectNetwork(network)}
onClick={() => wifiConnectionContext.selectNetwork(network)} >
> <ListItemAvatar>
<ListItemAvatar> <Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar> </ListItemAvatar>
</ListItemAvatar> <ListItemText
<ListItemText primary={network.ssid}
primary={network.ssid} secondary={
secondary={ 'Security: ' +
'Security: ' + networkSecurityMode(network) +
networkSecurityMode(network) + ', Ch: ' +
', Ch: ' + network.channel +
network.channel + ', bssid: ' +
', bssid: ' + network.bssid
network.bssid }
} />
/> <ListItemIcon>
<ListItemIcon> <Badge badgeContent={network.rssi + 'dBm'}>
<Badge badgeContent={network.rssi + 'dBm'}> <WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} /> </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, useMemo, useState } from 'react'; import { memo, useCallback, useContext, 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,16 +55,14 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useMemo( const table_theme = useTheme({
() => Table: `
useTheme({
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;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -74,7 +72,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -87,7 +85,7 @@ const ManageUsers = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: center; text-align: center;
} }
@@ -95,44 +93,36 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const noAdminConfigured = useCallback( const noAdminConfigured = () => !data?.users.find((u) => u.admin);
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = useCallback( const removeUser = (toRemove: UserType) => {
(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 = useCallback(() => { const createUser = () => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}, []); };
const editUser = useCallback((toEdit: UserType) => { const editUser = (toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}, []); };
const cancelEditingUser = useCallback(() => { const cancelEditingUser = () => {
setUser(undefined); setUser(undefined);
}, []); };
const doneEditingUser = useCallback(() => { const doneEditingUser = () => {
if (user && data) { if (user && data) {
const users = [ const users = [
...data.users.filter( ...data.users.filter(
@@ -144,26 +134,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 = useCallback((username: string) => { const generateTokenForUser = (username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}, []); };
const onSubmit = useCallback(async () => { const onSubmit = async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}, [saveData, authenticatedContext]); };
const onCancelSubmit = useCallback(async () => { const onCancelSubmit = async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}, [loadData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -177,15 +167,10 @@ const ManageUsers = () => {
admin: boolean; admin: boolean;
} }
// add id to the type, needed for the table const user_table = data.users.map((u) => ({
const user_table = useMemo( ...u,
() => id: u.username
data.users.map((u) => ({ })) as UserType2[];
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return ( return (
<> <>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo } 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,19 +15,15 @@ const Security = () => {
const location = useLocation(); const location = useLocation();
const matchedRoutes = useMemo( const matchedRoutes = matchRoutes(
() => [
matchRoutes( {
[ path: '/settings/security/settings',
{ element: <ManageUsers />
path: '/settings/security/settings', },
element: <ManageUsers /> { 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()} mt={1} /> <MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, 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 = useCallback(async () => { const validateAndDone = 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,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -36,16 +34,14 @@ 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;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -55,7 +51,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -69,26 +65,20 @@ const SystemActivity = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:not(:first-of-type) { &:not(:first-of-type) {
text-align: center; text-align: center;
} }
` `
}), });
[]
)
);
const showName = useCallback( const showName = (id: number) => {
(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 = useCallback((stat: Stat) => { const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
@@ -100,14 +90,18 @@ 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) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Table <Table
data={{ nodes: data.stats }} data={{ nodes: data.stats }}
theme={stats_theme} theme={stats_theme}
@@ -136,10 +130,8 @@ 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, useMemo } from 'react'; import { type FC, memo } 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,16 +127,15 @@ 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 = useMemo(() => { const mqttStatusText = !data
if (!data) return ''; ? ''
if (!data.enabled) return LL.NOT_ENABLED(); : !data.enabled
return data.connected ? LL.NOT_ENABLED()
? `${LL.CONNECTED(0)} (${data.connect_count})` : data.connected
: `${LL.DISCONNECTED()} (${data.connect_count})`; ? `${LL.CONNECTED(0)} (${data.connect_count})`
}, [data, LL]); : `${LL.DISCONNECTED()} (${data.connect_count})`;
if (!data) { if (!data) {
return ( return (

View File

@@ -1,5 +1,3 @@
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';
@@ -67,12 +65,16 @@ const NTPStatus = () => {
} }
}; };
const content = useMemo(() => { if (!data) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -121,10 +123,8 @@ 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,5 +1,3 @@
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';
@@ -124,16 +122,20 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
const content = useMemo(() => { if (!data) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
</> </>
)} )}
</List> </List>
); </SectionContent>
}, [data, error, loadData, LL, theme]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;

View File

@@ -1,25 +1,16 @@
import { useCallback, useContext, useMemo, useState } from 'react'; import { useContext } 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,
@@ -27,12 +18,10 @@ 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 { type APIcall, busConnectionStatus } from 'app/main/types'; import { 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';
@@ -41,9 +30,6 @@ 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 = (
@@ -72,24 +58,7 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false); const { data, send: loadData, error } = useRequest(readSystemStatus);
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();
@@ -97,10 +66,8 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
// Memoize derived status values to avoid recalculation on every render const busStatus = (() => {
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)})`;
@@ -111,12 +78,10 @@ const SystemStatus = () => {
default: default:
return 'EMS state unknown'; return 'EMS state unknown';
} }
}, [data?.bus_status, data?.bus_uptime, LL]); })();
// Memoize derived status values to avoid recalculation on every render const systemStatus = (() => {
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:
@@ -129,14 +94,12 @@ 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 = useMemo(() => { const busStatusHighlight = (() => {
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;
@@ -147,11 +110,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.bus_status, theme.palette]); })();
const ntpStatus = useMemo(() => { const ntpStatus = (() => {
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();
@@ -164,11 +126,10 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.ntp_status, data?.ntp_time, LL]); })();
const ntpStatusHighlight = useMemo(() => { const ntpStatusHighlight = (() => {
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;
@@ -179,11 +140,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
}, [data?.ntp_status, theme.palette]); })();
const networkStatusHighlight = useMemo(() => { const networkStatusHighlight = (() => {
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:
@@ -198,11 +158,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.network_status, theme.palette]); })();
const networkStatus = useMemo(() => { const networkStatus = (() => {
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);
@@ -223,227 +182,103 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.network_status, data?.wifi_rssi, LL]); })();
const activeHighlight = useCallback( const activeHighlight = (value: boolean) =>
(value: boolean) => value ? theme.palette.success.main : theme.palette.info.main;
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
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 || ''} />;
}
if (!data || !LL) {
return ( return (
<> <SectionContent>
<List> <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
<ListMenuItem </SectionContent>
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={apStatusText}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
{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>; return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/>
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
</SectionContent>
);
}; };
export default SystemStatus; export default SystemStatus;

View File

@@ -1,11 +1,4 @@
import { import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
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';
@@ -185,8 +178,7 @@ const SystemLog = () => {
}; };
}, [data]); // Recalculate when data changes (in case layout shifts) }, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render const handleLogMessage = (message: { data: string }) => {
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) => {
@@ -200,7 +192,7 @@ const SystemLog = () => {
const newLog = [...log, logentry]; const newLog = [...log, logentry];
return newLog; return newLog;
}); });
}, []); };
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
@@ -211,7 +203,7 @@ const SystemLog = () => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const onDownload = useCallback(() => { const onDownload = () => {
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');
@@ -225,11 +217,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 = useCallback(async () => { const saveSettings = 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);
@@ -246,7 +238,7 @@ const SystemLog = () => {
} }
}, [logEntries.length, autoscroll]); }, [logEntries.length, autoscroll]);
const sendReadCommand = useCallback(() => { const sendReadCommand = () => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -257,7 +249,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 { useCallback, useMemo, useRef, useState } from 'react'; import { 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,39 +57,31 @@ 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 message = const statusMessage =
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
? LL.APPLICATION_RESTARTING() ? LL.APPLICATION_RESTARTING()
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE() ? LL.RESTARTING_PRE()
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed' ? 'Upload Failed'
: LL.RESTARTING_POST(); : LL.RESTARTING_POST();
const uploading = const isUploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress = const progressValue =
uploading && status isUploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0; : 0;
return { const onCancel = async () => {
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

@@ -1,922 +0,0 @@
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 === 1 && LL.UPGRADE_IMPORTANT_MESSAGES_1()}
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
<Typography sx={{ mt: 2 }}>
<Link
target="_blank"
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
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, useMemo } from 'react'; import { type FC, type PropsWithChildren, memo } 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,17 @@ 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,
const backgroundColor = paletteKey[shade]; string
>;
return { Icon, backgroundColor }; const backgroundColor = paletteKey[shade];
}, [level, theme]);
return ( return (
<Box <Box

View File

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

View File

@@ -18,7 +18,6 @@ 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);
}, []); }, []);
@@ -28,7 +27,6 @@ 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, useCallback, useMemo } from 'react'; import { memo } 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,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pathnames = useMemo( const pathnames = location.pathname.split('/').filter((x) => x);
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = useCallback(() => { const handleBackClick = () => {
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, useMemo } from 'react'; import { memo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
@@ -24,22 +24,18 @@ interface LayoutDrawerProps {
} }
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
// Memoize drawer content to prevent unnecessary re-renders const drawer = (
const drawer = useMemo( <>
() => ( <Toolbar disableGutters>
<> <Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<Toolbar disableGutters> <LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}> <Typography variant="h6">{PROJECT_NAME}</Typography>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} /> </Box>
<Typography variant="h6">{PROJECT_NAME}</Typography> <Divider absolute />
</Box> </Toolbar>
<Divider absolute /> <Divider />
</Toolbar> <LayoutMenu />
<Divider /> </>
<LayoutMenu />
</>
),
[]
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react'; import { memo, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -18,13 +18,15 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => { const LayoutMenuComponent = () => {
const { me } = useContext(AuthenticatedContext); const { me, versions } = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const handleMenuToggle = useCallback(() => { const upgradeAvailable = versions?.current?.upgradeable ?? false;
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev); setMenuOpen((prev) => !prev);
}, []); };
return ( return (
<> <>
@@ -105,6 +107,7 @@ 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, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material'; import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -11,60 +11,52 @@ 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 = useMemo(() => routeMatches(to, pathname), [to, pathname]); const selected = routeMatches(to, pathname);
// Memoize dynamic styles based on selected state const buttonStyles: SxProps<Theme> = {
const buttonStyles: SxProps<Theme> = useMemo( transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
() => ({ backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', borderRadius: '8px',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', margin: '2px 8px',
borderRadius: '8px', '&:hover': {
margin: '2px 8px', backgroundColor: 'rgba(68, 82, 211, 0.39)'
'&:hover': { },
backgroundColor: 'rgba(68, 82, 211, 0.39)' '&::before': {
}, content: '""',
'&::before': { position: 'absolute',
content: '""', left: 0,
position: 'absolute', top: 0,
left: 0, bottom: 0,
top: 0, width: selected ? '3px' : '0px',
bottom: 0, backgroundColor: '#90caf9',
width: selected ? '3px' : '0px', transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
backgroundColor: '#90caf9', }
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' };
}
}),
[selected]
);
const iconStyles: SxProps<Theme> = useMemo( const iconStyles: SxProps<Theme> = {
() => ({ 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> = useMemo( const textStyles: SxProps<Theme> = {
() => ({ 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
@@ -78,6 +70,20 @@ 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,6 +5,7 @@ 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,
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
text: string; text: string;
to?: string; to?: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@@ -28,15 +30,40 @@ 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 }: ListMenuItemProps) => ( ({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
<Icon /> <Icon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={label} secondary={text} /> <ListItemText
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</> </>
) )
); );
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
label, label,
text, text,
to, to,
disabled disabled,
badge
}: ListMenuItemProps) => ( }: ListMenuItemProps) => (
<> <>
{to && !disabled ? ( {to && !disabled ? (
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@@ -75,6 +104,7 @@ 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, useCallback } from 'react'; import { memo } 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 = useCallback(() => { const handleReset = () => {
blocker.reset?.(); blocker.reset?.();
}, [blocker]); };
const handleProceed = useCallback(() => { const handleProceed = () => {
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, useCallback } from 'react'; import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -16,12 +16,9 @@ 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 = useCallback( const handleTabChange = (_event: unknown, path: string) => {
(_event: unknown, path: string) => { void navigate(path);
void navigate(path); };
},
[navigate]
);
return ( return (
<Tabs <Tabs

View File

@@ -91,7 +91,9 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
).upgradeImportantMessageType; ).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n); setUpgradeImportantMessageType(upgradeImportantMessageType_n);
if (upgradeImportantMessageType_n === 0) { if (upgradeImportantMessageType_n === 0) {
onFileSelected(file); if (file) {
onFileSelected(file);
}
} }
}) })
.onError((error) => { .onError((error) => {

View File

@@ -3,6 +3,7 @@ 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';
@@ -10,7 +11,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 } from 'types'; import type { Me, VersionsResponse } from 'types';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context'; import { AuthenticationContext } from './context';
@@ -20,17 +21,34 @@ 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');
@@ -40,6 +58,7 @@ 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('/');
} }
@@ -49,8 +68,9 @@ 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(() => { .then(async () => {
setMe(AuthenticationApi.decodeMeJWT(accessToken)); setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true); setInitialized(true);
}) })
.catch(() => { .catch(() => {
@@ -67,15 +87,16 @@ 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,
...(me && { me }) refreshVersions,
...(me && { me }),
...(versions && { versions })
}), }),
[signIn, signOut, me, refresh] [signIn, signOut, me, refresh, refreshVersions, versions]
); );
if (initialized) { if (initialized) {

View File

@@ -1,12 +1,14 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { Me } from 'types'; import type { Me, VersionsResponse } 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,8 +247,7 @@ 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_1: 'Vždy', AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'Immer', AP_PROVIDE_TEXT_2: 'Wenn Netzwerkverbindung verloren geht',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'Always', AP_PROVIDE_TEXT_2: 'When network connection is lost',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'toujours', AP_PROVIDE_TEXT_2: 'quand la connexion réseau est perdue',
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',
@@ -262,7 +261,6 @@ 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",

View File

@@ -247,8 +247,7 @@ 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_1: 'sempre', AP_PROVIDE_TEXT_2: 'quando la connessione di rete è persa',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'altijd', AP_PROVIDE_TEXT_2: 'als netwerk verbinding verloren gaat',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'alltid', AP_PROVIDE_TEXT_2: 'når nettverksforbindelsen er utilgjengelig',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,7 +247,6 @@ 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ł',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'vždy', AP_PROVIDE_TEXT_2: 'keď je sieťové pripojenie stratené',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'alltid', AP_PROVIDE_TEXT_2: 'när nätverksanslutningen är bortkopplad',
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',
@@ -262,7 +261,6 @@ 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',

View File

@@ -247,8 +247,7 @@ 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_1: 'her zaman', AP_PROVIDE_TEXT_2: 'Ağ bağlantısı kesildiğinde',
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',
@@ -262,7 +261,6 @@ 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ı',

View File

@@ -1,5 +1,4 @@
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,3 +7,4 @@ 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

@@ -0,0 +1,23 @@
// Types for the `getVersions` action response coming from the device.
// The device proxies the request to emsesp.org/versions.json. If the device
// is offline the `stable` and `dev` fields are omitted.
export interface VersionInfo {
version: string;
date: string;
}
export interface RemoteVersionInfo extends VersionInfo {
upgradeable: boolean;
}
export interface CurrentVersionInfo extends VersionInfo {
type: 'stable' | 'dev';
upgradeable: boolean;
}
export interface VersionsResponse {
current: CurrentVersionInfo;
stable?: RemoteVersionInfo;
dev?: RemoteVersionInfo;
}

View File

@@ -1,34 +1,27 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, 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] => {
// Set initial value - only computed once on mount const [state, setState] = useState<T>(() => {
const _initial_value = useMemo(() => {
try { try {
const local_storage_value_str = localStorage.getItem(`state:${id}`); const stored = localStorage.getItem(`state:${id}`);
// If there is a value stored in localStorage, use that if (stored) {
if (local_storage_value_str) { return JSON.parse(stored) as T;
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 {
const state_str = JSON.stringify(state); localStorage.setItem(`state:${id}`, 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, useMemo, useState } from 'react'; import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -54,61 +54,44 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
} }
}, [readData]); }, [readData]);
const saveData = useCallback(async () => { const saveData = 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; // Early return - save succeeded but needs reboot return;
} }
// 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([]); // Clear flags so user can retry setDirtyFlags([]);
} }
}, [data, writeData, origData, updateData]); };
return useMemo( return {
() => ({ loadData,
loadData, saveData,
saveData, saving: !!saving,
saving: !!saving, updateDataValue,
updateDataValue, data: data as D,
data: data as D, origData: origData as D,
origData: origData as D, dirtyFlags,
dirtyFlags, setDirtyFlags,
setDirtyFlags, setOrigData,
setOrigData, blocker,
blocker, errorMessage,
errorMessage, restartNeeded
restartNeeded };
}),
[
loadData,
saveData,
saving,
updateDataValue,
data,
origData,
dirtyFlags,
blocker,
errorMessage,
restartNeeded
]
);
}; };

View File

@@ -6,9 +6,6 @@ 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 = '=';
@@ -100,6 +97,10 @@ 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) {
@@ -210,9 +211,11 @@ const imageOptimizationPlugin = {
}; };
export default defineConfig( export default defineConfig(
({ command, mode }: { command: string; mode: string }) => { async ({ 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: {
@@ -229,8 +232,7 @@ 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

@@ -231,7 +231,7 @@ SyslogService::QueuedLogMessage::QueuedLogMessage(unsigned long id, std::shared_
: id_(id) : id_(id)
, content_(std::move(content)) { , content_(std::move(content)) {
// Added for EMS-ESP // Added for EMS-ESP
if (time_good_ || emsesp::EMSESP::system_.network_connected()) { if (time_good_ || emsesp::EMSESP::network_.network_connected()) {
#if UUID_SYSLOG_HAVE_GETTIMEOFDAY #if UUID_SYSLOG_HAVE_GETTIMEOFDAY
if (gettimeofday(&time_, nullptr) != 0) { if (gettimeofday(&time_, nullptr) != 0) {
time_.tv_sec = (time_t)-1; time_.tv_sec = (time_t)-1;

View File

@@ -12,6 +12,7 @@
#include "SecuritySettingsService.h" #include "SecuritySettingsService.h"
#include "StatefulService.h" #include "StatefulService.h"
#include "Network.h" #include "Network.h"
// #include "IPAddress.h"
#include <espMqttClient.h> #include <espMqttClient.h>
@@ -21,7 +22,6 @@
#define NTP_SETTINGS_FILE "/config/ntpSettings.json" #define NTP_SETTINGS_FILE "/config/ntpSettings.json"
#define EMSESP_SETTINGS_FILE "/config/emsespSettings.json" #define EMSESP_SETTINGS_FILE "/config/emsespSettings.json"
#define AP_MODE_ALWAYS 0
class DummySettings { class DummySettings {
public: public:
// SYSTEM // SYSTEM
@@ -49,6 +49,22 @@ class DummySettings {
uint16_t keepAlive = 60; uint16_t keepAlive = 60;
bool cleanSession = false; bool cleanSession = false;
uint8_t entity_format = 1; uint8_t entity_format = 1;
String CORSOrigin = "*";
uint8_t tx_power = 0;
String bssid = "";
String localIP = "";
String gatewayIP = "";
String subnetMask = "";
bool staticIPConfig = false;
String dnsIP1 = "";
String dnsIP2 = "";
bool enableMDNS = true;
bool enableCORS = false;
uint8_t channel = 1;
bool ssid_hidden = false;
uint8_t max_clients = 4;
bool ssidHidden = false;
uint8_t maxClients = 4;
uint16_t publish_time_boiler = 10; uint16_t publish_time_boiler = 10;
uint16_t publish_time_thermostat = 10; uint16_t publish_time_thermostat = 10;
@@ -59,21 +75,10 @@ class DummySettings {
uint16_t publish_time_heartbeat = 60; uint16_t publish_time_heartbeat = 60;
uint32_t publish_time_water = 0; uint32_t publish_time_water = 0;
String hostname = "ems-esp"; String hostname = "ems-esp";
String jwtSecret = "ems-esp"; String jwtSecret = "ems-esp";
String ssid = "ems-esp"; String ssid = "ems-esp";
String password = "ems-esp"; String password = "ems-esp";
String bssid = "";
String localIP = "";
String gatewayIP = "";
String subnetMask = "";
bool staticIPConfig = false;
String dnsIP1 = "";
String dnsIP2 = "";
bool enableMDNS = true;
bool enableCORS = false;
String CORSOrigin = "*";
uint8_t tx_power = 0;
// AP // AP
uint8_t provisionMode = 0; uint8_t provisionMode = 0;

View File

@@ -33,7 +33,7 @@ class String {
return lhs; return lhs;
} }
bool isEmpty() { bool isEmpty() const {
return _str.empty(); return _str.empty();
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "mock-api", "name": "mock-api",
"version": "3.8.2", "version": "3.9.0",
"description": "mock api for EMS-ESP", "description": "mock api for EMS-ESP",
"author": "proddy, emsesp.org", "author": "proddy, emsesp.org",
"license": "MIT", "license": "MIT",
@@ -15,5 +15,5 @@
"itty-router": "^5.0.23", "itty-router": "^5.0.23",
"prettier": "^3.8.3" "prettier": "^3.8.3"
}, },
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820" "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
} }

View File

@@ -46,8 +46,8 @@ packages:
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2': '@babel/parser@7.29.3':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -185,7 +185,7 @@ snapshots:
'@babel/generator@7.29.1': '@babel/generator@7.29.1':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
@@ -197,14 +197,14 @@ snapshots:
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2': '@babel/parser@7.29.3':
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/template@7.28.6': '@babel/template@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/traverse@7.29.0': '@babel/traverse@7.29.0':
@@ -212,7 +212,7 @@ snapshots:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1 '@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0 '@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/template': 7.28.6 '@babel/template': 7.28.6
'@babel/types': 7.29.0 '@babel/types': 7.29.0
debug: 4.4.3 debug: 4.4.3
@@ -249,7 +249,7 @@ snapshots:
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)':
dependencies: dependencies:
'@babel/generator': 7.29.1 '@babel/generator': 7.29.1
'@babel/parser': 7.29.2 '@babel/parser': 7.29.3
'@babel/traverse': 7.29.0 '@babel/traverse': 7.29.0
'@babel/types': 7.29.0 '@babel/types': 7.29.0
javascript-natural-sort: 0.7.1 javascript-natural-sort: 0.7.1

View File

@@ -6,7 +6,6 @@ const router = AutoRouter();
const REST_ENDPOINT_ROOT = '/rest/'; const REST_ENDPOINT_ROOT = '/rest/';
const API_ENDPOINT_ROOT = '/api/'; const API_ENDPOINT_ROOT = '/api/';
const GH_ENDPOINT_ROOT = '/gh/'; // for mock GitHub API for version checking
// HTTP HEADERS for msgpack // HTTP HEADERS for msgpack
const headers = { const headers = {
@@ -128,7 +127,7 @@ let system_status = {
} }
], ],
// partitions: [], // partitions: [],
developer_mode: true, developer_mode: settings.developer_mode,
model: '', model: '',
board: '', board: '',
// model: 'BBQKees Electronics EMS Gateway E32 V2 (E32 V2.0 P3/2024011)', // model: 'BBQKees Electronics EMS Gateway E32 V2 (E32 V2.0 P3/2024011)',
@@ -142,13 +141,13 @@ let DEV_VERSION_IS_UPGRADEABLE: boolean;
let STABLE_VERSION_IS_UPGRADEABLE: boolean; let STABLE_VERSION_IS_UPGRADEABLE: boolean;
let THIS_VERSION: string; let THIS_VERSION: string;
let LATEST_STABLE_VERSION = '3.8.2'; let LATEST_STABLE_VERSION = '3.8.2';
let LATEST_DEV_VERSION = '3.8.3-dev.2'; let LATEST_DEV_VERSION = '3.9.0-dev.1';
// scenarios for testing versioning // scenarios for testing versioning
let version_test = 0; // on latest stable, or switch to dev // let version_test = 0; // on latest stable, or switch to dev
// let version_test = 1; // on latest dev, or switch back to stable // let version_test = 1; // on latest dev, or switch back to stable
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev // let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
// let version_test = 3; // upgrade dev to latest, or switch to stable let version_test = 3; // upgrade dev to latest, or switch to stable
// let version_test = 4; // downgrade to an older dev, or switch back to stable // let version_test = 4; // downgrade to an older dev, or switch back to stable
switch (version_test as number) { switch (version_test as number) {
@@ -302,10 +301,10 @@ function updateMask(entity: any, de: any, dd: any) {
const old_custom_name = dd.nodes[dd_objIndex].cn; const old_custom_name = dd.nodes[dd_objIndex].cn;
console.log( console.log(
'comparing names, old (' + 'comparing names, old (' +
old_custom_name + old_custom_name +
') with new (' + ') with new (' +
new_custom_name + new_custom_name +
')' ')'
); );
if (old_custom_name !== new_custom_name) { if (old_custom_name !== new_custom_name) {
changed = true; changed = true;
@@ -403,48 +402,74 @@ function upgradeImportantMessages(version: string) {
// see if its a filename with a .bin extension // see if its a filename with a .bin extension
if (version.endsWith('.bin')) { if (version.endsWith('.bin')) {
upgradeImportantMessageType_n = 1; // 1 means 3.9 and factory reset required upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning factory reset required
} else if (version.endsWith('.md')) { } else if (version.endsWith('.md')) {
upgradeImportantMessageType_n = 0; upgradeImportantMessageType_n = 0; // use default 0, no message
} else { } else {
// this is a version string like "3.9.0" // this is a version string like "3.9.0"
upgradeImportantMessageType_n = 2; // upgradeImportantMessageType_n = 2; // make it 2, for testing, meaning a major version upgrade
upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning a factory reset is required
} }
console.log('upgradeImportantMessageType: ' + upgradeImportantMessageType_n); console.log('upgradeImportantMessageType: version=' + version + ' type=' + upgradeImportantMessageType_n);
return { upgradeImportantMessageType: upgradeImportantMessageType_n }; return { upgradeImportantMessageType: upgradeImportantMessageType_n };
} }
// called by Action endpoint checkUpgrade // called by Action endpoint getVersions
function check_upgrade(version: string) { // Set MOCK_OFFLINE = true to simulate a device with no internet (omits stable/dev).
let data = {}; const MOCK_OFFLINE = false;
if (version) { function get_versions() {
const dev_version = version.split(',')[0]; const isDev = THIS_VERSION.includes('dev');
const stable_version = version.split(',')[1]; const currentUpgradeable =
!MOCK_OFFLINE &&
(isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE);
console.log( const data: {
'Upgrade this version (' + current: {
THIS_VERSION + version: string;
') to dev (' + type: 'stable' | 'dev';
dev_version + date: string;
') is ' + upgradeable: boolean;
(DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
' and to stable (' +
stable_version +
') is ' +
(STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO')
);
data = {
emsesp_version: THIS_VERSION,
dev_upgradeable: DEV_VERSION_IS_UPGRADEABLE,
stable_upgradeable: STABLE_VERSION_IS_UPGRADEABLE
}; };
} else { stable?: { version: string; date: string; upgradeable: boolean };
console.log('requesting ems-esp version (' + THIS_VERSION + ')'); dev?: { version: string; date: string; upgradeable: boolean };
data = { } = {
emsesp_version: THIS_VERSION current: {
version: THIS_VERSION,
type: isDev ? 'dev' : 'stable',
date: '2026-04-25T12:00:00',
upgradeable: currentUpgradeable
}
};
if (!MOCK_OFFLINE) {
data.stable = {
version: LATEST_STABLE_VERSION,
date: '2026-04-25',
upgradeable: STABLE_VERSION_IS_UPGRADEABLE
};
data.dev = {
version: LATEST_DEV_VERSION,
date: '2026-04-25',
upgradeable: DEV_VERSION_IS_UPGRADEABLE
}; };
} }
console.log(
'getVersions: current=' +
THIS_VERSION +
' stable=' +
LATEST_STABLE_VERSION +
' (upgradeable=' +
(STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
') dev=' +
LATEST_DEV_VERSION +
' (upgradeable=' +
(DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
')' +
(MOCK_OFFLINE ? ' [offline]' : '')
);
return data; return data;
} }
@@ -4579,6 +4604,7 @@ router
.post(EMSESP_SETTINGS_ENDPOINT, async (request: any) => { .post(EMSESP_SETTINGS_ENDPOINT, async (request: any) => {
settings = await request.json(); settings = await request.json();
console.log('application settings saved', settings); console.log('application settings saved', settings);
system_status.developer_mode = settings.developer_mode;
return status(200); // no restart needed return status(200); // no restart needed
// return status(205); // reboot required // return status(205); // reboot required
}) })
@@ -5172,13 +5198,9 @@ router
} else if (action === 'getCustomSupport') { } else if (action === 'getCustomSupport') {
// send custom support // send custom support
return custom_support(); return custom_support();
} else if (action === 'checkUpgrade') { } else if (action === 'getVersions') {
// check upgrade // get versions
// check if content has a param return get_versions();
if (!content.param) {
return check_upgrade('');
}
return check_upgrade(content.param);
} else if (action === 'uploadURL') { } else if (action === 'uploadURL') {
// upload URL // upload URL
console.log('upload File from URL', content.param); console.log('upload File from URL', content.param);
@@ -5233,27 +5255,6 @@ router
return status(404); // not found return status(404); // not found
}); });
// Mock GitHub API
// https://api.github.com/repos/emsesp/EMS-ESP32/releases
router
.get(GH_ENDPOINT_ROOT + '/tags/latest', () => {
const data = {
name: 'v' + LATEST_DEV_VERSION,
published_at: new Date().toISOString() // use todays date
};
console.log('returning latest development version (today): ', data);
return data;
})
.get(GH_ENDPOINT_ROOT + '/latest', () => {
const data = {
name: 'v' + LATEST_STABLE_VERSION,
published_at: '2025-03-01T13:29:13.999Z'
};
console.log('returning latest stable version: ', data);
return data;
});
// const logger: ResponseHandler = (response, request) => { // const logger: ResponseHandler = (response, request) => {
// console.log( // console.log(
// response.status, // response.status,

View File

@@ -39,7 +39,7 @@ build_flags =
-D CONFIG_ASYNC_TCP_PRIORITY=10 ; default -D CONFIG_ASYNC_TCP_PRIORITY=10 ; default
-D CONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; default -D CONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; default
-D CONFIG_ASYNC_TCP_RUNNING_CORE=1 ; force async_tcp task to be on same core as Arduino app (default is any core) -D CONFIG_ASYNC_TCP_RUNNING_CORE=1 ; force async_tcp task to be on same core as Arduino app (default is any core)
-D CONFIG_ASYNC_TCP_STACK_SIZE=6144 ; default is 16KB/8192*2 -D CONFIG_ASYNC_TCP_STACK_SIZE=8192 ; default is 16KB/8192*2
; ESPAsyncWebServer ; ESPAsyncWebServer
; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8 ; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8
; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32 ; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32
@@ -91,7 +91,7 @@ board_build.filesystem = littlefs
lib_deps = lib_deps =
bblanchon/ArduinoJson @ 7.4.3 bblanchon/ArduinoJson @ 7.4.3
ESP32Async/AsyncTCP @ 3.4.10 ESP32Async/AsyncTCP @ 3.4.10
ESP32Async/ESPAsyncWebServer @ 3.10.3 ESP32Async/ESPAsyncWebServer @ 3.11.0
https://github.com/mobizt/ReadyMail.git @ 0.4.0 https://github.com/mobizt/ReadyMail.git @ 0.4.0
https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3 https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3
; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8 ; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8

View File

@@ -1330,4 +1330,8 @@ zyxwvutsrqponmlkjihgfedcba
öffnen öffnen
česky česky
živanović živanović
MWDT MWDT
juststopped
handshaked
startm
netifs

View File

@@ -1,7 +1,7 @@
sonar.organization=emsesp sonar.organization=emsesp
sonar.projectKey=emsesp_EMS-ESP32 sonar.projectKey=emsesp_EMS-ESP32
sonar.projectName=EMS-ESP32 sonar.projectName=EMS-ESP32
sonar.projectVersion=3.8.2 sonar.projectVersion=3.9.0
sonar.sources=./src sonar.sources=./src
sonar.cfamily.compile-commands=bw-output/compile_commands.json sonar.cfamily.compile-commands=bw-output/compile_commands.json
sonar.sourceEncoding=UTF-8 sonar.sourceEncoding=UTF-8

View File

@@ -4,110 +4,17 @@
APSettingsService::APSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager) APSettingsService::APSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager)
: _httpEndpoint(APSettings::read, APSettings::update, this, server, AP_SETTINGS_SERVICE_PATH, securityManager) : _httpEndpoint(APSettings::read, APSettings::update, this, server, AP_SETTINGS_SERVICE_PATH, securityManager)
, _fsPersistence(APSettings::read, APSettings::update, this, fs, AP_SETTINGS_FILE) , _fsPersistence(APSettings::read, APSettings::update, this, fs, AP_SETTINGS_FILE) {
, _dnsServer(nullptr)
, _lastManaged(0)
, _reconfigureAp(false)
, _connected(0) {
addUpdateHandler([this] { reconfigureAP(); }, false);
} }
void APSettingsService::begin() { void APSettingsService::begin() {
_fsPersistence.readFromFS(); _fsPersistence.readFromFS();
// disabled for delayed start, first try station mode
// reconfigureAP();
}
void APSettingsService::reconfigureAP() {
_lastManaged = uuid::get_uptime() - MANAGE_NETWORK_DELAY;
_reconfigureAp = true;
}
void APSettingsService::loop() {
const uint8_t was_connected = _connected;
if (WiFi.isConnected()) {
_connected |= 1U;
} else {
_connected &= ~1U;
}
if (ETH.connected()) {
_connected |= 2U;
} else {
_connected &= ~2U;
}
// wait 10 sec before starting AP
if (was_connected && !_connected) {
_lastManaged = uuid::get_uptime();
}
const unsigned long currentMillis = uuid::get_uptime();
if ((currentMillis - _lastManaged) >= MANAGE_NETWORK_DELAY) {
_lastManaged = currentMillis;
manageAP();
}
if (_dnsServer) {
handleDNS();
}
}
void APSettingsService::manageAP() {
const WiFiMode_t currentWiFiMode = WiFi.getMode();
if (_state.provisionMode == AP_MODE_ALWAYS || (_state.provisionMode == AP_MODE_DISCONNECTED && !_connected)) {
if (_reconfigureAp || currentWiFiMode == WIFI_OFF || currentWiFiMode == WIFI_STA) {
startAP();
}
} else if ((currentWiFiMode == WIFI_AP || currentWiFiMode == WIFI_AP_STA) && _connected && (_reconfigureAp || !WiFi.softAPgetStationNum())) {
stopAP();
}
_reconfigureAp = false;
}
void APSettingsService::startAP() {
WiFi.softAPenableIPv6(); // force IPV6, same as for WiFi - fixes https://github.com/emsesp/EMS-ESP32/issues/1922
WiFi.softAPConfig(_state.localIP, _state.gatewayIP, _state.subnetMask);
esp_wifi_set_bandwidth(static_cast<wifi_interface_t>(ESP_IF_WIFI_AP), WIFI_BW_HT20);
WiFi.softAP(_state.ssid.c_str(), _state.password.c_str(), _state.channel, _state.ssidHidden, _state.maxClients);
#if CONFIG_IDF_TARGET_ESP32C3
WiFi.setTxPower(WIFI_POWER_8_5dBm); // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi
#endif
if (!_dnsServer) {
const IPAddress apIp = WiFi.softAPIP();
char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%u.%u.%u.%u", apIp[0], apIp[1], apIp[2], apIp[3]);
emsesp::EMSESP::logger().info("Starting Access Point with captive portal on %s", ipStr);
_dnsServer = new DNSServer;
_dnsServer->start(DNS_PORT, "*", apIp);
}
}
void APSettingsService::stopAP() {
if (_dnsServer) {
emsesp::EMSESP::logger().info("Stopping Access Point");
_dnsServer->stop();
delete _dnsServer;
_dnsServer = nullptr;
}
WiFi.softAPdisconnect(true);
}
void APSettingsService::handleDNS() {
if (_dnsServer) {
_dnsServer->processNextRequest();
}
} }
APNetworkStatus APSettingsService::getAPNetworkStatus() { APNetworkStatus APSettingsService::getAPNetworkStatus() {
const WiFiMode_t currentWiFiMode = WiFi.getMode(); return emsesp::EMSESP::network_.ap_connected() ? APNetworkStatus::ACTIVE : APNetworkStatus::INACTIVE;
const bool apActive = (currentWiFiMode == WIFI_AP || currentWiFiMode == WIFI_AP_STA);
if (apActive && _state.provisionMode != AP_MODE_ALWAYS && WiFi.status() == WL_CONNECTED) {
return APNetworkStatus::LINGERING;
}
return apActive ? APNetworkStatus::ACTIVE : APNetworkStatus::INACTIVE;
} }
void APSettings::read(const APSettings & settings, JsonObject root) { void APSettings::read(const APSettings & settings, JsonObject root) {
root["provision_mode"] = settings.provisionMode; root["provision_mode"] = settings.provisionMode;
root["ssid"] = settings.ssid; root["ssid"] = settings.ssid;
@@ -125,12 +32,11 @@ StateUpdateResult APSettings::update(JsonObject root, APSettings & settings) {
newSettings.provisionMode = static_cast<uint8_t>(root["provision_mode"] | FACTORY_AP_PROVISION_MODE); newSettings.provisionMode = static_cast<uint8_t>(root["provision_mode"] | FACTORY_AP_PROVISION_MODE);
switch (settings.provisionMode) { switch (settings.provisionMode) {
case AP_MODE_ALWAYS:
case AP_MODE_DISCONNECTED: case AP_MODE_DISCONNECTED:
case AP_MODE_NEVER: case AP_MODE_NEVER:
break; break;
default: default:
newSettings.provisionMode = AP_MODE_ALWAYS; newSettings.provisionMode = AP_MODE_DISCONNECTED;
} }
newSettings.ssid = root["ssid"] | FACTORY_AP_SSID; newSettings.ssid = root["ssid"] | FACTORY_AP_SSID;
@@ -148,5 +54,10 @@ StateUpdateResult APSettings::update(JsonObject root, APSettings & settings) {
} }
settings = newSettings; settings = newSettings;
// if the AP mode has changed, force a disconnect and reconnect
if (settings.provisionMode != newSettings.provisionMode) {
emsesp::EMSESP::network_.reconnect();
}
return StateUpdateResult::CHANGED; return StateUpdateResult::CHANGED;
} }

View File

@@ -5,9 +5,6 @@
#include "FSPersistence.h" #include "FSPersistence.h"
#include "JsonUtils.h" #include "JsonUtils.h"
#include <DNSServer.h>
#include <IPAddress.h>
#ifndef FACTORY_AP_PROVISION_MODE #ifndef FACTORY_AP_PROVISION_MODE
#define FACTORY_AP_PROVISION_MODE AP_MODE_DISCONNECTED #define FACTORY_AP_PROVISION_MODE AP_MODE_DISCONNECTED
#endif #endif
@@ -47,14 +44,10 @@
#define AP_SETTINGS_FILE "/config/apSettings.json" #define AP_SETTINGS_FILE "/config/apSettings.json"
#define AP_SETTINGS_SERVICE_PATH "/rest/apSettings" #define AP_SETTINGS_SERVICE_PATH "/rest/apSettings"
#define AP_MODE_ALWAYS 0
#define AP_MODE_DISCONNECTED 1 #define AP_MODE_DISCONNECTED 1
#define AP_MODE_NEVER 2 #define AP_MODE_NEVER 2
#define MANAGE_NETWORK_DELAY 10000 enum APNetworkStatus { ACTIVE = 0, INACTIVE };
#define DNS_PORT 53
enum APNetworkStatus { ACTIVE = 0, INACTIVE, LINGERING };
class APSettings { class APSettings {
public: public:
@@ -84,26 +77,11 @@ class APSettingsService : public StatefulService<APSettings> {
APSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager); APSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager);
void begin(); void begin();
void loop();
APNetworkStatus getAPNetworkStatus(); APNetworkStatus getAPNetworkStatus();
private: private:
HttpEndpoint<APSettings> _httpEndpoint; HttpEndpoint<APSettings> _httpEndpoint;
FSPersistence<APSettings> _fsPersistence; FSPersistence<APSettings> _fsPersistence;
// for the captive portal
DNSServer * _dnsServer;
// for the management delay loop
volatile unsigned long _lastManaged;
volatile bool _reconfigureAp;
volatile uint8_t _connected;
void reconfigureAP();
void manageAP();
void startAP();
void stopAP();
void handleDNS();
}; };
#endif #endif

View File

@@ -1,5 +1,7 @@
#include "APStatus.h" #include "APStatus.h"
#include <emsesp.h>
APStatus::APStatus(AsyncWebServer * server, SecurityManager * securityManager, APSettingsService * apSettingsService) APStatus::APStatus(AsyncWebServer * server, SecurityManager * securityManager, APSettingsService * apSettingsService)
: _apSettingsService(apSettingsService) { : _apSettingsService(apSettingsService) {
securityManager->addEndpoint(server, AP_STATUS_SERVICE_PATH, AuthenticationPredicates::IS_AUTHENTICATED, [this](AsyncWebServerRequest * request) { securityManager->addEndpoint(server, AP_STATUS_SERVICE_PATH, AuthenticationPredicates::IS_AUTHENTICATED, [this](AsyncWebServerRequest * request) {
@@ -12,9 +14,9 @@ void APStatus::apStatus(AsyncWebServerRequest * request) {
JsonObject root = response->getRoot(); JsonObject root = response->getRoot();
root["status"] = _apSettingsService->getAPNetworkStatus(); root["status"] = _apSettingsService->getAPNetworkStatus();
root["ip_address"] = WiFi.softAPIP().toString(); root["ip_address"] = emsesp::EMSESP::network_.getLocalIP();
root["mac_address"] = WiFi.softAPmacAddress(); root["mac_address"] = emsesp::EMSESP::network_.getMacAddress();
root["station_num"] = WiFi.softAPgetStationNum(); root["station_num"] = emsesp::EMSESP::network_.getStationNum();
response->setLength(); response->setLength();
request->send(response); request->send(response);

View File

@@ -1,12 +1,7 @@
#ifndef APStatus_h #ifndef APStatus_h
#define APStatus_h #define APStatus_h
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <IPAddress.h>
#include "SecurityManager.h" #include "SecurityManager.h"
#include "APSettingsService.h" #include "APSettingsService.h"

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