Compare commits

..
72 Commits
Author SHA1 Message Date
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
290 changed files with 17121 additions and 20660 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"
}
}
+2 -2
View File
@@ -35,6 +35,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 +102,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 +975,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>
-42
View File
@@ -1,45 +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
- added eth_10mbit to Network Settings, which forces 10BASE-T half-duplex & autonegotiation off, halving the PHYs power draw. Its off by default as it will cause issues on managed switches with a port forced to 100Mb [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
- cleaner MD5 and firmware uploading in WebUI [#3222](https://github.com/emsesp/EMS-ESP32/issues/3222)
- pc0Flow in l/min [#3224](https://github.com/emsesp/EMS-ESP32/issues/3224)
- remove HA config for excluded entities [#3229](https://github.com/emsesp/EMS-ESP32/issues/3229)
## Fixed
- shunting yard show json
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
- 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)
- solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223)
## 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)
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
+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 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",
+26 -161
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",
@@ -16,11 +15,10 @@
"enableMDNS": true, "enableMDNS": true,
"enableCORS": false, "enableCORS": false,
"CORSOrigin": "*", "CORSOrigin": "*",
"tx_power": 0, "tx_power": 0
"eth_10mbit": false
}, },
"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,
@@ -33,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,
@@ -53,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"
@@ -84,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,
@@ -100,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,
@@ -110,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,
@@ -134,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,
@@ -237,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"
}
]
} }
}, },
{ {
@@ -278,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 -6042
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -13,7 +13,7 @@ telegram_type_id,name,is_fetched
0x19,UBAMonitorSlow, 0x19,UBAMonitorSlow,
0x1A,UBASetPoints, 0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus, 0x1C,UBAMaintenanceStatus,
0x1E,HydrTemp, 0x1E,WM10TempMessage,
0x23,JunkersSetMixer,fetched 0x23,JunkersSetMixer,fetched
0x27,UBASettingsWW,fetched 0x27,UBASettingsWW,fetched
0x28,WeatherComp,fetched 0x28,WeatherComp,fetched
@@ -104,7 +104,7 @@ telegram_type_id,name,is_fetched
0x0292,HPMode,fetched 0x0292,HPMode,fetched
0x0293,HPMode,fetched 0x0293,HPMode,fetched
0x0294,HPMode,fetched 0x0294,HPMode,fetched
0x029B,RC300Curves,fetched 0x029B,RC300Curves,
0x029C,RC300Curves, 0x029C,RC300Curves,
0x029D,RC300Curves, 0x029D,RC300Curves,
0x029E,RC300Curves, 0x029E,RC300Curves,
@@ -112,10 +112,10 @@ telegram_type_id,name,is_fetched
0x02A0,RC300Curves, 0x02A0,RC300Curves,
0x02A1,RC300Curves, 0x02A1,RC300Curves,
0x02A2,RC300Curves, 0x02A2,RC300Curves,
0x02A5,RC300Monitor, 0x02A5,CRFMonitor,
0x02A6,RC300Monitor, 0x02A6,RC300Monitor,
0x02A7,RC300Monitor, 0x02A7,RC300Monitor,
0x02A8,RC300Monitor, 0x02A8,CRFMonitor,
0x02A9,RC300Monitor, 0x02A9,RC300Monitor,
0x02AA,RC300Monitor, 0x02AA,RC300Monitor,
0x02AB,RC300Monitor, 0x02AB,RC300Monitor,
@@ -172,20 +172,19 @@ telegram_type_id,name,is_fetched
0x0469,HPSet, 0x0469,HPSet,
0x046A,HPSet, 0x046A,HPSet,
0x0470,RC300Summer3,fetched 0x0470,RC300Summer3,fetched
0x0471,RC300Summer2, 0x0471,RC300Sumr2,
0x0472,RC300Summer2, 0x0472,RC300Sumr2,
0x0473,RC300Summer2, 0x0473,RC300Sumr2,
0x0474,RC300Summer2, 0x0474,RC300Sumr2,
0x0475,RC300Summer2, 0x0475,RC300Sumr2,
0x0476,RC300Summer2, 0x0476,RC300Sumr2,
0x0477,RC300Summer2, 0x0477,RC300Sumr2,
0x0478,RC300Summer2, 0x0478,RC300Sumr2,
0x047B,HP2, 0x047B,HP2,
0x0484,HPSilentMode,fetched 0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched 0x0485,HpCooling,fetched
0x0486,HpInConfig,fetched 0x0486,HpInConfig,fetched
0x0488,HPValve,fetched 0x0488,HPValve,fetched
0x0489,HPpassive,
0x048A,HpPool,fetched 0x048A,HpPool,fetched
0x048B,HPPumps,fetched 0x048B,HPPumps,fetched
0x048D,HpPower,fetched 0x048D,HpPower,fetched
@@ -200,7 +199,7 @@ telegram_type_id,name,is_fetched
0x04A2,HpInput,fetched 0x04A2,HpInput,fetched
0x04A5,HPFan,fetched 0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched 0x04A7,HPPowerLimit,fetched
0x04AA,HPPower, 0x04AA,HPPower2,fetched
0x04AE,HPEnergy,fetched 0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched 0x04AF,HPMeters,fetched
0x055C,VentilationSet,fetched 0x055C,VentilationSet,fetched
1 telegram_type_id name is_fetched
13 0x19 UBAMonitorSlow
14 0x1A UBASetPoints
15 0x1C UBAMaintenanceStatus
16 0x1E HydrTemp WM10TempMessage
17 0x23 JunkersSetMixer fetched
18 0x27 UBASettingsWW fetched
19 0x28 WeatherComp fetched
104 0x0292 HPMode fetched
105 0x0293 HPMode fetched
106 0x0294 HPMode fetched
107 0x029B RC300Curves fetched
108 0x029C RC300Curves
109 0x029D RC300Curves
110 0x029E RC300Curves
112 0x02A0 RC300Curves
113 0x02A1 RC300Curves
114 0x02A2 RC300Curves
115 0x02A5 RC300Monitor CRFMonitor
116 0x02A6 RC300Monitor
117 0x02A7 RC300Monitor
118 0x02A8 RC300Monitor CRFMonitor
119 0x02A9 RC300Monitor
120 0x02AA RC300Monitor
121 0x02AB RC300Monitor
172 0x0469 HPSet
173 0x046A HPSet
174 0x0470 RC300Summer3 fetched
175 0x0471 RC300Summer2 RC300Sumr2
176 0x0472 RC300Summer2 RC300Sumr2
177 0x0473 RC300Summer2 RC300Sumr2
178 0x0474 RC300Summer2 RC300Sumr2
179 0x0475 RC300Summer2 RC300Sumr2
180 0x0476 RC300Summer2 RC300Sumr2
181 0x0477 RC300Summer2 RC300Sumr2
182 0x0478 RC300Summer2 RC300Sumr2
183 0x047B HP2
184 0x0484 HPSilentMode fetched
185 0x0485 HpCooling fetched
186 0x0486 HpInConfig fetched
187 0x0488 HPValve fetched
0x0489 HPpassive
188 0x048A HpPool fetched
189 0x048B HPPumps fetched
190 0x048D HpPower fetched
199 0x04A2 HpInput fetched
200 0x04A5 HPFan fetched
201 0x04A7 HPPowerLimit fetched
202 0x04AA HPPower HPPower2 fetched
203 0x04AE HPEnergy fetched
204 0x04AF HPMeters fetched
205 0x055C VentilationSet fetched
+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.4.0", "@mui/icons-material": "^9.2.0",
"@mui/material": "^9.4.0", "@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.1", "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.5.0", "@types/node": "^25.7.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.7", "@types/react-dom": "^19.2.3",
"concurrently": "^10.0.5", "axe-core": "^4.12.1",
"eslint": "^10.10.0", "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.51.2", "vite": "^8.0.12",
"typescript": "^6.0.3", "vite-plugin-imagemin": "^0.6.1",
"typescript-eslint": "^8.70.0", "vite-tsconfig-paths": "^6.1.1"
"vite": "^8.2.2",
"vite-plugin-imagemin": "^0.6.1"
}, },
"packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
} }
+743 -677
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()
});
+26 -4
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,13 +13,35 @@ 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) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, { return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: UPLOAD_TIMEOUT, timeout: UPLOAD_TIMEOUT
enableUpload: true
}); });
}; };
-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_EXECUTED());
})
.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_EXECUTED());
})
.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 -47
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',
@@ -307,7 +284,6 @@ export const BOARD_PROFILES = {
LOLIN: 'Lolin D32', LOLIN: 'Lolin D32',
OLIMEX: 'Olimex ESP32-EVB', OLIMEX: 'Olimex ESP32-EVB',
OLIMEXPOE: 'Olimex ESP32-POE', OLIMEXPOE: 'Olimex ESP32-POE',
OLIMEXPOEW: 'Olimex ESP32-POE-ISO WROVER (PSRAM)',
C3MINI: 'Wemos C3 Mini', C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini', S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3' S3MINI: 'Liligo S3'
@@ -363,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;
} }
@@ -383,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;
@@ -430,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 {
@@ -473,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
+41 -27
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)}
@@ -165,7 +175,11 @@ const DownloadUpload = () => {
{LL.UPLOAD()} {LL.UPLOAD()}
</Typography> </Typography>
<SingleUpload text={LL.UPLOAD_BACKUP_TEXT()} doRestart={doRestart} /> <Typography sx={{ pb: 2 }} color="warning" variant="body1">
{LL.UPLOAD_TEXT()}:
</Typography>
<SingleUpload doRestart={doRestart} />
</SectionContent> </SectionContent>
); );
}; };
+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 text={LL.UPLOAD_FIRMWARE_TEXT()} 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';
@@ -22,10 +23,10 @@ import {
import * as NetworkApi from 'api/network'; import * as NetworkApi from 'api/network';
import { API } from 'api/app'; import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
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,
@@ -36,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';
@@ -51,8 +50,6 @@ import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
const NetworkSettings = () => { const NetworkSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { data: hardwareData } = useRequest(readSystemStatus);
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext); const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
@@ -92,11 +89,10 @@ 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: '*'
eth_10mbit: current_data ? current_data.eth_10mbit : false
}) })
); );
} }
@@ -125,22 +121,22 @@ 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 || !hardwareData) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
@@ -177,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}
@@ -250,23 +246,6 @@ const NetworkSettings = () => {
} }
label={LL.NETWORK_LOW_BAND()} label={LL.NETWORK_LOW_BAND()}
/> />
{hardwareData.esp_platform === 'ESP32' && (
<>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Ethernet
</Typography>
<BlockFormControlLabel
control={
<Checkbox
name="eth_10mbit"
checked={data.eth_10mbit}
onChange={updateFormValue}
/>
}
label="Force 10Mbit half-duplex"
/>
</>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.GENERAL_OPTIONS()} {LL.GENERAL_OPTIONS()}
</Typography> </Typography>
@@ -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;
+19 -11
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) {
@@ -369,7 +377,7 @@ const SystemLog = () => {
) : ( ) : (
<> <>
{data.developer_mode && ( {data.developer_mode && (
<IconButton onClick={sendReadCommand} aria-label={LL.RUN()}> <IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<PlayArrowIcon color="primary" /> <PlayArrowIcon color="primary" />
</IconButton> </IconButton>
)} )}
+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';
@@ -14,10 +14,9 @@ import { LinearProgressWithLabel } from './LinearProgressWithLabel';
interface SingleUploadProps { interface SingleUploadProps {
doRestart: () => void; doRestart: () => void;
text: string;
} }
const SingleUpload = ({ doRestart, text }: SingleUploadProps) => { const SingleUpload = ({ doRestart }: SingleUploadProps) => {
const [md5, setMd5] = useState<string>(); const [md5, setMd5] = useState<string>();
const [file, setFile] = useState<File>(); const [file, setFile] = useState<File>();
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -30,22 +29,11 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
} = useRequest(SystemApi.uploadFile, { } = useRequest(SystemApi.uploadFile, {
immediate: false immediate: false
}).onSuccess(({ data }) => { }).onSuccess(({ data }) => {
let payload = data; if (data && typeof data === 'object' && 'md5' in data) {
if (typeof payload === 'string' && payload.length > 0) { setMd5((data as { md5: string }).md5);
try { toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL());
payload = JSON.parse(payload);
} catch {
payload = data;
}
}
if (payload && typeof payload === 'object' && 'md5' in payload) {
setMd5((payload as { md5: string }).md5);
toast.success(LL.UPLOAD_MD5_RECEIVED());
setFile(undefined); setFile(undefined);
} else { } else {
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
toast.success(LL.UPLOAD_MD5_MATCHED());
}
doRestart(); doRestart();
} }
}); });
@@ -92,17 +80,12 @@ const SingleUpload = ({ doRestart, text }: SingleUploadProps) => {
</Button> </Button>
</> </>
) : ( ) : (
<DragNdrop <DragNdrop text={LL.UPLOAD_DROP_TEXT()} onFileSelected={setFile} />
text={(md5 ? LL.UPLOAD_MD5_RECEIVED() : text) + '...'}
onFileSelected={setFile}
/>
)} )}
{md5 && ( {md5 && (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Typography variant="body2" color="success"> <Typography variant="body2">{'MD5: ' + md5}</Typography>
{'MD5: ' + md5}
</Typography>
</Box> </Box>
)} )}
</> </>
@@ -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

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