Compare commits

..
74 Commits
Author SHA1 Message Date
proddy c8a96352ec 3.8.4 2026-08-04 08:13:45 +02:00
proddy 02caaef916 fix firmware size check 2026-08-03 09:59:46 +02:00
proddy ba0f4cd008 3.8.3 2026-08-02 09:56:04 +02:00
proddy 45f7d91b43 Merge remote-tracking branch 'origin/dev' 2026-08-02 09:55:48 +02:00
proddy ee3a5d231a Merge remote-tracking branch 'origin/dev' 2026-05-12 20:15:32 +02:00
proddy e8f57b6343 v3.8.1 2026-01-11 19:28:38 +01:00
proddy a1ab81de66 Merge branch 'dev' 2026-01-11 19:14:11 +01:00
proddy 28135c225b Merge branch 'dev' 2025-12-31 21:26:15 +01:00
proddy eaa277fef0 v3.7.2 2025-03-22 10:32:03 +01:00
proddy e418b7d8e7 fix routing in tabs - #2264 2024-11-30 21:31:42 +01:00
proddy a4e3be6a69 3.7.1 changelog 2024-11-29 10:30:14 +01:00
proddy a1bc5bb055 3.7.1 2024-11-29 10:30:05 +01:00
proddy f444ca31e0 Merge remote-tracking branch 'origin/dev' 2024-10-27 11:41:47 +01:00
proddy 1b70b55989 update demo url 2024-08-10 14:58:48 +02:00
proddy 1487f30c43 Merge remote-tracking branch 'origin/dev' for 3.6.5 2024-03-23 17:56:05 +01:00
Proddy e00eb8e64f 3.6.4 2023-11-26 20:11:36 +01:00
Proddy f41bb3671c 3.6.4 2023-11-24 07:36:36 +01:00
Proddy 22c75e6df3 3.6.4 2023-11-24 07:36:29 +01:00
proddy 5ab10b7aa6 fixes #1450 2023-11-22 09:48:09 +01:00
Proddy ee5fd4d0eb 3.6.3 2023-11-21 14:40:47 +01:00
Proddy 46f35bc67c another patch on 3.6.3 2023-11-21 14:40:32 +01:00
Proddy ec85a7ec24 3.6.3 (refershed) 2023-11-19 21:24:01 +01:00
Proddy 02f2389587 add workflow_dispatch: 2023-11-18 18:51:12 +01:00
Proddy 7f140021aa quick fix - https://github.com/emsesp/EMS-ESP32/pull/1438 2023-11-18 18:47:28 +01:00
Proddy 6796962c1e 3.6.3 2023-11-18 13:35:20 +01:00
Proddy df6de21cf4 Merge remote-tracking branch 'origin/dev' 2023-11-18 13:35:04 +01:00
Proddy df9f75a5c9 updated yarn for 3.6.2 2023-10-01 17:42:54 +02:00
Proddy 7bd8710eb6 3.6.2 2023-10-01 17:40:09 +02:00
Proddy 32f2c6d341 Merge remote-tracking branch 'origin/dev' 2023-10-01 17:40:02 +02:00
Proddy 86919c1684 Merge branch 'origin/dev' 2023-09-09 14:12:07 +02:00
Proddy 86e29515e7 build s3 2023-08-15 18:44:24 +02:00
Proddy 46eb4185d7 update with 3.6.0 2023-08-13 14:37:13 +02:00
Proddy 8da6761a48 Merge branch 'dev' 2023-08-13 14:32:41 +02:00
Proddy 9233f0dfcc v3.5.1 - merge with patch 2023-03-11 16:06:05 +01:00
Proddy 292f743b14 Update bug_report.md 2023-02-19 11:18:08 +01:00
Proddy dd6dfffd57 Delete questions---troubleshooting.md 2023-02-19 10:49:52 +01:00
Proddy ec705a5307 Delete feature_request.md 2023-02-19 10:49:45 +01:00
Proddy f45f071710 Create config.yml 2023-02-19 10:49:32 +01:00
Proddy f3858546de Merge branch 'origin/dev' 2023-02-06 21:58:27 +01:00
Proddy d0ac0b7804 update with version on dev 2022-10-30 16:58:37 +01:00
Proddy d8284ec09f Merge pull request #705 from MichaelDvP/main
v3.4.4 Fix for new installations with filesystem not initializing
2022-10-29 10:46:41 +02:00
MichaelDvP 6e982acde8 v3.4.4 Fix for new installations with filesystem not initializing 2022-10-28 10:50:51 +02:00
Proddy 8c94ce99b2 quick fix for filesystem initialization 2022-10-08 09:23:00 +02:00
proddy fc057d18c9 Merge remote-tracking branch 'origin/dev' 2022-09-18 14:33:23 +02:00
Proddy 18e9b99413 Merge remote-tracking branch 'origin/dev' into main 2022-05-29 16:16:38 +02:00
Proddy a47e0e8266 update for 3.4.0 2022-05-23 21:20:45 +02:00
Proddy f412ddc716 Merge remote-tracking branch 'origin/dev' into main 2022-05-23 21:20:36 +02:00
proddy 29110e96e5 Merge remote-tracking branch 'origin/dev' 2022-01-20 10:51:40 +01:00
proddy b65866217a 3.4.0 2021-11-28 23:03:28 +01:00
proddy 611e3b1243 Merge remote-tracking branch 'origin/dev' 2021-11-28 23:03:15 +01:00
proddy 2ca0a0c634 v3.2.1 merged from dev 2021-08-08 14:46:14 +02:00
proddy 7eb1f061b7 Merge remote-tracking branch 'origin/dev' for 3.2.0 release 2021-08-06 12:06:08 +02:00
proddy 50459a23fe force v16 of nodejs 2021-06-26 11:13:07 +02:00
proddy 5bf53c3389 3.1.1 2021-06-26 11:03:03 +02:00
proddy 4b7aa95be3 Merge remote-tracking branch 'origin/dev' 2021-06-26 11:02:55 +02:00
Proddy 70943f5758 Update pre_release.yml 2021-05-16 15:52:09 +02:00
Proddy 3bc280b817 Delete check_code.yml 2021-05-16 15:51:56 +02:00
Proddy 62b15a5319 Update pre_release.yml 2021-05-16 15:35:06 +02:00
Proddy 8dd18802d6 Update tagged_release.yml 2021-05-16 15:34:45 +02:00
proddy 57a516a83a updated README and images 2021-05-09 15:13:16 +02:00
proddy a57fdaa4b3 Merge remote-tracking branch 'origin/dev' into main 2021-05-04 12:21:51 +02:00
proddy 4841e42286 Merge remote-tracking branch 'origin/dev' into main 2021-03-30 16:35:18 +02:00
proddy 8c2d2b06ed cleaned up old changelog 2021-03-18 20:59:09 +01:00
proddy 38c8b1b7f0 3.0.0 2021-03-18 20:58:21 +01:00
proddy 6fb5933a02 Merge remote-tracking branch 'origin/dev' into main 2021-03-18 20:58:12 +01:00
proddy c0944433be remove workspace.code-workspace 2021-03-16 17:41:42 +01:00
Proddy 478e6362c9 Merge pull request #27 from FauthD:main
Add global names to Dallas sensors to avoid ugly <unknown> and other …
2021-03-16 17:39:00 +01:00
fauthd 4d6354db78 Add stuff to gitignore, add vscode workspace 2021-03-16 16:47:09 +01:00
fauthd beab0f0c77 Add global names to Dallas sensors to avoid ugly <unknown> and other issues in HA 2021-03-16 16:00:23 +01:00
Proddy c17749bd22 Update README.md 2021-03-14 23:43:33 +01:00
proddy 2bad769c5c build: include assets 2021-03-14 21:20:51 +01:00
proddy 8ad89ca64b move repo 2021-03-14 21:05:15 +01:00
proddy 9244d8daec Semantic Commit Messages 2021-03-14 18:10:57 +01:00
proddy 02d01334b2 update new build 2021-03-14 17:37:18 +01:00
280 changed files with 15826 additions and 18661 deletions
-20
View File
@@ -77,23 +77,3 @@ jobs:
files: | files: |
CHANGELOG_LATEST.md CHANGELOG_LATEST.md
./build/firmware/*.* ./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+1 -28
View File
@@ -27,17 +27,10 @@ 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
@@ -68,23 +61,3 @@ jobs:
files: | files: |
CHANGELOG.md CHANGELOG.md
./build/firmware/*.* ./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
-40
View File
@@ -1,40 +0,0 @@
name: 'Update versions'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-version:
name: 'Update versions in Cloudflare KV store'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get and Send EMS-ESP version to Cloudflare
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
KV_ENV="stable"
else
KV_ENV="dev"
fi
JSON_DATA=$(jq -n \
--arg version "$version" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "KV_ENV: $KV_ENV"
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo
+1 -5
View File
@@ -2,7 +2,6 @@
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/extensions.json .vscode/extensions.json
.vscode/launch.json .vscode/launch.json
.vscode/settings.json
# c++ compiling # c++ compiling
.clang_complete .clang_complete
@@ -64,7 +63,7 @@ words-found-verbose.txt
# sonarlint # sonarlint
compile_commands.json compile_commands.json
# other files # pioarduino + hybrid
managed_components managed_components
dependencies.lock dependencies.lock
CMakeLists.txt CMakeLists.txt
@@ -76,6 +75,3 @@ pnpm-lock.yaml
.cache/ .cache/
interface/.tsbuildinfo interface/.tsbuildinfo
test/test_api/package-lock.json test/test_api/package-lock.json
.clangd
mklittlefs
.cursor
+101
View File
@@ -0,0 +1,101 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript"
],
"eslint.codeActionsOnSave.rules": null,
"eslint.nodePath": "interface/.yarn/sdks",
"eslint.workingDirectories": ["interface"],
"prettier.prettierPath": "",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.tsx": "typescriptreact",
"*.tcc": "cpp",
"optional": "cpp",
"istream": "cpp",
"ostream": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"array": "cpp",
"functional": "cpp",
"regex": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"string": "cpp",
"string_view": "cpp",
"atomic": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"random": "cpp",
"set": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"todo-tree.filtering.excludeGlobs": [
"**/vendor/**",
"**/node_modules/**",
"**/dist/**",
"**/bower_components/**",
"**/build/**",
"**/.vscode/**",
"**/.github/**",
"**/_output/**",
"**/*.min.*",
"**/*.map",
"**/ArduinoJson/**"
],
"cSpell.enableFiletypes": [
"ini",
"makefile"
],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
"sonarlint.connectedMode.project": {
"connectionId": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}
}
+8 -2
View File
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.4] 4 August 2026
## Fixed
- Allow auto-download and install of firmware versions which are smaller to allow update to the latest 3.9.0-development version.
## [3.8.3] 1 August 2026 ## [3.8.3] 1 August 2026
## Added ## Added
@@ -35,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104) - set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161) - language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
## [3.8.2] 12 May 2026 ## [3.8.2] 12 May 2026
## Added ## Added
@@ -101,7 +108,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- board profile `CUSTOM` can only be selected in developer mode - board profile `CUSTOM` can only be selected in developer mode
- mqtt sends round values without decimals (`28` instead of `28.0`) - mqtt sends round values without decimals (`28` instead of `28.0`)
## [3.8.0] 31 December 2025 ## [3.8.0] 31 December 2025
## Added ## Added
@@ -975,4 +981,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- some names of mqtt-tags like in v2.2.1 - some names of mqtt-tags like in v2.2.1
- new ESP32 partition side to allow for smoother OTA and fallback - new ESP32 partition side to allow for smoother OTA and fallback
- Network Gateway IP is optional (#682)emsesp/EMS-ESP - Network Gateway IP is optional (#682)emsesp/EMS-ESP
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32> - moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
-35
View File
@@ -1,38 +1,3 @@
# Changelog # Changelog
For more details go to [emsesp.org](https://emsesp.org/). For more details go to [emsesp.org](https://emsesp.org/).
## [3.9.0]
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
## Added
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
- TLS support with 4MB boards without PSRAM
## Fixed
- shunting yard show json
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
- LED stayed off on a healthy system when "Disable LED" was unchecked
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
## Changed
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
- Scheduler name is now mandatory
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
- remove devices without entities not listed in 0x07 telegram
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
- set `None` for undefined values in HA `val_tpl`
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
+4 -3
View File
@@ -47,7 +47,7 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
#---------------------------------------------------------------------- #----------------------------------------------------------------------
TARGET := emsesp TARGET := emsesp
BUILD := build BUILD := build
SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
LIBRARIES := LIBRARIES :=
@@ -65,6 +65,7 @@ CXX_STANDARD := -std=gnu++20
#---------------------------------------------------------------------- #----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500 DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
DEFINES += -DNO_TLS_SUPPORT
DEFINES += $(ARGS) DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\" DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
@@ -112,8 +113,8 @@ 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 CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture 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
+1
View File
@@ -5,6 +5,7 @@
}, },
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DTASMOTA_SDK",
"-DARDUINO_LOLIN_C3_MINI", "-DARDUINO_LOLIN_C3_MINI",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_MODE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1" "-DARDUINO_USB_CDC_ON_BOOT=1"
+1
View File
@@ -6,6 +6,7 @@
"core": "esp32", "core": "esp32",
"extra_flags": [ "extra_flags": [
"-DBOARD_HAS_PSRAM", "-DBOARD_HAS_PSRAM",
"-DTASMOTA_SDK",
"-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0" "-DARDUINO_USB_MODE=0"
], ],
+1 -1
View File
@@ -21,7 +21,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS", "name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "32MB", "flash_size": "32MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L", "f_cpu": "240000000L",
"f_flash": "40000000L", "f_flash": "40000000L",
"flash_mode": "dio", "flash_mode": "dio",
@@ -18,7 +19,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS", "name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "16MB", "flash_size": "16MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
+1 -1
View File
@@ -19,7 +19,7 @@
"arduino", "arduino",
"espidf" "espidf"
], ],
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS", "name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
"upload": { "upload": {
"flash_size": "16MB", "flash_size": "16MB",
"maximum_ram_size": 327680, "maximum_ram_size": 327680,
+1
View File
@@ -1,6 +1,7 @@
{ {
"build": { "build": {
"core": "esp32", "core": "esp32",
"extra_flags": "-DTASMOTA_SDK",
"f_cpu": "240000000L", "f_cpu": "240000000L",
"f_flash": "40000000L", "f_flash": "40000000L",
"flash_mode": "dio", "flash_mode": "dio",
+25 -159
View File
@@ -1,7 +1,6 @@
{ {
"type": "systembackup", "type": "systembackup",
"version": "3.9.0", "version": "3.8.2",
"date": "2026-06-14T10:59:16",
"systembackup": [ "systembackup": [
{ {
"type": "settings", "type": "settings",
@@ -19,7 +18,7 @@
"tx_power": 0 "tx_power": 0
}, },
"AP": { "AP": {
"provision_mode": 2, "provision_mode": 1,
"ssid": "ems-esp", "ssid": "ems-esp",
"password": "ems-esp-neo", "password": "ems-esp-neo",
"channel": 1, "channel": 1,
@@ -32,13 +31,13 @@
"MQTT": { "MQTT": {
"enableTLS": false, "enableTLS": false,
"rootCA": "", "rootCA": "",
"enabled": true, "enabled": false,
"host": "192.168.1.2", "host": "",
"port": 1883, "port": 1883,
"base": "ems-esp", "base": "ems-esp",
"username": "username", "username": "",
"password": "password", "password": "",
"client_id": "esp32", "client_id": "esp32-b8ffc9ec",
"keep_alive": 60, "keep_alive": 60,
"clean_session": false, "clean_session": false,
"entity_format": 1, "entity_format": 1,
@@ -52,17 +51,17 @@
"publish_time_heartbeat": 60, "publish_time_heartbeat": 60,
"mqtt_qos": 0, "mqtt_qos": 0,
"mqtt_retain": false, "mqtt_retain": false,
"ha_enabled": true, "ha_enabled": false,
"nested_format": 1, "nested_format": 1,
"discovery_prefix": "homeassistant", "discovery_prefix": "homeassistant",
"discovery_type": 0, "discovery_type": 0,
"ha_number_mode": 1, "ha_number_mode": 0,
"publish_single": false, "publish_single": false,
"publish_single2cmd": false, "publish_single2cmd": false,
"send_response": false "send_response": false
}, },
"NTP": { "NTP": {
"enabled": true, "enabled": false,
"server": "time.google.com", "server": "time.google.com",
"tz_label": "Europe/Amsterdam", "tz_label": "Europe/Amsterdam",
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3" "tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
@@ -83,12 +82,11 @@
] ]
}, },
"Settings": { "Settings": {
"version": "3.9.0", "version": "3.8.2-dev.22",
"board_profile": "E32V2_2", "board_profile": "E32V2_2",
"platform": "ESP32", "platform": "ESP32",
"system_name": "prod",
"locale": "en", "locale": "en",
"tx_mode": 1, "tx_mode": 5,
"ems_bus_id": 73, "ems_bus_id": 73,
"syslog_enabled": false, "syslog_enabled": false,
"syslog_level": 3, "syslog_level": 3,
@@ -99,7 +97,7 @@
"boiler_heatingoff": false, "boiler_heatingoff": false,
"remote_timeout": 24, "remote_timeout": 24,
"remote_timeout_en": false, "remote_timeout_en": false,
"shower_timer": true, "shower_timer": false,
"shower_alert": false, "shower_alert": false,
"shower_alert_coldshot": 10, "shower_alert_coldshot": 10,
"shower_alert_trigger": 7, "shower_alert_trigger": 7,
@@ -109,7 +107,7 @@
"dallas_gpio": 14, "dallas_gpio": 14,
"dallas_parasite": false, "dallas_parasite": false,
"led_gpio": 32, "led_gpio": 32,
"hide_led": true, "hide_led": false,
"led_type": 1, "led_type": 1,
"low_clock": false, "low_clock": false,
"telnet_enabled": true, "telnet_enabled": true,
@@ -133,74 +131,26 @@
"modbus_port": 502, "modbus_port": 502,
"modbus_max_clients": 10, "modbus_max_clients": 10,
"modbus_timeout": 300, "modbus_timeout": 300,
"developer_mode": true, "developer_mode": false
"disable_reset": false,
"email_enabled": true,
"email_security": 2,
"email_server": "smtp.gmail.com",
"email_port": 587,
"email_login": "test@emsesp.org",
"email_pass": "password",
"email_sender": "test@emsesp.org",
"email_recp": "test@emsesp.org",
"email_subject": "ems-esp notification"
}
},
{
"type": "commands",
"Commands": {
"commands": [
{
"id": 1,
"cmd": "{\"url\":\"https://api.pushover.net/1/messages.json\"}",
"value": "{\"user\":\"userkey\", \"token\":\"tokenkey\", \"message\":\"custom/message2\"}",
"name": "push_over"
},
{
"id": 2,
"cmd": "system/message",
"value": "hello",
"name": "sendmessage"
},
{
"id": 3,
"cmd": "system/message",
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
"name": "sendversions"
},
{
"id": 4,
"cmd": "custom/message",
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
"name": "getversions"
},
{
"id": 5,
"cmd": "system/sendmail",
"value": "\"test email with \" + custom/message2",
"name": "sendmail"
}
]
} }
}, },
{ {
"type": "schedule", "type": "schedule",
"Schedule": { "Schedule": {
"schedule": [ "schedule": []
{
"id": 1,
"flags": 128,
"active": false,
"time": "",
"cmd_name": "sendmail",
"name": "sendmailtest"
}
]
} }
}, },
{ {
"type": "customizations", "type": "customizations",
"Customizations": { "Customizations": {
"ts": [
{
"id": "28_1767_7B13_2502",
"name": "gateway_temperature",
"offset": 0,
"is_system": true
}
],
"as": [ "as": [
{ {
"gpio": 39, "gpio": 39,
@@ -236,36 +186,7 @@
{ {
"type": "entities", "type": "entities",
"Entities": { "Entities": {
"entities": [ "entities": []
{
"id": 0,
"ram": 1,
"device_id": 0,
"type_id": 0,
"offset": 0,
"factor": 1,
"name": "message",
"uom": 0,
"value_type": 9,
"writeable": true,
"hide": false,
"value": "{\"stable\":{\"version\":\"3.8.3\",\"date\":\"2026-08-01\"},\"dev\":{\"version\":\"3.9.0-dev.0\",\"date\":\"2026-08-01T09:55:29Z\"}}"
},
{
"id": 1,
"ram": 1,
"device_id": 0,
"type_id": 0,
"offset": 0,
"factor": 1,
"name": "message2",
"uom": 0,
"value_type": 9,
"writeable": true,
"hide": false,
"value": "testing"
}
]
} }
}, },
{ {
@@ -277,65 +198,10 @@
{ {
"type": "nvs", "type": "nvs",
"nvs": [ "nvs": [
{
"type": 1,
"key": "nompower",
"value": 30
},
{
"type": 33,
"key": "boot",
"value": "3.9.0"
},
{
"type": 17,
"key": "led",
"value": 0
},
{
"type": 33,
"key": "intTemp",
"value": "28_1767_7B13_2502"
},
{
"type": 4,
"key": "d_boot",
"value": 1
},
{
"type": 33,
"key": "app1",
"value": "3.9.0"
},
{
"type": 33,
"key": "app0",
"value": "3.9.0"
},
{
"type": 66,
"key": "nrgheat",
"value": 49355.29793
},
{
"type": 4,
"key": "d_app1",
"value": 1781336521
},
{ {
"type": 1, "type": 1,
"key": "fresh_firmware", "key": "fresh_firmware",
"value": 0 "value": 0
},
{
"type": 4,
"key": "d_app0",
"value": 1781363944
},
{
"type": 66,
"key": "nrgww",
"value": 41852.79107
} }
] ]
} }
File diff suppressed because it is too large Load Diff
+5998 -6011
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@ build_flags =
; WiFi settings ; WiFi settings
-D FACTORY_WIFI_SSID=\"\" -D FACTORY_WIFI_SSID=\"\"
-D FACTORY_WIFI_PASSWORD=\"\" -D FACTORY_WIFI_PASSWORD=\"\"
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\" -D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
; Access point settings ; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED -D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
@@ -24,7 +24,7 @@ build_flags =
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\" -D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\" -D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
-D FACTORY_NTP_SERVER=\"time.google.com\" -D FACTORY_NTP_SERVER=\"time.google.com\"
-D FACTORY_NTP_THERMOSTAT_SYNC=1 -D FACTORY_NTP_THERMOSTAT_SYNC=0
; MQTT settings ; MQTT settings
-D FACTORY_MQTT_ENABLED=false -D FACTORY_MQTT_ENABLED=false
+36 -27
View File
@@ -1,9 +1,9 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.9.0", "version": "3.8.2",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "emsesp.org", "author": "proddy, emsesp.org",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -23,40 +23,49 @@
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\"" "standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
}, },
"dependencies": { "dependencies": {
"@alova/adapter-xhr": "2.3.3", "@alova/adapter-xhr": "2.3.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.3.1", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.3.1", "@mui/material": "^9.2.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.5", "alova": "3.5.1",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"preact": "^10.29.8", "magic-string": "^0.30.21",
"react": "^19.2.8", "mime-types": "^3.0.2",
"react-dom": "^19.2.8", "preact": "^10.29.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.7.0", "react-icons": "^5.7.0",
"react-router": "^8.3.0", "react-router": "^7.18.1",
"typesafe-i18n": "^5.27.1" "react-toastify": "^11.1.0",
"typesafe-i18n": "^5.27.1",
"typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@preact/preset-vite": "^2.10.6", "@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^26.2.0", "@types/node": "^25.7.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.3",
"concurrently": "^10.0.5", "axe-core": "^4.12.1",
"eslint": "^10.9.1", "concurrently": "^9.2.4",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"etag": "^1.8.1", "prettier": "^3.9.5",
"mime-types": "^3.0.2", "rollup-plugin-visualizer": "^7.0.1",
"prettier": "^3.9.6", "terser": "^5.47.1",
"rollup-plugin-visualizer": "^7.1.1", "typescript-eslint": "^8.59.3",
"terser": "^5.50.0", "vite": "^8.0.12",
"typescript": "^6.0.3", "vite-plugin-imagemin": "^0.6.1",
"typescript-eslint": "^8.68.0", "vite-tsconfig-paths": "^6.1.1"
"vite": "^8.2.2",
"vite-plugin-imagemin": "^0.6.1"
}, },
"packageManager": "pnpm@11.24.0+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000" "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
} }
+689 -474
View File
File diff suppressed because it is too large Load Diff
+32 -7
View File
@@ -1,8 +1,33 @@
allowBuilds: allowBuilds:
cwebp-bin: false cwebp-bin: set this to true or false
esbuild: false esbuild: set this to true or false
gifsicle: false gifsicle: set this to true or false
jpegtran-bin: false jpegtran-bin: set this to true or false
mozjpeg: false mozjpeg: set this to true or false
optipng-bin: false optipng-bin: set this to true or false
pngquant-bin: false pngquant-bin: set this to true or false
minimumReleaseAgeExclude:
- '@babel/code-frame@7.29.7'
- '@babel/compat-data@7.29.7'
- '@babel/core@7.29.7'
- '@babel/generator@7.29.7'
- '@babel/helper-compilation-targets@7.29.7'
- '@babel/helper-globals@7.29.7'
- '@babel/helper-module-imports@7.29.7'
- '@babel/helper-module-transforms@7.29.7'
- '@babel/helper-string-parser@7.29.7'
- '@babel/helper-validator-identifier@7.29.7'
- '@babel/helper-validator-option@7.29.7'
- '@babel/helpers@7.29.7'
- '@babel/parser@7.29.7'
- '@babel/template@7.29.7'
- '@babel/traverse@7.29.7'
- '@babel/types@7.29.7'
onlyBuiltDependencies:
- cwebp-bin
- esbuild
- gifsicle
- jpegtran-bin
- mozjpeg
- optipng-bin
- pngquant-bin
+1 -38
View File
@@ -46,38 +46,6 @@ ${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]); static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
`; `;
// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES
// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded
// into firmware flash. `en` is always kept as the fallback. Unset => embed all.
const ALL_LOCALES = [
'cs',
'de',
'en',
'fr',
'it',
'nl',
'no',
'pl',
'sk',
'sv',
'tr'
];
const localeAllowList = (process.env.VITE_APP_LOCALES || '')
.split(',')
.map((locale) => locale.trim())
.filter(Boolean);
const isExcludedLocaleChunk = (relativeFilePath) => {
if (localeAllowList.length === 0) return false;
const base = relativeFilePath.split(sep).pop();
const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base);
if (!match) return false;
const code = match[1];
// Only treat known locale codes as locale chunks; never drop the en fallback.
if (!ALL_LOCALES.includes(code) || code === 'en') return false;
return !localeAllowList.includes(code);
};
const getFilesSync = (dir, files = []) => { const getFilesSync = (dir, files = []) => {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name); const entryPath = resolve(dir, entry.name);
@@ -148,12 +116,7 @@ writeStream.write(ARDUINO_INCLUDES);
const buildPath = resolve(sourcePath); const buildPath = resolve(sourcePath);
for (const filePath of getFilesSync(buildPath)) { for (const filePath of getFilesSync(buildPath)) {
const relativeFilePath = relative(buildPath, filePath); writeFile(relative(buildPath, filePath), readFileSync(filePath));
if (isExcludedLocaleChunk(relativeFilePath)) {
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
continue;
}
writeFile(relativeFilePath, readFileSync(filePath));
} }
writeStream.write(generateWWWClass()); writeStream.write(generateWWWClass());
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+37 -37
View File
@@ -1,15 +1,14 @@
import { memo, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { Outlet } from 'react-router'; import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme'; import CustomTheme from 'CustomTheme';
import { Toaster } from 'components/toast';
import { Authentication } from 'contexts/authentication';
import TypesafeI18n from 'i18n/i18n-react'; import TypesafeI18n from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types'; import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'; import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
const ALL_LOCALES = [ const AVAILABLE_LOCALES = [
'de', 'de',
'en', 'en',
'it', 'it',
@@ -23,50 +22,51 @@ const ALL_LOCALES = [
'cs' 'cs'
] as Locales[]; ] as Locales[];
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset, // Static toast configuration - no need to recreate on every render
// every locale is available. `en` is always kept as the fallback locale, and the const TOAST_CONTAINER_PROPS = {
// progmem generator embeds the matching subset into firmware flash. position: 'bottom-left' as const,
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '') autoClose: 3000,
.split(',') hideProgressBar: false,
.map((locale) => locale.trim()) newestOnTop: false,
.filter(Boolean); closeOnClick: true,
rtl: false,
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length pauseOnFocusLoss: true,
? ALL_LOCALES.filter( draggable: false,
(locale) => locale === 'en' || localeAllowList.includes(locale) pauseOnHover: false,
) transition: Zoom,
: ALL_LOCALES; closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => { const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en'); const [locale, setLocale] = useState<Locales>('en');
useEffect(() => { // Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = async () => { const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const stored = localStorage.getItem('lang'); const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
// Ignore a stored locale that isn't available (e.g. trimmed from this build). localStorage.setItem('lang', newLocale);
const newLocale = setLocale(newLocale);
stored && AVAILABLE_LOCALES.includes(stored as Locales) await loadLocaleAsync(newLocale);
? (stored as Locales) setWasLoaded(true);
: browserLocale;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
};
void initializeLocale();
}, []); }, []);
useEffect(() => {
void initializeLocale();
}, [initializeLocale]);
if (!wasLoaded) return null; if (!wasLoaded) return null;
return ( return (
<TypesafeI18n locale={locale}> <TypesafeI18n locale={locale}>
<CustomTheme> <CustomTheme>
<Authentication> <AppRouting />
<Outlet /> <ToastContainer {...TOAST_CONTAINER_PROPS} />
</Authentication>
<Toaster />
</CustomTheme> </CustomTheme>
</TypesafeI18n> </TypesafeI18n>
); );
+72
View File
@@ -0,0 +1,72 @@
import { type FC, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
interface SecurityRedirectProps {
readonly message: string;
readonly signOut?: boolean;
}
const RootRedirect: FC<SecurityRedirectProps> = memo(
({ message, signOut = false }) => {
const { signOut: contextSignOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const { LL } = useI18nContext();
return (
<Authentication>
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Authentication>
);
});
export default AppRouting;
+79
View File
@@ -0,0 +1,79 @@
import { memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
import Devices from 'app/main/Devices';
import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import UserProfile from 'app/main/UserProfile';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings';
import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus';
import Activity from 'app/status/Activity';
import HardwareStatus from 'app/status/HardwareStatus';
import MqttStatus from 'app/status/MqttStatus';
import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = memo(() => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} />
<Route path="/sensors/*" element={<Sensors />} />
<Route path="/help/*" element={<Help />} />
<Route path="/user/*" element={<UserProfile />} />
<Route path="/status/*" element={<Status />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Layout>
);
});
export default AuthenticatedRouting;
+17 -12
View File
@@ -1,4 +1,5 @@
import { memo, useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward'; import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material'; import { Box, Button, Paper, Typography } from '@mui/material';
@@ -6,19 +7,18 @@ import type { Theme } from '@mui/material/styles';
import * as AuthenticationApi from 'components/routing/authentication'; import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { import {
LanguageSelector, LanguageSelector,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication'; import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env'; import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { SignInRequest } from 'types'; import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils'; import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators'; import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SignIn = memo(() => { const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
@@ -43,13 +43,18 @@ const SignIn = memo(() => {
} }
}); });
const updateLoginRequestValue = updateValue((updater) => // Memoize callback to prevent recreation on every render
setSignInRequest( const updateLoginRequestValue = useMemo(
updater as unknown as (prevState: SignInRequest) => SignInRequest () =>
) updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
); );
const signIn = async () => { const signIn = useCallback(async () => {
await callSignIn(signInRequest).catch((event: Error) => { await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') { if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN()); toast.warning(LL.INVALID_LOGIN());
@@ -58,9 +63,9 @@ const SignIn = memo(() => {
} }
setProcessing(false); setProcessing(false);
}); });
}; }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = async () => { const validateAndSignIn = useCallback(async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({ SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s') required: LL.IS_REQUIRED('%s')
@@ -72,9 +77,9 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false); setProcessing(false);
} }
}; }, [signInRequest, signIn, LL]);
const submitOnEnter = onEnterCallback(signIn); const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
// get rid of scrollbar // get rid of scrollbar
useEffect(() => { useEffect(() => {
+2 -21
View File
@@ -4,8 +4,6 @@ import type {
APIcall, APIcall,
Action, Action,
Activity, Activity,
CommandItem,
Commands,
CoreData, CoreData,
DashboardData, DashboardData,
DeviceData, DeviceData,
@@ -104,7 +102,8 @@ export const readSchedule = () =>
o_deleted: si.deleted, o_deleted: si.deleted,
o_flags: si.flags, o_flags: si.flags,
o_time: si.time, o_time: si.time,
o_cmd_name: si.cmd_name, o_cmd: si.cmd,
o_value: si.value,
o_name: si.name o_name: si.name
})); }));
} }
@@ -112,24 +111,6 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) => export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data); alovaInstance.Post('/rest/schedule', data);
// Commands
export const readCommands = () =>
alovaInstance.Get<CommandItem[]>('/rest/commands', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
const commands = (data as Commands).commands;
return commands.map((ci) => ({
...ci,
o_id: ci.id,
o_cmd: ci.cmd,
o_value: ci.value,
o_name: ci.name
}));
}
});
export const writeCommands = (data: Commands) =>
alovaInstance.Post('/rest/commands', data);
// Modules // Modules
export const readModules = () => export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', { alovaInstance.Get<ModuleItem[]>('/rest/modules', {
+9
View File
@@ -57,3 +57,12 @@ export const alovaInstance = createAlova({
onSuccess: handleResponse onSuccess: handleResponse
} }
}); });
export const alovaInstanceGH = createAlova({
baseURL:
process.env.NODE_ENV === 'development'
? '/gh'
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});
+25 -2
View File
@@ -1,8 +1,8 @@
import type { LogSettings, SystemStatus } from 'types'; import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance } from './endpoints'; import { alovaInstance, alovaInstanceGH } from './endpoints';
// systemStatus - also used in System Monitor to ping the ESP // systemStatus - also used to ping in System Monitor for pinging
export const readSystemStatus = () => export const readSystemStatus = () =>
alovaInstance.Get<SystemStatus>('/rest/systemStatus'); alovaInstance.Get<SystemStatus>('/rest/systemStatus');
@@ -13,6 +13,29 @@ export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data); alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log'); export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from GitHub
// cache for 10 minutes to stop getting the IP blocked by GitHub
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.substring(1),
published_at: response.data.published_at
};
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
published_at: response.data.published_at
};
}
});
const UPLOAD_TIMEOUT = 60000; // 1 minute const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => { export const uploadFile = (file: File) => {
-284
View File
@@ -1,284 +0,0 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, writeCommands } from '../../api/app';
import CommandsDialog from './CommandsDialog';
import type { CommandItem, Commands as CommandsType } from './types';
import { commandItemValidation } from './validators';
const INTERVAL_DELAY = 30000;
const MIN_ID = -100;
const MAX_ID = 100;
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
cmd: '',
value: '',
name: '',
deleted: false
};
const commandsTheme = {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const Commands = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedItem, setSelectedItem] = useState<CommandItem>();
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.COMMANDS());
const {
data: commands,
send: fetchCommands,
error
} = useRequest(readCommands, {
initialData: []
});
const { send: updateCommands } = useRequest(
(data: CommandsType) => writeCommands(data),
{ immediate: false }
);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useInterval(() => {
if (numChanges === 0) {
void fetchCommands();
}
}, INTERVAL_DELAY);
const theme = useTheme(commandsTheme);
const saveCommands = async () => {
try {
await updateCommands({
commands: commands
.filter((ci: CommandItem) => !ci.deleted)
.map((ci: CommandItem) => ({
id: ci.id,
cmd: ci.cmd,
value: ci.value,
name: ci.name
}))
});
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchCommands();
setNumChanges(0);
}
};
const editItem = (ci: CommandItem) => {
setCreating(false);
setSelectedItem(ci);
setDialogOpen(true);
if (ci.o_name === undefined) {
ci.o_name = ci.name;
}
};
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchCommands().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: CommandItem) => {
setDialogOpen(false);
void updateState(readCommands(), (data: CommandItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((ci) =>
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
);
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
return new_data;
});
};
const addItem = () => {
setCreating(true);
const newItem: CommandItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_COMMAND_ITEM
};
setSelectedItem(newItem);
setDialogOpen(true);
};
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
const renderCommands = () => {
if (!commands) {
return (
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{ nodes: filteredCommands }}
theme={theme}
layout={{ custom: true }}
>
{(tableList: CommandItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ci: CommandItem) => (
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
<Cell>{ci.cmd}</Cell>
<Cell>{ci.value}</Cell>
<Cell>{ci.name}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
{LL.COMMANDS_HELP_1()}.
</Typography>
{renderCommands()}
{selectedItem && (
<CommandsDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedItem}
validator={commandItemValidation(commands, selectedItem)}
/>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Box sx={{ flexGrow: 1 }}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveCommands}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addItem}
>
{LL.ADD(0)}
</Button>
</ButtonRow>
</Box>
</Box>
</SectionContent>
);
};
export default Commands;
-206
View File
@@ -1,206 +0,0 @@
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { CommandItem } from './types';
interface CommandsDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ci: CommandItem) => void;
selectedItem: CommandItem;
validator: Schema;
}
const CommandsDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator
}: CommandsDialogProps) => {
const { LL } = useI18nContext();
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useEffect(() => {
setHasChanges(hasChanged(editItem));
}, [editItem]);
const handleSave = async (itemToSave: CommandItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
} finally {
// setHasChanges(false);
}
};
const save = async () => {
await handleSave(editItem);
};
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeCommand(editItem.name);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ` : LL.UPDATE() + '/' + LL.RUN()}&nbsp;
{LL.COMMAND(0)}
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
value={editItem.name}
fullWidth
margin="normal"
onChange={updateFormValue}
/>
</DialogContent>
<DialogActions>
{!creating && (
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
{hasChanges && (
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
)}
{!creating && !hasChanges && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={execute}
color="success"
>
{LL.RUN()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default CommandsDialog;
+72 -50
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import 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';
@@ -25,7 +26,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -57,18 +57,20 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
useInterval(() => { const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}); }, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
const hasEntityChanged = (ei: EntityItem) => { const hasEntityChanged = useCallback((ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -84,19 +86,21 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted || ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '') (ei.value || '') !== (ei.o_value || '')
); );
}; }, []);
const entity_theme = useTheme({ const entity_theme = useMemo(
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;
} }
@@ -116,7 +120,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;
@@ -125,7 +129,7 @@ const CustomEntities = () => {
height: 36px; height: 36px;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -136,9 +140,11 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}); }),
[]
);
const saveEntities = async () => { const saveEntities = useCallback(async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -167,41 +173,44 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}; }, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = (ei: EntityItem) => { const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogCancel = async () => { const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => { const onDialogSave = useCallback(
setDialogOpen(false); (updatedItem: EntityItem) => {
void updateState(readCustomEntities(), (data: EntityItem[]) => { setDialogOpen(false);
const new_data = creating void updateState(readCustomEntities(), (data: EntityItem[]) => {
? [ const new_data = creating
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), ? [
updatedItem ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
] updatedItem
: data.map((ei) => ]
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei : data.map((ei) =>
); ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); );
return new_data; setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
}); return new_data;
}; });
},
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => { const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -219,9 +228,9 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const addEntityItem = () => { const addEntityItem = useCallback(() => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,27 +248,30 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const formatValue = (value: unknown, uom: number) => { const formatValue = useCallback((value: unknown, uom: number) => {
return value === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + ? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}; }, []);
const showHex = (value: number, digit: number) => { const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}; }, []);
const filteredAndSortedEntities = const filteredAndSortedEntities = useMemo(
entities () =>
?.filter((ei: EntityItem) => !ei.deleted) entities
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? []; ?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = () => { const renderEntity = useCallback(() => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -316,7 +328,17 @@ const CustomEntities = () => {
)} )}
</Table> </Table>
); );
}; }, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return ( return (
<SectionContent> <SectionContent>
+37 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -23,12 +23,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types'; import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types'; import type { EntityItem } from './types';
@@ -68,10 +68,14 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem); const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue( const updateFormValue = useMemo(
setEditItem as unknown as React.Dispatch< () =>
React.SetStateAction<Record<string, unknown>> updateValue(
> setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -101,16 +105,16 @@ const CustomEntitiesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown' if (reason !== 'backdropClick') {
) => { onClose();
if (reason !== 'backdropClick') { }
onClose(); },
} [onClose]
}; );
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -134,21 +138,27 @@ const CustomEntitiesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const remove = () => { const remove = useCallback(() => {
onSave({ ...editItem, deleted: true }); const itemWithDeleted = { ...editItem, deleted: true };
}; onSave(itemWithDeleted);
}, [editItem, onSave]);
const dup = () => { const dup = useCallback(() => {
onDup(editItem); onDup(editItem);
}; }, [editItem, onDup]);
const uomMenuItems = DeviceValueUOM_s.map((val, i) => ( // Memoize UOM menu items to avoid recreating on every render
<MenuItem key={val} value={i}> const uomMenuItems = useMemo(
{val} () =>
</MenuItem> DeviceValueUOM_s.map((val, i) => (
)); <MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
+85 -60
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
@@ -45,7 +46,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { import {
@@ -171,17 +171,19 @@ const Customizations = () => {
); );
}; };
const entities_theme = useTheme({ const entities_theme = useMemo(
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;
} }
@@ -192,7 +194,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;
@@ -204,7 +206,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;
@@ -220,7 +222,7 @@ const Customizations = () => {
background-color: #177ac9; background-color: #177ac9;
} }
`, `,
Cell: ` Cell: `
&:nth-of-type(2) { &:nth-of-type(2) {
padding: 8px; padding: 8px;
} }
@@ -234,7 +236,9 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}); }),
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -283,23 +287,26 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = (de: DeviceEntity) => { const isCommand = useCallback((de: DeviceEntity) => {
return de.n && de.n[0] === '!'; return de.n && de.n[0] === '!';
}; }, []);
const formatName = (de: DeviceEntity, withShortname: boolean) => { const formatName = useCallback(
let name: string; (de: DeviceEntity, withShortname: boolean) => {
if (isCommand(de)) { let name: string;
name = de.t if (isCommand(de)) {
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` name = de.t
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
} else if (de.cn && de.cn !== '') { : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
name = de.t ? `${de.t} ${de.cn}` : de.cn; } else if (de.cn && de.cn !== '') {
} else { name = de.t ? `${de.t} ${de.cn}` : de.cn;
name = de.t ? `${de.t} ${de.n}` : de.n || ''; } else {
} name = de.t ? `${de.t} ${de.n}` : de.n || '';
return withShortname ? `${name} ${de.id}` : name; }
}; return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -329,27 +336,33 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = (de: DeviceEntity) => const filter_entity = useCallback(
(de.m & selectedFilters || !selectedFilters) && (de: DeviceEntity) =>
formatName(de, true).toLowerCase().includes(search.toLowerCase()); (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => { const maskDisabled = useCallback(
setDeviceEntities((prev) => (set: boolean) => {
prev.map((de) => { setDeviceEntities((prev) =>
if (filter_entity(de)) { prev.map((de) => {
const excludeMask = if (filter_entity(de)) {
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; const excludeMask =
return { DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
...de, return {
m: set ? de.m | excludeMask : de.m & ~excludeMask ...de,
}; m: set ? de.m | excludeMask : de.m & ~excludeMask
} };
return de; }
}) return de;
); })
}; );
},
[filter_entity]
);
const resetCustomization = async () => { const resetCustomization = useCallback(async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -359,27 +372,30 @@ const Customizations = () => {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true); setRestarting(true);
} }
}; }, [sendResetCustomizations, LL]);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
(prev) => (prev) =>
prev?.map((de) => prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? [] ) ?? []
); );
}; }, []);
const onDialogSave = (updatedItem: DeviceEntity) => { const onDialogSave = useCallback(
setDialogOpen(false); (updatedItem: DeviceEntity) => {
updateDeviceEntity(updatedItem); setDialogOpen(false);
}; updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = (de: DeviceEntity) => { const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
return; return;
} }
@@ -390,9 +406,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const saveCustomization = async () => { const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return; return;
} }
@@ -425,9 +441,9 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
}; }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
const renameDevice = async () => { const renameDevice = useCallback(async () => {
await sendDeviceName({ await sendDeviceName({
id: selectedDevice, id: selectedDevice,
name: selectedDeviceName, name: selectedDeviceName,
@@ -443,7 +459,14 @@ const Customizations = () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}; }, [
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -489,8 +512,7 @@ const Customizations = () => {
</MenuItem> </MenuItem>
{devices.devices.map( {devices.devices.map(
(device: Device) => (device: Device) =>
device.id < 90 && device.id < 90 && (
device.e !== 0 && (
<MenuItem key={device.id} value={device.id}> <MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn}) {device.n}&nbsp;({device.tn})
</MenuItem> </MenuItem>
@@ -540,7 +562,10 @@ const Customizations = () => {
</> </>
); );
const filteredEntities = deviceEntities.filter((de) => filter_entity(de)); const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => { const renderDeviceData = () => {
return ( return (
+42 -32
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -57,16 +57,23 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue( const updateFormValue = useMemo(
setEditItem as unknown as React.Dispatch< () =>
React.SetStateAction<Record<string, unknown>> updateValue(
> setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
const isWriteableNumber = const isWriteableNumber = useMemo(
typeof editItem.v === 'number' && () =>
editItem.w && typeof editItem.v === 'number' &&
!(editItem.m & DeviceEntityMask.DV_READONLY); editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -75,16 +82,16 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown' if (reason !== 'backdropClick') {
) => { onClose();
if (reason !== 'backdropClick') { }
onClose(); },
} [onClose]
}; );
const save = () => { const save = useCallback(() => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
@@ -95,31 +102,34 @@ const CustomizationsDialog = ({
} else { } else {
onSave(editItem); onSave(editItem);
} }
}; }, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}; }, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue <LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`} label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n} value={editItem.n}
/> />
<LabelValue <LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}> <Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
+101 -104
View File
@@ -1,12 +1,12 @@
import { memo, useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IconContext } from 'react-icons/lib'; import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined'; import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import { import {
@@ -29,7 +29,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils'; import { useInterval, usePersistState } from 'utils';
@@ -76,38 +75,42 @@ const Dashboard = memo(() => {
{ {
immediate: false immediate: false
} }
) );
.onSuccess(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const deviceValueDialogSave = useCallback(
if (!selectedDashboardItem) { async (devicevalue: DeviceValue) => {
return; if (!selectedDashboardItem) {
} return;
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction() }
if (devicevalue.v !== undefined) {
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(() => {
setDeviceValueDialogOpen(false); toast.success(LL.WRITE_CMD_SENT());
setSelectedDashboardItem(undefined); })
}; .catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useTheme({ const dashboard_theme = useMemo(
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 {
@@ -117,7 +120,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;
} }
@@ -125,7 +128,9 @@ const Dashboard = memo(() => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: [...data.nodes] },
@@ -159,71 +164,79 @@ const Dashboard = memo(() => {
} }
}); });
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => { useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll showAll
? tree.fns.onAddAll(nodeIds) // expand tree ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
const showType = (n?: string, t?: number) => { const showType = useCallback(
// if we have a name show it (n?: string, t?: number) => {
if (n) { // if we have a name show it
return n; if (n) {
} return n;
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.COMMAND:
return LL.COMMANDS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
} }
} if (t) {
return ''; // otherwise pick translation based on type
}; switch (t) {
case DeviceType.CUSTOM:
const showName = (di: DashboardItem) => { return LL.CUSTOM_ENTITIES(0);
if (di.id < 100) { case DeviceType.ANALOGSENSOR:
// if its a device (parent node) and has entities return LL.ANALOG_SENSORS();
if (di.nodes?.length) { case DeviceType.TEMPERATURESENSOR:
return ( return LL.TEMP_SENSORS();
<span style={{ fontSize: '15px' }}> case DeviceType.SCHEDULER:
<DeviceIcon type_id={di.t ?? 0} /> return LL.SCHEDULER();
&nbsp;&nbsp;{showType(di.n, di.t)} default:
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span> break;
</span> }
);
} }
} return '';
if (di.dv) { },
return <span>{di.dv.id.slice(2)}</span>; [LL]
} );
return null;
};
const hasMask = (id: string, mask: number) => const showName = useCallback(
(parseInt(id.slice(0, 2), 16) & mask) === mask; (di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
},
[showType]
);
const editDashboardValue = (di: DashboardItem) => { const hasMask = useCallback(
// don't execute on parent nodes (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
if (!me.admin || di.id <= 99) { []
return; );
}
if (di.dv?.c) { const editDashboardValue = useCallback(
setSelectedDashboardItem(di); (di: DashboardItem) => {
setDeviceValueDialogOpen(true); if (me.admin && di.dv?.c) {
} setSelectedDashboardItem(di);
}; setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
const handleShowAll = ( const handleShowAll = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
@@ -235,9 +248,10 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = data.nodes.filter( const hasFavEntities = useMemo(
(item: DashboardItem) => item.id <= 90 () => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
).length; [data.nodes]
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {
@@ -330,34 +344,20 @@ const Dashboard = memo(() => {
<Cell> <Cell>
{me.admin && {me.admin &&
di.dv?.c && di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && !hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
(di.dv.v === '' || di.dv.v === undefined ? (
<IconButton
size="small"
aria-label={LL.RUN()}
onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
>
<PlayArrowIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
) : (
<IconButton <IconButton
size="small" size="small"
aria-label={ aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0) LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
} }
onClick={() => editDashboardValue(di)} onClick={() => editDashboardValue(di)}
style={{ backgroundColor: 'transparent' }}
> >
<EditIcon <EditIcon
color="primary" color="primary"
sx={{ fontSize: 16 }} sx={{ fontSize: 16 }}
/> />
</IconButton> </IconButton>
))} )}
</Cell> </Cell>
</> </>
) : ( ) : (
@@ -402,10 +402,7 @@ const Dashboard = memo(() => {
{selectedDashboardItem && selectedDashboardItem.dv && ( {selectedDashboardItem && selectedDashboardItem.dv && (
<DevicesDialog <DevicesDialog
open={deviceValueDialogOpen} open={deviceValueDialogOpen}
onClose={() => { onClose={() => setDeviceValueDialogOpen(false)}
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
}}
onSave={deviceValueDialogSave} onSave={deviceValueDialogSave}
selectedItem={selectedDashboardItem.dv} selectedItem={selectedDashboardItem.dv}
writeable={true} writeable={true}
-1
View File
@@ -38,7 +38,6 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.CUSTOM]: MdPlaylistAdd, [DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors, [DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null, [DeviceType.SYSTEM]: null,
[DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime, [DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors, [DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan [DeviceType.VENTILATION]: PiFan
+122 -113
View File
@@ -4,10 +4,12 @@ import {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import ConstructionIcon from '@mui/icons-material/Construction'; import ConstructionIcon from '@mui/icons-material/Construction';
@@ -63,7 +65,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -132,19 +133,21 @@ const Devices = memo(() => {
}; };
}, []); }, []);
const leftOffset = () => { const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0; if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect(); const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0; if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}; }, []);
const common_theme = useTheme({ const common_theme = useMemo(
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;
@@ -152,7 +155,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 {
@@ -162,78 +165,88 @@ const Devices = memo(() => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}); }),
[]
);
const device_theme = useTheme([ const device_theme = useMemo(
common_theme, () =>
{ useTheme([
BaseRow: ` common_theme,
font-size: 15px; {
.td { BaseRow: `
height: 28px; font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
`, `,
Table: ` BaseRow: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; .td {
`, height: 32px;
HeaderRow: ` }
.th { `,
padding: 8px; BaseCell: `
`, &:nth-of-type(1) {
Row: ` border-left: 1px solid #177ac9;
&: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 { ]),
height: 32px; [common_theme]
} );
`,
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) {
@@ -332,8 +345,10 @@ const Devices = memo(() => {
return sc; return sc;
}; };
const hasMask = (id: string, mask: number) => const hasMask = useCallback(
(parseInt(id.slice(0, 2), 16) & mask) === mask; (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
@@ -558,26 +573,16 @@ const Devices = memo(() => {
<CircularProgress sx={{ margin: 1 }} size={18} /> <CircularProgress sx={{ margin: 1 }} size={18} />
)} )}
{tableList.map((device: Device) => ( {tableList.map((device: Device) => (
<Row key={device.id} item={device} disabled={device.e === 0}> <Row key={device.id} item={device}>
<Cell> <Cell>
<DeviceIcon type_id={device.t} /> <DeviceIcon type_id={device.t} />
&nbsp;&nbsp; &nbsp;&nbsp;
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}> {device.n}
{device.n} <span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span> </span>
{device.e !== 0 && (
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
)}
</Cell>
<Cell stiff>
<ButtonTooltip
title={`DeviceID: 0x${('00' + device.d.toString(16).toUpperCase()).slice(-2)}, ProductID: ${device.p}`}
>
<span>{device.tn}</span>
</ButtonTooltip>
</Cell> </Cell>
<Cell stiff>{device.tn}</Cell>
</Row> </Row>
))} ))}
</Body> </Body>
@@ -592,7 +597,6 @@ const Devices = memo(() => {
const deviceValueDialogClose = () => { const deviceValueDialogClose = () => {
setDeviceValueDialogOpen(false); setDeviceValueDialogOpen(false);
setSelectedDeviceValue(undefined);
if (selectedDevice !== undefined) { if (selectedDevice !== undefined) {
void sendDeviceData(selectedDevice); void sendDeviceData(selectedDevice);
} }
@@ -603,35 +607,41 @@ const Devices = memo(() => {
return; return;
} }
const showDeviceValue = (dv: DeviceValue) => { const showDeviceValue = useCallback((dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}; }, []);
const renderNameCell = (dv: DeviceValue) => ( const renderNameCell = useCallback(
<> (dv: DeviceValue) => (
{dv.id.slice(2)}&nbsp; <>
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( {dv.id.slice(2)}&nbsp;
<StarIcon color="primary" sx={{ fontSize: 12 }} /> {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
)} <StarIcon color="primary" sx={{ fontSize: 12 }} />
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( )}
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
)} <EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( )}
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
)} <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
</> )}
</>
),
[hasMask]
); );
const shown_data = onlyFav const shown_data = useMemo(() => {
? deviceData.nodes.filter( if (onlyFav) {
return deviceData.nodes.filter(
(dv: DeviceValue) => (dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
)
: deviceData.nodes.filter((dv: DeviceValue) =>
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
@@ -788,7 +798,6 @@ const Devices = memo(() => {
<IconButton <IconButton
size="small" size="small"
onClick={() => showDeviceValue(dv)} onClick={() => showDeviceValue(dv)}
style={{ backgroundColor: 'transparent' }}
> >
{dv.v === '' ? ( {dv.v === '' ? (
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} /> <PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
+105 -126
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
@@ -19,18 +18,15 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { DeviceValueUOM, DeviceValueUOM_s, getDeviceValueKind } from './types'; import { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types'; import type { DeviceValue } from './types';
interface DevicesDialogProps { interface DevicesDialogProps {
@@ -56,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -65,48 +61,36 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const { send: executeCommand } = useRequest( const save = useCallback(async () => {
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const doAction = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
if (editItem.v === undefined && editItem.c !== undefined) { await validate(validator, editItem);
await executeCommand(editItem.c);
} else {
await validate(validator, editItem);
}
onSave(editItem); onSave(editItem);
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const setUom = (uom?: DeviceValueUOM) => { const setUom = useCallback(
if (uom === undefined) { (uom?: DeviceValueUOM) => {
return; if (uom === undefined) {
} return;
switch (uom) { }
case DeviceValueUOM.HOURS: switch (uom) {
return LL.HOURS(); case DeviceValueUOM.HOURS:
case DeviceValueUOM.MINUTES: return LL.HOURS();
return LL.MINUTES(); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.SECONDS: return LL.MINUTES();
return LL.SECONDS(); case DeviceValueUOM.SECONDS:
default: return LL.SECONDS();
return DeviceValueUOM_s[uom]; default:
} return DeviceValueUOM_s[uom];
}; }
},
[LL]
);
const showHelperText = (dv: DeviceValue) => { const showHelperText = useCallback((dv: DeviceValue) => {
if (dv.h) return dv.h; if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | '); if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) { if (dv.m !== undefined && dv.x !== undefined) {
@@ -117,23 +101,26 @@ const DevicesDialog = ({
); );
} }
return undefined; return undefined;
}; }, []);
const kind = getDeviceValueKind(selectedItem); const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle = const dialogTitle = useMemo(() => {
kind === 'command' || kind === 'deviceCommand' if (isCommand) return LL.RUN_COMMAND();
? LL.RUN() + ' ' + LL.COMMAND(0) return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
: kind === 'scheduler' }, [isCommand, writeable, LL]);
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0);
const buttonLabel = const buttonLabel = useMemo(() => {
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE(); return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = showHelperText(editItem); const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0); const valueLabel = LL.VALUE(0);
@@ -144,69 +131,67 @@ const DevicesDialog = ({
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{editItem.id.slice(2)} {editItem.id.slice(2)}
</Typography> </Typography>
{kind !== 'command' && ( <Grid container>
<Grid container> <Grid size={12}>
<Grid size={12}> {editItem.l ? (
{editItem.l ? ( <TextField
<TextField name="v"
name="v" value={editItem.v}
value={editItem.v} aria-label={valueLabel}
aria-label={valueLabel} disabled={!writeable}
disabled={!writeable} sx={{ width: '30ch' }}
sx={{ width: '30ch' }} select
select onChange={updateFormValue}
onChange={updateFormValue} >
> {editItem.l.map((val) => (
{editItem.l.map((val) => ( <MenuItem value={val} key={val}>
<MenuItem value={val} key={val}> {val}
{val} </MenuItem>
</MenuItem> ))}
))} </TextField>
</TextField> ) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? ( <ValidatedTextField
<ValidatedTextField fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors || {}} name="v"
name="v" label={valueLabel}
label={valueLabel} value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)} autoFocus
autoFocus disabled={!writeable}
disabled={!writeable} type="number"
type="number" sx={{ width: '30ch' }}
sx={{ width: '30ch' }} onChange={updateFormValue}
onChange={updateFormValue} slotProps={{
slotProps={{ htmlInput: editItem.s
htmlInput: editItem.s ? { min: editItem.m, max: editItem.x, step: editItem.s }
? { min: editItem.m, max: editItem.x, step: editItem.s } : {},
: {}, input: {
input: { startAdornment: (
startAdornment: ( <InputAdornment position="start">
<InputAdornment position="start"> {setUom(editItem.u)}
{setUom(editItem.u)} </InputAdornment>
</InputAdornment> )
) }
} }}
}} />
/> ) : (
) : ( <ValidatedTextField
<ValidatedTextField fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors || {}} name="v"
name="v" label={valueLabel}
label={valueLabel} value={editItem.v}
value={editItem.v} disabled={!writeable}
disabled={!writeable} sx={{ width: '30ch' }}
sx={{ width: '30ch' }} multiline={!editItem.u}
multiline={!editItem.u} onChange={updateFormValue}
onChange={updateFormValue} />
/>
)}
</Grid>
{writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)} )}
</Grid> </Grid>
)} {writeable && helperText && (
<Grid>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -228,16 +213,10 @@ const DevicesDialog = ({
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
startIcon={ startIcon={<WarningIcon color="warning" />}
kind === 'command' ? (
<PlayArrowIcon />
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
<WarningIcon color="warning" />
) : undefined
}
variant="outlined" variant="outlined"
onClick={doAction} onClick={save}
color={kind !== 'value' ? 'success' : 'primary'} color="primary"
> >
{buttonLabel} {buttonLabel}
</Button> </Button>
+98 -67
View File
@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
@@ -9,6 +11,7 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
// Available mask values
const MASK_VALUES = [ const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -17,95 +20,123 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128 DeviceEntityMask.DV_DELETED // 128
]; ];
const getMaskNumber = (newMask: string[]): number => /**
newMask.reduce((mask, entry) => mask | Number(entry), 0); * Converts an array of mask strings to a bitmask number
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
const getMaskString = (mask: number): string[] => /**
MASK_VALUES.filter((value) => (mask & value) === value).map((value) => * Converts a bitmask number to an array of mask strings
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value) String(value)
); );
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = (_event: unknown, mask: string[]) => { const handleChange = useCallback(
const newMask = getMaskNumber(mask); (_event: unknown, mask: string[]) => {
const updatedDe = { ...de }; // Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// If entity has no name and is set to readonly, also exclude from web // Apply business logic for mask interactions
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { // If entity has no name and is set to readonly, also exclude from web
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
} else { updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
updatedDe.m = newMask; } else {
} 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={getMaskString(de.m)} value={maskStringValue}
onChange={handleChange} onChange={handleChange}
> >
<ToggleButton <ToggleButton value="8" disabled={isFavoriteDisabled}>
value="8" <OptionIcon type="favorite" isSet={isFavoriteSet} />
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="4" disabled={isReadonlyDisabled}>
value="4" <OptionIcon type="readonly" isSet={isReadonlySet} />
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
value="2" <OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton value="1" disabled={isWebExcludeDisabled}>
value="1" <OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon <OptionIcon type="deleted" isSet={isDeletedSet} />
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );
+52 -27
View File
@@ -1,5 +1,6 @@
import { memo, useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone'; import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -24,7 +25,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
@@ -60,8 +60,6 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9' bgcolor: '#72caf9'
}; };
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => { const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
@@ -74,7 +72,12 @@ const HelpComponent = () => {
}); });
const [imgError, setImgError] = useState<boolean>(false); const [imgError, setImgError] = useState<boolean>(false);
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => { const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) { if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as { const { Support } = event.data as {
Support: { img_url?: string; html?: string[] }; Support: { img_url?: string; html?: string[] };
@@ -97,26 +100,47 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
const helpLinks: HelpLink[] = [ // Optimize API call memoization
{ const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
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 imageSrc = const handleDownloadSystemInfo = useCallback(() => {
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url; void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <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>
@@ -133,13 +157,13 @@ const HelpComponent = () => {
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={IMAGE_STYLES} sx={IMAGE_STYLES}
onError={() => setImgError(true)} onError={handleImageError}
src={imageSrc} src={imageSrc}
/> />
</Stack> </Stack>
)} )}
{me?.admin && ( {isAdmin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <ListItem key={href}>
@@ -167,7 +191,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => void sendAPI(SYSTEM_INFO_API)} onClick={handleDownloadSystemInfo}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -190,6 +214,7 @@ const HelpComponent = () => {
); );
}; };
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent); const Help = memo(HelpComponent);
export default Help; export default Help;
+78 -60
View File
@@ -1,5 +1,6 @@
import { memo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle'; import CircleIcon from '@mui/icons-material/Circle';
@@ -24,7 +25,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app'; import { readModules, writeModules } from '../../api/app';
@@ -69,53 +69,58 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme({ const modules_theme = useTheme(
Table: ` useMemo(
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; () => ({
`, Table: `
BaseRow: ` --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
font-size: 14px; `,
.td { BaseRow: `
height: 32px; font-size: 14px;
} .td {
`, height: 32px;
BaseCell: ` }
&:nth-of-type(1) { `,
text-align: center; BaseCell: `
} &:nth-of-type(1) {
`, text-align: center;
HeaderRow: ` }
text-transform: uppercase; `,
background-color: black; HeaderRow: `
color: #90CAF9; text-transform: uppercase;
.th { background-color: black;
border-bottom: 1px solid #565656; color: #90CAF9;
height: 36px; .th {
} border-bottom: 1px solid #565656;
`, height: 36px;
Row: ` }
background-color: #1e1e1e; `,
position: relative; Row: `
cursor: pointer; background-color: #1e1e1e;
.td { position: relative;
border-top: 1px solid #565656; cursor: pointer;
border-bottom: 1px solid #565656; .td {
} border-top: 1px solid #565656;
&:hover .td { border-bottom: 1px solid #565656;
border-top: 1px solid #177ac9; }
border-bottom: 1px solid #177ac9; &:hover .td {
} border-top: 1px solid #177ac9;
&:nth-of-type(odd) .td { border-bottom: 1px solid #177ac9;
background-color: #303030; }
} &:nth-of-type(odd) .td {
` background-color: #303030;
}); }
`
}),
[]
)
);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const updateModuleItem = (updatedItem: ModuleItem) => { const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => { void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) => const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -123,25 +128,28 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length); setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data; return new_data;
}); });
}; }, []);
const onDialogSave = (updatedItem: ModuleItem) => { const onDialogSave = useCallback(
setDialogOpen(false); (updatedItem: ModuleItem) => {
updateModuleItem(updatedItem); setDialogOpen(false);
}; updateModuleItem(updatedItem);
},
[updateModuleItem]
);
const editModuleItem = (mi: ModuleItem) => { const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const onCancel = async () => { const onCancel = useCallback(async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchModules]);
const saveModules = async () => { const saveModules = useCallback(async () => {
try { try {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
@@ -159,9 +167,9 @@ const Modules = () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
} }
}; }, [modules, updateModules, LL, fetchModules]);
const renderContent = () => { const content = useMemo(() => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -254,12 +262,22 @@ const Modules = () => {
</Box> </Box>
</> </>
); );
}; }, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()} {content}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}
+17 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,10 +37,14 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue( const updateFormValue = useMemo(
setEditItem as unknown as React.Dispatch< () =>
React.SetStateAction<Record<string, unknown>> updateValue(
> 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
@@ -50,13 +54,18 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = () => { const handleSave = useCallback(() => {
onSave(editItem); onSave(editItem);
}; }, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
+92 -73
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import 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';
@@ -25,11 +26,10 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { readCommands, readSchedule, writeSchedule } from '../../api/app'; import { readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog'; import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types'; import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types'; import type { Schedule, ScheduleItem } from './types';
@@ -54,13 +54,14 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
deleted: false, deleted: false,
flags: FLAG_ALL_DAYS, flags: FLAG_ALL_DAYS,
time: '', time: '',
cmd_name: '', cmd: '',
value: '',
name: '' name: ''
}; };
const scheduleTheme = { const scheduleTheme = {
Table: ` Table: `
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px; --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -69,8 +70,11 @@ const scheduleTheme = {
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) { &:nth-of-type(1) {
text-align: 8px; text-align: center;
} }
`, `,
HeaderRow: ` HeaderRow: `
@@ -96,6 +100,7 @@ const scheduleTheme = {
}; };
const scheduleTypeLabels: Record<number, string> = { const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer', [ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition', [ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change' [ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
@@ -120,11 +125,6 @@ const Scheduler = () => {
initialData: [] initialData: []
}); });
const { data: commandNames } = useRequest(readCommands, {
initialData: [],
initializing: true
});
const { send: updateSchedule } = useRequest( const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data), (data: Schedule) => writeSchedule(data),
{ {
@@ -132,7 +132,7 @@ const Scheduler = () => {
} }
); );
const hasScheduleChanged = (si: ScheduleItem) => { const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return ( return (
si.id !== si.o_id || si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') || (si.name || '') !== (si.o_name || '') ||
@@ -140,15 +140,18 @@ const Scheduler = () => {
si.deleted !== si.o_deleted || si.deleted !== si.o_deleted ||
si.flags !== si.o_flags || si.flags !== si.o_flags ||
si.time !== si.o_time || si.time !== si.o_time ||
si.cmd_name !== si.o_cmd_name si.cmd !== si.o_cmd ||
si.value !== si.o_value
); );
}; }, []);
useInterval(() => { const intervalCallback = useCallback(() => {
if (numChanges === 0) { if (numChanges === 0) {
void fetchSchedule(); void fetchSchedule();
} }
}, INTERVAL_DELAY); }, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { const formatter = new Intl.DateTimeFormat(locale, {
@@ -166,7 +169,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme); const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = async () => { const saveSchedule = useCallback(async () => {
try { try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
@@ -176,7 +179,8 @@ const Scheduler = () => {
active: condensed_si.active, active: condensed_si.active,
flags: condensed_si.flags, flags: condensed_si.flags,
time: condensed_si.time, time: condensed_si.time,
cmd_name: condensed_si.cmd_name, cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name name: condensed_si.name
})) }))
}); });
@@ -188,43 +192,46 @@ const Scheduler = () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
} }
}; }, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = (si: ScheduleItem) => { const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) { if (si.o_name === undefined) {
si.o_name = si.name; si.o_name = si.name;
} }
}; }, []);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogCancel = async () => { const onDialogCancel = useCallback(async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchSchedule]);
const onDialogSave = (updatedItem: ScheduleItem) => { const onDialogSave = useCallback(
setDialogOpen(false); (updatedItem: ScheduleItem) => {
void updateState(readSchedule(), (data: ScheduleItem[]) => { setDialogOpen(false);
const new_data = creating void updateState(readSchedule(), (data: ScheduleItem[]) => {
? [...data, updatedItem] const new_data = creating
: data.map((si) => ? [...data, updatedItem]
si.id === updatedItem.id ? { ...si, ...updatedItem } : si : data.map((si) =>
); si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data; return new_data;
}); });
}; },
[creating, hasScheduleChanged]
);
const addScheduleItem = () => { const addScheduleItem = useCallback(() => {
setCreating(true); setCreating(true);
const newItem: ScheduleItem = { const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -232,29 +239,36 @@ const Scheduler = () => {
}; };
setSelectedScheduleItem(newItem); setSelectedScheduleItem(newItem);
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const filteredAndSortedSchedule = schedule const filteredAndSortedSchedule = useMemo(
.filter((si: ScheduleItem) => !si.deleted) () =>
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags); schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => { const dayBox = useCallback(
const dayIndex = Math.log(flag) / LOG_2; (si: ScheduleItem, flag: number) => {
const isActive = (si.flags & flag) === flag; const dayIndex = Math.log(flag) / LOG_2;
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>
{!isLast && <Divider orientation="vertical" flexItem />} <Divider orientation="vertical" flexItem />
</> </>
); );
}; },
[dow]
);
const scheduleType = (si: ScheduleItem) => { const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags]; const label = scheduleTypeLabels[si.flags];
return ( return (
@@ -264,9 +278,9 @@ const Scheduler = () => {
</Typography> </Typography>
</Box> </Box>
); );
}; }, []);
const renderSchedule = () => { const renderSchedule = useCallback(() => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -285,10 +299,9 @@ const Scheduler = () => {
<HeaderRow> <HeaderRow>
<HeaderCell /> <HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell> <HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff> <HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
{LL.TIME(0)}/{LL.CONDITION()}
</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell> <HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell> <HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow> </HeaderRow>
</Header> </Header>
@@ -298,16 +311,12 @@ const Scheduler = () => {
<Cell stiff> <Cell stiff>
<CircleIcon <CircleIcon
color={si.active ? 'success' : 'error'} color={si.active ? 'success' : 'error'}
sx={{ sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
fontSize: ICON_SIZE,
ml: '4px',
position: 'relative',
top: '2px'
}}
/> />
</Cell> </Cell>
<Cell stiff> <Cell stiff>
<Stack spacing={0.5} direction="row"> <Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > SCHEDULE_FLAG_THRESHOLD ? ( {si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si) scheduleType(si)
) : ( ) : (
@@ -318,13 +327,14 @@ const Scheduler = () => {
{dayBox(si, ScheduleFlag.SCHEDULE_THU)} {dayBox(si, ScheduleFlag.SCHEDULE_THU)}
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)} {dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)} {dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)} {dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
</> </>
)} )}
</Stack> </Stack>
</Cell> </Cell>
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell> <Cell>{si.time}</Cell>
<Cell>{si.cmd_name}</Cell> <Cell>{si.cmd}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.name}</Cell> <Cell>{si.name}</Cell>
</Row> </Row>
))} ))}
@@ -333,7 +343,17 @@ const Scheduler = () => {
)} )}
</Table> </Table>
); );
}; }, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return ( return (
<SectionContent> <SectionContent>
@@ -352,7 +372,6 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem} selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)} validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow} dow={dow}
commandNames={commandNames.map((ci) => ci.name)}
/> />
)} )}
+210 -147
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined'; import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import { import {
Box, Box,
@@ -14,7 +14,6 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid, Grid,
MenuItem,
TextField, TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
@@ -22,12 +21,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils'; import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { ScheduleFlag } from './types'; import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types'; import type { ScheduleItem } from './types';
@@ -61,12 +60,6 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT ScheduleFlag.SCHEDULE_SAT
] as const; ] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -75,7 +68,6 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem; selectedItem: ScheduleItem;
validator: Schema; validator: Schema;
dow: string[]; dow: string[];
commandNames: string[];
} }
const SchedulerDialog = ({ const SchedulerDialog = ({
@@ -85,24 +77,33 @@ const SchedulerDialog = ({
onSave, onSave,
selectedItem, selectedItem,
validator, validator,
dow, dow
commandNames
}: SchedulerDialogProps) => { }: SchedulerDialogProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem); const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue( const updateFormValue = useMemo(
setEditItem as unknown as React.Dispatch< () =>
React.SetStateAction<Record<string, unknown>> updateValue(
> setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
setEditItem(selectedItem); setEditItem(selectedItem);
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType( setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY ? ScheduleFlag.SCHEDULE_DAY
@@ -111,89 +112,129 @@ const SchedulerDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = async (itemToSave: ScheduleItem) => { // Helper function to handle save operations
try { const handleSave = useCallback(
setFieldErrors(undefined); async (itemToSave: ScheduleItem) => {
await validate(validator, itemToSave); try {
onSave(itemToSave); setFieldErrors(undefined);
} catch (error) { await validate(validator, itemToSave);
setFieldErrors((error as ValidationError).fieldErrors); onSave(itemToSave);
} } catch (error) {
}; setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const save = async () => { const save = useCallback(async () => {
await handleSave(editItem); await handleSave(editItem);
}; }, [editItem, handleSave]);
const remove = () => { const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true }); onSave({ ...editItem, deleted: true });
}; }, [editItem, onSave]);
const DayOfWeekButton = (flag: number) => { // Optimize DOW flag conversion
const dayIndex = Math.log2(flag); const getFlagDOWnumber = useCallback((flags: string[]) => {
const isSelected = (editItem.flags & flag) === flag; return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
return ( }, []);
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }} const getFlagDOWstring = useCallback((f: number) => {
color={isSelected ? 'primary' : 'grey'} return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
> String(flag)
{dow[dayIndex]}
</Typography>
); );
}; }, []);
const handleClose = ( // Day of week display component
_event: React.SyntheticEvent, const DayOfWeekButton = useCallback(
reason: 'backdropClick' | 'escapeKeyDown' (flag: number) => {
) => { const dayIndex = Math.log2(flag);
if (reason !== 'backdropClick') { const isSelected = (editItem.flags & flag) === flag;
onClose(); 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 handleScheduleTypeChange = ( const timeFieldLabel = useMemo(() => {
_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 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();
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}>
@@ -246,6 +287,14 @@ const SchedulerDialog = ({
{LL.CONDITION()} {LL.CONDITION()}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{isDaySchedule && ( {isDaySchedule && (
@@ -263,74 +312,78 @@ const SchedulerDialog = ({
</ToggleButtonGroup> </ToggleButtonGroup>
)} )}
<Grid container> {!isImmediateSchedule && (
<BlockFormControlLabel <>
control={ <Grid container>
<Checkbox <BlockFormControlLabel
checked={editItem.active} control={
onChange={updateFormValue} <Checkbox
name="active" checked={editItem.active}
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
/> />
} </Grid>
label={LL.ACTIVE()} <Grid container>
/> {needsTimeField ? (
<CircleIcon <>
color={editItem.active ? 'success' : 'error'} <TextField
sx={{ name="time"
fontSize: 16, type="time"
mt: '12px' label={timeFieldLabel}
}} value={timeFieldValue}
/> margin="normal"
</Grid> onChange={updateFormValue}
/>
<Grid container> {isTimerSchedule && (
{needsTimeField ? ( <Typography
<> sx={{ ml: 2, mt: 4 }}
<TextField color="warning"
name="time" variant="body2"
type="time" >
label={timeFieldLabel} {LL.SCHEDULER_HELP_2()}
value={timeFieldValue} </Typography>
margin="normal" )}
onChange={updateFormValue} </>
/> ) : (
{isTimerSchedule && ( <TextField
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2"> name="time"
00:00 = {LL.SCHEDULER_HELP_2()} label={timeFieldLabel}
</Typography> multiline
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)} )}
</> </Grid>
) : ( </>
<TextField )}
name="time" <ValidatedTextField
label={timeFieldLabel} fieldErrors={fieldErrors || {}}
multiline name="cmd"
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)}
</Grid>
<TextField
name="cmd_name"
label={LL.COMMAND(0)} label={LL.COMMAND(0)}
value={editItem.cmd_name} multiline
fullWidth fullWidth
select value={editItem.cmd}
margin="normal" margin="normal"
onChange={updateFormValue} onChange={updateFormValue}
> />
{commandNames.map((name) => ( <TextField
<MenuItem key={name} value={name}> name="value"
{name} label={LL.VALUE(0)}
</MenuItem> multiline
))} margin="normal"
</TextField> fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="name" name="name"
label={LL.NAME(0)} label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
value={editItem.name} value={editItem.name}
fullWidth fullWidth
margin="normal" margin="normal"
@@ -367,6 +420,16 @@ const SchedulerDialog = ({
> >
{creating ? LL.ADD(0) : LL.UPDATE()} {creating ? LL.ADD(0) : LL.UPDATE()}
</Button> </Button>
{isImmediateSchedule && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={saveandactivate}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
+290 -246
View File
@@ -1,4 +1,5 @@
import { useContext, useRef, useState } from 'react'; import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
@@ -20,7 +21,6 @@ import { useTheme } from '@table-library/react-table-library/theme';
import type { State } from '@table-library/react-table-library/types/common'; import type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
@@ -158,16 +158,18 @@ const Sensors = () => {
} }
); );
useInterval(() => { const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}); }, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = useCallback((state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -175,7 +177,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />; return <KeyboardArrowUpOutlinedIcon />;
} }
return <UnfoldMoreOutlinedIcon />; return <UnfoldMoreOutlinedIcon />;
}; }, []);
const analog_sort = useSort( const analog_sort = useSort(
{ nodes: sensorData.as }, { nodes: sensorData.as },
@@ -232,104 +234,119 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => { const formatDurationMin = useCallback(
const totalMs = duration_min * MS_PER_MINUTE; (duration_min: number) => {
const days = Math.trunc(totalMs / MS_PER_DAY); const totalMs = duration_min * MS_PER_MINUTE;
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; const days = Math.trunc(totalMs / MS_PER_DAY);
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60; const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
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 = (value: unknown, uom: DeviceValueUOM) => { const formatValue = useCallback(
if (value === undefined) { (value: unknown, uom: DeviceValueUOM) => {
return ''; if (value === undefined) {
} return '';
if (typeof value !== 'number') { }
return value as string; if (typeof value !== 'number') {
} return value as string;
switch (uom) { }
case DeviceValueUOM.HOURS: switch (uom) {
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); case DeviceValueUOM.HOURS:
case DeviceValueUOM.MINUTES: return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.SECONDS: return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
return LL.NUM_SECONDS({ num: value }); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.NONE: return LL.NUM_SECONDS({ num: value });
return new Intl.NumberFormat().format(value); case DeviceValueUOM.NONE:
case DeviceValueUOM.DEGREES: return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.DEGREES:
case DeviceValueUOM.FAHRENHEIT: case DeviceValueUOM.DEGREES_R:
return ( case DeviceValueUOM.FAHRENHEIT:
new Intl.NumberFormat(undefined, { return (
minimumFractionDigits: 1 new Intl.NumberFormat(undefined, {
}).format(value) + minimumFractionDigits: 1
' ' + }).format(value) +
DeviceValueUOM_s[uom] ' ' +
); DeviceValueUOM_s[uom]
default: );
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; default:
} return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}; }
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => { const updateTemperatureSensor = useCallback(
if (me.admin) { (ts: TemperatureSensor) => {
ts.o_n = ts.n; if (me.admin) {
setSelectedTemperatureSensor(ts); ts.o_n = ts.n;
setTemperatureDialogOpen(true); setSelectedTemperatureSensor(ts);
} setTemperatureDialogOpen(true);
}; }
},
[me.admin]
);
const onTemperatureDialogClose = () => { const onTemperatureDialogClose = useCallback(() => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}; }, [fetchSensorData]);
const onTemperatureDialogSave = async (ts: TemperatureSensor) => { const onTemperatureDialogSave = useCallback(
await sendTemperatureSensor({ async (ts: TemperatureSensor) => {
id: ts.id, await sendTemperatureSensor({
name: ts.n, id: ts.id,
offset: ts.o, name: ts.n,
is_system: ts.s offset: ts.o,
}) is_system: ts.s
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
.catch(() => { .then(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
.finally(() => { .catch(() => {
setTemperatureDialogOpen(false); toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
setSelectedTemperatureSensor(undefined); })
void fetchSensorData(); .finally(() => {
}); setTemperatureDialogOpen(false);
}; setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = (as: AnalogSensor) => { const updateAnalogSensor = useCallback(
if (me.admin) { (as: AnalogSensor) => {
setCreating(false); if (me.admin) {
as.o_n = as.n; setCreating(false);
setSelectedAnalogSensor(as); as.o_n = as.n;
setAnalogDialogOpen(true); setSelectedAnalogSensor(as);
} setAnalogDialogOpen(true);
}; }
},
[me.admin]
);
const onAnalogDialogClose = () => { const onAnalogDialogClose = useCallback(() => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}; }, [fetchSensorData]);
const addAnalogSensor = () => { const addAnalogSensor = useCallback(() => {
if (firstAvailableGPIO.current === undefined) { if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO()); toast.error(LL.NO_GPIO());
return; return;
@@ -349,167 +366,194 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}; }, []);
const onAnalogDialogSave = async (as: AnalogSensor) => { const onAnalogDialogSave = useCallback(
await sendAnalogSensor({ async (as: AnalogSensor) => {
id: as.id, await sendAnalogSensor({
gpio: as.g, id: as.id,
name: as.n, gpio: as.g,
offset: as.o, name: as.n,
factor: as.f, offset: as.o,
uom: as.u, factor: as.f,
type: as.t, uom: as.u,
deleted: as.d, type: as.t,
is_system: as.s deleted: as.d,
}) is_system: as.s
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
}) })
.catch(() => { .then(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
}) })
.finally(() => { .catch(() => {
setAnalogDialogOpen(false); toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
setSelectedAnalogSensor(undefined); })
void fetchSensorData(); .finally(() => {
}); setAnalogDialogOpen(false);
}; setSelectedAnalogSensor(undefined);
void fetchSensorData();
const RenderAnalogSensors = ( });
<Table },
data={{ nodes: sensorData.as }} [sendAnalogSensor, LL, fetchSensorData]
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 RenderTemperatureSensors = ( const RenderAnalogSensors = useMemo(
<Table () => (
data={{ nodes: sensorData.ts }} <Table
theme={temperature_theme} data={{ nodes: sensorData.as }}
sort={temperature_sort} theme={analog_theme}
layout={{ custom: true }} sort={analog_sort}
> layout={{ custom: true }}
{(tableList: TemperatureSensor[]) => ( >
<> {(tableList: AnalogSensor[]) => (
<Header> <>
<HeaderRow> <Header>
<HeaderCell resize> <HeaderRow>
<Button <HeaderCell stiff>
fullWidth <Button
style={HEADER_BUTTON_STYLE} fullWidth
endIcon={getSortIcon(temperature_sort.state, 'NAME')} style={HEADER_BUTTON_STYLE}
onClick={() => endIcon={getSortIcon(analog_sort.state, 'GPIO')}
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' }) 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)}
> >
{LL.NAME(0)} <Cell stiff>{as.g}</Cell>
</Button> <Cell>{as.n}</Cell>
</HeaderCell> <Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
<HeaderCell stiff> {(as.t === AnalogType.DIGITAL_OUT &&
<Button as.g !== GPIO_25 &&
fullWidth as.g !== GPIO_26) ||
style={HEADER_BUTTON_STYLE_END} as.t === AnalogType.DIGITAL_IN ||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')} as.t === AnalogType.PULSE ? (
onClick={() => <Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
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)}
> >
{LL.VALUE(0)} <Cell>{ts.n}</Cell>
</Button> <Cell>{formatValue(ts.t, ts.u)}</Cell>
</HeaderCell> </Row>
</HeaderRow> ))}
</Header> </Body>
<Body> </>
{tableList.map((ts: TemperatureSensor) => ( )}
<Row </Table>
style={{ color: ts.s ? 'grey' : 'inherit' }} ),
key={ts.id} [
item={ts} temperature_sort,
onClick={() => updateTemperatureSensor(ts)} temperature_theme,
> getSortIcon,
<Cell>{ts.n}</Cell> sensorData.ts,
<Cell>{formatValue(ts.t, ts.u)}</Cell> LL,
</Row> updateTemperatureSensor,
))} formatValue
</Body> ]
</>
)}
</Table>
); );
return ( return (
+94 -60
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -18,12 +18,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types'; import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types'; import type { AnalogSensor } from './types';
@@ -53,54 +53,84 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue((updater) => const updateFormValue = useMemo(
setEditItem( () =>
(prev) => updateValue((updater) =>
updater( setEditItem(
prev as unknown as Record<string, unknown> (prev) =>
) as unknown as AnalogSensor updater(
) prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
); );
const isCounterOrRate = // Memoize helper functions to check sensor type conditions
editItem.t === AnalogType.COUNTER || const isCounterOrRate = useMemo(
editItem.t === AnalogType.RATE || () =>
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); editItem.t === AnalogType.COUNTER ||
const isCounter = editItem.t === AnalogType.RATE ||
editItem.t === AnalogType.COUNTER || (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2); [editItem.t]
const isFreqType = );
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2; const isCounter = useMemo(
const isPWM = () =>
editItem.t === AnalogType.PWM_0 || editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.PWM_1 || (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
editItem.t === AnalogType.PWM_2; [editItem.t]
const isDACOutGPIO = );
editItem.t === AnalogType.DIGITAL_OUT && const isFreqType = useMemo(
(editItem.g === 25 || editItem.g === 26); () => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
const isDigitalOutGPIO = [editItem.t]
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]
);
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({ // Memoize menu items to avoid recreation on each render
name: val, const analogTypeMenuItems = useMemo(
value: i + 1 () =>
})) AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => ( .map(({ name, value }) => (
<MenuItem <MenuItem
key={name} key={name}
value={value} value={value}
disabled={disabledTypeList?.includes(value)} disabled={disabledTypeList?.includes(value)}
> >
{name} {name}
</MenuItem> </MenuItem>
)); )),
[disabledTypeList]
);
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>
)),
[]
);
const analogGPIOMenuItems = () => const analogGPIOMenuItems = () =>
// add selectedItem.g to the list // add selectedItem.g to the list
@@ -127,16 +157,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown' if (reason !== 'backdropClick') {
) => { onClose();
if (reason !== 'backdropClick') { }
onClose(); },
} [onClose]
}; );
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -144,13 +174,17 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const remove = () => { const remove = useCallback(() => {
onSave({ ...editItem, d: true }); onSave({ ...editItem, d: true });
}; }, [editItem, onSave]);
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`; const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -16,12 +16,12 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
import type { TemperatureSensor } from './types'; import type { TemperatureSensor } from './types';
@@ -50,12 +50,16 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue( const updateFormValue = useMemo(
setEditItem as unknown as ( () =>
updater: ( updateValue(
prevState: Readonly<Record<string, unknown>> setEditItem as unknown as (
) => Record<string, unknown> updater: (
) => void prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
); );
useEffect(() => { useEffect(() => {
@@ -65,13 +69,16 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = (_event: React.SyntheticEvent, reason?: string) => { const handleClose = useCallback(
if (reason !== 'backdropClick') { (_event: React.SyntheticEvent, reason?: string) => {
onClose(); if (reason !== 'backdropClick') {
} onClose();
}; }
},
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -79,11 +86,29 @@ const SensorsTemperatureDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [validator, editItem, onSave]);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -103,23 +128,12 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={numberValue(editItem.o)} value={offsetValue}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={{ slotProps={slotProps}
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/> />
</Grid> </Grid>
</Grid> </Grid>
+3 -3
View File
@@ -1,4 +1,4 @@
import { memo, useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import { import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE()); useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = () => { const handleSignOut = useCallback(() => {
signOut(true); signOut(true);
}; }, [signOut]);
return ( return (
<SectionContent> <SectionContent>
+8 -46
View File
@@ -1,5 +1,4 @@
export interface Settings { export interface Settings {
system_name: string;
locale: string; locale: string;
tx_mode: number; tx_mode: number;
ems_bus_id: number; ems_bus_id: number;
@@ -44,17 +43,7 @@ export interface Settings {
modbus_port: number; modbus_port: number;
modbus_max_clients: number; modbus_max_clients: number;
modbus_timeout: number; modbus_timeout: number;
email_enabled: boolean;
email_security: number;
email_server: string;
email_port: number;
email_login: string;
email_pass: string;
email_sender: string;
email_recp: string;
email_subject: string;
developer_mode: boolean; developer_mode: boolean;
disable_reset: boolean;
} }
export enum busConnectionStatus { export enum busConnectionStatus {
@@ -210,18 +199,6 @@ export enum DeviceValueUOM {
HERTZ HERTZ
} }
export type DeviceValueKind =
'value' | 'scheduler' | 'command' | 'deviceCommand' | 'deviceEntity';
export function getDeviceValueKind(item: DeviceValue): DeviceValueKind {
if (!item.c) return 'value';
if (item.v !== undefined && item.l !== undefined && item.u === undefined)
return 'scheduler';
if (item.v === undefined && item.l === undefined) return 'command';
if (item.u === DeviceValueUOM.NONE) return 'deviceCommand';
return 'deviceEntity';
}
export const DeviceValueUOM_s = [ export const DeviceValueUOM_s = [
'', '',
'°C', '°C',
@@ -362,19 +339,21 @@ export enum DeviceEntityMask {
} }
export interface ScheduleItem { export interface ScheduleItem {
id: number; // unique index for table id: number; // unique index
active: boolean; active: boolean;
deleted?: boolean; deleted?: boolean;
flags: number; flags: number;
time: string; // also used for Condition and On Change time: string; // also used for Condition and On Change
cmd_name: string; // references a named Command cmd: string;
name: string; value: string;
name: string; // can be empty
o_id?: number; o_id?: number;
o_active?: boolean; o_active?: boolean;
o_deleted?: boolean; o_deleted?: boolean;
o_flags?: number; o_flags?: number;
o_time?: string; o_time?: string;
o_cmd_name?: string; o_cmd?: string;
o_value?: string;
o_name?: string; o_name?: string;
} }
@@ -382,23 +361,6 @@ export interface Schedule {
readonly schedule: readonly ScheduleItem[]; readonly schedule: readonly ScheduleItem[];
} }
export interface CommandItem {
id: number;
cmd: string;
value: string;
name: string;
deleted?: boolean;
o_id?: number;
o_cmd?: string;
o_value?: string;
o_name?: string;
o_deleted?: boolean;
}
export interface Commands {
readonly commands: readonly CommandItem[];
}
export interface ModuleItem { export interface ModuleItem {
id: number; // unique index id: number; // unique index
key: string; key: string;
@@ -429,7 +391,8 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8 SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1 SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130 // bit 2 SCHEDULE_CONDITION = 130, // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
} }
export interface EntityItem { export interface EntityItem {
@@ -472,7 +435,6 @@ export const enum DeviceType {
ANALOGSENSOR = 2, ANALOGSENSOR = 2,
SCHEDULER = 3, SCHEDULER = 3,
CUSTOM = 4, CUSTOM = 4,
COMMAND = 5,
BOILER, BOILER,
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,
+3 -26
View File
@@ -1,10 +1,9 @@
import Schema from 'validators/schema'; import Schema from 'async-validator';
import type { InternalRuleItem } from 'validators/schema'; import type { InternalRuleItem } from 'async-validator';
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared'; import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type { import type {
AnalogSensor, AnalogSensor,
CommandItem,
DeviceValue, DeviceValue,
EntityItem, EntityItem,
ScheduleItem, ScheduleItem,
@@ -233,29 +232,7 @@ export const schedulerItemValidation = (
scheduleItem: ScheduleItem scheduleItem: ScheduleItem
) => ) =>
new Schema({ new Schema({
name: [ name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueNameValidator(schedule, scheduleItem.o_name)
],
cmd_name: [{ required: true, message: 'Command is required' }]
});
export const uniqueCommandNameValidator = (
commands: CommandItem[],
o_name?: string
) => createUniqueNameValidator(commands, o_name);
export const commandItemValidation = (
commands: CommandItem[],
commandItem: CommandItem
) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueCommandNameValidator(commands, commandItem.o_name)
],
cmd: [ cmd: [
{ required: true, message: 'Command is required' }, { required: true, message: 'Command is required' },
{ {
+20 -10
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -6,6 +6,7 @@ import { Button, Checkbox, MenuItem } from '@mui/material';
import * as APApi from 'api/ap'; import * as APApi from 'api/ap';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -21,9 +22,9 @@ import type { APSettingsType } from 'types';
import { APProvisionMode } from 'types'; import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createAPSettingsValidator, validate } from 'validators'; import { ValidationError, createAPSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
export const isAPEnabled = ({ provision_mode }: APSettingsType) => export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion // Efficient range function without recursion
@@ -62,16 +63,22 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty( const updateFormValue = useMemo(
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]
); );
const apEnabled = data ? isAPEnabled(data) : false; // Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
const validateAndSubmit = async () => { // Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
@@ -81,7 +88,7 @@ const APSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [data, saveData]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -101,6 +108,9 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
{LL.AP_PROVIDE_TEXT_1()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}> <MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()} {LL.AP_PROVIDE_TEXT_2()}
</MenuItem> </MenuItem>
@@ -1,4 +1,5 @@
import { useContext, useEffect, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
@@ -19,6 +20,7 @@ import { readSystemStatus } from 'api/system';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import SystemMonitor from 'app/status/SystemMonitor'; import SystemMonitor from 'app/status/SystemMonitor';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -26,16 +28,12 @@ import {
FormLoader, FormLoader,
MessageBox, MessageBox,
SectionContent, SectionContent,
ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app'; import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types'; import { BOARD_PROFILES } from '../main/types';
@@ -69,14 +67,6 @@ const ApplicationSettings = () => {
update: writeSettings update: writeSettings
}); });
// the menu drawer reads the system name from the authentication context
const { setSystemName } = useContext(AuthenticatedContext);
useEffect(() => {
if (origData?.system_name !== undefined) {
setSystemName(origData.system_name);
}
}, [origData?.system_name, setSystemName]);
const [restarting, setRestarting] = useState<boolean>(); const [restarting, setRestarting] = useState<boolean>();
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -116,79 +106,82 @@ const ApplicationSettings = () => {
}); });
}); });
const SecondsInputProps = { // Memoized input props to prevent recreation on every render
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> const SecondsInputProps = useMemo(
}; () => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = { const MinutesInputProps = useMemo(
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> () => ({
}; endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = { const HoursInputProps = useMemo(
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> () => ({
}; endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => { const updateBoardProfile = useCallback(
await readBoardProfile(board_profile).catch((error: Error) => { async (board_profile: string) => {
toast.error(error.message); await readBoardProfile(board_profile).catch((error: Error) => {
}); toast.error(error.message);
}; });
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = async () => { const validateAndSubmit = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
await saveData();
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} finally {
await saveData();
} }
}; }, [data, saveData]);
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => { const changeBoardProfile = useCallback(
const boardProfile = event.target.value; (event: React.ChangeEvent<HTMLInputElement>) => {
updateFormValue(event); const boardProfile = event.target.value;
if (boardProfile === 'CUSTOM') { updateFormValue(event);
updateDataValue({ if (boardProfile === 'CUSTOM') {
...data, updateDataValue({
board_profile: boardProfile ...data,
}); board_profile: boardProfile
} else { });
void updateBoardProfile(boardProfile); } else {
} void updateBoardProfile(boardProfile);
}; }
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => { const restart = useCallback(async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}; }, [validateAndSubmit, doRestart]);
const sendmail = async () => { // Memoize board profile select items to prevent recreation
await sendAPI({ const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
device: 'system',
cmd: 'sendmail',
data: 'Email notification test successful!',
id: 0
})
.then(() => {
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
})
.catch((error: Error) => {
toast.error(error.message);
});
};
const boardProfileItems = boardProfileSelectItems();
const content = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {
@@ -197,19 +190,7 @@ const ApplicationSettings = () => {
return ( return (
<> <>
<Typography variant="h6" color="primary"> <Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.SYSTEM(0)}&nbsp;
{LL.CUSTOMIZATIONS()}
</Typography>
<TextField
name="system_name"
label={LL.SYSTEM_NAME()}
value={data.system_name}
variant="outlined"
onChange={updateFormValue}
margin="normal"
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SERVICES()} {LL.SERVICES()}
</Typography> </Typography>
<Typography color="secondary">API</Typography> <Typography color="secondary">API</Typography>
@@ -370,148 +351,6 @@ const ApplicationSettings = () => {
</Grid> </Grid>
</Grid> </Grid>
)} )}
<Typography color="secondary">eMail</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.email_enabled}
onChange={updateFormValue}
name="email_enabled"
disabled={!hardwareData.psram}
/>
}
label={
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
Enable eMail notification
{!hardwareData.psram && (
<Typography variant="caption">
&nbsp; &#40;{LL.IS_REQUIRED('PSRAM')}&#41;
</Typography>
)}
</Typography>
}
/>
{data.email_enabled && (
<>
<Grid
container
spacing={2}
direction="row"
sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_server"
label="SMTP Server"
variant="outlined"
value={data.email_server}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
sx={{ width: '12ch' }}
name="email_port"
variant="outlined"
label="Port"
value={numberValue(data.email_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
sx={{ width: '20ch' }}
name="email_security"
label={LL.SECURITY(0)}
value={data.email_security}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>SSL</MenuItem>
<MenuItem value={2}>StartTLS</MenuItem>
</TextField>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_login"
label="Login"
variant="outlined"
value={data.email_login}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
fieldErrors={fieldErrors || {}}
name="email_pass"
label="Password"
variant="outlined"
value={data.email_pass}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_sender"
label="From"
variant="outlined"
value={data.email_sender}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_recp"
label="To"
variant="outlined"
value={data.email_recp}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="email_subject"
label="Subject"
variant="outlined"
value={data.email_subject}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<Button
sx={{ mt: 3 }}
variant="outlined"
color="primary"
disabled={dirtyFlags.length !== 0}
onClick={sendmail}
>
Send test email
</Button>
</Grid>
</Grid>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()} {LL.SENSORS()}
</Typography> </Typography>
@@ -912,16 +751,6 @@ const ApplicationSettings = () => {
} }
label={LL.DEVELOPER_MODE()} label={LL.DEVELOPER_MODE()}
/> />
<BlockFormControlLabel
control={
<Checkbox
checked={data.disable_reset}
onChange={updateFormValue}
name="disable_reset"
/>
}
label={LL.DISABLE_RESET()}
/>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
<Checkbox <Checkbox
+36 -26
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -26,7 +27,6 @@ import {
SingleUpload, SingleUpload,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
try { try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,33 +65,16 @@ const DownloadUpload = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
setRestarting(false); setRestarting(false);
} }
}; }, [sendAPI]);
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = () => { const handleCloseBackupDialog = useCallback(() => {
setConfirmBackup(false); setConfirmBackup(false);
}; }, []);
const handleDownload = (type: string) => () => { const renderBackupDialog = useMemo(
void sendExportData(type); () => (
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Dialog <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmBackup} open={confirmBackup}
@@ -115,13 +98,40 @@ const DownloadUpload = () => {
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={handleDownload('systembackup')} onClick={() => handleDownload('systembackup')()}
color="primary" color="primary"
> >
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
),
[confirmBackup, handleCloseBackupDialog, LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{renderBackupDialog}
<Typography sx={{ pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
+46 -34
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
@@ -16,6 +17,7 @@ import {
import * as MqttApi from 'api/mqtt'; import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -26,12 +28,10 @@ import {
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types'; import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, createMqttSettingsValidator, validate } from 'validators'; import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
import { callAction } from '../../api/app'; import { callAction } from '../../api/app';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = () => { const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' }) void callAction({ action: 'resetMQTT' })
.then(() => { .then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful'); toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,20 +65,29 @@ const MqttSettings = () => {
.catch((error) => { .catch((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
}; }, []);
const updateFormValue = updateValueDirty( const updateFormValue = useMemo(
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]
); );
const SecondsInputProps = { const SecondsInputProps = useMemo(
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> () => ({
}; endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const validateAndSubmit = async () => { const emptyFieldErrors = useMemo(() => ({}), []);
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -87,22 +96,25 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [data, saveData]);
const publishIntervalFields = [ const publishIntervalFields = useMemo(
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, () => [
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
name: 'publish_time_thermostat', {
label: LL.MQTT_INT_THERMOSTATS(), name: 'publish_time_thermostat',
validated: false label: LL.MQTT_INT_THERMOSTATS(),
}, validated: false
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false }, { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false }, { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false }, { name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } { name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
]; { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
],
[LL]
);
if (!data) { if (!data) {
return ( return (
@@ -142,7 +154,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -154,7 +166,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -166,7 +178,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -207,7 +219,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -426,7 +438,7 @@ const MqttSettings = () => {
<Grid key={field.name}> <Grid key={field.name}>
{field.validated ? ( {field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name={field.name} name={field.name}
label={field.label} label={field.label}
slotProps={{ slotProps={{
+81 -43
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -23,6 +24,7 @@ import { readNTPSettings } from 'api/ntp';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { updateState } from 'alova/client'; import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -32,13 +34,11 @@ import {
ValidatedTextField, ValidatedTextField,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types'; import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils'; import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import type { ValidateFieldsError } from 'validators/schema';
import { import {
TIME_ZONES, TIME_ZONES,
@@ -67,16 +67,19 @@ const NTPSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems(); const timeZoneItems = useTimeZoneSelectItems();
const timeZoneItemsT = timeZoneSelectItemsT(); const timeZoneItemsT = timeZoneSelectItemsT();
const selectedTzValue = data // Memoized selected timezone value
? selectedTimeZone(data.tz_label, data.tz_format) const selectedTzValue = useMemo(
: undefined; () => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
const selectedTzValueT = data );
? selectedTimeZone(data.tz_label_t, data.tz_format_t) const selectedTzValueT = useMemo(
: undefined; () => (data ? selectedTimeZone(data.tz_label_t, data.tz_format_t) : undefined),
[data?.tz_label_t, data?.tz_format_t]
);
const [localTime, setLocalTime] = useState<string>(''); const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
@@ -89,22 +92,32 @@ const NTPSettings = () => {
} }
); );
const updateFormValue = updateValueDirty( // Memoize updateFormValue to prevent recreation on every render
origData as unknown as Record<string, unknown>, const updateFormValue = useMemo(
dirtyFlags, () =>
setDirtyFlags, updateValueDirty(
updateDataValue as (value: unknown) => void origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => // Memoize updateLocalTime handler
setLocalTime(event.target.value); const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const openSetTime = () => { // Memoize openSetTime handler
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}; }, []);
const configureTime = async () => { // Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -117,11 +130,13 @@ const NTPSettings = () => {
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}; }, [localTime, updateTime, LL, loadData]);
const handleCloseSetTime = () => setSettingTime(false); // Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
const validateAndSubmit = async () => { // Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -130,27 +145,35 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}; }, [data, saveData]);
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => { // Memoize timezone change handler
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ const changeTimeZone = useCallback(
...settings, (event: React.ChangeEvent<HTMLInputElement>) => {
tz_label: event.target.value, void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
tz_format: TIME_ZONES[event.target.value] ...settings,
})); tz_label: event.target.value,
updateFormValue(event); tz_format: TIME_ZONES[event.target.value]
}; }));
updateFormValue(event);
},
[updateFormValue]
);
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => { const changeTimeZoneT = useCallback(
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ (event: React.ChangeEvent<HTMLInputElement>) => {
...settings, void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
tz_label_t: event.target.value, ...settings,
tz_format_t: TIME_ZONES[event.target.value] tz_label_t: event.target.value,
})); tz_format_t: TIME_ZONES[event.target.value]
updateFormValue(event); }));
}; updateFormValue(event);
},
[updateFormValue]
);
const renderContent = () => { // Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
@@ -276,12 +299,27 @@ const NTPSettings = () => {
)} )}
</> </>
); );
}; }, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
selectedTzValueT,
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>
+160 -78
View File
@@ -1,108 +1,190 @@
import { useContext } from 'react'; import { useCallback, useMemo, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build'; import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { List } from '@mui/material'; import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
import { API } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const upgradeAvailable = versions?.current?.upgradeable ?? false; const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const firmwareText = versions?.current?.version const [restarting, setRestarting] = useState<boolean>();
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
return ( const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
<SectionContent> immediate: false
<List> });
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
<ListMenuItem const doFormat = useCallback(async () => {
icon={TuneIcon} await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
bgcolor="#134ba2" setRestarting(true);
label={LL.APPLICATION()} setConfirmFactoryReset(false);
text={LL.APPLICATION_SETTINGS_1()} });
to="application" }, [sendAPI]);
/>
<ListMenuItem const handleFactoryResetClose = useCallback(() => {
icon={SettingsEthernetIcon} setConfirmFactoryReset(false);
bgcolor="#40828f" }, []);
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem const handleFactoryResetClick = useCallback(() => {
icon={SettingsInputAntennaIcon} setConfirmFactoryReset(true);
bgcolor="#5f9a5f" }, []);
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem const content = useMemo(() => {
icon={AccessTimeIcon} return (
bgcolor="#c5572c" <>
label="NTP" <List>
text={LL.CONFIGURE(LL.LOCAL_TIME(1))} <ListMenuItem
to="ntp" icon={TuneIcon}
/> bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem <ListMenuItem
icon={DeviceHubIcon} icon={SettingsEthernetIcon}
bgcolor="#68374d" bgcolor="#40828f"
label="MQTT" label={LL.NETWORK(0)}
text={LL.CONFIGURE('MQTT')} text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="mqtt" to="network"
/> />
<ListMenuItem <ListMenuItem
icon={LockIcon} icon={SettingsInputAntennaIcon}
label={LL.SECURITY(0)} bgcolor="#5f9a5f"
text={LL.SECURITY_1()} label={LL.ACCESS_POINT(0)}
to="security" text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
/> to="ap"
/>
<ListMenuItem <ListMenuItem
icon={ViewModuleIcon} icon={AccessTimeIcon}
bgcolor="#efc34b" bgcolor="#c5572c"
label={LL.MODULES()} label="NTP"
text={LL.MODULES_1()} text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="modules" to="ntp"
/> />
<ListMenuItem <ListMenuItem
icon={ImportExportIcon} icon={DeviceHubIcon}
bgcolor="#5d89f7" bgcolor="#68374d"
label={LL.DOWNLOAD_UPLOAD()} label="MQTT"
text={LL.DOWNLOAD_UPLOAD_1()} text={LL.CONFIGURE('MQTT')}
to="downloadUpload" to="mqtt"
/> />
</List>
</SectionContent> <ListMenuItem
); 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;
+16 -4
View File
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = { export const TIME_ZONES: Record<string, string> = {
@@ -470,16 +472,26 @@ export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}> <MenuItem key={label} value={label}>
{label} {label}
</MenuItem> </MenuItem>
)); ));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return precomputedTimeZoneItems; return precomputedTimeZoneItems;
} }
-946
View File
@@ -1,946 +0,0 @@
import { memo, useContext, useEffect, useState } from 'react';
import { Link } from 'react-router';
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 { toast } from 'components/toast';
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;
disable_reset: 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,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
upgradeImportantMessageType: number;
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(
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>
<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, refreshVersions } = 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 [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const latestVersion: VersionInfo | undefined = versions?.stable
? { version: versions.stable.version, date: versions.stable.date }
: undefined;
const latestDevVersion: VersionInfo | undefined = versions?.dev
? { version: versions.dev.version, date: versions.dev.date }
: undefined;
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);
useEffect(() => {
if (internetLive) {
return;
}
const interval = setInterval(() => {
void refreshVersions();
}, 3000);
return () => clearInterval(interval);
}, [internetLive, refreshVersions]);
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);
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">
{LL.INTERNET_CONNECTION_CHECK()}&hellip;
</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}
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="success"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
whiteSpace: 'nowrap',
gap: 1
}}
>
{!data.disable_reset && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
)}
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="success"
>
{LL.RESTART()}
</Button>
</Box>
</>
)}
</SectionContent>
);
};
export default memo(Version);
+49 -15
View File
@@ -1,5 +1,12 @@
import { memo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { Outlet, useMatch, useNavigate } from 'react-router'; import {
Navigate,
Route,
Routes,
matchRoutes,
useLocation,
useNavigate
} from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
@@ -7,32 +14,52 @@ import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork } from 'types'; import type { WiFiNetwork } from 'types';
import NetworkSettings from './NetworkSettings';
import { WiFiConnectionContext } from './WiFiConnectionContext'; import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
const Network = () => { const Network = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.NETWORK(0)); useLayoutTitle(LL.NETWORK(0));
const routerTab = useMatch('/settings/network/:tab')?.pathname || false; // this also works!
// const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false;
const matchedRoutes = matchRoutes(
[
{
path: '/settings/network/settings',
element: <NetworkSettings />
},
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
],
useLocation()
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = (network: WiFiNetwork) => { const selectNetwork = useCallback(
setSelectedNetwork(network); (network: WiFiNetwork) => {
void navigate('/settings/network/settings'); setSelectedNetwork(network);
}; void navigate('/settings/network/settings');
},
[navigate]
);
const deselectNetwork = () => { const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}; }, []);
const contextValue = { const contextValue = useMemo(
...(selectedNetwork && { selectedNetwork }), () => ({
selectNetwork, ...(selectedNetwork && { selectedNetwork }),
deselectNetwork selectNetwork,
}; deselectNetwork
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return ( return (
<WiFiConnectionContext.Provider value={contextValue}> <WiFiConnectionContext.Provider value={contextValue}>
@@ -43,7 +70,14 @@ const Network = () => {
/> />
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} /> <Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
</RouterTabs> </RouterTabs>
<Outlet /> <Routes>
<Route path="scan" element={<WiFiNetworkScanner />} />
<Route path="settings" element={<NetworkSettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/network/settings" />}
/>
</Routes>
</WiFiConnectionContext.Provider> </WiFiConnectionContext.Provider>
); );
}; };
@@ -1,4 +1,5 @@
import { memo, useCallback, useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
@@ -25,6 +26,7 @@ import { API } from 'api/app';
import { updateState, useRequest } from 'alova/client'; import { updateState, useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types'; import type { APIcall } from 'app/main/types';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation, BlockNavigation,
@@ -35,13 +37,11 @@ import {
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkSettingsType } from 'types'; import type { NetworkSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import { createNetworkSettingsValidator } from 'validators/network'; import { createNetworkSettingsValidator } from 'validators/network';
import type { ValidateFieldsError } from 'validators/schema';
import SystemMonitor from '../../status/SystemMonitor'; import SystemMonitor from '../../status/SystemMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext'; import { WiFiConnectionContext } from './WiFiConnectionContext';
@@ -89,7 +89,7 @@ const NetworkSettings = () => {
static_ip_config: false, static_ip_config: false,
bandwidth20: false, bandwidth20: false,
tx_power: 0, tx_power: 0,
nosleep: true, nosleep: false,
enableMDNS: true, enableMDNS: true,
enableCORS: false, enableCORS: false,
CORSOrigin: '*' CORSOrigin: '*'
@@ -121,19 +121,19 @@ const NetworkSettings = () => {
deselectNetwork(); deselectNetwork();
}, [data, saveData, deselectNetwork]); }, [data, saveData, deselectNetwork]);
const setCancel = async () => { const setCancel = useCallback(async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}; }, [deselectNetwork, loadData]);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="ssid" name="ssid"
label="SSID" label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.ssid} value={data.ssid}
@@ -1,4 +1,4 @@
import { memo, useRef, useState } from 'react'; import { memo, useCallback, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = () => { const renderNetworkScanner = useCallback(() => {
if (!networkList) { if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />; return <FormLoader errorMessage={errorMessage || ''} />;
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}; }, [networkList, errorMessage]);
return ( return (
<SectionContent> <SectionContent>
@@ -1,4 +1,4 @@
import { memo, useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,31 +63,34 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => ( const renderNetwork = useCallback(
<ListItem (network: WiFiNetwork) => (
key={network.bssid} <ListItem
onClick={() => wifiConnectionContext.selectNetwork(network)} key={network.bssid}
> onClick={() => wifiConnectionContext.selectNetwork(network)}
<ListItemAvatar> >
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar> <ListItemAvatar>
</ListItemAvatar> <Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
<ListItemText </ListItemAvatar>
primary={network.ssid} <ListItemText
secondary={ primary={network.ssid}
'Security: ' + secondary={
networkSecurityMode(network) + 'Security: ' +
', Ch: ' + networkSecurityMode(network) +
network.channel + ', Ch: ' +
', bssid: ' + network.channel +
network.bssid ', bssid: ' +
} network.bssid
/> }
<ListItemIcon> />
<Badge badgeContent={network.rssi + 'dBm'}> <ListItemIcon>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} /> <Badge badgeContent={network.rssi + 'dBm'}>
</Badge> <WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</ListItemIcon> </Badge>
</ListItem> </ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {
@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,14 +55,16 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useTheme({ const table_theme = useMemo(
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;
@@ -72,7 +74,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;
@@ -85,7 +87,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;
} }
@@ -93,36 +95,44 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const noAdminConfigured = () => !data?.users.find((u) => u.admin); const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = (toRemove: UserType) => { const removeUser = useCallback(
if (!data) return; (toRemove: UserType) => {
const users = data.users.filter((u) => u.username !== toRemove.username); if (!data) return;
updateDataValue({ ...data, users }); const users = data.users.filter((u) => u.username !== toRemove.username);
setChanged(changed + 1); updateDataValue({ ...data, users });
}; setChanged(changed + 1);
},
[data, updateDataValue, changed]
);
const createUser = () => { const createUser = useCallback(() => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}; }, []);
const editUser = (toEdit: UserType) => { const editUser = useCallback((toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}; }, []);
const cancelEditingUser = () => { const cancelEditingUser = useCallback(() => {
setUser(undefined); setUser(undefined);
}; }, []);
const doneEditingUser = () => { const doneEditingUser = useCallback(() => {
if (user && data) { if (user && data) {
const users = [ const users = [
...data.users.filter( ...data.users.filter(
@@ -134,26 +144,26 @@ const ManageUsers = () => {
setUser(undefined); setUser(undefined);
setChanged(changed + 1); setChanged(changed + 1);
} }
}; }, [user, data, updateDataValue, changed]);
const closeGenerateToken = useCallback(() => { const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined); setGeneratingToken(undefined);
}, []); }, []);
const generateTokenForUser = (username: string) => { const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}; }, []);
const onSubmit = async () => { const onSubmit = useCallback(async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}; }, [saveData, authenticatedContext]);
const onCancelSubmit = async () => { const onCancelSubmit = useCallback(async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}; }, [loadData]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -167,10 +177,15 @@ const ManageUsers = () => {
admin: boolean; admin: boolean;
} }
const user_table = data.users.map((u) => ({ // add id to the type, needed for the table
...u, const user_table = useMemo(
id: u.username () =>
})) as UserType2[]; data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return ( return (
<> <>
@@ -1,16 +1,35 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Outlet, useMatch } from 'react-router'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle } from 'components'; import { RouterTabs, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import ManageUsers from './ManageUsers';
import SecuritySettings from './SecuritySettings';
const Security = () => { const Security = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0)); useLayoutTitle(LL.SECURITY(0));
const routerTab = useMatch('/settings/security/:tab')?.pathname || false; const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
[
{
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
location
),
[location]
);
const routerTab = matchedRoutes?.[0]?.route.path || false;
return ( return (
<> <>
@@ -21,7 +40,14 @@ const Security = () => {
/> />
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} /> <Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
</RouterTabs> </RouterTabs>
<Outlet /> <Routes>
<Route path="users" element={<ManageUsers />} />
<Route path="settings" element={<SecuritySettings />} />
<Route
path="*"
element={<Navigate replace to="/settings/security/settings" />}
/>
</Routes>
</> </>
); );
}; };
@@ -6,6 +6,7 @@ import { Button } from '@mui/material';
import * as SecurityApi from 'api/security'; import * as SecurityApi from 'api/security';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockNavigation, BlockNavigation,
ButtonRow, ButtonRow,
@@ -19,7 +20,6 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types'; import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators'; import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
import type { ValidateFieldsError } from 'validators/schema';
const SecuritySettings = () => { const SecuritySettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} /> <MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
+5 -5
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -14,6 +14,8 @@ import {
} from '@mui/material'; } from '@mui/material';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
ValidatedPasswordField, ValidatedPasswordField,
@@ -23,8 +25,6 @@ import { useI18nContext } from 'i18n/i18n-react';
import type { UserType } from 'types'; import type { UserType } from 'types';
import { updateValue } from 'utils'; import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators'; import { ValidationError, validate } from 'validators';
import type Schema from 'validators/schema';
import type { ValidateFieldsError } from 'validators/schema';
interface UserFormProps { interface UserFormProps {
creating: boolean; creating: boolean;
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
} }
}, [open]); }, [open]);
const validateAndDone = async () => { const validateAndDone = useCallback(async () => {
if (user) { if (user) {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
} }
}; }, [user, validator, onDoneEditing]);
return ( return (
<Dialog <Dialog
+33 -25
View File
@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -34,14 +36,16 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({ const stats_theme = tableTheme(
Table: ` useMemo(
() => ({
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;
@@ -51,7 +55,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;
@@ -65,20 +69,26 @@ 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 = (id: number) => { const showName = useCallback(
const name: keyof Translation['STATUS_NAMES'] = (id: number) => {
id.toString() as keyof Translation['STATUS_NAMES']; const name: keyof Translation['STATUS_NAMES'] =
return LL.STATUS_NAMES[name](); id.toString() as keyof Translation['STATUS_NAMES'];
}; return LL.STATUS_NAMES[name]();
},
[LL]
);
const showQuality = (stat: Stat) => { const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
@@ -90,18 +100,14 @@ const SystemActivity = () => {
} else { } else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
} }
}; }, []);
const content = useMemo(() => {
if (!data) {
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}
@@ -130,8 +136,10 @@ const SystemActivity = () => {
</> </>
)} )}
</Table> </Table>
</SectionContent> );
); }, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; export default SystemActivity;
+9 -8
View File
@@ -1,4 +1,4 @@
import { type FC, memo } from 'react'; import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,15 +127,16 @@ const MqttStatus = () => {
void loadData(); void loadData();
}); });
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || ''; const errorMessage = error?.message || '';
const mqttStatusText = !data const mqttStatusText = useMemo(() => {
? '' if (!data) return '';
: !data.enabled if (!data.enabled) return LL.NOT_ENABLED();
? LL.NOT_ENABLED() return data.connected
: data.connected ? `${LL.CONNECTED(0)} (${data.connect_count})`
? `${LL.CONNECTED(0)} (${data.connect_count})` : `${LL.DISCONNECTED()} (${data.connect_count})`;
: `${LL.DISCONNECTED()} (${data.connect_count})`; }, [data, LL]);
if (!data) { if (!data) {
return ( return (
+11 -11
View File
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -65,16 +67,12 @@ const NTPStatus = () => {
} }
}; };
if (!data) { const content = useMemo(() => {
return ( if (!data) {
<SectionContent> return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} /> }
</SectionContent>
);
}
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -123,8 +121,10 @@ const NTPStatus = () => {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
</SectionContent> );
); }, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
}; };
export default NTPStatus; export default NTPStatus;
+15 -15
View File
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite'; import GiteIcon from '@mui/icons-material/Gite';
@@ -122,20 +124,16 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
if (!data) { const content = useMemo(() => {
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>
@@ -229,8 +227,10 @@ const NetworkStatus = () => {
</> </>
)} )}
</List> </List>
</SectionContent> );
); }, [data, error, loadData, LL, theme]);
return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;
+276 -111
View File
@@ -1,16 +1,25 @@
import { useContext } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev'; import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory'; import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router'; import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi'; import WifiIcon from '@mui/icons-material/Wifi';
import { import {
Avatar, Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
@@ -18,10 +27,12 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { busConnectionStatus } from 'app/main/types'; import { type APIcall, busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components'; import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -30,6 +41,9 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time'; import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = ( const formatDurationSec = (
@@ -58,7 +72,24 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const { data, send: loadData, error } = useRequest(readSystemStatus); const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
useInterval(() => { useInterval(() => {
void loadData(); void loadData();
@@ -66,8 +97,10 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
const busStatus = (() => { // Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown'; if (!data) return 'EMS state unknown';
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -78,10 +111,12 @@ const SystemStatus = () => {
default: default:
return 'EMS state unknown'; return 'EMS state unknown';
} }
})(); }, [data?.bus_status, data?.bus_uptime, LL]);
const systemStatus = (() => { // Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
if (!data) return '??'; if (!data) return '??';
switch (data.status) { switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
@@ -94,12 +129,14 @@ const SystemStatus = () => {
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0)); return LL.GPIO_OF(LL.FAILED(0));
default: default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK'; return 'OK';
} }
})(); }, [data?.status, LL]);
const busStatusHighlight = (() => { const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -110,10 +147,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
})(); }, [data?.bus_status, theme.palette]);
const ntpStatus = (() => { const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
@@ -126,10 +164,11 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
})(); }, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = (() => { const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main; if (!data) return theme.palette.error.main;
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -140,10 +179,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
})(); }, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = (() => { const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -158,10 +198,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
})(); }, [data?.network_status, theme.palette]);
const networkStatus = (() => { const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1); return LL.INACTIVE(1);
@@ -182,103 +223,227 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
})(); }, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) => const activeHighlight = useCallback(
value ? theme.palette.success.main : theme.palette.info.main; (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
if (!data || !LL) { [theme.palette]
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
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>
); );
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
);
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<List>
<ListMenuItem
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>;
}; };
export default SystemStatus; export default SystemStatus;
+18 -10
View File
@@ -1,4 +1,12 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -25,7 +33,6 @@ import {
SectionContent, SectionContent,
useLayoutTitle useLayoutTitle
} from 'components'; } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types'; import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types'; import { LogLevel } from 'types';
@@ -178,7 +185,8 @@ const SystemLog = () => {
}; };
}, [data]); // Recalculate when data changes (in case layout shifts) }, [data]); // Recalculate when data changes (in case layout shifts)
const handleLogMessage = (message: { data: string }) => { // Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data; const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry; const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => { setLogEntries((log) => {
@@ -192,7 +200,7 @@ const SystemLog = () => {
const newLog = [...log, logentry]; const newLog = [...log, logentry];
return newLog; return newLog;
}); });
}; }, []);
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
@@ -203,7 +211,7 @@ const SystemLog = () => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const onDownload = () => { const onDownload = useCallback(() => {
const result = logEntries const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n'); .join('\n');
@@ -217,11 +225,11 @@ const SystemLog = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}; }, [logEntries]);
const saveSettings = async () => { const saveSettings = useCallback(async () => {
await saveData(); await saveData();
}; }, [saveData]);
// handle scrolling - optimized to only scroll when needed // handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -238,7 +246,7 @@ const SystemLog = () => {
} }
}, [logEntries.length, autoscroll]); }, [logEntries.length, autoscroll]);
const sendReadCommand = () => { const sendReadCommand = useCallback(() => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -249,7 +257,7 @@ const SystemLog = () => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
} }
}; }, [readValue, readOpen, send]);
const content = () => { const content = () => {
if (!data) { if (!data) {
+31 -35
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
@@ -57,31 +57,39 @@ const SystemMonitor = () => {
void send(); void send();
}, 1000); // check every 1 second }, 1000); // check every 1 second
const status = data?.status; const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
const statusMessage = const message =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE() ? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? 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 isUploading = const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progressValue = const progress =
isUploading && status uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0; : 0;
const onCancel = async () => { return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined); setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/'; document.location.href = '/';
}; }, [setSystemStatus]);
return ( return (
<Box <Box
@@ -145,21 +153,9 @@ const SystemMonitor = () => {
{LL.PLEASE_WAIT()}&hellip; {LL.PLEASE_WAIT()}&hellip;
</Typography> </Typography>
{isUploading && ( {isUploading && (
<> <Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}> <LinearProgressWithLabel value={progressValue} />
<LinearProgressWithLabel value={progressValue} /> </Box>
</Box>
<Button
sx={{ ml: 2, mt: 2 }}
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={onCancel}
>
{LL.CANCEL()}
</Button>
</>
)} )}
</> </>
)} )}
+931
View File
@@ -0,0 +1,931 @@
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { getDevVersion, getStableVersion } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
interface UpgradeCheckData {
emsesp_version: string;
dev_upgradeable: boolean;
stable_upgradeable: boolean;
}
interface VersionInfo {
name: string;
published_at?: string;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
partitionVersion?: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.name
: version?.name}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version?.published_at && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.published_at))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
downloadOnly,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
upgradeImportantMessageType: number;
downloadOnly: boolean;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = useMemo(() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.name}/${filename}`;
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// Helper function moved outside component
const getPlatform = (data: VersionData): string => {
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
};
const Version = () => {
const { LL, locale } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
// State management
const [restarting, setRestarting] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const { send: sendCheckUpgrade } = useRequest(
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
{ immediate: false }
).onSuccess((event) => {
const data = event.data as UpgradeCheckData;
setDevUpgradeAvailable(data.dev_upgradeable);
setStableUpgradeAvailable(data.stable_upgradeable);
});
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const {
data,
send: loadData,
error
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
const systemData = event.data as VersionData;
if (systemData.arduino_version.startsWith('Tasmota')) {
setDownloadOnly(true);
}
setUsingDevVersion(systemData.emsesp_version.includes('dev'));
});
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { data: latestVersion } = useRequest(getStableVersion);
const { data: latestDevVersion } = useRequest(getDevVersion);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
useState<number>(0);
const { send: checkUpgradeImportantMessages } = useRequest(
(version: string) =>
callAction({ action: 'upgradeImportantMessages', param: version }),
{
immediate: false
}
)
.onSuccess((event) => {
const upgradeImportantMessageType_n = (
event.data as { upgradeImportantMessageType: number }
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Memoized values
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
// Memoize filtered partitions to avoid recomputing on every render
const otherPartitions = useMemo(
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
[data]
);
const setPartitionVersionInfo = useCallback(
(partition: string) => {
setShowVersionInfo(3);
// search for the partition in the data.partitions array
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
name: partitionData.version,
published_at: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
},
[data]
);
const doRestart = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
}, [sendAPI]);
const installFirmwareURL = useCallback(
async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
},
[sendUploadURL, doRestart]
);
const installPartitionFirmware = useCallback(
async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
},
[sendSetPartition]
);
const showPartitionDialog = useCallback(
(version: string, partition: string, install_date: string) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ name: version, published_at: install_date });
setPartition(partition);
},
[]
);
const showFirmwareDialog = useCallback(
(useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
void checkUpgradeImportantMessages(
useDevVersion ? latestDevVersion?.name : latestVersion?.name
);
setOpenInstallDialog(true);
},
[latestDevVersion, latestVersion, fetchDevVersion]
);
const closeInstallDialog = useCallback(() => {
setOpenInstallDialog(false);
}, []);
const closeInstallPartitionDialog = useCallback(() => {
setOpenInstallPartitionDialog(false);
}, []);
const handleVersionInfoClose = useCallback(() => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
}, []);
// check upgrades - only once when both versions are available
const upgradeCheckedRef = useRef(false);
useEffect(() => {
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
upgradeCheckedRef.current = true;
const versions = `${latestDevVersion.name},${latestVersion.name}`;
sendCheckUpgrade(versions)
.catch((error: Error) => {
toast.error(`Failed to check for upgrades: ${error.message}`);
})
.finally(() => {
setInternetLive(true);
});
}
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
useLayoutTitle('EMS-ESP Firmware');
// Memoized button rendering logic
const showButtons = useCallback(
(showingDev: boolean) => {
const choice = showingDev
? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined
: usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
: stableUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
</Button>
);
},
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">
{LL.STORED_VERSIONS()}
</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.name || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
</>
);
}, [
data,
error,
loadData,
LL,
platform,
internetLive,
latestVersion,
latestDevVersion,
showVersionInfo,
locale,
openInstallDialog,
fetchDevVersion,
downloadOnly,
me.admin,
showButtons,
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart,
otherPartitions,
setPartitionVersionInfo,
showPartitionDialog,
partitionVersion,
partition,
firmwareSize,
closeInstallPartitionDialog,
installPartitionFirmware
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default memo(Version);
+13 -12
View File
@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, memo } from 'react'; import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
@@ -38,17 +38,18 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const Icon = LEVEL_ICONS[level]; const { Icon, backgroundColor } = useMemo(() => {
const palettePath = LEVEL_PALETTE_PATHS[level]; const Icon = LEVEL_ICONS[level];
const [paletteKeyName, shade] = palettePath.split('.') as [ const palettePath = LEVEL_PALETTE_PATHS[level];
keyof typeof theme.palette, const [key, shade] = palettePath.split('.') as [
string keyof typeof theme.palette,
]; string
const paletteKey = theme.palette[paletteKeyName] as unknown as Record< ];
string, const paletteKey = theme.palette[key] as unknown as Record<string, string>;
string const backgroundColor = paletteKey[shade];
>;
const backgroundColor = paletteKey[shade]; return { Icon, backgroundColor };
}, [level, theme]);
return ( return (
<Box <Box
@@ -1,10 +1,10 @@
import { memo, useContext } from 'react'; import { memo, useCallback, useContext, useMemo } from 'react';
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material'; import { MenuItem, TextField } from '@mui/material';
import CZflag from 'i18n/CS.svg'; import CZflag from 'i18n/CZ.svg';
import DEflag from 'i18n/DE.svg'; import DEflag from 'i18n/DE.svg';
import FRflag from 'i18n/FR.svg'; import FRflag from 'i18n/FR.svg';
import GBflag from 'i18n/GB.svg'; import GBflag from 'i18n/GB.svg';
@@ -44,14 +44,27 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => { const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext); const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
target async ({ target }) => {
}) => { const loc = target.value as Locales;
const loc = target.value as Locales; localStorage.setItem('lang', loc);
localStorage.setItem('lang', loc); await loadLocaleAsync(loc);
await loadLocaleAsync(loc); setLocale(loc);
setLocale(loc); },
}; [setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return ( return (
<TextField <TextField
@@ -63,12 +76,7 @@ const LanguageSelector = () => {
size="small" size="small"
select select
> >
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( {menuItems}
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
</TextField> </TextField>
); );
}; };
@@ -1,4 +1,4 @@
import { memo, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = () => { const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev); setShowPassword((prev) => !prev);
}; }, []);
return ( return (
<ValidatedTextField <ValidatedTextField
@@ -4,7 +4,7 @@ import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material'; import { FormHelperText, TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material'; import type { TextFieldProps } from '@mui/material';
import type { ValidateFieldsError } from 'validators/schema'; import type { ValidateFieldsError } from 'async-validator';
interface ValidatedFieldProps { interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError; fieldErrors?: ValidateFieldsError;
@@ -18,6 +18,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME); const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation(); const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => { const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev); setMobileOpen((prev) => !prev);
}, []); }, []);
@@ -27,6 +28,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false); setMobileOpen(false);
}, [pathname]); }, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]); const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return ( return (
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,11 +39,14 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pathnames = location.pathname.split('/').filter((x) => x); const pathnames = useMemo(
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = () => { const handleBackClick = useCallback(() => {
void navigate('/' + pathnames[0]); void navigate('/' + pathnames[0]);
}; }, [navigate, pathnames]);
return ( return (
<AppBar position="fixed" sx={appBarStyles}> <AppBar position="fixed" sx={appBarStyles}>
@@ -1,8 +1,7 @@
import { memo, useContext } from 'react'; import { memo, useMemo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { AuthenticatedContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env'; import { PROJECT_NAME } from 'env';
import { DRAWER_WIDTH } from './Layout'; import { DRAWER_WIDTH } from './Layout';
@@ -25,33 +24,22 @@ interface LayoutDrawerProps {
} }
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
const { systemName } = useContext(AuthenticatedContext); // Memoize drawer content to prevent unnecessary re-renders
const drawer = useMemo(
const drawer = ( () => (
<> <>
<Toolbar disableGutters> <Toolbar disableGutters>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} /> <LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Box <Typography variant="h6">{PROJECT_NAME}</Typography>
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<Typography>{PROJECT_NAME}</Typography>
{systemName && (
<Typography color="secondary" variant="body2">
{systemName}
</Typography>
)}
</Box> </Box>
</Box> <Divider absolute />
<Divider absolute /> </Toolbar>
</Toolbar> <Divider />
<Divider /> <LayoutMenu />
<LayoutMenu /> </>
</> ),
[]
); );
return ( return (
+4 -14
View File
@@ -1,4 +1,4 @@
import { memo, useContext, useState } from 'react'; import { memo, useCallback, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -8,7 +8,6 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import LiveHelpIcon from '@mui/icons-material/LiveHelp'; import LiveHelpIcon from '@mui/icons-material/LiveHelp';
import MoreTimeIcon from '@mui/icons-material/MoreTime'; import MoreTimeIcon from '@mui/icons-material/MoreTime';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import SensorsIcon from '@mui/icons-material/Sensors'; import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
@@ -19,15 +18,13 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => { const LayoutMenuComponent = () => {
const { me, versions } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const upgradeAvailable = versions?.current?.upgradeable ?? false; const handleMenuToggle = useCallback(() => {
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev); setMenuOpen((prev) => !prev);
}; }, []);
return ( return (
<> <>
@@ -81,12 +78,6 @@ const LayoutMenuComponent = () => {
disabled={!me.admin} disabled={!me.admin}
to={`/customizations`} to={`/customizations`}
/> />
<LayoutMenuItem
icon={PlaylistPlayIcon}
label={LL.COMMANDS()}
disabled={!me.admin}
to={`/commands`}
/>
<LayoutMenuItem <LayoutMenuItem
icon={MoreTimeIcon} icon={MoreTimeIcon}
label={LL.SCHEDULER()} label={LL.SCHEDULER()}
@@ -114,7 +105,6 @@ const LayoutMenuComponent = () => {
label={LL.SETTINGS(0)} label={LL.SETTINGS(0)}
disabled={!me.admin} disabled={!me.admin}
to="/settings" to="/settings"
badge={upgradeAvailable}
/> />
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} /> <LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider /> <Divider />
@@ -1,7 +1,7 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material'; import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -11,52 +11,60 @@ interface LayoutMenuItemProps {
label: string; label: string;
to: string; to: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const LayoutMenuItemComponent = ({ const LayoutMenuItemComponent = ({
icon: Icon, icon: Icon,
label, label,
to, to,
disabled, disabled
badge
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = routeMatches(to, pathname); const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
const buttonStyles: SxProps<Theme> = { // Memoize dynamic styles based on selected state
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', const buttonStyles: SxProps<Theme> = useMemo(
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', () => ({
borderRadius: '8px', transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
margin: '2px 8px', backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
'&:hover': { borderRadius: '8px',
backgroundColor: 'rgba(68, 82, 211, 0.39)' margin: '2px 8px',
}, '&:hover': {
'&::before': { backgroundColor: 'rgba(68, 82, 211, 0.39)'
content: '""', },
position: 'absolute', '&::before': {
left: 0, content: '""',
top: 0, position: 'absolute',
bottom: 0, left: 0,
width: selected ? '3px' : '0px', top: 0,
backgroundColor: '#90caf9', bottom: 0,
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' width: selected ? '3px' : '0px',
} backgroundColor: '#90caf9',
}; transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
}
}),
[selected]
);
const iconStyles: SxProps<Theme> = { const iconStyles: SxProps<Theme> = useMemo(
color: selected ? '#90caf9' : '#9e9e9e', () => ({
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', color: selected ? '#90caf9' : '#9e9e9e',
transform: selected ? 'scale(1.1)' : 'scale(1)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transitionProperty: 'color, transform' transform: selected ? 'scale(1.1)' : 'scale(1)',
}; transitionProperty: 'color, transform'
}),
[selected]
);
const textStyles: SxProps<Theme> = { const textStyles: SxProps<Theme> = useMemo(
color: selected ? '#90caf9' : '#f5f5f5', () => ({
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', color: selected ? '#90caf9' : '#f5f5f5',
transitionProperty: 'color, font-weight' transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
}; transitionProperty: 'color, font-weight'
}),
[selected]
);
return ( return (
<ListItemButton <ListItemButton
@@ -70,20 +78,6 @@ const LayoutMenuItemComponent = ({
<Icon /> <Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText> <ListItemText sx={textStyles}>{label}</ListItemText>
{badge && (
<Box
aria-label="update available"
sx={{
width: 8,
height: 8,
ml: 1,
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
flexShrink: 0
}}
/>
)}
</ListItemButton> </ListItemButton>
); );
}; };
@@ -5,7 +5,6 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { import {
Avatar, Avatar,
Box,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemButton, ListItemButton,
@@ -21,7 +20,6 @@ interface ListMenuItemProps {
text: string; text: string;
to?: string; to?: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@@ -30,40 +28,15 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle' verticalAlign: 'middle'
}; };
const Badge = () => (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
);
const RenderIcon = memo( const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => ( ({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
<Icon /> <Icon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText primary={label} secondary={text} />
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</> </>
) )
); );
@@ -74,8 +47,7 @@ const LayoutMenuItem = ({
label, label,
text, text,
to, to,
disabled, disabled
badge
}: ListMenuItemProps) => ( }: ListMenuItemProps) => (
<> <>
{to && !disabled ? ( {to && !disabled ? (
@@ -93,7 +65,6 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@@ -104,7 +75,6 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItem> </ListItem>
)} )}
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useCallback } from 'react';
import type { Blocker } from 'react-router'; import type { Blocker } from 'react-router';
import { import {
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const handleReset = () => { const handleReset = useCallback(() => {
blocker.reset?.(); blocker.reset?.();
}; }, [blocker]);
const handleProceed = () => { const handleProceed = useCallback(() => {
blocker.proceed?.(); blocker.proceed?.();
}; }, [blocker]);
return ( return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}> <Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
@@ -1,14 +1,17 @@
import { memo, useContext } from 'react'; import { memo, useContext } from 'react';
import { Navigate, Outlet } from 'react-router'; import type { FC } from 'react';
import { Navigate } from 'react-router';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import type { RequiredChildrenProps } from 'utils';
// Layout-route guard: renders nested admin routes only for admins, otherwise const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
// redirects home. Must be used inside the authenticated route subtree so that const authenticatedContext = useContext(AuthenticatedContext);
// AuthenticatedContext (and `me`) is available. return authenticatedContext.me.admin ? (
const RequireAdmin = () => { <>{children}</>
const { me } = useContext(AuthenticatedContext); ) : (
return me.admin ? <Outlet /> : <Navigate replace to="/" />; <Navigate replace to="/" />
);
}; };
export default memo(RequireAdmin); export default memo(RequireAdmin);
@@ -1,35 +0,0 @@
import { memo, useContext, useEffect, useRef } from 'react';
import { Navigate } from 'react-router';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
type RootRedirectKind = 'unauthorized' | 'fileUpdated';
// Shows a one-shot toast and bounces back to "/". Used by the /unauthorized and
// /fileUpdated routes. Resolves its own i18n message so it can be used directly
// as a static route element.
const RootRedirect = ({ kind }: { kind: RootRedirectKind }) => {
const { LL } = useI18nContext();
const { signOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
useEffect(() => {
// Guard against StrictMode double-invoke / re-renders.
if (hasShownToast.current) return;
hasShownToast.current = true;
if (kind === 'unauthorized') {
signOut(false);
toast.success(LL.PLEASE_SIGNIN());
} else {
toast.success(LL.UPLOAD_SUCCESSFUL());
}
// Run once on mount.
}, []);
return <Navigate to="/" replace />;
};
export default memo(RootRedirect);
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -16,9 +16,12 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (_event: unknown, path: string) => { const handleTabChange = useCallback(
void navigate(path); (_event: unknown, path: string) => {
}; void navigate(path);
},
[navigate]
);
return ( return (
<Tabs <Tabs
@@ -2,4 +2,3 @@ export { default as RouterTabs } from './RouterTabs';
export { default as RequireAdmin } from './RequireAdmin'; export { default as RequireAdmin } from './RequireAdmin';
export { default as RequireAuthenticated } from './RequireAuthenticated'; export { default as RequireAuthenticated } from './RequireAuthenticated';
export { default as RequireUnauthenticated } from './RequireUnauthenticated'; export { default as RequireUnauthenticated } from './RequireUnauthenticated';
export { default as RootRedirect } from './RootRedirect';
-140
View File
@@ -1,140 +0,0 @@
import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import Alert from '@mui/material/Alert';
import Grow from '@mui/material/Grow';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import Stack from '@mui/material/Stack';
import {
type ToastItem,
type ToastSeverity,
getSnapshot,
removeToast,
subscribe
} from './toastStore';
const AUTO_CLOSE_MS = 3000;
const TICK_MS = 50;
// Semantic accent colors users already expect:
// success → green, error → red, warning → amber, info → blue.
const ACCENT: Record<ToastSeverity, string> = {
success: '#16a34a',
error: '#dc2626',
warning: '#f59e0b',
info: '#2563eb'
};
// Single toast row: owns its auto-dismiss timer + countdown progress bar, pauses
// while the window is unfocused (matching react-toastify's pauseOnFocusLoss).
const ToastRow = memo(({ item }: { item: ToastItem }) => {
const [open, setOpen] = useState(true);
const [remaining, setRemaining] = useState(AUTO_CLOSE_MS);
const remainingRef = useRef(AUTO_CLOSE_MS);
useEffect(() => {
let paused = document.hidden;
const onVisibility = () => {
paused = document.hidden;
};
document.addEventListener('visibilitychange', onVisibility);
const timer = setInterval(() => {
if (paused) return;
remainingRef.current = Math.max(0, remainingRef.current - TICK_MS);
setRemaining(remainingRef.current);
if (remainingRef.current === 0) setOpen(false);
}, TICK_MS);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
const accent = ACCENT[item.severity];
return (
<Grow in={open} onExited={() => removeToast(item.id)}>
<Alert
severity={item.severity}
variant="standard"
onClick={() => setOpen(false)}
action={
<IconButton
size="small"
aria-label="close"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
}}
sx={{ color: '#9ca3af', '&:hover': { color: '#374151' } }}
>
<CloseIcon fontSize="small" />
</IconButton>
}
sx={{
position: 'relative',
width: 'fit-content',
minWidth: 300,
maxWidth: 360,
cursor: 'pointer',
overflow: 'hidden',
borderRadius: '8px',
bgcolor: '#f3f4f6',
color: '#1f2937',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.18)',
borderLeft: `4px solid ${accent}`,
alignItems: 'center',
'& .MuiAlert-icon': { color: accent, alignItems: 'center' },
// '& .MuiAlert-message': {
// py: '8px',
// color: '#1f2937'
// },
'& .MuiAlert-action': { alignItems: 'center', pt: 0, mr: '-4px' }
}}
>
{item.message}
<LinearProgress
variant="determinate"
value={(remaining / AUTO_CLOSE_MS) * 100}
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 3,
backgroundColor: 'transparent',
'& .MuiLinearProgress-bar': { backgroundColor: accent, opacity: 0.55 }
}}
/>
</Alert>
</Grow>
);
});
const Toaster = memo(() => {
const toasts = useSyncExternalStore(subscribe, getSnapshot);
return (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 16,
left: 16,
zIndex: (theme) => theme.zIndex.snackbar,
pointerEvents: 'none',
'& > *': { pointerEvents: 'auto' }
}}
>
{toasts.map((item) => (
<ToastRow key={item.id} item={item} />
))}
</Stack>
);
});
export default Toaster;
-3
View File
@@ -1,3 +0,0 @@
export { default as Toaster } from './Toaster';
export { toast } from './toastStore';
export type { ToastSeverity } from './toastStore';
@@ -1,47 +0,0 @@
export type ToastSeverity = 'success' | 'error' | 'info' | 'warning';
export interface ToastItem {
id: number;
severity: ToastSeverity;
message: string;
}
let toasts: ToastItem[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
const emit = () => {
for (const listener of listeners) listener();
};
export const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const getSnapshot = (): ToastItem[] => toasts;
const add = (severity: ToastSeverity, message: string): number => {
const id = nextId++;
toasts = [...toasts, { id, severity, message }];
emit();
return id;
};
export const removeToast = (id: number): void => {
const next = toasts.filter((t) => t.id !== id);
if (next.length !== toasts.length) {
toasts = next;
emit();
}
};
// Imperative API mirroring the subset of react-toastify used across the app.
export const toast = {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
warning: (message: string) => add('warning', message)
};
@@ -7,6 +7,7 @@ import {
useState useState
} from 'react'; } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import CloudUploadIcon from '@mui/icons-material/CloudUpload';
@@ -27,7 +28,6 @@ import { callAction } from 'api/app';
import { dialogStyle } from '@/CustomTheme'; import { dialogStyle } from '@/CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({ const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
@@ -91,9 +91,7 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
).upgradeImportantMessageType; ).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n); setUpgradeImportantMessageType(upgradeImportantMessageType_n);
if (upgradeImportantMessageType_n === 0) { if (upgradeImportantMessageType_n === 0) {
if (file) { onFileSelected(file);
onFileSelected(file);
}
} }
}) })
.onError((error) => { .onError((error) => {
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
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';
@@ -6,7 +7,6 @@ import { Box, Button, Typography } from '@mui/material';
import * as SystemApi from 'api/system'; import * as SystemApi from 'api/system';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import DragNdrop from './DragNdrop'; import DragNdrop from './DragNdrop';
@@ -1,58 +1,36 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { redirect } from 'react-router';
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';
import { useRequest } from 'alova/client'; 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 { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { Me, VersionsResponse } from 'types'; import type { Me } from 'types';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context'; import { AuthenticationContext } from './context';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => { const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const navigate = useNavigate();
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 [systemName, setSystemName] = useState<string>();
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) => {
const response = event.data as VersionsResponse;
setVersions(response);
setSystemName(response.system_name);
})
.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');
@@ -62,10 +40,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const signOut = (doRedirect: boolean) => { const signOut = (doRedirect: boolean) => {
AuthenticationApi.clearAccessToken(); AuthenticationApi.clearAccessToken();
setMe(undefined); setMe(undefined);
setVersions(undefined);
setSystemName(undefined);
if (doRedirect) { if (doRedirect) {
void navigate('/', { replace: true }); redirect('/');
} }
}; };
@@ -73,9 +49,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
await sendVerifyAuthorization() await sendVerifyAuthorization()
.then(async () => { .then(() => {
setMe(AuthenticationApi.decodeMeJWT(accessToken)); setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true); setInitialized(true);
}) })
.catch(() => { .catch(() => {
@@ -92,20 +67,20 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
// cache object to prevent re-renders
const obj = useMemo(
() => ({
signIn,
signOut,
refresh,
...(me && { me })
}),
[signIn, signOut, me, refresh]
);
if (initialized) { if (initialized) {
return ( return (
<AuthenticationContext.Provider <AuthenticationContext.Provider value={obj}>
value={{
signIn,
signOut,
refresh,
refreshVersions,
setSystemName,
...(me && { me }),
...(versions && { versions }),
...(systemName !== undefined && { systemName })
}}
>
{children} {children}
</AuthenticationContext.Provider> </AuthenticationContext.Provider>
); );
@@ -1,16 +1,12 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { Me, VersionsResponse } from 'types'; import type { Me } from 'types';
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
refresh: () => Promise<void>; refresh: () => Promise<void>;
signIn: (accessToken: string) => void; signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void; signOut: (redirect: boolean) => void;
me?: Me; me?: Me;
versions?: VersionsResponse;
refreshVersions: () => Promise<void>;
systemName?: string;
setSystemName: (systemName: string) => void;
} }
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue; const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 207 B

+30 -14
View File
@@ -3,6 +3,7 @@ import type { Translation } from '../i18n-types';
const cs: Translation = { const cs: Translation = {
LANGUAGE: 'Jazyk', LANGUAGE: 'Jazyk',
RETRY: 'Zkusit znovu', RETRY: 'Zkusit znovu',
LOADING: 'Načítání',
IS_REQUIRED: '{0} je vyžadováno', IS_REQUIRED: '{0} je vyžadováno',
SIGN_IN: 'Přihlásit se', SIGN_IN: 'Přihlásit se',
SIGN_OUT: 'Odhlásit se', SIGN_OUT: 'Odhlásit se',
@@ -30,17 +31,19 @@ const cs: Translation = {
ID_OF: '{0} ID', ID_OF: '{0} ID',
DEVICE: 'Zařízení', DEVICE: 'Zařízení',
PRODUCT: 'Produkt', PRODUCT: 'Produkt',
BRAND: 'Značka',
VERSION: 'Verze', VERSION: 'Verze',
BRAND: 'Značka',
ENTITY_NAME: 'Název entity', ENTITY_NAME: 'Název entity',
VALUE: '{{value|Value}}', VALUE: '{{value|Value}}',
DEVICES: 'Zařízení', DEVICES: 'Zařízení',
SENSORS: 'Senzory', SENSORS: 'Senzory',
RUN_COMMAND: 'Zavolat příkaz',
CHANGE_VALUE: 'Změnit hodnotu', CHANGE_VALUE: 'Změnit hodnotu',
CANCEL: 'Zrušit', CANCEL: 'Zrušit',
REMOVE_ALL: 'Odebrat vše', REMOVE_ALL: 'Odebrat vše',
APPLY_CHANGES: 'Použít změny ({0})', APPLY_CHANGES: 'Použít změny ({0})',
UPDATE: 'Aktualizovat', UPDATE: 'Aktualizovat',
EXECUTE: 'Provést',
REMOVE: 'Odebrat', REMOVE: 'Odebrat',
PROBLEM_UPDATING: 'Problém s aktualizací', PROBLEM_UPDATING: 'Problém s aktualizací',
PROBLEM_LOADING: 'Problém s načítáním', PROBLEM_LOADING: 'Problém s načítáním',
@@ -49,6 +52,8 @@ const cs: Translation = {
SETTINGS: 'Nastavení', SETTINGS: 'Nastavení',
UPDATED_OF: '{0} Aktualizováno', UPDATED_OF: '{0} Aktualizováno',
UPDATE_OF: '{0} Aktualizace', UPDATE_OF: '{0} Aktualizace',
REMOVED_OF: '{0} Odebráno',
DELETION_OF: '{0} Smazání',
OFFSET: 'Offset', OFFSET: 'Offset',
FACTOR: 'Faktor', FACTOR: 'Faktor',
FREQ: 'Frekvence', FREQ: 'Frekvence',
@@ -61,14 +66,17 @@ const cs: Translation = {
TEMP_SENSORS: 'Teplotní senzory', TEMP_SENSORS: 'Teplotní senzory',
WRITE_CMD_SENT: 'Zapisovací příkaz odeslán', WRITE_CMD_SENT: 'Zapisovací příkaz odeslán',
EMS_BUS_WARNING: 'EMS sběrnice je odpojena. Pokud toto varování přetrvává i po několika sekundách, zkontrolujte nastavení a profil desky', EMS_BUS_WARNING: 'EMS sběrnice je odpojena. Pokud toto varování přetrvává i po několika sekundách, zkontrolujte nastavení a profil desky',
EMS_BUS_SCANNING: 'Prohledávání zařízení na EMS sběrnici...',
CONNECTED: 'Připojeno', CONNECTED: 'Připojeno',
TX_ISSUES: 'Problémy s Tx - zkontrolujte režim Tx', TX_ISSUES: 'Problémy s Tx - zkontrolujte režim Tx',
DISCONNECTED: 'Odpojeno', DISCONNECTED: 'Odpojeno',
EMS_SCAN: 'Opravdu chcete zahájit úplné skenování zařízení na EMS sběrnici?',
DATA_TRAFFIC: 'Datový provoz', DATA_TRAFFIC: 'Datový provoz',
EMS_DEVICE: 'EMS zařízení', EMS_DEVICE: 'EMS zařízení',
SUCCESS: 'ÚSPĚCH', SUCCESS: 'ÚSPĚCH',
FAIL: 'SELHÁNÍ', FAIL: 'SELHÁNÍ',
QUALITY: 'KVALITA', QUALITY: 'KVALITA',
SCAN: 'Skenovat',
STATUS_NAMES: [ STATUS_NAMES: [
'Přijaté EMS telegramy (Rx)', 'Přijaté EMS telegramy (Rx)',
'Čtení EMS (Tx)', 'Čtení EMS (Tx)',
@@ -94,8 +102,8 @@ const cs: Translation = {
PHY_TYPE: 'Typ Eth PHY', PHY_TYPE: 'Typ Eth PHY',
DISABLED: 'zakázáno', DISABLED: 'zakázáno',
TX_MODE: 'EMS Tx režim', TX_MODE: 'EMS Tx režim',
EMS_BUS: '{{Bus|EMS Bus}}',
HARDWARE: 'Hardware', HARDWARE: 'Hardware',
EMS_BUS: '{{BUS|EMS BUS}}',
GENERAL_OPTIONS: 'Obecné možnosti', GENERAL_OPTIONS: 'Obecné možnosti',
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)', LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
HIDE_LED: 'Skrýt LED', HIDE_LED: 'Skrýt LED',
@@ -119,6 +127,7 @@ const cs: Translation = {
ENUM_FORMAT: 'Formát enum v API/MQTT', ENUM_FORMAT: 'Formát enum v API/MQTT',
INDEX: 'Index', INDEX: 'Index',
ENABLE_PARASITE: 'Povolit 1-Wire parazitní napájení', ENABLE_PARASITE: 'Povolit 1-Wire parazitní napájení',
LOGGING: 'Protokolování',
LOG_HEX: 'Protokolovat EMS telegramy v hexadecimálním formátu', LOG_HEX: 'Protokolovat EMS telegramy v hexadecimálním formátu',
ENABLE_SYSLOG: 'Povolit Syslog', ENABLE_SYSLOG: 'Povolit Syslog',
LOG_LEVEL: 'Úroveň protokolování', LOG_LEVEL: 'Úroveň protokolování',
@@ -163,6 +172,7 @@ const cs: Translation = {
CLOSE: 'Zavřít', CLOSE: 'Zavřít',
USE: 'Použít', USE: 'Použít',
FACTORY_RESET: 'Obnovení továrního nastavení', FACTORY_RESET: 'Obnovení továrního nastavení',
SYSTEM_FACTORY_TEXT: 'Zařízení bylo obnoveno do továrního nastavení a nyní se restartuje',
SYSTEM_FACTORY_TEXT_DIALOG: 'Opravdu chcete resetovat zařízení EMS-ESP do továrního nastavení?', SYSTEM_FACTORY_TEXT_DIALOG: 'Opravdu chcete resetovat zařízení EMS-ESP do továrního nastavení?',
AVAILABLE_VERSION: 'Nejnovější dostupné verze', AVAILABLE_VERSION: 'Nejnovější dostupné verze',
STABLE: 'Stabilní', STABLE: 'Stabilní',
@@ -237,7 +247,8 @@ const cs: Translation = {
TIME_ZONE: 'Časová zóna', TIME_ZONE: 'Časová zóna',
ACCESS_POINT: 'Přístupový bod', ACCESS_POINT: 'Přístupový bod',
AP_PROVIDE: 'Povolit přístupový bod', AP_PROVIDE: 'Povolit přístupový bod',
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené', AP_PROVIDE_TEXT_1: 'Vždy',
AP_PROVIDE_TEXT_2: 'Když je WiFi odpojena',
AP_PROVIDE_TEXT_3: 'Nikdy', AP_PROVIDE_TEXT_3: 'Nikdy',
AP_PREFERRED_CHANNEL: 'Preferovaný kanál', AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
AP_HIDE_SSID: 'Skrýt SSID', AP_HIDE_SSID: 'Skrýt SSID',
@@ -247,8 +258,11 @@ const cs: Translation = {
NETWORK_SCAN: 'Skenovat WiFi sítě', NETWORK_SCAN: 'Skenovat WiFi sítě',
IDLE: 'Nečinný', IDLE: 'Nečinný',
LOST: 'Ztraceno', LOST: 'Ztraceno',
SCANNING: 'Skenování',
SCAN_AGAIN: 'Skenovat znovu', SCAN_AGAIN: 'Skenovat znovu',
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',
@@ -274,14 +288,18 @@ const cs: Translation = {
STAY: 'Zůstat', STAY: 'Zůstat',
LEAVE: 'Odejít', LEAVE: 'Odejít',
SCHEDULER: 'Plánovač', SCHEDULER: 'Plánovač',
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže', SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže. Nastavte jedinečný název pro povolení/zakázání aktivace přes API/MQTT',
SCHEDULER_HELP_2: 'při startu', SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu',
SCHEDULE: 'Harmonogram', SCHEDULE: 'Harmonogram',
TIME: 'Čas', TIME: 'Čas',
TIMER: 'Časovač', TIMER: 'Časovač',
ONCHANGE: 'Při změně', ONCHANGE: 'Při změně',
CONDITION: 'Podmínka', CONDITION: 'Podmínka',
IMMEDIATE: 'Ihned',
SCHEDULE_UPDATED: 'Harmonogram aktualizován', SCHEDULE_UPDATED: 'Harmonogram aktualizován',
SCHEDULE_TIMER_1: 'při startu',
SCHEDULE_TIMER_2: 'každou minutu',
SCHEDULE_TIMER_3: 'každou hodinu',
CUSTOM_ENTITIES: 'Vlastní entity', CUSTOM_ENTITIES: 'Vlastní entity',
ENTITIES_HELP_1: 'Definujte vlastní EMS entity nebo dynamické uživatelské proměnné', ENTITIES_HELP_1: 'Definujte vlastní EMS entity nebo dynamické uživatelské proměnné',
ENTITIES_UPDATED: 'Entity aktualizovány', ENTITIES_UPDATED: 'Entity aktualizovány',
@@ -297,7 +315,9 @@ const cs: Translation = {
ACTIVELOW: 'Aktivní nízká', ACTIVELOW: 'Aktivní nízká',
UNCHANGED: 'Beze změny', UNCHANGED: 'Beze změny',
ALWAYS: 'Vždy', ALWAYS: 'Vždy',
ACTIVITY: 'Aktivita',
CONFIGURE: '{0} Konfigurovat', CONFIGURE: '{0} Konfigurovat',
SYSTEM_MEMORY: 'Paměť systému',
APPLICATION_SETTINGS_1: 'Upravit nastavení aplikace EMS-ESP', APPLICATION_SETTINGS_1: 'Upravit nastavení aplikace EMS-ESP',
SECURITY_1: 'Přidat nebo odebrat uživatele', SECURITY_1: 'Přidat nebo odebrat uživatele',
DOWNLOAD_UPLOAD_1: 'Stáhnout a nahrát nastavení a firmware', DOWNLOAD_UPLOAD_1: 'Stáhnout a nahrát nastavení a firmware',
@@ -309,6 +329,7 @@ const cs: Translation = {
RENAME: 'Přejmenovat', RENAME: 'Přejmenovat',
ENABLE_MODBUS: 'Povolit Modbus', ENABLE_MODBUS: 'Povolit Modbus',
VIEW_LOG: 'Zobrazit záznam pro diagnostiku problémů', VIEW_LOG: 'Zobrazit záznam pro diagnostiku problémů',
UPLOAD_DRAG: 'přetáhněte soubor sem nebo klikněte pro výběr',
SERVICES: 'Služby', SERVICES: 'Služby',
ALLVALUES: 'Všechny hodnoty', ALLVALUES: 'Všechny hodnoty',
SPECIAL_FUNCTIONS: 'Speciální funkce', SPECIAL_FUNCTIONS: 'Speciální funkce',
@@ -322,7 +343,6 @@ const cs: Translation = {
AUTO_SCROLL: 'Automatické rolování', AUTO_SCROLL: 'Automatické rolování',
DASHBOARD: 'Dashboard', DASHBOARD: 'Dashboard',
DEVELOPER_MODE: 'Režim vývojáře', DEVELOPER_MODE: 'Režim vývojáře',
DISABLE_RESET: 'Zakázat obnovení továrního nastavení',
BYTES: 'Bajty', BYTES: 'Bajty',
BITMASK: 'Bit Mask', BITMASK: 'Bit Mask',
DUPLICATE: 'Duplikát', DUPLICATE: 'Duplikát',
@@ -334,22 +354,18 @@ const cs: Translation = {
THIS_VERSION: 'Tato verze', THIS_VERSION: 'Tato verze',
PLATFORM: 'Platforma', PLATFORM: 'Platforma',
RELEASE_TYPE: 'Typ sestavení', RELEASE_TYPE: 'Typ sestavení',
INTERNET_CONNECTION_REQUIRED: 'Pro automatickou kontrolu a instalaci aktualizací je třeba internetové připojení',
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi', SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru', FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
NO_DATA: 'žádné údaje',
USER_PROFILE: 'Uživatelský profil', USER_PROFILE: 'Uživatelský profil',
STORED_VERSIONS: 'Uložené verze', STORED_VERSIONS: 'Uložené verze',
ONLINE_HELP: 'online nápověda', ONLINE_HELP: 'online nápověda',
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy', UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.', UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.', UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?', WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?'
TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán',
SYSTEM_NAME: 'Název systému',
COMMANDS: 'Příkazy',
COMMANDS_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
COMMAND_SENT: 'Příkaz odeslán',
RUN: 'Spustit příkaz',
INTERNET_CONNECTION_CHECK: 'Kontrola online pro dostupné verze'
}; };
export default cs; export default cs;

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