mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-09-14 14:14:09 +00:00
Compare commits
67
Commits
8eb1a47b5a
..
v3.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28135c225b | ||
|
|
eaa277fef0 | ||
|
|
e418b7d8e7 | ||
|
|
a4e3be6a69 | ||
|
|
a1bc5bb055 | ||
|
|
f444ca31e0 | ||
|
|
1b70b55989 | ||
|
|
1487f30c43 | ||
|
|
e00eb8e64f | ||
|
|
f41bb3671c | ||
|
|
22c75e6df3 | ||
|
|
5ab10b7aa6 | ||
|
|
ee5fd4d0eb | ||
|
|
46f35bc67c | ||
|
|
ec85a7ec24 | ||
|
|
02f2389587 | ||
|
|
7f140021aa | ||
|
|
6796962c1e | ||
|
|
df6de21cf4 | ||
|
|
df9f75a5c9 | ||
|
|
7bd8710eb6 | ||
|
|
32f2c6d341 | ||
|
|
86919c1684 | ||
|
|
86e29515e7 | ||
|
|
46eb4185d7 | ||
|
|
8da6761a48 | ||
|
|
9233f0dfcc | ||
|
|
292f743b14 | ||
|
|
dd6dfffd57 | ||
|
|
ec705a5307 | ||
|
|
f45f071710 | ||
|
|
f3858546de | ||
|
|
d0ac0b7804 | ||
|
|
d8284ec09f | ||
|
|
6e982acde8 | ||
|
|
8c94ce99b2 | ||
|
|
fc057d18c9 | ||
|
|
18e9b99413 | ||
|
|
a47e0e8266 | ||
|
|
f412ddc716 | ||
|
|
29110e96e5 | ||
|
|
b65866217a | ||
|
|
611e3b1243 | ||
|
|
2ca0a0c634 | ||
|
|
7eb1f061b7 | ||
|
|
50459a23fe | ||
|
|
5bf53c3389 | ||
|
|
4b7aa95be3 | ||
|
|
70943f5758 | ||
|
|
3bc280b817 | ||
|
|
62b15a5319 | ||
|
|
8dd18802d6 | ||
|
|
57a516a83a | ||
|
|
a57fdaa4b3 | ||
|
|
4841e42286 | ||
|
|
8c2d2b06ed | ||
|
|
38c8b1b7f0 | ||
|
|
6fb5933a02 | ||
|
|
c0944433be | ||
|
|
478e6362c9 | ||
|
|
4d6354db78 | ||
|
|
beab0f0c77 | ||
|
|
c17749bd22 | ||
|
|
2bad769c5c | ||
|
|
8ad89ca64b | ||
|
|
9244d8daec | ||
|
|
02d01334b2 |
@@ -22,7 +22,7 @@ _Make sure your have performed every step and checked the applicable boxes befor
|
||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
||||
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
||||
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
|
||||
- [ ] Searched the issue in the [chat](https://discord.gg/GP9DPSgeJq)
|
||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
||||
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
||||
|
||||
```json
|
||||
|
||||
@@ -7,5 +7,5 @@ contact_links:
|
||||
url: https://github.com/emsesp/EMS-ESP32/discussions
|
||||
about: EMS-ESP usage Questions, Feature Requests and Projects.
|
||||
- name: EMS-ESP Users Chat
|
||||
url: https://discord.gg/GP9DPSgeJq
|
||||
url: https://discord.gg/3J3GgnzpyT
|
||||
about: Chat for feedback, questions and troubleshooting.
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build-webUI
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
@@ -62,13 +62,13 @@ jobs:
|
||||
platformio run
|
||||
|
||||
- name: Commit the generated files
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Development Build v${{steps.build_info.outputs.VERSION}}
|
||||
@@ -77,23 +77,3 @@ jobs:
|
||||
files: |
|
||||
CHANGELOG_LATEST.md
|
||||
./build/firmware/*.*
|
||||
|
||||
- name: Update version in Cloudflare KV store
|
||||
if: github.repository == 'emsesp/EMS-ESP32'
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
||||
run: |
|
||||
JSON_DATA=$(jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{version: $version, date: $date}')
|
||||
echo "JSON_DATA: $JSON_DATA"
|
||||
curl -sS --fail-with-body \
|
||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_DATA"
|
||||
echo
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: GitHub Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Build Wrapper
|
||||
|
||||
@@ -26,18 +26,11 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
|
||||
|
||||
- name: Get the EMS-ESP version
|
||||
id: build_info
|
||||
run: |
|
||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
@@ -46,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build-webUI
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
@@ -61,30 +54,10 @@ jobs:
|
||||
platformio run
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
prerelease: false
|
||||
files: |
|
||||
CHANGELOG.md
|
||||
./build/firmware/*.*
|
||||
|
||||
- name: Update version in Cloudflare KV store
|
||||
if: github.repository == 'emsesp/EMS-ESP32'
|
||||
env:
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
||||
run: |
|
||||
JSON_DATA=$(jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{version: $version, date: $date}')
|
||||
echo "JSON_DATA: $JSON_DATA"
|
||||
curl -sS --fail-with-body \
|
||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_DATA"
|
||||
echo
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Build webUI
|
||||
run: |
|
||||
platformio run -e build-webUI
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build modbus
|
||||
run: |
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
uses: emsesp/action-automatic-releases@v1.0.1
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
with:
|
||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
||||
|
||||
@@ -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
@@ -2,7 +2,6 @@
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/extensions.json
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
|
||||
# c++ compiling
|
||||
.clang_complete
|
||||
@@ -64,7 +63,7 @@ words-found-verbose.txt
|
||||
# sonarlint
|
||||
compile_commands.json
|
||||
|
||||
# other files
|
||||
# pioarduino + hybrid
|
||||
managed_components
|
||||
dependencies.lock
|
||||
CMakeLists.txt
|
||||
@@ -76,6 +75,3 @@ pnpm-lock.yaml
|
||||
.cache/
|
||||
interface/.tsbuildinfo
|
||||
test/test_api/package-lock.json
|
||||
.clangd
|
||||
mklittlefs
|
||||
.cursor
|
||||
|
||||
Vendored
+101
@@ -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"
|
||||
}
|
||||
}
|
||||
+5
-101
@@ -5,104 +5,7 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.8.3] 1 August 2026
|
||||
|
||||
## Added
|
||||
|
||||
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
|
||||
|
||||
## Fixed
|
||||
|
||||
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
|
||||
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
|
||||
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
||||
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
|
||||
- fix setting date/time on Junkers thermostats
|
||||
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
|
||||
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
|
||||
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
|
||||
- possible memory leaks fixed.
|
||||
- repeated startup schedules compute value
|
||||
- call `system/message` called from scheduler processes compute twice
|
||||
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
|
||||
|
||||
## Changed
|
||||
|
||||
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
||||
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
|
||||
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
|
||||
- block too many GET requests [mentioned in #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)
|
||||
|
||||
## [3.8.2] 12 May 2026
|
||||
|
||||
## Added
|
||||
|
||||
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
|
||||
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
|
||||
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
|
||||
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
|
||||
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
|
||||
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
|
||||
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
|
||||
- full system backup and restore
|
||||
- auto-logic to set ht3/ems+ tx-mode
|
||||
- polariity for digital_in sensors [#3070](https://github.com/emsesp/EMS-ESP32/discussions/3070)
|
||||
|
||||
## Fixed
|
||||
|
||||
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
|
||||
- missing translations [#3015](https://github.com/emsesp/EMS-ESP32/issues/3015)
|
||||
- custom entities check fetch length
|
||||
- modbus initialization [#3064](https://github.com/emsesp/EMS-ESP32/issues/3064)
|
||||
|
||||
## Changed
|
||||
|
||||
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
|
||||
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
|
||||
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
|
||||
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
|
||||
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
|
||||
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
|
||||
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
|
||||
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
|
||||
- fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017)
|
||||
- move http client from stack to heap
|
||||
- heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021)
|
||||
- check and read 0x470 as summer2_typeids[0] only if received [#2686](https://github.com/emsesp/EMS-ESP32/issues/2686), [#3055](https://github.com/emsesp/EMS-ESP32/issues/3055)
|
||||
- default bus-id: gateway1(0x49), tx-mode: auto
|
||||
|
||||
## [3.8.1] 11 January 2026
|
||||
|
||||
## Added
|
||||
|
||||
- update time saved in nvs
|
||||
- heatpump entities [#2883](https://github.com/emsesp/EMS-ESP32/issues/2883)
|
||||
- HA input number format (mode) selectable box/slider (slider for max range 100) [#2900](https://github.com/emsesp/EMS-ESP32/discussions/2900)
|
||||
|
||||
## Fixed
|
||||
|
||||
- fix EMS bus disconnected errors on some systems [#2881](https://github.com/emsesp/EMS-ESP32/issues/2881)
|
||||
- selflowtemp fix [#2876](https://github.com/emsesp/EMS-ESP32/issues/2876)
|
||||
- updated valid GPIOs for ESP32S2, ESP32S3 and ESP32 that caused custom systems to block gpios [#2887](https://github.com/emsesp/EMS-ESP32/issues/2887)
|
||||
- Junkers wwcharge offset [#2860](https://github.com/emsesp/EMS-ESP32/issues/2860)
|
||||
- fixed minflowtemp [#2890](https://github.com/emsesp/EMS-ESP32/issues/2890)
|
||||
- don't add HA uom/classes for bool values [#2885](https://github.com/emsesp/EMS-ESP32/issues/2885)
|
||||
- fixed missing progress bar on web firmware uploads
|
||||
|
||||
## Changed
|
||||
|
||||
- snapshot gpios stored in temporary ram
|
||||
- GPIOs stored along with the name and reported in log if conflicting
|
||||
- free GPIOs depend on board profile [#2901](https://github.com/emsesp/EMS-ESP32/issues/2901)
|
||||
- prefer PSram for mqtt queue [#2889](https://github.com/emsesp/EMS-ESP32/issues/2889)
|
||||
- day schedule defult to all days, no day selected is not allowed
|
||||
- board profile `CUSTOM` can only be selected in developer mode
|
||||
- mqtt sends round values without decimals (`28` instead of `28.0`)
|
||||
|
||||
|
||||
## [3.8.0] 31 December 2025
|
||||
## [3.8.0]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -123,8 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- FW200 display options [#2610](https://github.com/emsesp/EMS-ESP32/discussions/2610)
|
||||
- CR11 mode settings OFF/MANUAL depends on selTemp [#2437](https://github.com/emsesp/EMS-ESP32/issues/2437)
|
||||
- implemented eFuse settings for BBQKees boards to store model type and ESP chipset
|
||||
- analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
|
||||
- analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
|
||||
- Analogsensors for pulse output [#2624](https://github.com/emsesp/EMS-ESP32/discussions/2624)
|
||||
- Analogsensors frequency input [#2631](https://github.com/emsesp/EMS-ESP32/discussions/2631)
|
||||
- SRC plus thermostats [#2636](https://github.com/emsesp/EMS-ESP32/issues/2636)
|
||||
- Greenstar 2000 [#2645](https://github.com/emsesp/EMS-ESP32/issues/2645)
|
||||
- RC3xx `dhw modetype` [#2659](https://github.com/emsesp/EMS-ESP32/discussions/2659)
|
||||
@@ -187,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- added Home Assistant device details to MQTT Discovery for all devices
|
||||
- device_class and state_class changes for HA MQTT Discovery [#2825](https://github.com/emsesp/EMS-ESP32/issues/2825)
|
||||
|
||||
|
||||
## [3.7.2] 22 March 2025
|
||||
|
||||
## Added
|
||||
@@ -975,4 +879,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
|
||||
- new ESP32 partition side to allow for smoother OTA and fallback
|
||||
- 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>
|
||||
|
||||
@@ -1,38 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
For more details go to [emsesp.org](https://emsesp.org/).
|
||||
|
||||
## [3.9.0]
|
||||
|
||||
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
|
||||
|
||||
## Added
|
||||
|
||||
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
|
||||
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
|
||||
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
|
||||
- TLS support with 4MB boards without PSRAM
|
||||
|
||||
## Fixed
|
||||
|
||||
- shunting yard show json
|
||||
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
|
||||
- LED stayed off on a healthy system when "Disable LED" was unchecked
|
||||
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
|
||||
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
|
||||
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
|
||||
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
|
||||
|
||||
## Changed
|
||||
|
||||
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
|
||||
- Scheduler name is now mandatory
|
||||
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
|
||||
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
|
||||
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
|
||||
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
|
||||
- remove devices without entities not listed in 0x07 telegram
|
||||
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
|
||||
- set `None` for undefined values in HA `val_tpl`
|
||||
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
|
||||
|
||||
|
||||
@@ -47,27 +47,27 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
||||
#----------------------------------------------------------------------
|
||||
TARGET := emsesp
|
||||
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
|
||||
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
|
||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/semver 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/semver 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 :=
|
||||
|
||||
CPPCHECK = cppcheck
|
||||
CHECKFLAGS = -q --force --std=gnu++20
|
||||
CHECKFLAGS = -q --force --std=gnu++17
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Languages Standard
|
||||
#----------------------------------------------------------------------
|
||||
C_STANDARD := -std=c20
|
||||
CXX_STANDARD := -std=gnu++20
|
||||
C_STANDARD := -std=c17
|
||||
CXX_STANDARD := -std=gnu++17
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Defined Symbols
|
||||
#----------------------------------------------------------------------
|
||||
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 += $(ARGS)
|
||||
|
||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.8.0-dev.0\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Sources & Files
|
||||
@@ -79,10 +79,6 @@ SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
||||
|
||||
# Exclude files not needed for standalone build, if they exist
|
||||
CSOURCES := $(filter-out src/core/ModuleLibrary.c,$(CSOURCES))
|
||||
CXXSOURCES := $(filter-out src/core/ModuleLibrary.cpp,$(CXXSOURCES))
|
||||
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
|
||||
@@ -112,8 +108,8 @@ CXX := /usr/bin/g++
|
||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||
CPPFLAGS += -ggdb -g3 -MMD
|
||||
CPPFLAGS += -flto=auto
|
||||
CPPFLAGS += -Wall -Wextra -Werror
|
||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture
|
||||
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
||||
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
||||
CPPFLAGS += -Os -DNDEBUG
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="https://emsesp.org">
|
||||
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
|
||||
</a>
|
||||
<a href="https://discord.gg/GP9DPSgeJq">
|
||||
<a href="https://discord.gg/3J3GgnzpyT">
|
||||
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
|
||||
</a>
|
||||
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
|
||||
@@ -32,8 +32,7 @@
|
||||
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
||||
[](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://github.com/emsesp/EMS-ESP32/releases)
|
||||
[](https://discord.gg/GP9DPSgeJq)
|
||||
[](https://deepwiki.com/emsesp/EMS-ESP32)
|
||||
[](https://discord.gg/3J3GgnzpyT)
|
||||
|
||||
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
||||
[](https://github.com/emsesp/EMS-ESP32/network)
|
||||
@@ -41,8 +40,7 @@
|
||||
|
||||
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
||||
|
||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl>. These gateways are tested thoroughly and certified to work with EMS-ESP.
|
||||
|
||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
||||
|
||||
## 📦 **Key Features**
|
||||
|
||||
@@ -66,27 +64,29 @@ Head over to the [Installation Guide](https://emsesp.org/Installing) section of
|
||||
|
||||
## 📋 **Documentation**
|
||||
|
||||
Visit [emsesp.org](https://emsesp.org) for more details on how to setup and configure EMS-ESP. You'll also find more a collection of example configuarations, Frequently Asked Questions and Troubleshooting tips.
|
||||
Visit [emsesp.org](https://emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
|
||||
|
||||
## 💬 **Getting Support**
|
||||
|
||||
To chat with the community reach out on our [Discord Server](https://discord.gg/GP9DPSgeJq).
|
||||
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
||||
|
||||
If you find an issue or have a request, see the [Getting Support](https://emsesp.org/Support/) section of the documentation. Note if you are using a non-BBQKees EMS gateway, you may need to contact the manufacturer for support.
|
||||
If you find an issue or have a request, see [how to request support](https://emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
|
||||
## 🎥 **Live Demo**
|
||||
|
||||
To see a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language and use any username and password to log in. Note whast you're seeing is static example data so not all features are operational.
|
||||
For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data.
|
||||
|
||||
## 💖 **Contributors**
|
||||
|
||||
EMS-ESP is a project originally created by [proddy](https://github.com/proddy) and maintained by the ems-esp community.
|
||||
EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned and maintained by both [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP) with support from [BBQKees Electronics](https://bbqkees-electronics.nl).
|
||||
|
||||
If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated.
|
||||
|
||||
## 📦 **Building**
|
||||
|
||||
See the [Building the firmware](https://emsesp.org/Building) guide in the documentation for instructions on how to build EMS-ESP from this source code.
|
||||
To build the web interface only, run `platformio run -e build_webUI`. This will install the necessary dependencies and build the web interface and also create the embedded code used need to build the firmware. You can run the web interface locally by going to the `interface` directory and running `pnpm standalone`.
|
||||
|
||||
To build the firmware, run `platformio run`. This will build the firmware for all ESP32 modules and place the binaries in the `build/firmware` folder. If you want to configure the build for a single platform create a local `pio_local.ni` file in the root directory (see example in `pio_local.ini_example`).
|
||||
|
||||
## 📢 **Libraries used**
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any security vulnerabilities using the [Contact Form](https://emsesp.org/About/#-contact).
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DTASMOTA_SDK",
|
||||
"-DARDUINO_LOLIN_C3_MINI",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DTASMOTA_SDK",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=0"
|
||||
],
|
||||
@@ -36,8 +37,8 @@
|
||||
"flash_size": "4MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 4194304,
|
||||
"use_1200bps_touch": false,
|
||||
"wait_for_upload_port": false,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"arduino",
|
||||
"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": {
|
||||
"flash_size": "32MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DTASMOTA_SDK",
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "40000000L",
|
||||
"flash_mode": "dio",
|
||||
@@ -18,7 +19,7 @@
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"arduino",
|
||||
"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": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DTASMOTA_SDK",
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "40000000L",
|
||||
"flash_mode": "dio",
|
||||
|
||||
+1
-5
@@ -9,7 +9,6 @@
|
||||
}
|
||||
],
|
||||
"dictionaries": ["project-words"],
|
||||
"caseSensitive": false,
|
||||
"ignorePaths": [
|
||||
"node_modules",
|
||||
"compile_commands.json",
|
||||
@@ -36,9 +35,6 @@
|
||||
"managed_components/**",
|
||||
"pnpm-*.yaml",
|
||||
"vite.config.ts",
|
||||
"lib/esp32-psram/**",
|
||||
"test/test_api/test_api.h",
|
||||
"lib_standalone/**",
|
||||
"**/*.js"
|
||||
"lib/esp32-psram/**"
|
||||
]
|
||||
}
|
||||
+74
-332
@@ -1,343 +1,85 @@
|
||||
{
|
||||
"type": "systembackup",
|
||||
"version": "3.9.0",
|
||||
"date": "2026-06-14T10:59:16",
|
||||
"systembackup": [
|
||||
{
|
||||
"type": "settings",
|
||||
"Network": {
|
||||
"ssid": "",
|
||||
"bssid": "",
|
||||
"password": "",
|
||||
"hostname": "ems-esp",
|
||||
"static_ip_config": false,
|
||||
"bandwidth20": false,
|
||||
"nosleep": true,
|
||||
"enableMDNS": true,
|
||||
"enableCORS": false,
|
||||
"CORSOrigin": "*",
|
||||
"tx_power": 0
|
||||
},
|
||||
"AP": {
|
||||
"provision_mode": 2,
|
||||
"ssid": "ems-esp",
|
||||
"password": "ems-esp-neo",
|
||||
"channel": 1,
|
||||
"ssid_hidden": false,
|
||||
"max_clients": 4,
|
||||
"local_ip": "192.168.4.1",
|
||||
"gateway_ip": "192.168.4.1",
|
||||
"subnet_mask": "255.255.255.0"
|
||||
},
|
||||
"MQTT": {
|
||||
"enableTLS": false,
|
||||
"rootCA": "",
|
||||
"enabled": true,
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"base": "ems-esp",
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"client_id": "esp32",
|
||||
"keep_alive": 60,
|
||||
"clean_session": false,
|
||||
"entity_format": 1,
|
||||
"publish_time_boiler": 10,
|
||||
"publish_time_thermostat": 10,
|
||||
"publish_time_solar": 10,
|
||||
"publish_time_mixer": 10,
|
||||
"publish_time_water": 10,
|
||||
"publish_time_other": 60,
|
||||
"publish_time_sensor": 10,
|
||||
"publish_time_heartbeat": 60,
|
||||
"mqtt_qos": 0,
|
||||
"mqtt_retain": false,
|
||||
"ha_enabled": true,
|
||||
"nested_format": 1,
|
||||
"discovery_prefix": "homeassistant",
|
||||
"discovery_type": 0,
|
||||
"ha_number_mode": 1,
|
||||
"publish_single": false,
|
||||
"publish_single2cmd": false,
|
||||
"send_response": false
|
||||
},
|
||||
"NTP": {
|
||||
"enabled": true,
|
||||
"server": "time.google.com",
|
||||
"tz_label": "Europe/Amsterdam",
|
||||
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
},
|
||||
"Security": {
|
||||
"jwt_secret": "ems-esp-neo",
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"admin": true
|
||||
},
|
||||
{
|
||||
"username": "guest",
|
||||
"password": "guest",
|
||||
"admin": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"Settings": {
|
||||
"version": "3.9.0",
|
||||
"board_profile": "E32V2_2",
|
||||
"platform": "ESP32",
|
||||
"system_name": "prod",
|
||||
"locale": "en",
|
||||
"tx_mode": 1,
|
||||
"ems_bus_id": 73,
|
||||
"syslog_enabled": false,
|
||||
"syslog_level": 3,
|
||||
"trace_raw": false,
|
||||
"syslog_mark_interval": 0,
|
||||
"syslog_host": "",
|
||||
"syslog_port": 514,
|
||||
"boiler_heatingoff": false,
|
||||
"remote_timeout": 24,
|
||||
"remote_timeout_en": false,
|
||||
"shower_timer": true,
|
||||
"shower_alert": false,
|
||||
"shower_alert_coldshot": 10,
|
||||
"shower_alert_trigger": 7,
|
||||
"shower_min_duration": 180,
|
||||
"rx_gpio": 4,
|
||||
"tx_gpio": 5,
|
||||
"dallas_gpio": 14,
|
||||
"dallas_parasite": false,
|
||||
"led_gpio": 32,
|
||||
"hide_led": true,
|
||||
"led_type": 1,
|
||||
"low_clock": false,
|
||||
"telnet_enabled": true,
|
||||
"notoken_api": false,
|
||||
"readonly_mode": false,
|
||||
"analog_enabled": true,
|
||||
"pbutton_gpio": 34,
|
||||
"solar_maxflow": 30,
|
||||
"fahrenheit": false,
|
||||
"bool_format": 1,
|
||||
"bool_dashboard": 1,
|
||||
"enum_format": 1,
|
||||
"weblog_level": 6,
|
||||
"weblog_buffer": 50,
|
||||
"weblog_compact": true,
|
||||
"phy_type": 1,
|
||||
"eth_power": 15,
|
||||
"eth_phy_addr": 0,
|
||||
"eth_clock_mode": 1,
|
||||
"modbus_enabled": false,
|
||||
"modbus_port": 502,
|
||||
"modbus_max_clients": 10,
|
||||
"modbus_timeout": 300,
|
||||
"developer_mode": true,
|
||||
"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": "settings",
|
||||
"Network": {
|
||||
"ssid": "my_wifi_ssid",
|
||||
"bssid": "",
|
||||
"password": "my_wifi_password",
|
||||
"hostname": "ems-esp"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
"AP": {
|
||||
"provision_mode": 2,
|
||||
"ssid": "ems-esp",
|
||||
"password": "ems-esp-neo",
|
||||
"channel": 1,
|
||||
"ssid_hidden": false,
|
||||
"max_clients": 4,
|
||||
"local_ip": "192.168.4.1",
|
||||
"gateway_ip": "192.168.4.1",
|
||||
"subnet_mask": "255.255.255.0"
|
||||
},
|
||||
{
|
||||
"type": "schedule",
|
||||
"Schedule": {
|
||||
"schedule": [
|
||||
{
|
||||
"id": 1,
|
||||
"flags": 128,
|
||||
"active": false,
|
||||
"time": "",
|
||||
"cmd_name": "sendmail",
|
||||
"name": "sendmailtest"
|
||||
}
|
||||
]
|
||||
}
|
||||
"MQTT": {
|
||||
"enableTLS": false,
|
||||
"rootCA": "",
|
||||
"enabled": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 1883,
|
||||
"base": "ems-esp",
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"client_id": "ems-esp",
|
||||
"entity_format": 1,
|
||||
"publish_time_boiler": 10,
|
||||
"publish_time_thermostat": 10,
|
||||
"publish_time_solar": 10,
|
||||
"publish_time_mixer": 10,
|
||||
"publish_time_water": 10,
|
||||
"publish_time_other": 60,
|
||||
"publish_time_sensor": 10,
|
||||
"publish_time_heartbeat": 60,
|
||||
"mqtt_qos": 0,
|
||||
"mqtt_retain": false,
|
||||
"ha_enabled": false,
|
||||
"nested_format": 1,
|
||||
"discovery_prefix": "homeassistant",
|
||||
"discovery_type": 0,
|
||||
"publish_single": false,
|
||||
"publish_single2cmd": false,
|
||||
"send_response": false
|
||||
},
|
||||
{
|
||||
"type": "customizations",
|
||||
"Customizations": {
|
||||
"as": [
|
||||
{
|
||||
"gpio": 39,
|
||||
"name": "core_voltage",
|
||||
"offset": 0,
|
||||
"factor": 0.003771,
|
||||
"uom": 23,
|
||||
"type": 3,
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"gpio": 36,
|
||||
"name": "supply_voltage",
|
||||
"offset": 0,
|
||||
"factor": 0.017,
|
||||
"uom": 23,
|
||||
"type": 3,
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"gpio": 2,
|
||||
"name": "led",
|
||||
"offset": 0,
|
||||
"factor": 1,
|
||||
"uom": 0,
|
||||
"type": 6,
|
||||
"is_system": true
|
||||
}
|
||||
],
|
||||
"masked_entities": []
|
||||
}
|
||||
"NTP": {
|
||||
"enabled": true,
|
||||
"server": "time.google.com",
|
||||
"tz_label": "Europe/Amsterdam",
|
||||
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
},
|
||||
{
|
||||
"type": "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"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "modules",
|
||||
"Modules": {
|
||||
"modules": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nvs",
|
||||
"nvs": [
|
||||
"Security": {
|
||||
"jwt_secret": "ems-esp-neo",
|
||||
"users": [
|
||||
{
|
||||
"type": 1,
|
||||
"key": "nompower",
|
||||
"value": 30
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"admin": true
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"key": "fresh_firmware",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"type": 4,
|
||||
"key": "d_app0",
|
||||
"value": 1781363944
|
||||
},
|
||||
{
|
||||
"type": 66,
|
||||
"key": "nrgww",
|
||||
"value": 41852.79107
|
||||
"username": "guest",
|
||||
"password": "guest",
|
||||
"admin": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"Settings": {
|
||||
"board_profile": "S3",
|
||||
"locale": "en",
|
||||
"tx_mode": 1,
|
||||
"ems_bus_id": 11,
|
||||
"boiler_heatingoff": false,
|
||||
"hide_led": true,
|
||||
"telnet_enabled": true,
|
||||
"notoken_api": false,
|
||||
"analog_enabled": true,
|
||||
"fahrenheit": false,
|
||||
"bool_format": 1,
|
||||
"bool_dashboard": 1,
|
||||
"enum_format": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+5311
-7303
File diff suppressed because it is too large
Load Diff
+5902
-6011
File diff suppressed because it is too large
Load Diff
+10
-12
@@ -72,7 +72,6 @@ telegram_type_id,name,is_fetched
|
||||
0xE6,UBAParametersPlus,fetched
|
||||
0xE9,UBAMonitorWWPlus,
|
||||
0xEA,UBAParameterWWPlus,fetched
|
||||
0xEB,PumpKick,fetched
|
||||
0x0101,ISM1Set,fetched
|
||||
0x0103,ISM1StatusMessage,fetched
|
||||
0x0104,ISM2StatusMessage,
|
||||
@@ -112,10 +111,10 @@ telegram_type_id,name,is_fetched
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,CRFMonitor,
|
||||
0x02A5,RC300Monitor,fetched
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,CRFMonitor,
|
||||
0x02A8,RC300Monitor,
|
||||
0x02A9,RC300Monitor,
|
||||
0x02AA,RC300Monitor,
|
||||
0x02AB,RC300Monitor,
|
||||
@@ -171,15 +170,14 @@ telegram_type_id,name,is_fetched
|
||||
0x0468,HPSet,
|
||||
0x0469,HPSet,
|
||||
0x046A,HPSet,
|
||||
0x0470,RC300Summer3,fetched
|
||||
0x0471,RC300Sumr2,
|
||||
0x0472,RC300Sumr2,
|
||||
0x0473,RC300Sumr2,
|
||||
0x0474,RC300Sumr2,
|
||||
0x0475,RC300Sumr2,
|
||||
0x0476,RC300Sumr2,
|
||||
0x0477,RC300Sumr2,
|
||||
0x0478,RC300Sumr2,
|
||||
0x0471,RC300Summer2,
|
||||
0x0472,RC300Summer2,
|
||||
0x0473,RC300Summer2,
|
||||
0x0474,RC300Summer2,
|
||||
0x0475,RC300Summer2,
|
||||
0x0476,RC300Summer2,
|
||||
0x0477,RC300Summer2,
|
||||
0x0478,RC300Summer2,
|
||||
0x047B,HP2,
|
||||
0x0484,HPSilentMode,fetched
|
||||
0x0485,HpCooling,fetched
|
||||
|
||||
|
@@ -3,7 +3,7 @@ build_flags =
|
||||
; WiFi settings
|
||||
-D FACTORY_WIFI_SSID=\"\"
|
||||
-D FACTORY_WIFI_PASSWORD=\"\"
|
||||
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\"
|
||||
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
|
||||
|
||||
; Access point settings
|
||||
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
|
||||
@@ -24,7 +24,6 @@ build_flags =
|
||||
-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_SERVER=\"time.google.com\"
|
||||
-D FACTORY_NTP_THERMOSTAT_SYNC=1
|
||||
|
||||
; MQTT settings
|
||||
-D FACTORY_MQTT_ENABLED=false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"adapter": "react",
|
||||
"baseLocale": "pl",
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json"
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default defineConfig(
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
prettierConfig,
|
||||
|
||||
+41
-32
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "EMS-ESP",
|
||||
"version": "3.9.0",
|
||||
"version": "3.8.0",
|
||||
"description": "EMS-ESP WebUI",
|
||||
"homepage": "https://emsesp.org",
|
||||
"author": "emsesp.org",
|
||||
"author": "proddy, emsesp.org",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -12,51 +12,60 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
|
||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
||||
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
|
||||
"typesafe-i18n": "typesafe-i18n --no-watch",
|
||||
"build-webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
||||
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
|
||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
||||
"lint": "eslint . --fix",
|
||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/adapter-xhr": "2.3.3",
|
||||
"@alova/adapter-xhr": "2.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.3.1",
|
||||
"@mui/material": "^9.3.1",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@table-library/react-table-library": "4.1.15",
|
||||
"alova": "^3.5.5",
|
||||
"alova": "3.4.1",
|
||||
"async-validator": "^4.2.5",
|
||||
"etag": "^1.8.1",
|
||||
"formidable": "^3.5.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"preact": "^10.29.8",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-router": "^8.3.0",
|
||||
"typesafe-i18n": "^5.27.1"
|
||||
"magic-string": "^0.30.21",
|
||||
"mime-types": "^3.0.2",
|
||||
"preact": "^10.28.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.11.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"concurrently": "^10.0.5",
|
||||
"eslint": "^10.9.1",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axe-core": "^4.11.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"etag": "^1.8.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"prettier": "^3.9.6",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"terser": "^5.50.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
"vite": "^8.2.2",
|
||||
"vite-plugin-imagemin": "^0.6.1"
|
||||
"prettier": "^3.7.4",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"terser": "^5.44.1",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
"vite-tsconfig-paths": "^6.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.24.0+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000"
|
||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||
}
|
||||
|
||||
Generated
-5899
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
allowBuilds:
|
||||
cwebp-bin: false
|
||||
esbuild: false
|
||||
gifsicle: false
|
||||
jpegtran-bin: false
|
||||
mozjpeg: false
|
||||
optipng-bin: false
|
||||
pngquant-bin: false
|
||||
onlyBuiltDependencies:
|
||||
- cwebp-bin
|
||||
- esbuild
|
||||
- gifsicle
|
||||
- jpegtran-bin
|
||||
- mozjpeg
|
||||
- optipng-bin
|
||||
- pngquant-bin
|
||||
|
||||
@@ -24,60 +24,22 @@ let bundleStats = {
|
||||
other: { count: 0, uncompressed: 0, compressed: 0 }
|
||||
};
|
||||
|
||||
// AsyncWebHandler that performs the lookup.
|
||||
const generateWWWClass = () => `// Bundle Statistics:
|
||||
const generateWWWClass =
|
||||
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
||||
// Bundle Statistics:
|
||||
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
||||
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
||||
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
||||
// - Generated on: ${new Date().toISOString()}
|
||||
|
||||
struct WWWAsset {
|
||||
${INDENT}const char * uri;
|
||||
${INDENT}const char * contentType;
|
||||
${INDENT}const uint8_t * content;
|
||||
${INDENT}size_t len;
|
||||
${INDENT}const char * etag; // already includes enclosing double quotes
|
||||
class WWWData {
|
||||
${INDENT}public:
|
||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
||||
${INDENT.repeat(2)}}
|
||||
};
|
||||
|
||||
static const WWWAsset WWW_ASSETS[] = {
|
||||
${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "\\"${f.rawHash}\\""},`).join('\n')}
|
||||
};
|
||||
|
||||
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 = []) => {
|
||||
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
||||
const entryPath = resolve(dir, entry.name);
|
||||
@@ -110,7 +72,6 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
||||
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
|
||||
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
|
||||
const rawHash = hash.replace(/^"|"$/g, '');
|
||||
|
||||
zipBuffer.forEach((b) => {
|
||||
if (!(size % bytesPerLine)) {
|
||||
@@ -133,8 +94,7 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
mimeType,
|
||||
variable,
|
||||
size,
|
||||
hash,
|
||||
rawHash
|
||||
hash
|
||||
});
|
||||
|
||||
totalSize += size;
|
||||
@@ -148,12 +108,7 @@ writeStream.write(ARDUINO_INCLUDES);
|
||||
|
||||
const buildPath = resolve(sourcePath);
|
||||
for (const filePath of getFilesSync(buildPath)) {
|
||||
const relativeFilePath = relative(buildPath, filePath);
|
||||
if (isExcludedLocaleChunk(relativeFilePath)) {
|
||||
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
|
||||
continue;
|
||||
}
|
||||
writeFile(relativeFilePath, readFileSync(filePath));
|
||||
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
||||
}
|
||||
|
||||
writeStream.write(generateWWWClass());
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.6 KiB |
+38
-38
@@ -1,15 +1,14 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { ToastContainer, Zoom } from 'react-toastify';
|
||||
|
||||
import AppRouting from 'AppRouting';
|
||||
import CustomTheme from 'CustomTheme';
|
||||
import { Toaster } from 'components/toast';
|
||||
import { Authentication } from 'contexts/authentication';
|
||||
import TypesafeI18n from 'i18n/i18n-react';
|
||||
import type { Locales } from 'i18n/i18n-types';
|
||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||
|
||||
const ALL_LOCALES = [
|
||||
const AVAILABLE_LOCALES = [
|
||||
'de',
|
||||
'en',
|
||||
'it',
|
||||
@@ -20,53 +19,54 @@ const ALL_LOCALES = [
|
||||
'sk',
|
||||
'sv',
|
||||
'tr',
|
||||
'cs'
|
||||
'cz'
|
||||
] as Locales[];
|
||||
|
||||
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
|
||||
// every locale is available. `en` is always kept as the fallback locale, and the
|
||||
// progmem generator embeds the matching subset into firmware flash.
|
||||
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
|
||||
.split(',')
|
||||
.map((locale) => locale.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
|
||||
? ALL_LOCALES.filter(
|
||||
(locale) => locale === 'en' || localeAllowList.includes(locale)
|
||||
)
|
||||
: ALL_LOCALES;
|
||||
// Static toast configuration - no need to recreate on every render
|
||||
const TOAST_CONTAINER_PROPS = {
|
||||
position: 'bottom-left' as const,
|
||||
autoClose: 3000,
|
||||
hideProgressBar: false,
|
||||
newestOnTop: false,
|
||||
closeOnClick: true,
|
||||
rtl: false,
|
||||
pauseOnFocusLoss: true,
|
||||
draggable: false,
|
||||
pauseOnHover: false,
|
||||
transition: Zoom,
|
||||
closeButton: false,
|
||||
theme: 'dark' as const,
|
||||
toastStyle: {
|
||||
border: '1px solid #177ac9',
|
||||
width: 'fit-content'
|
||||
}
|
||||
};
|
||||
|
||||
const App = memo(() => {
|
||||
const [wasLoaded, setWasLoaded] = useState(false);
|
||||
const [locale, setLocale] = useState<Locales>('en');
|
||||
|
||||
useEffect(() => {
|
||||
const initializeLocale = async () => {
|
||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||
const stored = localStorage.getItem('lang');
|
||||
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
|
||||
const newLocale =
|
||||
stored && AVAILABLE_LOCALES.includes(stored as Locales)
|
||||
? (stored as Locales)
|
||||
: browserLocale;
|
||||
localStorage.setItem('lang', newLocale);
|
||||
setLocale(newLocale);
|
||||
await loadLocaleAsync(newLocale);
|
||||
setWasLoaded(true);
|
||||
};
|
||||
void initializeLocale();
|
||||
// Memoize locale initialization to prevent unnecessary re-runs
|
||||
const initializeLocale = useCallback(async () => {
|
||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
||||
localStorage.setItem('lang', newLocale);
|
||||
setLocale(newLocale);
|
||||
await loadLocaleAsync(newLocale);
|
||||
setWasLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void initializeLocale();
|
||||
}, [initializeLocale]);
|
||||
|
||||
if (!wasLoaded) return null;
|
||||
|
||||
return (
|
||||
<TypesafeI18n locale={locale}>
|
||||
<CustomTheme>
|
||||
<Authentication>
|
||||
<Outlet />
|
||||
</Authentication>
|
||||
<Toaster />
|
||||
<AppRouting />
|
||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||
</CustomTheme>
|
||||
</TypesafeI18n>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import {
|
||||
LoadingSpinner,
|
||||
RequireAuthenticated,
|
||||
RequireUnauthenticated
|
||||
} from 'components';
|
||||
import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
// Lazy load route components for better code splitting
|
||||
const SignIn = lazy(() => import('SignIn'));
|
||||
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
|
||||
|
||||
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>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<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>
|
||||
</Suspense>
|
||||
</Authentication>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppRouting;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Suspense, lazy, memo, useContext } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
|
||||
import { Layout, LoadingSpinner } from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
|
||||
// Lazy load all route components for better code splitting
|
||||
const Dashboard = lazy(() => import('app/main/Dashboard'));
|
||||
const Devices = lazy(() => import('app/main/Devices'));
|
||||
const Sensors = lazy(() => import('app/main/Sensors'));
|
||||
const Help = lazy(() => import('app/main/Help'));
|
||||
const Customizations = lazy(() => import('app/main/Customizations'));
|
||||
const Scheduler = lazy(() => import('app/main/Scheduler'));
|
||||
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
|
||||
const Modules = lazy(() => import('app/main/Modules'));
|
||||
const UserProfile = lazy(() => import('app/main/UserProfile'));
|
||||
|
||||
const Status = lazy(() => import('app/status/Status'));
|
||||
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
|
||||
const Activity = lazy(() => import('app/status/Activity'));
|
||||
const SystemLog = lazy(() => import('app/status/SystemLog'));
|
||||
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
|
||||
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
|
||||
const APStatus = lazy(() => import('app/status/APStatus'));
|
||||
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
|
||||
const Version = lazy(() => import('app/status/Version'));
|
||||
|
||||
const Settings = lazy(() => import('app/settings/Settings'));
|
||||
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
|
||||
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
|
||||
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
|
||||
const APSettings = lazy(() => import('app/settings/APSettings'));
|
||||
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
|
||||
const Network = lazy(() => import('app/settings/network/Network'));
|
||||
const Security = lazy(() => import('app/settings/security/Security'));
|
||||
|
||||
const AuthenticatedRouting = memo(() => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<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>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
|
||||
export default AuthenticatedRouting;
|
||||
+34
-33
@@ -1,24 +1,23 @@
|
||||
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 { Box, Button, Paper, Typography } from '@mui/material';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
|
||||
import * as AuthenticationApi from 'components/routing/authentication';
|
||||
import { useRequest } from 'alova/client';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
LanguageSelector,
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticationContext } from 'contexts/authentication';
|
||||
import { PROJECT_NAME } from 'env';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { SignInRequest } from 'types';
|
||||
import { onEnterCallback, updateValue } from 'utils';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
||||
|
||||
const SignIn = memo(() => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
@@ -37,19 +36,24 @@ const SignIn = memo(() => {
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
).onSuccess((response: { data: { access_token: string } }) => {
|
||||
).onSuccess((response) => {
|
||||
if (response.data) {
|
||||
authenticationContext.signIn(response.data.access_token);
|
||||
}
|
||||
});
|
||||
|
||||
const updateLoginRequestValue = updateValue((updater) =>
|
||||
setSignInRequest(
|
||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
||||
)
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const updateLoginRequestValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setSignInRequest(
|
||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const signIn = async () => {
|
||||
const signIn = useCallback(async () => {
|
||||
await callSignIn(signInRequest).catch((event: Error) => {
|
||||
if (event.message === 'Unauthorized') {
|
||||
toast.warning(LL.INVALID_LOGIN());
|
||||
@@ -58,9 +62,9 @@ const SignIn = memo(() => {
|
||||
}
|
||||
setProcessing(false);
|
||||
});
|
||||
};
|
||||
}, [callSignIn, signInRequest, LL]);
|
||||
|
||||
const validateAndSignIn = async () => {
|
||||
const validateAndSignIn = useCallback(async () => {
|
||||
setProcessing(true);
|
||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||
required: LL.IS_REQUIRED('%s')
|
||||
@@ -69,12 +73,13 @@ const SignIn = memo(() => {
|
||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||
await signIn();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
}, [signInRequest, signIn, LL]);
|
||||
|
||||
const submitOnEnter = onEnterCallback(signIn);
|
||||
// Memoize callback to prevent recreation on every render
|
||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||
|
||||
// get rid of scrollbar
|
||||
useEffect(() => {
|
||||
@@ -87,15 +92,13 @@ const SignIn = memo(() => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={(theme: Theme) => ({
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
margin: 'auto',
|
||||
padding: 2,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
maxWidth: theme.breakpoints.values.sm
|
||||
})}
|
||||
display="flex"
|
||||
height="100vh"
|
||||
margin="auto"
|
||||
padding={2}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
maxWidth={(theme) => theme.breakpoints.values.sm}
|
||||
>
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
@@ -108,18 +111,16 @@ const SignIn = memo(() => {
|
||||
width: '100%'
|
||||
})}
|
||||
>
|
||||
<Typography sx={{ mb: 1 }} variant="h4">
|
||||
<Typography mb={1} variant="h4">
|
||||
{PROJECT_NAME}
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
mt={1}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
|
||||
@@ -4,8 +4,6 @@ import type {
|
||||
APIcall,
|
||||
Action,
|
||||
Activity,
|
||||
CommandItem,
|
||||
Commands,
|
||||
CoreData,
|
||||
DashboardData,
|
||||
DeviceData,
|
||||
@@ -104,7 +102,8 @@ export const readSchedule = () =>
|
||||
o_deleted: si.deleted,
|
||||
o_flags: si.flags,
|
||||
o_time: si.time,
|
||||
o_cmd_name: si.cmd_name,
|
||||
o_cmd: si.cmd,
|
||||
o_value: si.value,
|
||||
o_name: si.name
|
||||
}));
|
||||
}
|
||||
@@ -112,24 +111,6 @@ export const readSchedule = () =>
|
||||
export const writeSchedule = (data: Schedule) =>
|
||||
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
|
||||
export const readModules = () =>
|
||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
||||
|
||||
@@ -57,3 +57,12 @@ export const alovaInstance = createAlova({
|
||||
onSuccess: handleResponse
|
||||
}
|
||||
});
|
||||
|
||||
export const alovaInstanceGH = createAlova({
|
||||
baseURL:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '/gh'
|
||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||
statesHook: ReactHook,
|
||||
requestAdapter: xhrRequestAdapter()
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 = () =>
|
||||
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
||||
|
||||
@@ -13,6 +13,29 @@ export const updateLogSettings = (data: LogSettings) =>
|
||||
alovaInstance.Post('/rest/logSettings', data);
|
||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||
|
||||
// Get versions from GitHub
|
||||
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
||||
export const getStableVersion = () =>
|
||||
alovaInstanceGH.Get('latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.substring(1),
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
}
|
||||
});
|
||||
export const getDevVersion = () =>
|
||||
alovaInstanceGH.Get('tags/latest', {
|
||||
cacheFor: 60 * 10 * 1000,
|
||||
transform(response: { data: { name: string; published_at: string } }) {
|
||||
return {
|
||||
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
||||
published_at: response.data.published_at
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||
|
||||
export const uploadFile = (file: File) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -1,206 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
|
||||
import { callAction } from '@/api/app';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import type { CommandItem } from './types';
|
||||
|
||||
interface CommandsDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (ci: CommandItem) => void;
|
||||
selectedItem: CommandItem;
|
||||
validator: Schema;
|
||||
}
|
||||
|
||||
const CommandsDialog = ({
|
||||
open,
|
||||
creating,
|
||||
onClose,
|
||||
onSave,
|
||||
selectedItem,
|
||||
validator
|
||||
}: CommandsDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
||||
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
setEditItem(selectedItem);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const hasChanged = (ci: CommandItem) =>
|
||||
ci.id !== ci.o_id ||
|
||||
(ci.name || '') !== (ci.o_name || '') ||
|
||||
ci.cmd !== ci.o_cmd ||
|
||||
ci.value !== ci.o_value ||
|
||||
ci.deleted !== ci.o_deleted;
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(hasChanged(editItem));
|
||||
}, [editItem]);
|
||||
|
||||
const handleSave = async (itemToSave: CommandItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
} finally {
|
||||
// setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
await handleSave(editItem);
|
||||
};
|
||||
|
||||
const { send: executeCommand } = useRequest(
|
||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||
{ immediate: false }
|
||||
)
|
||||
.onSuccess(() => {
|
||||
toast.success(LL.COMMAND_SENT());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const execute = async () => {
|
||||
await executeCommand(editItem.name);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
};
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>
|
||||
{creating ? `${LL.ADD(1)} ` : LL.UPDATE() + '/' + LL.RUN()}
|
||||
{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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
@@ -57,18 +57,20 @@ const CustomEntities = () => {
|
||||
initialData: []
|
||||
});
|
||||
|
||||
useInterval(() => {
|
||||
const intervalCallback = useCallback(() => {
|
||||
if (!dialogOpen && !numChanges) {
|
||||
void fetchEntities();
|
||||
}
|
||||
});
|
||||
}, [dialogOpen, numChanges, fetchEntities]);
|
||||
|
||||
useInterval(intervalCallback);
|
||||
|
||||
const { send: writeEntities } = useRequest(
|
||||
(data: Entities) => writeCustomEntities(data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const hasEntityChanged = (ei: EntityItem) => {
|
||||
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
||||
return (
|
||||
ei.id !== ei.o_id ||
|
||||
ei.ram !== ei.o_ram ||
|
||||
@@ -84,19 +86,21 @@ const CustomEntities = () => {
|
||||
ei.deleted !== ei.o_deleted ||
|
||||
(ei.value || '') !== (ei.o_value || '')
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const entity_theme = useTheme({
|
||||
Table: `
|
||||
const entity_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -116,7 +120,7 @@ const CustomEntities = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -125,7 +129,7 @@ const CustomEntities = () => {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -136,9 +140,11 @@ const CustomEntities = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const saveEntities = async () => {
|
||||
const saveEntities = useCallback(async () => {
|
||||
await writeEntities({
|
||||
entities: entities
|
||||
.filter((ei: EntityItem) => !ei.deleted)
|
||||
@@ -167,41 +173,44 @@ const CustomEntities = () => {
|
||||
await fetchEntities();
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
}, [entities, writeEntities, LL, fetchEntities]);
|
||||
|
||||
const editEntityItem = (ei: EntityItem) => {
|
||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
||||
setCreating(false);
|
||||
setSelectedEntityItem(ei);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogClose = () => {
|
||||
const onDialogClose = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogCancel = async () => {
|
||||
const onDialogCancel = useCallback(async () => {
|
||||
await fetchEntities().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
}, [fetchEntities]);
|
||||
|
||||
const onDialogSave = (updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((ei) =>
|
||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||
);
|
||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
? [
|
||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||
updatedItem
|
||||
]
|
||||
: data.map((ei) =>
|
||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||
);
|
||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||
return new_data;
|
||||
});
|
||||
},
|
||||
[creating, hasEntityChanged]
|
||||
);
|
||||
|
||||
const onDialogDup = (item: EntityItem) => {
|
||||
const onDialogDup = useCallback((item: EntityItem) => {
|
||||
setCreating(true);
|
||||
setSelectedEntityItem({
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
@@ -219,9 +228,9 @@ const CustomEntities = () => {
|
||||
value: item.value
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addEntityItem = () => {
|
||||
const addEntityItem = useCallback(() => {
|
||||
setCreating(true);
|
||||
setSelectedEntityItem({
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
@@ -239,27 +248,30 @@ const CustomEntities = () => {
|
||||
value: ''
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatValue = (value: unknown, uom: number) => {
|
||||
const formatValue = useCallback((value: unknown, uom: number) => {
|
||||
return value === undefined
|
||||
? ''
|
||||
: typeof value === 'number'
|
||||
? new Intl.NumberFormat().format(value) +
|
||||
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
||||
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showHex = (value: number, digit: number) => {
|
||||
const showHex = useCallback((value: number, digit: number) => {
|
||||
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedEntities =
|
||||
entities
|
||||
?.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
|
||||
const filteredAndSortedEntities = useMemo(
|
||||
() =>
|
||||
entities
|
||||
?.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
||||
[entities]
|
||||
);
|
||||
|
||||
const renderEntity = () => {
|
||||
const renderEntity = useCallback(() => {
|
||||
if (!entities) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||
@@ -298,15 +310,13 @@ const CustomEntities = () => {
|
||||
/>
|
||||
)}
|
||||
</Cell>
|
||||
<Cell>{ei.ram > 0 ? '' : showHex(ei.device_id as number, 2)}</Cell>
|
||||
<Cell>{ei.ram > 0 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
||||
<Cell>{ei.ram > 0 ? '' : ei.offset}</Cell>
|
||||
<Cell>
|
||||
{ei.ram === 1
|
||||
? 'RAM'
|
||||
: ei.ram === 2
|
||||
? 'NVS'
|
||||
: DeviceValueTypeNames[ei.value_type]}
|
||||
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
|
||||
</Cell>
|
||||
<Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
||||
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
|
||||
<Cell>
|
||||
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
|
||||
</Cell>
|
||||
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
|
||||
</Row>
|
||||
@@ -316,14 +326,24 @@ const CustomEntities = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
entities,
|
||||
error,
|
||||
fetchEntities,
|
||||
entity_theme,
|
||||
editEntityItem,
|
||||
LL,
|
||||
filteredAndSortedEntities,
|
||||
showHex,
|
||||
formatValue
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.ENTITIES_HELP_1()}.
|
||||
</Typography>
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
|
||||
{renderEntity()}
|
||||
|
||||
@@ -339,8 +359,8 @@ const CustomEntities = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box mt={2} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges > 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -362,7 +382,7 @@ const CustomEntities = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -7,7 +7,7 @@ import DoneIcon from '@mui/icons-material/Done';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { validate } from 'validators';
|
||||
|
||||
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { EntityItem } from './types';
|
||||
@@ -68,10 +68,14 @@ const CustomEntitiesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,16 +105,16 @@ const CustomEntitiesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
@@ -132,23 +136,29 @@ const CustomEntitiesDialog = ({
|
||||
}
|
||||
onSave(processedItem);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const remove = () => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
};
|
||||
const remove = useCallback(() => {
|
||||
const itemWithDeleted = { ...editItem, deleted: true };
|
||||
onSave(itemWithDeleted);
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const dup = () => {
|
||||
const dup = useCallback(() => {
|
||||
onDup(editItem);
|
||||
};
|
||||
}, [editItem, onDup]);
|
||||
|
||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
));
|
||||
// Memoize UOM menu items to avoid recreating on every render
|
||||
const uomMenuItems = useMemo(
|
||||
() =>
|
||||
DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -168,7 +178,7 @@ const CustomEntitiesDialog = ({
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid sx={{ mt: 3 }}>
|
||||
<Grid mt={3}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -195,17 +205,16 @@ const CustomEntitiesDialog = ({
|
||||
>
|
||||
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
|
||||
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
|
||||
<MenuItem value={2}>NVS-{LL.VALUE(1)}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
{editItem.ram > 0 && (
|
||||
{editItem.ram === 1 && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="value"
|
||||
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
|
||||
type="string"
|
||||
value={editItem.value}
|
||||
value={editItem.value as string}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
fullWidth
|
||||
@@ -228,7 +237,7 @@ const CustomEntitiesDialog = ({
|
||||
)}
|
||||
{editItem.ram === 0 && (
|
||||
<>
|
||||
<Grid sx={{ mt: 3 }}>
|
||||
<Grid mt={3}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -250,7 +259,7 @@ const CustomEntitiesDialog = ({
|
||||
margin="normal"
|
||||
sx={{ width: '11ch' }}
|
||||
type="string"
|
||||
value={editItem.device_id}
|
||||
value={editItem.device_id as string}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
@@ -270,7 +279,7 @@ const CustomEntitiesDialog = ({
|
||||
margin="normal"
|
||||
sx={{ width: '11ch' }}
|
||||
type="string"
|
||||
value={editItem.type_id}
|
||||
value={editItem.type_id as string}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
@@ -371,7 +380,7 @@ const CustomEntitiesDialog = ({
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="factor"
|
||||
label={LL.BITMASK()}
|
||||
value={editItem.factor}
|
||||
value={editItem.factor as string}
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
@@ -394,7 +403,7 @@ const CustomEntitiesDialog = ({
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box flexGrow={1}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBlocker, useLocation } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import {
|
||||
@@ -111,14 +111,13 @@ const Customizations = () => {
|
||||
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
|
||||
useState<string>(''); // needed for API URL
|
||||
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
|
||||
const [selectedDeviceBrand, setSelectedDeviceBrand] = useState<string>('');
|
||||
|
||||
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const { send: sendDeviceName } = useRequest(
|
||||
(data: { id: number; name: string; brand: string }) => writeDeviceName(data),
|
||||
(data: { id: number; name: string }) => writeDeviceName(data),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
@@ -171,17 +170,19 @@ const Customizations = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const entities_theme = useTheme({
|
||||
Table: `
|
||||
const entities_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(3) {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -192,7 +193,7 @@ const Customizations = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -204,7 +205,7 @@ const Customizations = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -220,7 +221,7 @@ const Customizations = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -234,7 +235,9 @@ const Customizations = () => {
|
||||
padding-right: 8px;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
function hasEntityChanged(de: DeviceEntity) {
|
||||
return (
|
||||
@@ -264,7 +267,6 @@ const Customizations = () => {
|
||||
if (device) {
|
||||
setSelectedDeviceTypeNameURL(device.url || '');
|
||||
setSelectedDeviceName(device.n);
|
||||
setSelectedDeviceBrand(device.b);
|
||||
}
|
||||
setNumChanges(0);
|
||||
setRestartNeeded(false);
|
||||
@@ -283,23 +285,26 @@ const Customizations = () => {
|
||||
return value as string;
|
||||
}
|
||||
|
||||
const isCommand = (de: DeviceEntity) => {
|
||||
const isCommand = useCallback((de: DeviceEntity) => {
|
||||
return de.n && de.n[0] === '!';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatName = (de: DeviceEntity, withShortname: boolean) => {
|
||||
let name: string;
|
||||
if (isCommand(de)) {
|
||||
name = de.t
|
||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||
} else if (de.cn && de.cn !== '') {
|
||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||
} else {
|
||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||
}
|
||||
return withShortname ? `${name} ${de.id}` : name;
|
||||
};
|
||||
const formatName = useCallback(
|
||||
(de: DeviceEntity, withShortname: boolean) => {
|
||||
let name: string;
|
||||
if (isCommand(de)) {
|
||||
name = de.t
|
||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||
} else if (de.cn && de.cn !== '') {
|
||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||
} else {
|
||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||
}
|
||||
return withShortname ? `${name} ${de.id}` : name;
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
|
||||
const getMaskNumber = (newMask: string[]) => {
|
||||
let new_mask = 0;
|
||||
@@ -329,27 +334,33 @@ const Customizations = () => {
|
||||
return new_masks;
|
||||
};
|
||||
|
||||
const filter_entity = (de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
||||
const filter_entity = useCallback(
|
||||
(de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) &&
|
||||
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
||||
[selectedFilters, search, formatName]
|
||||
);
|
||||
|
||||
const maskDisabled = (set: boolean) => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (filter_entity(de)) {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
}
|
||||
return de;
|
||||
})
|
||||
);
|
||||
};
|
||||
const maskDisabled = useCallback(
|
||||
(set: boolean) => {
|
||||
setDeviceEntities((prev) =>
|
||||
prev.map((de) => {
|
||||
if (filter_entity(de)) {
|
||||
const excludeMask =
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
return {
|
||||
...de,
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
}
|
||||
return de;
|
||||
})
|
||||
);
|
||||
},
|
||||
[filter_entity]
|
||||
);
|
||||
|
||||
const resetCustomization = async () => {
|
||||
const resetCustomization = useCallback(async () => {
|
||||
try {
|
||||
await sendResetCustomizations();
|
||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||
@@ -359,27 +370,30 @@ const Customizations = () => {
|
||||
setConfirmReset(false);
|
||||
setRestarting(true);
|
||||
}
|
||||
};
|
||||
}, [sendResetCustomizations, LL]);
|
||||
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
setDeviceEntities(
|
||||
(prev) =>
|
||||
prev?.map((de) =>
|
||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||
) ?? []
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
};
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
},
|
||||
[updateDeviceEntity]
|
||||
);
|
||||
|
||||
const editDeviceEntity = (de: DeviceEntity) => {
|
||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||
return;
|
||||
}
|
||||
@@ -390,9 +404,9 @@ const Customizations = () => {
|
||||
|
||||
setSelectedDeviceEntity(de);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveCustomization = async () => {
|
||||
const saveCustomization = useCallback(async () => {
|
||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||
return;
|
||||
}
|
||||
@@ -425,14 +439,10 @@ const Customizations = () => {
|
||||
.finally(() => {
|
||||
setOriginalSettings(deviceEntities);
|
||||
});
|
||||
};
|
||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||
|
||||
const renameDevice = async () => {
|
||||
await sendDeviceName({
|
||||
id: selectedDevice,
|
||||
name: selectedDeviceName,
|
||||
brand: selectedDeviceBrand
|
||||
})
|
||||
const renameDevice = useCallback(async () => {
|
||||
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.NAME(1)));
|
||||
})
|
||||
@@ -443,35 +453,24 @@ const Customizations = () => {
|
||||
setRename(false);
|
||||
await fetchCoreData();
|
||||
});
|
||||
};
|
||||
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
|
||||
|
||||
const renderDeviceList = () => (
|
||||
<>
|
||||
<Typography sx={{ mb: 1 }} color="warning" variant="body1">
|
||||
{LL.CUSTOMIZATIONS_HELP_1()}.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 2 }}>
|
||||
<Box mb={1} color="warning.main">
|
||||
<Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
|
||||
{rename ? (
|
||||
<>
|
||||
<TextField
|
||||
name="device"
|
||||
label={LL.EMS_DEVICE()}
|
||||
style={{ minWidth: '48%' }}
|
||||
variant="outlined"
|
||||
value={selectedDeviceName}
|
||||
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
name="brand"
|
||||
label={LL.BRAND()}
|
||||
style={{ minWidth: '48%' }}
|
||||
variant="outlined"
|
||||
value={selectedDeviceBrand}
|
||||
onChange={(e) => setSelectedDeviceBrand(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
</>
|
||||
<TextField
|
||||
name="device"
|
||||
label={LL.EMS_DEVICE()}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={selectedDeviceName}
|
||||
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
name="device"
|
||||
@@ -489,8 +488,7 @@ const Customizations = () => {
|
||||
</MenuItem>
|
||||
{devices.devices.map(
|
||||
(device: Device) =>
|
||||
device.id < 90 &&
|
||||
device.e !== 0 && (
|
||||
device.id < 90 && (
|
||||
<MenuItem key={device.id} value={device.id}>
|
||||
{device.n} ({device.tn})
|
||||
</MenuItem>
|
||||
@@ -540,27 +538,35 @@ const Customizations = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
|
||||
const filteredEntities = useMemo(
|
||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
||||
[deviceEntities, filter_entity]
|
||||
);
|
||||
|
||||
const renderDeviceData = () => {
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ mt: 1, mb: 1 }} color="warning" variant="body2">
|
||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
||||
|
||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
||||
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
||||
{LL.CUSTOMIZATIONS_HELP_4()}
|
||||
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()}
|
||||
|
||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
||||
</Typography>
|
||||
<Box color="warning.main">
|
||||
<Typography variant="body2" mt={1} mb={1}>
|
||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
||||
|
||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
||||
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
||||
{LL.CUSTOMIZATIONS_HELP_4()}
|
||||
<OptionIcon type="web_exclude" isSet={true} />=
|
||||
{LL.CUSTOMIZATIONS_HELP_5()}
|
||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid
|
||||
container
|
||||
mb={1}
|
||||
mt={0}
|
||||
spacing={2}
|
||||
direction="row"
|
||||
sx={{ mb: 1, mt: 0, justifyContent: 'flex-start', alignItems: 'center' }}
|
||||
justifyContent="flex-start"
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid>
|
||||
<TextField
|
||||
@@ -749,8 +755,8 @@ const Customizations = () => {
|
||||
</Button>
|
||||
</MessageBox>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -782,12 +788,10 @@ const Customizations = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
return restarting ? (
|
||||
<SystemMonitor />
|
||||
) : (
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{renderContent()}
|
||||
{restarting ? <SystemMonitor /> : renderContent()}
|
||||
{selectedDeviceEntity && (
|
||||
<SettingsCustomizationsDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -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 CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -37,7 +37,7 @@ interface LabelValueProps {
|
||||
|
||||
const LabelValue = memo(({ label, value }: LabelValueProps) => (
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{label}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
@@ -57,16 +57,23 @@ const CustomizationsDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const isWriteableNumber =
|
||||
typeof editItem.v === 'number' &&
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
||||
const isWriteableNumber = useMemo(
|
||||
() =>
|
||||
typeof editItem.v === 'number' &&
|
||||
editItem.w &&
|
||||
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
||||
[editItem.v, editItem.w, editItem.m]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -75,16 +82,16 @@ const CustomizationsDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
const save = useCallback(() => {
|
||||
if (
|
||||
isWriteableNumber &&
|
||||
editItem.mi &&
|
||||
@@ -95,33 +102,36 @@ const CustomizationsDialog = ({
|
||||
} else {
|
||||
onSave(editItem);
|
||||
}
|
||||
};
|
||||
}, [isWriteableNumber, editItem, onSave]);
|
||||
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
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 (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
||||
<LabelValue
|
||||
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
||||
value={editItem.n}
|
||||
/>
|
||||
<LabelValue
|
||||
label={LL.WRITEABLE()}
|
||||
value={
|
||||
editItem.w ? (
|
||||
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
|
||||
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Box mt={1} mb={2}>
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
||||
</Box>
|
||||
|
||||
@@ -162,7 +172,7 @@ const CustomizationsDialog = ({
|
||||
</Grid>
|
||||
|
||||
{error && (
|
||||
<Typography sx={{ mt: 2 }} variant="body2" color="error">
|
||||
<Typography variant="body2" color="error" mt={2}>
|
||||
Error: Check min and max values
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -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 { Link } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import {
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval, usePersistState } from 'utils';
|
||||
@@ -76,38 +75,42 @@ const Dashboard = memo(() => {
|
||||
{
|
||||
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) => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction()
|
||||
if (devicevalue.v !== undefined) {
|
||||
const deviceValueDialogSave = useCallback(
|
||||
async (devicevalue: DeviceValue) => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
|
||||
}
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
};
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||
.then(() => {
|
||||
toast.success(LL.WRITE_CMD_SENT());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
});
|
||||
},
|
||||
[selectedDashboardItem, sendDeviceValue, LL]
|
||||
);
|
||||
|
||||
const dashboard_theme = useTheme({
|
||||
Table: `
|
||||
const dashboard_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1e1e1e;
|
||||
&:nth-of-type(odd) .td {
|
||||
@@ -117,7 +120,7 @@ const Dashboard = memo(() => {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -125,7 +128,9 @@ const Dashboard = memo(() => {
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const tree = useTree(
|
||||
{ nodes: [...data.nodes] },
|
||||
@@ -159,71 +164,79 @@ const Dashboard = memo(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const nodeIds = useMemo(
|
||||
() => data.nodes.map((item: DashboardItem) => item.id),
|
||||
[data.nodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
|
||||
showAll
|
||||
? tree.fns.onAddAll(nodeIds) // expand tree
|
||||
: tree.fns.onRemoveAll(); // collapse tree
|
||||
}, [parentNodes]);
|
||||
|
||||
const showType = (n?: string, t?: number) => {
|
||||
// if we have a name show it
|
||||
if (n) {
|
||||
return n;
|
||||
}
|
||||
if (t) {
|
||||
// otherwise pick translation based on type
|
||||
switch (t) {
|
||||
case DeviceType.CUSTOM:
|
||||
return LL.CUSTOM_ENTITIES(0);
|
||||
case DeviceType.ANALOGSENSOR:
|
||||
return LL.ANALOG_SENSORS();
|
||||
case DeviceType.TEMPERATURESENSOR:
|
||||
return LL.TEMP_SENSORS();
|
||||
case DeviceType.COMMAND:
|
||||
return LL.COMMANDS();
|
||||
case DeviceType.SCHEDULER:
|
||||
return LL.SCHEDULER();
|
||||
default:
|
||||
break;
|
||||
const showType = useCallback(
|
||||
(n?: string, t?: number) => {
|
||||
// if we have a name show it
|
||||
if (n) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const showName = (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} />
|
||||
{showType(di.n, di.t)}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</span>
|
||||
);
|
||||
if (t) {
|
||||
// otherwise pick translation based on type
|
||||
switch (t) {
|
||||
case DeviceType.CUSTOM:
|
||||
return LL.CUSTOM_ENTITIES(0);
|
||||
case DeviceType.ANALOGSENSOR:
|
||||
return LL.ANALOG_SENSORS();
|
||||
case DeviceType.TEMPERATURESENSOR:
|
||||
return LL.TEMP_SENSORS();
|
||||
case DeviceType.SCHEDULER:
|
||||
return LL.SCHEDULER();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (di.dv) {
|
||||
return <span>{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return '';
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
|
||||
const hasMask = (id: string, mask: number) =>
|
||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||
const showName = useCallback(
|
||||
(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} />
|
||||
{showType(di.n, di.t)}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (di.dv) {
|
||||
return <span>{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[showType]
|
||||
);
|
||||
|
||||
const editDashboardValue = (di: DashboardItem) => {
|
||||
// don't execute on parent nodes
|
||||
if (!me.admin || di.id <= 99) {
|
||||
return;
|
||||
}
|
||||
const hasMask = useCallback(
|
||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||
[]
|
||||
);
|
||||
|
||||
if (di.dv?.c) {
|
||||
setSelectedDashboardItem(di);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}
|
||||
};
|
||||
const editDashboardValue = useCallback(
|
||||
(di: DashboardItem) => {
|
||||
if (me.admin && di.dv?.c) {
|
||||
setSelectedDashboardItem(di);
|
||||
setDeviceValueDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
|
||||
const handleShowAll = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
@@ -235,9 +248,10 @@ const Dashboard = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasFavEntities = data.nodes.filter(
|
||||
(item: DashboardItem) => item.id <= 90
|
||||
).length;
|
||||
const hasFavEntities = useMemo(
|
||||
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
||||
[data.nodes]
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!data) {
|
||||
@@ -248,8 +262,12 @@ const Dashboard = memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data.connected && (
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
)}
|
||||
|
||||
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
||||
<MessageBox sx={{ mb: 2 }} level="warning">
|
||||
<MessageBox mb={2} level="warning">
|
||||
<Typography>
|
||||
{LL.NO_DATA_1()}
|
||||
<Link to="/customizations" style={{ color: 'white' }}>
|
||||
@@ -266,12 +284,10 @@ const Dashboard = memo(() => {
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'nowrap',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
flexWrap="nowrap"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
@@ -294,7 +310,7 @@ const Dashboard = memo(() => {
|
||||
</Box>
|
||||
|
||||
{data.nodes.length > 0 ? (
|
||||
<Box sx={{ mt: 1, justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<Box mt={1} justifyContent="center" flexDirection="column">
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
@@ -330,34 +346,20 @@ const Dashboard = memo(() => {
|
||||
<Cell>
|
||||
{me.admin &&
|
||||
di.dv?.c &&
|
||||
!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>
|
||||
) : (
|
||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={
|
||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
||||
}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<EditIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
))}
|
||||
)}
|
||||
</Cell>
|
||||
</>
|
||||
) : (
|
||||
@@ -375,8 +377,13 @@ const Dashboard = memo(() => {
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography sx={{ mt: 1 }} color="warning" variant="body1">
|
||||
<Box
|
||||
display="flex"
|
||||
// justifyContent="flex-end"
|
||||
// flexWrap="nowrap"
|
||||
// whiteSpace="nowrap"
|
||||
>
|
||||
<Typography mt={1} color="warning.main" variant="body1">
|
||||
no data
|
||||
</Typography>
|
||||
<Tooltip title={LL.DASHBOARD_1()}>
|
||||
@@ -402,10 +409,7 @@ const Dashboard = memo(() => {
|
||||
{selectedDashboardItem && selectedDashboardItem.dv && (
|
||||
<DevicesDialog
|
||||
open={deviceValueDialogOpen}
|
||||
onClose={() => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDashboardItem(undefined);
|
||||
}}
|
||||
onClose={() => setDeviceValueDialogOpen(false)}
|
||||
onSave={deviceValueDialogSave}
|
||||
selectedItem={selectedDashboardItem.dv}
|
||||
writeable={true}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
|
||||
import { CgSmartHomeBoiler } from 'react-icons/cg';
|
||||
import { FaSolarPanel } from 'react-icons/fa';
|
||||
@@ -16,9 +15,14 @@ import { PiFan, PiGauge } from 'react-icons/pi';
|
||||
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
|
||||
import { VscVmConnect } from 'react-icons/vsc';
|
||||
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
import { DeviceType } from './types';
|
||||
|
||||
const deviceIconLookup: Record<DeviceType, IconType | null> = {
|
||||
const deviceIconLookup: Record<
|
||||
DeviceType,
|
||||
React.ComponentType<SvgIconProps> | null
|
||||
> = {
|
||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
||||
@@ -38,7 +42,6 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
|
||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||
[DeviceType.SYSTEM]: null,
|
||||
[DeviceType.COMMAND]: MdPlaylistAdd,
|
||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||
[DeviceType.VENTILATION]: PiFan
|
||||
|
||||
+128
-129
@@ -4,10 +4,12 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import ConstructionIcon from '@mui/icons-material/Construction';
|
||||
@@ -63,7 +65,6 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
@@ -132,19 +133,21 @@ const Devices = memo(() => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftOffset = () => {
|
||||
const leftOffset = useCallback(() => {
|
||||
const devicesWindow = document.getElementById('devices-window');
|
||||
if (!devicesWindow) return 0;
|
||||
const { left, right } = devicesWindow.getBoundingClientRect();
|
||||
if (!left || !right) return 0;
|
||||
return left + (right - left < 400 ? 0 : 200);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const common_theme = useTheme({
|
||||
BaseRow: `
|
||||
const common_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -152,7 +155,7 @@ const Devices = memo(() => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1E1E1E;
|
||||
.td {
|
||||
@@ -162,78 +165,88 @@ const Devices = memo(() => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const device_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
BaseRow: `
|
||||
font-size: 15px;
|
||||
.td {
|
||||
height: 28px;
|
||||
const device_theme = useMemo(
|
||||
() =>
|
||||
useTheme([
|
||||
common_theme,
|
||||
{
|
||||
BaseRow: `
|
||||
font-size: 15px;
|
||||
.td {
|
||||
height: 28px;
|
||||
}
|
||||
`,
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
padding: 8px;
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
|
||||
const data_theme = useMemo(
|
||||
() =>
|
||||
useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
overflow-y: scroll;
|
||||
::-webkit-scrollbar {
|
||||
display:none;
|
||||
}
|
||||
`,
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
padding: 8px;
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
BaseRow: `
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
border-left: 1px solid #177ac9;
|
||||
},
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
},
|
||||
&:nth-of-type(3) {
|
||||
border-right: 1px solid #177ac9;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
border-top: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`
|
||||
}
|
||||
]);
|
||||
|
||||
const 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;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
BaseRow: `
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
border-left: 1px solid #177ac9;
|
||||
},
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
},
|
||||
&:nth-of-type(3) {
|
||||
border-right: 1px solid #177ac9;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
.th {
|
||||
border-top: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}
|
||||
]);
|
||||
`
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
@@ -332,8 +345,10 @@ const Devices = memo(() => {
|
||||
return sc;
|
||||
};
|
||||
|
||||
const hasMask = (id: string, mask: number) =>
|
||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||
const hasMask = useCallback(
|
||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDownloadCsv = () => {
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
@@ -519,19 +534,9 @@ const Devices = memo(() => {
|
||||
const renderCoreData = () => (
|
||||
<>
|
||||
{!coreData.connected ? (
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}>
|
||||
(
|
||||
<Link
|
||||
target="_blank"
|
||||
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting"
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{LL.ONLINE_HELP()}
|
||||
</Link>
|
||||
)
|
||||
</MessageBox>
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
) : (
|
||||
<Box sx={{ justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<Box justifyContent="center" flexDirection="column">
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
@@ -558,26 +563,16 @@ const Devices = memo(() => {
|
||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||
)}
|
||||
{tableList.map((device: Device) => (
|
||||
<Row key={device.id} item={device} disabled={device.e === 0}>
|
||||
<Row key={device.id} item={device}>
|
||||
<Cell>
|
||||
<DeviceIcon type_id={device.t} />
|
||||
|
||||
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
|
||||
{device.n}
|
||||
{device.n}
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
</span>
|
||||
{device.e !== 0 && (
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({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 stiff>{device.tn}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
@@ -592,7 +587,6 @@ const Devices = memo(() => {
|
||||
|
||||
const deviceValueDialogClose = () => {
|
||||
setDeviceValueDialogOpen(false);
|
||||
setSelectedDeviceValue(undefined);
|
||||
if (selectedDevice !== undefined) {
|
||||
void sendDeviceData(selectedDevice);
|
||||
}
|
||||
@@ -603,35 +597,41 @@ const Devices = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const showDeviceValue = (dv: DeviceValue) => {
|
||||
const showDeviceValue = useCallback((dv: DeviceValue) => {
|
||||
setSelectedDeviceValue(dv);
|
||||
setDeviceValueDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderNameCell = (dv: DeviceValue) => (
|
||||
<>
|
||||
{dv.id.slice(2)}
|
||||
{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_API_MQTT_EXCLUDE) && (
|
||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</>
|
||||
const renderNameCell = useCallback(
|
||||
(dv: DeviceValue) => (
|
||||
<>
|
||||
{dv.id.slice(2)}
|
||||
{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_API_MQTT_EXCLUDE) && (
|
||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[hasMask]
|
||||
);
|
||||
|
||||
const shown_data = onlyFav
|
||||
? deviceData.nodes.filter(
|
||||
const shown_data = useMemo(() => {
|
||||
if (onlyFav) {
|
||||
return deviceData.nodes.filter(
|
||||
(dv: DeviceValue) =>
|
||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: deviceData.nodes.filter((dv: DeviceValue) =>
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
return deviceData.nodes.filter((dv: DeviceValue) =>
|
||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [deviceData.nodes, onlyFav, search]);
|
||||
|
||||
const deviceIndex = coreData.devices.findIndex(
|
||||
(d: Device) => d.id === device_select.state.id
|
||||
@@ -660,12 +660,12 @@ const Devices = memo(() => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Grid container sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography noWrap variant="subtitle1" color="warning">
|
||||
<Grid container justifyContent="space-between">
|
||||
<Typography noWrap variant="subtitle1" color="warning.main">
|
||||
{deviceInfo.n} (
|
||||
{deviceInfo.tn})
|
||||
</Typography>
|
||||
<Grid sx={{ justifyContent: 'flex-end' }}>
|
||||
<Grid justifyContent="flex-end">
|
||||
<ButtonTooltip title={LL.CLOSE()}>
|
||||
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
||||
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
@@ -788,7 +788,6 @@ const Devices = memo(() => {
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => showDeviceValue(dv)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
{dv.v === '' ? (
|
||||
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -19,18 +18,15 @@ import {
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import { callAction } from '@/api/app';
|
||||
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 { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { validate } from 'validators';
|
||||
|
||||
import { DeviceValueUOM, DeviceValueUOM_s, getDeviceValueKind } from './types';
|
||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||
import type { DeviceValue } from './types';
|
||||
|
||||
interface DevicesDialogProps {
|
||||
@@ -56,7 +52,7 @@ const DevicesDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -65,48 +61,36 @@ const DevicesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const { send: executeCommand } = useRequest(
|
||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||
{ immediate: false }
|
||||
)
|
||||
.onSuccess(() => {
|
||||
toast.success(LL.COMMAND_SENT());
|
||||
})
|
||||
.onError((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const doAction = async () => {
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
if (editItem.v === undefined && editItem.c !== undefined) {
|
||||
await executeCommand(editItem.c);
|
||||
} else {
|
||||
await validate(validator, editItem);
|
||||
}
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const setUom = (uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
};
|
||||
const setUom = useCallback(
|
||||
(uom?: DeviceValueUOM) => {
|
||||
if (uom === undefined) {
|
||||
return;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return LL.HOURS();
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return LL.MINUTES();
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.SECONDS();
|
||||
default:
|
||||
return DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
|
||||
const showHelperText = (dv: DeviceValue) => {
|
||||
const showHelperText = useCallback((dv: DeviceValue) => {
|
||||
if (dv.h) return dv.h;
|
||||
if (dv.l) return dv.l.join(' | ');
|
||||
if (dv.m !== undefined && dv.x !== undefined) {
|
||||
@@ -117,23 +101,26 @@ const DevicesDialog = ({
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const kind = getDeviceValueKind(selectedItem);
|
||||
const isCommand = useMemo(
|
||||
() => selectedItem.v === '' && selectedItem.c,
|
||||
[selectedItem.v, selectedItem.c]
|
||||
);
|
||||
|
||||
const dialogTitle =
|
||||
kind === 'command' || kind === 'deviceCommand'
|
||||
? LL.RUN() + ' ' + LL.COMMAND(0)
|
||||
: kind === 'scheduler'
|
||||
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
|
||||
: writeable
|
||||
? LL.CHANGE_VALUE()
|
||||
: LL.VALUE(0);
|
||||
const dialogTitle = useMemo(() => {
|
||||
if (isCommand) return LL.RUN_COMMAND();
|
||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||
}, [isCommand, writeable, LL]);
|
||||
|
||||
const buttonLabel =
|
||||
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE();
|
||||
const buttonLabel = useMemo(() => {
|
||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||
}, [isCommand, LL]);
|
||||
|
||||
const helperText = showHelperText(editItem);
|
||||
const helperText = useMemo(
|
||||
() => showHelperText(editItem),
|
||||
[editItem, showHelperText]
|
||||
);
|
||||
|
||||
const valueLabel = LL.VALUE(0);
|
||||
|
||||
@@ -141,72 +128,70 @@ const DevicesDialog = ({
|
||||
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||
{editItem.id.slice(2)}
|
||||
</Typography>
|
||||
{kind !== 'command' && (
|
||||
<Grid container>
|
||||
<Grid size={12}>
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{editItem.l.map((val) => (
|
||||
<MenuItem value={val} key={val}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
type="number"
|
||||
sx={{ width: '30ch' }}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: editItem.s
|
||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||
: {},
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{setUom(editItem.u)}
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
multiline={!editItem.u}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{writeable && helperText && (
|
||||
<Grid>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
</Grid>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
||||
</Box>
|
||||
<Grid container>
|
||||
<Grid size={12}>
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{editItem.l.map((val) => (
|
||||
<MenuItem value={val} key={val}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
type="number"
|
||||
sx={{ width: '30ch' }}
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: editItem.s
|
||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||
: {},
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{setUom(editItem.u)}
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={valueLabel}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
multiline={!editItem.u}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
{writeable && helperText && (
|
||||
<Grid>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
@@ -228,16 +213,10 @@ const DevicesDialog = ({
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={
|
||||
kind === 'command' ? (
|
||||
<PlayArrowIcon />
|
||||
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
|
||||
<WarningIcon color="warning" />
|
||||
) : undefined
|
||||
}
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={doAction}
|
||||
color={kind !== 'value' ? 'success' : 'primary'}
|
||||
onClick={save}
|
||||
color="primary"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
import OptionIcon from './OptionIcon';
|
||||
@@ -9,6 +11,7 @@ interface EntityMaskToggleProps {
|
||||
de: DeviceEntity;
|
||||
}
|
||||
|
||||
// Available mask values
|
||||
const MASK_VALUES = [
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||
@@ -17,95 +20,123 @@ const MASK_VALUES = [
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a specific mask bit is set
|
||||
*/
|
||||
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
||||
|
||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||
const handleChange = (_event: unknown, mask: string[]) => {
|
||||
const newMask = getMaskNumber(mask);
|
||||
const updatedDe = { ...de };
|
||||
const handleChange = useCallback(
|
||||
(_event: unknown, mask: string[]) => {
|
||||
// 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
|
||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
} else {
|
||||
updatedDe.m = newMask;
|
||||
}
|
||||
// Apply business logic for mask interactions
|
||||
// If entity has no name and is set to readonly, also exclude from web
|
||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
} else {
|
||||
updatedDe.m = newMask;
|
||||
}
|
||||
|
||||
// If excluded from web, cannot be favorite
|
||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
// If excluded from web, cannot be favorite
|
||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
|
||||
onUpdate(updatedDe);
|
||||
};
|
||||
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 (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={getMaskString(de.m)}
|
||||
value={maskStringValue}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<ToggleButton
|
||||
value="8"
|
||||
disabled={
|
||||
hasMask(
|
||||
de.m,
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
|
||||
) || de.n === undefined
|
||||
}
|
||||
>
|
||||
<OptionIcon
|
||||
type="favorite"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
|
||||
/>
|
||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
value="4"
|
||||
disabled={
|
||||
!de.w ||
|
||||
hasMask(
|
||||
de.m,
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
|
||||
)
|
||||
}
|
||||
>
|
||||
<OptionIcon
|
||||
type="readonly"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
|
||||
/>
|
||||
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
value="2"
|
||||
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||
>
|
||||
<OptionIcon
|
||||
type="api_mqtt_exclude"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
|
||||
/>
|
||||
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
value="1"
|
||||
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||
>
|
||||
<OptionIcon
|
||||
type="web_exclude"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
|
||||
/>
|
||||
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="128">
|
||||
<OptionIcon
|
||||
type="deleted"
|
||||
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||
/>
|
||||
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useContext, useState } from 'react';
|
||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -24,7 +24,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { saveFile } from 'utils';
|
||||
@@ -43,7 +42,7 @@ interface CustomSupport {
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_URL = 'https://emsesp.org/media/images/installer.jpeg';
|
||||
const DEFAULT_IMAGE_URL = 'https://emsesp.org/_media/images/installer.jpeg';
|
||||
|
||||
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
|
||||
borderRadius: 3,
|
||||
@@ -60,8 +59,6 @@ const AVATAR_STYLES: SxProps<Theme> = {
|
||||
bgcolor: '#72caf9'
|
||||
};
|
||||
|
||||
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
|
||||
|
||||
const HelpComponent = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.HELP());
|
||||
@@ -74,7 +71,13 @@ const HelpComponent = () => {
|
||||
});
|
||||
const [imgError, setImgError] = useState<boolean>(false);
|
||||
|
||||
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
||||
// Memoize the request method to prevent re-creation on every render
|
||||
const getCustomSupportMethod = useMemo(
|
||||
() => callAction({ action: 'getCustomSupport' }),
|
||||
[]
|
||||
);
|
||||
|
||||
useRequest(getCustomSupportMethod).onSuccess((event) => {
|
||||
if (event?.data && Object.keys(event.data).length !== 0) {
|
||||
const { Support } = event.data as {
|
||||
Support: { img_url?: string; html?: string[] };
|
||||
@@ -97,34 +100,57 @@ const HelpComponent = () => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
const helpLinks: HelpLink[] = [
|
||||
{
|
||||
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()
|
||||
}
|
||||
];
|
||||
// Optimize API call memoization
|
||||
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
||||
|
||||
const imageSrc =
|
||||
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
|
||||
const handleDownloadSystemInfo = useCallback(() => {
|
||||
void sendAPI(apiCall);
|
||||
}, [sendAPI, apiCall]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImgError(true);
|
||||
}, []);
|
||||
|
||||
// Memoize help links to prevent recreation on every render
|
||||
const helpLinks: HelpLink[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
href: 'https://emsesp.org',
|
||||
icon: <MenuBookIcon />,
|
||||
label: () => LL.HELP_INFORMATION_1()
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/3J3GgnzpyT',
|
||||
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 (
|
||||
<SectionContent>
|
||||
{customSupport.html && (
|
||||
<Stack
|
||||
padding={1}
|
||||
mb={2}
|
||||
direction="row"
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
sx={{ padding: 1, mb: 2, ...SUPPORT_BOX_STYLES }}
|
||||
sx={SUPPORT_BOX_STYLES}
|
||||
>
|
||||
<Typography variant="subtitle1">
|
||||
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
|
||||
@@ -133,13 +159,13 @@ const HelpComponent = () => {
|
||||
component="img"
|
||||
referrerPolicy="no-referrer"
|
||||
sx={IMAGE_STYLES}
|
||||
onError={() => setImgError(true)}
|
||||
onError={handleImageError}
|
||||
src={imageSrc}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{me?.admin && (
|
||||
{isAdmin && (
|
||||
<List>
|
||||
{helpLinks.map(({ href, icon, label }) => (
|
||||
<ListItem key={href}>
|
||||
@@ -159,23 +185,23 @@ const HelpComponent = () => {
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
|
||||
<Typography sx={{ mb: 1 }} color="warning" variant="body1">
|
||||
{LL.HELP_INFORMATION_4()}:
|
||||
<Box p={2} color="warning.main">
|
||||
<Typography mb={1} variant="body1">
|
||||
{LL.HELP_INFORMATION_4()}.
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => void sendAPI(SYSTEM_INFO_API)}
|
||||
onClick={handleDownloadSystemInfo}
|
||||
>
|
||||
{LL.SUPPORT_INFORMATION(0)}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mt: 4 }} />
|
||||
|
||||
<Typography color="white" variant="subtitle1" align="center" sx={{ mt: 1 }}>
|
||||
<Typography color="white" variant="subtitle1" align="center" mt={1}>
|
||||
©
|
||||
<Link
|
||||
target="_blank"
|
||||
@@ -190,6 +216,7 @@ const HelpComponent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
const Help = memo(HelpComponent);
|
||||
|
||||
export default Help;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CircleIcon from '@mui/icons-material/Circle';
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import { readModules, writeModules } from '../../api/app';
|
||||
@@ -69,53 +69,58 @@ const Modules = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const modules_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`
|
||||
});
|
||||
const modules_theme = useTheme(
|
||||
useMemo(
|
||||
() => ({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
const onDialogClose = () => {
|
||||
const onDialogClose = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateModuleItem = (updatedItem: ModuleItem) => {
|
||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||
const new_data = data.map((mi) =>
|
||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||
@@ -123,25 +128,28 @@ const Modules = () => {
|
||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogSave = (updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
};
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
},
|
||||
[updateModuleItem]
|
||||
);
|
||||
|
||||
const editModuleItem = (mi: ModuleItem) => {
|
||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
||||
setSelectedModuleItem(mi);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onCancel = async () => {
|
||||
const onCancel = useCallback(async () => {
|
||||
await fetchModules().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
}, [fetchModules]);
|
||||
|
||||
const saveModules = async () => {
|
||||
const saveModules = useCallback(async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
modules.map((condensed_mi: ModuleItem) =>
|
||||
@@ -159,9 +167,9 @@ const Modules = () => {
|
||||
await fetchModules();
|
||||
setNumChanges(0);
|
||||
}
|
||||
};
|
||||
}, [modules, updateModules, LL, fetchModules]);
|
||||
|
||||
const renderContent = () => {
|
||||
const content = useMemo(() => {
|
||||
if (!modules) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||
@@ -178,9 +186,9 @@ const Modules = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.MODULES_DESCRIPTION()}.
|
||||
</Typography>
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body1">{LL.MODULES_DESCRIPTION()}.</Typography>
|
||||
</Box>
|
||||
<Table
|
||||
data={{ nodes: modules }}
|
||||
theme={modules_theme}
|
||||
@@ -228,8 +236,8 @@ const Modules = () => {
|
||||
)}
|
||||
</Table>
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box mt={1} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -254,12 +262,22 @@ const Modules = () => {
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
modules,
|
||||
fetchModules,
|
||||
error,
|
||||
modules_theme,
|
||||
editModuleItem,
|
||||
LL,
|
||||
numChanges,
|
||||
onCancel,
|
||||
saveModules
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{renderContent()}
|
||||
{content}
|
||||
{selectedModuleItem && (
|
||||
<ModulesDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
@@ -37,10 +37,14 @@ const ModulesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Sync form state when dialog opens or selected item changes
|
||||
@@ -50,13 +54,18 @@ const ModulesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(editItem);
|
||||
};
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const dialogTitle = useMemo(
|
||||
() => `${LL.EDIT()} ${editItem.key}`,
|
||||
[LL, editItem.key]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
||||
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
@@ -70,7 +79,7 @@ const ModulesDialog = ({
|
||||
label="Enabled"
|
||||
/>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2, mb: 1 }}>
|
||||
<Box mt={2} mb={1}>
|
||||
<TextField
|
||||
name="license"
|
||||
label="License Key"
|
||||
|
||||
@@ -2,18 +2,22 @@ import { memo } from 'react';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarOutlineIcon from '@mui/icons-material/StarOutlined';
|
||||
import StarOutlineIcon from '@mui/icons-material/StarOutline';
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
export type OptionType =
|
||||
'deleted' | 'readonly' | 'web_exclude' | 'api_mqtt_exclude' | 'favorite';
|
||||
| 'deleted'
|
||||
| 'readonly'
|
||||
| 'web_exclude'
|
||||
| 'api_mqtt_exclude'
|
||||
| 'favorite';
|
||||
|
||||
type IconPair = [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -25,11 +26,10 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
import { readCommands, readSchedule, writeSchedule } from '../../api/app';
|
||||
import { readSchedule, writeSchedule } from '../../api/app';
|
||||
import SettingsSchedulerDialog from './SchedulerDialog';
|
||||
import { ScheduleFlag } from './types';
|
||||
import type { Schedule, ScheduleItem } from './types';
|
||||
@@ -41,7 +41,6 @@ const MIN_ID = -100;
|
||||
const MAX_ID = 100;
|
||||
const ICON_SIZE = 16;
|
||||
const SCHEDULE_FLAG_THRESHOLD = 127;
|
||||
const FLAG_ALL_DAYS = 127;
|
||||
const REFERENCE_YEAR = 2017;
|
||||
const REFERENCE_MONTH = '01';
|
||||
const LOG_2 = Math.log(2);
|
||||
@@ -52,15 +51,16 @@ const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
|
||||
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
|
||||
active: false,
|
||||
deleted: false,
|
||||
flags: FLAG_ALL_DAYS,
|
||||
flags: ScheduleFlag.SCHEDULE_DAY,
|
||||
time: '',
|
||||
cmd_name: '',
|
||||
cmd: '',
|
||||
value: '',
|
||||
name: ''
|
||||
};
|
||||
|
||||
const scheduleTheme = {
|
||||
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: `
|
||||
font-size: 14px;
|
||||
@@ -69,8 +69,11 @@ const scheduleTheme = {
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
&:nth-of-type(1) {
|
||||
text-align: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
@@ -96,6 +99,7 @@ const scheduleTheme = {
|
||||
};
|
||||
|
||||
const scheduleTypeLabels: Record<number, string> = {
|
||||
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
|
||||
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
||||
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
||||
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
||||
@@ -120,11 +124,6 @@ const Scheduler = () => {
|
||||
initialData: []
|
||||
});
|
||||
|
||||
const { data: commandNames } = useRequest(readCommands, {
|
||||
initialData: [],
|
||||
initializing: true
|
||||
});
|
||||
|
||||
const { send: updateSchedule } = useRequest(
|
||||
(data: Schedule) => writeSchedule(data),
|
||||
{
|
||||
@@ -132,7 +131,7 @@ const Scheduler = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const hasScheduleChanged = (si: ScheduleItem) => {
|
||||
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
||||
return (
|
||||
si.id !== si.o_id ||
|
||||
(si.name || '') !== (si.o_name || '') ||
|
||||
@@ -140,15 +139,18 @@ const Scheduler = () => {
|
||||
si.deleted !== si.o_deleted ||
|
||||
si.flags !== si.o_flags ||
|
||||
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) {
|
||||
void fetchSchedule();
|
||||
}
|
||||
}, INTERVAL_DELAY);
|
||||
}, [numChanges, fetchSchedule]);
|
||||
|
||||
useInterval(intervalCallback, INTERVAL_DELAY);
|
||||
|
||||
useEffect(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
@@ -166,7 +168,7 @@ const Scheduler = () => {
|
||||
|
||||
const schedule_theme = useTheme(scheduleTheme);
|
||||
|
||||
const saveSchedule = async () => {
|
||||
const saveSchedule = useCallback(async () => {
|
||||
try {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
@@ -176,7 +178,8 @@ const Scheduler = () => {
|
||||
active: condensed_si.active,
|
||||
flags: condensed_si.flags,
|
||||
time: condensed_si.time,
|
||||
cmd_name: condensed_si.cmd_name,
|
||||
cmd: condensed_si.cmd,
|
||||
value: condensed_si.value,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
});
|
||||
@@ -188,43 +191,46 @@ const Scheduler = () => {
|
||||
await fetchSchedule();
|
||||
setNumChanges(0);
|
||||
}
|
||||
};
|
||||
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
||||
|
||||
const editScheduleItem = (si: ScheduleItem) => {
|
||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
||||
setCreating(false);
|
||||
setSelectedScheduleItem(si);
|
||||
setDialogOpen(true);
|
||||
if (si.o_name === undefined) {
|
||||
si.o_name = si.name;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogClose = () => {
|
||||
const onDialogClose = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogCancel = async () => {
|
||||
const onDialogCancel = useCallback(async () => {
|
||||
await fetchSchedule().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
}, [fetchSchedule]);
|
||||
|
||||
const onDialogSave = (updatedItem: ScheduleItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||
const new_data = creating
|
||||
? [...data, updatedItem]
|
||||
: data.map((si) =>
|
||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||
);
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ScheduleItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||
const new_data = creating
|
||||
? [...data, updatedItem]
|
||||
: data.map((si) =>
|
||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||
);
|
||||
|
||||
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);
|
||||
const newItem: ScheduleItem = {
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
@@ -232,29 +238,36 @@ const Scheduler = () => {
|
||||
};
|
||||
setSelectedScheduleItem(newItem);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedSchedule = schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
|
||||
const filteredAndSortedSchedule = useMemo(
|
||||
() =>
|
||||
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 dayIndex = Math.log(flag) / LOG_2;
|
||||
const isActive = (si.flags & flag) === flag;
|
||||
const dayBox = useCallback(
|
||||
(si: ScheduleItem, flag: number) => {
|
||||
const dayIndex = Math.log(flag) / LOG_2;
|
||||
const isActive = (si.flags & flag) === flag;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isLast && <Divider orientation="vertical" flexItem />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[dow]
|
||||
);
|
||||
|
||||
const scheduleType = (si: ScheduleItem) => {
|
||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
||||
const label = scheduleTypeLabels[si.flags];
|
||||
|
||||
return (
|
||||
@@ -264,9 +277,9 @@ const Scheduler = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderSchedule = () => {
|
||||
const renderSchedule = useCallback(() => {
|
||||
if (!schedule) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||
@@ -285,10 +298,9 @@ const Scheduler = () => {
|
||||
<HeaderRow>
|
||||
<HeaderCell />
|
||||
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
{LL.TIME(0)}/{LL.CONDITION()}
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
|
||||
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
@@ -298,16 +310,12 @@ const Scheduler = () => {
|
||||
<Cell stiff>
|
||||
<CircleIcon
|
||||
color={si.active ? 'success' : 'error'}
|
||||
sx={{
|
||||
fontSize: ICON_SIZE,
|
||||
ml: '4px',
|
||||
position: 'relative',
|
||||
top: '2px'
|
||||
}}
|
||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell stiff>
|
||||
<Stack spacing={0.5} direction="row">
|
||||
<Divider orientation="vertical" flexItem />
|
||||
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
||||
scheduleType(si)
|
||||
) : (
|
||||
@@ -318,13 +326,14 @@ const Scheduler = () => {
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
|
||||
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Cell>
|
||||
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
|
||||
<Cell>{si.cmd_name}</Cell>
|
||||
<Cell>{si.time}</Cell>
|
||||
<Cell>{si.cmd}</Cell>
|
||||
<Cell>{si.value}</Cell>
|
||||
<Cell>{si.name}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
@@ -333,14 +342,24 @@ const Scheduler = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
schedule,
|
||||
error,
|
||||
fetchSchedule,
|
||||
filteredAndSortedSchedule,
|
||||
schedule_theme,
|
||||
editScheduleItem,
|
||||
LL,
|
||||
dayBox,
|
||||
scheduleType
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||
{LL.SCHEDULER_HELP_1()}.
|
||||
</Typography>
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
{renderSchedule()}
|
||||
|
||||
{selectedScheduleItem && (
|
||||
@@ -352,12 +371,11 @@ const Scheduler = () => {
|
||||
selectedItem={selectedScheduleItem}
|
||||
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
|
||||
dow={dow}
|
||||
commandNames={commandNames.map((ci) => ci.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
@@ -379,7 +397,7 @@ const Scheduler = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import CircleIcon from '@mui/icons-material/Circle';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
MenuItem,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
@@ -22,20 +21,19 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||
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 { validate } from 'validators';
|
||||
|
||||
import { ScheduleFlag } from './types';
|
||||
import type { ScheduleItem } from './types';
|
||||
|
||||
// Constants
|
||||
const FLAG_MASK_127 = 127;
|
||||
const SCHEDULE_TYPE_THRESHOLD = 127;
|
||||
const FLAG_ALL_DAYS = 127;
|
||||
const SCHEDULE_TYPE_THRESHOLD = 128;
|
||||
const DEFAULT_TIME = '00:00';
|
||||
const TYPOGRAPHY_FONT_SIZE = 10;
|
||||
|
||||
@@ -61,12 +59,6 @@ const FLAG_VALUES = [
|
||||
ScheduleFlag.SCHEDULE_SAT
|
||||
] as const;
|
||||
|
||||
const getFlagDOWnumber = (flags: string[]) =>
|
||||
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||
|
||||
const getFlagDOWstring = (f: number) =>
|
||||
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
|
||||
|
||||
interface SchedulerDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
@@ -75,7 +67,6 @@ interface SchedulerDialogProps {
|
||||
selectedItem: ScheduleItem;
|
||||
validator: Schema;
|
||||
dow: string[];
|
||||
commandNames: string[];
|
||||
}
|
||||
|
||||
const SchedulerDialog = ({
|
||||
@@ -85,115 +76,163 @@ const SchedulerDialog = ({
|
||||
onSave,
|
||||
selectedItem,
|
||||
validator,
|
||||
dow,
|
||||
commandNames
|
||||
dow
|
||||
}: SchedulerDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
||||
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
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(
|
||||
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
|
||||
selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
|
||||
? ScheduleFlag.SCHEDULE_DAY
|
||||
: selectedItem.flags
|
||||
);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleSave = async (itemToSave: ScheduleItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
}
|
||||
};
|
||||
// Helper function to handle save operations
|
||||
const handleSave = useCallback(
|
||||
async (itemToSave: ScheduleItem) => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, itemToSave);
|
||||
onSave(itemToSave);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
},
|
||||
[validator, onSave]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
const save = useCallback(async () => {
|
||||
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 });
|
||||
};
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const DayOfWeekButton = (flag: number) => {
|
||||
const dayIndex = Math.log2(flag);
|
||||
const isSelected = (editItem.flags & flag) === flag;
|
||||
return (
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isSelected ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
// Optimize DOW flag conversion
|
||||
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
||||
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||
}, []);
|
||||
|
||||
const getFlagDOWstring = useCallback((f: number) => {
|
||||
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
||||
String(flag)
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
// Day of week display component
|
||||
const DayOfWeekButton = useCallback(
|
||||
(flag: number) => {
|
||||
const dayIndex = Math.log2(flag);
|
||||
const isSelected = (editItem.flags & flag) === flag;
|
||||
return (
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isSelected ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
[editItem.flags, dow]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const handleScheduleTypeChange = useCallback(
|
||||
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
|
||||
if (flag !== null) {
|
||||
setFieldErrors(undefined); // clear any validation errors
|
||||
setScheduleType(flag);
|
||||
// wipe the time field when changing the schedule type
|
||||
// set the flags based on type
|
||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag;
|
||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDOWChange = useCallback(
|
||||
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
||||
const newFlags = 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 = (
|
||||
_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 = (() => {
|
||||
const timeFieldLabel = useMemo(() => {
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
||||
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
||||
return LL.TIME(1);
|
||||
})();
|
||||
}, [scheduleType, LL]);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -246,6 +285,14 @@ const SchedulerDialog = ({
|
||||
{LL.CONDITION()}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isImmediateSchedule ? 'primary' : 'grey'}
|
||||
>
|
||||
{LL.IMMEDIATE()}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{isDaySchedule && (
|
||||
@@ -263,74 +310,76 @@ const SchedulerDialog = ({
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={editItem.active}
|
||||
onChange={updateFormValue}
|
||||
name="active"
|
||||
{!isImmediateSchedule && (
|
||||
<>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={editItem.active}
|
||||
onChange={updateFormValue}
|
||||
name="active"
|
||||
/>
|
||||
}
|
||||
label={LL.ACTIVE()}
|
||||
/>
|
||||
}
|
||||
label={LL.ACTIVE()}
|
||||
/>
|
||||
<CircleIcon
|
||||
color={editItem.active ? 'success' : 'error'}
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
mt: '12px'
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid container>
|
||||
{needsTimeField ? (
|
||||
<>
|
||||
<TextField
|
||||
name="time"
|
||||
type="time"
|
||||
label={timeFieldLabel}
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
{isTimerSchedule && (
|
||||
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
|
||||
00:00 = {LL.SCHEDULER_HELP_2()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid container>
|
||||
{needsTimeField ? (
|
||||
<>
|
||||
<TextField
|
||||
name="time"
|
||||
type="time"
|
||||
label={timeFieldLabel}
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
{isTimerSchedule && (
|
||||
<Box color="warning.main" ml={2} mt={4}>
|
||||
<Typography variant="body2">
|
||||
{LL.SCHEDULER_HELP_2()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
name="time"
|
||||
label={timeFieldLabel}
|
||||
multiline
|
||||
fullWidth
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
name="time"
|
||||
label={timeFieldLabel}
|
||||
multiline
|
||||
fullWidth
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
<TextField
|
||||
name="cmd_name"
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="cmd"
|
||||
label={LL.COMMAND(0)}
|
||||
value={editItem.cmd_name}
|
||||
multiline
|
||||
fullWidth
|
||||
select
|
||||
value={editItem.cmd}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{commandNames.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
/>
|
||||
<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)}
|
||||
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
|
||||
value={editItem.name}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
@@ -340,7 +389,7 @@ const SchedulerDialog = ({
|
||||
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box flexGrow={1}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
variant="outlined"
|
||||
@@ -367,6 +416,16 @@ const SchedulerDialog = ({
|
||||
>
|
||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||
</Button>
|
||||
{isImmediateSchedule && editItem.cmd !== '' && (
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
onClick={saveandactivate}
|
||||
color="success"
|
||||
>
|
||||
{LL.EXECUTE()}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+292
-255
@@ -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 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 { useRequest } from 'alova/client';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { useInterval } from 'utils';
|
||||
@@ -158,16 +158,18 @@ const Sensors = () => {
|
||||
}
|
||||
);
|
||||
|
||||
useInterval(() => {
|
||||
const intervalCallback = useCallback(() => {
|
||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||
void fetchSensorData();
|
||||
}
|
||||
});
|
||||
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
||||
|
||||
useInterval(intervalCallback);
|
||||
|
||||
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
||||
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
return <KeyboardArrowDownOutlinedIcon />;
|
||||
}
|
||||
@@ -175,7 +177,7 @@ const Sensors = () => {
|
||||
return <KeyboardArrowUpOutlinedIcon />;
|
||||
}
|
||||
return <UnfoldMoreOutlinedIcon />;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const analog_sort = useSort(
|
||||
{ nodes: sensorData.as },
|
||||
@@ -232,106 +234,121 @@ const Sensors = () => {
|
||||
|
||||
useLayoutTitle(LL.SENSORS());
|
||||
|
||||
const formatDurationMin = (duration_min: number) => {
|
||||
const totalMs = duration_min * MS_PER_MINUTE;
|
||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||
const formatDurationMin = useCallback(
|
||||
(duration_min: number) => {
|
||||
const totalMs = duration_min * MS_PER_MINUTE;
|
||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
}
|
||||
return parts.join(' ');
|
||||
};
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
}
|
||||
return parts.join(' ');
|
||||
},
|
||||
[LL]
|
||||
);
|
||||
|
||||
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
return value as string;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
};
|
||||
const formatValue = useCallback(
|
||||
(value: unknown, uom: DeviceValueUOM) => {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
return value as string;
|
||||
}
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
case DeviceValueUOM.MINUTES:
|
||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
},
|
||||
[formatDurationMin, LL]
|
||||
);
|
||||
|
||||
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
||||
if (me.admin) {
|
||||
ts.o_n = ts.n;
|
||||
setSelectedTemperatureSensor(ts);
|
||||
setTemperatureDialogOpen(true);
|
||||
}
|
||||
};
|
||||
const updateTemperatureSensor = useCallback(
|
||||
(ts: TemperatureSensor) => {
|
||||
if (me.admin) {
|
||||
ts.o_n = ts.n;
|
||||
setSelectedTemperatureSensor(ts);
|
||||
setTemperatureDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
|
||||
const onTemperatureDialogClose = () => {
|
||||
const onTemperatureDialogClose = useCallback(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
void fetchSensorData();
|
||||
};
|
||||
}, [fetchSensorData]);
|
||||
|
||||
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
||||
await sendTemperatureSensor({
|
||||
id: ts.id,
|
||||
name: ts.n,
|
||||
offset: ts.o,
|
||||
is_system: ts.s
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||
const onTemperatureDialogSave = useCallback(
|
||||
async (ts: TemperatureSensor) => {
|
||||
await sendTemperatureSensor({
|
||||
id: ts.id,
|
||||
name: ts.n,
|
||||
offset: ts.o,
|
||||
is_system: ts.s
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
setSelectedTemperatureSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
};
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setTemperatureDialogOpen(false);
|
||||
setSelectedTemperatureSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
},
|
||||
[sendTemperatureSensor, LL, fetchSensorData]
|
||||
);
|
||||
|
||||
const updateAnalogSensor = (as: AnalogSensor) => {
|
||||
if (me.admin) {
|
||||
setCreating(false);
|
||||
as.o_n = as.n;
|
||||
setSelectedAnalogSensor(as);
|
||||
setAnalogDialogOpen(true);
|
||||
}
|
||||
};
|
||||
const updateAnalogSensor = useCallback(
|
||||
(as: AnalogSensor) => {
|
||||
if (me.admin) {
|
||||
setCreating(false);
|
||||
as.o_n = as.n;
|
||||
setSelectedAnalogSensor(as);
|
||||
setAnalogDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[me.admin]
|
||||
);
|
||||
|
||||
const onAnalogDialogClose = () => {
|
||||
const onAnalogDialogClose = useCallback(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
void fetchSensorData();
|
||||
};
|
||||
}, [fetchSensorData]);
|
||||
|
||||
const addAnalogSensor = () => {
|
||||
const addAnalogSensor = useCallback(() => {
|
||||
if (firstAvailableGPIO.current === undefined) {
|
||||
toast.error(LL.NO_GPIO());
|
||||
toast.error('No available GPIO found');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
@@ -349,167 +366,194 @@ const Sensors = () => {
|
||||
o_n: ''
|
||||
});
|
||||
setAnalogDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
||||
await sendAnalogSensor({
|
||||
id: as.id,
|
||||
gpio: as.g,
|
||||
name: as.n,
|
||||
offset: as.o,
|
||||
factor: as.f,
|
||||
uom: as.u,
|
||||
type: as.t,
|
||||
deleted: as.d,
|
||||
is_system: as.s
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||
const onAnalogDialogSave = useCallback(
|
||||
async (as: AnalogSensor) => {
|
||||
await sendAnalogSensor({
|
||||
id: as.id,
|
||||
gpio: as.g,
|
||||
name: as.n,
|
||||
offset: as.o,
|
||||
factor: as.f,
|
||||
uom: as.u,
|
||||
type: as.t,
|
||||
deleted: as.d,
|
||||
is_system: as.s
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
setSelectedAnalogSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
};
|
||||
|
||||
const RenderAnalogSensors = (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
>
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((as: AnalogSensor) => (
|
||||
<Row
|
||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||
key={as.id}
|
||||
item={as}
|
||||
onClick={() => updateAnalogSensor(as)}
|
||||
>
|
||||
<Cell stiff>{as.g}</Cell>
|
||||
<Cell>{as.n}</Cell>
|
||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
||||
as.g !== GPIO_25 &&
|
||||
as.g !== GPIO_26) ||
|
||||
as.t === AnalogType.DIGITAL_IN ||
|
||||
as.t === AnalogType.PULSE ? (
|
||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
||||
) : (
|
||||
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||
})
|
||||
.finally(() => {
|
||||
setAnalogDialogOpen(false);
|
||||
setSelectedAnalogSensor(undefined);
|
||||
void fetchSensorData();
|
||||
});
|
||||
},
|
||||
[sendAnalogSensor, LL, fetchSensorData]
|
||||
);
|
||||
|
||||
const RenderTemperatureSensors = (
|
||||
<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' })
|
||||
}
|
||||
const RenderAnalogSensors = useMemo(
|
||||
() => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
>
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={HEADER_BUTTON_STYLE_END}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() =>
|
||||
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||
}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((as: AnalogSensor) => (
|
||||
<Row
|
||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||
key={as.id}
|
||||
item={as}
|
||||
onClick={() => updateAnalogSensor(as)}
|
||||
>
|
||||
{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' })
|
||||
}
|
||||
<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>
|
||||
),
|
||||
[
|
||||
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)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((ts: TemperatureSensor) => (
|
||||
<Row
|
||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||
key={ts.id}
|
||||
item={ts}
|
||||
onClick={() => updateTemperatureSensor(ts)}
|
||||
>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
),
|
||||
[
|
||||
temperature_sort,
|
||||
temperature_theme,
|
||||
getSortIcon,
|
||||
sensorData.ts,
|
||||
LL,
|
||||
updateTemperatureSensor,
|
||||
formatValue
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -547,14 +591,7 @@ const Sensors = () => {
|
||||
/>
|
||||
)}
|
||||
{sensorData?.analog_enabled === true && me.admin && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { validate } from 'validators';
|
||||
|
||||
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { AnalogSensor } from './types';
|
||||
@@ -53,54 +53,84 @@ const SensorsAnalogDialog = ({
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||
|
||||
const updateFormValue = updateValue((updater) =>
|
||||
setEditItem(
|
||||
(prev) =>
|
||||
updater(
|
||||
prev as unknown as Record<string, unknown>
|
||||
) as unknown as AnalogSensor
|
||||
)
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setEditItem(
|
||||
(prev) =>
|
||||
updater(
|
||||
prev as unknown as Record<string, unknown>
|
||||
) as unknown as AnalogSensor
|
||||
)
|
||||
),
|
||||
[setEditItem]
|
||||
);
|
||||
|
||||
const isCounterOrRate =
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
editItem.t === AnalogType.RATE ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||
const isCounter =
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||
const isFreqType =
|
||||
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
||||
const isPWM =
|
||||
editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2;
|
||||
const isDACOutGPIO =
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26);
|
||||
const isDigitalOutGPIO =
|
||||
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
||||
// Memoize helper functions to check sensor type conditions
|
||||
const isCounterOrRate = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
editItem.t === AnalogType.RATE ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||
[editItem.t]
|
||||
);
|
||||
const isCounter = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.COUNTER ||
|
||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||
[editItem.t]
|
||||
);
|
||||
const isFreqType = useMemo(
|
||||
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isPWM = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isDACOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26),
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
const isDigitalOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
editItem.g !== 25 &&
|
||||
editItem.g !== 26,
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
|
||||
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
|
||||
name: val,
|
||||
value: i + 1
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(({ name, value }) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={value}
|
||||
disabled={disabledTypeList?.includes(value)}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
));
|
||||
// Memoize menu items to avoid recreation on each render
|
||||
const analogTypeMenuItems = useMemo(
|
||||
() =>
|
||||
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(({ name, value }) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={value}
|
||||
disabled={disabledTypeList?.includes(value)}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
)),
|
||||
[disabledTypeList]
|
||||
);
|
||||
|
||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
));
|
||||
const uomMenuItems = useMemo(
|
||||
() =>
|
||||
DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
const analogGPIOMenuItems = () =>
|
||||
// add selectedItem.g to the list
|
||||
@@ -127,30 +157,34 @@ const SensorsAnalogDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const remove = () => {
|
||||
const remove = useCallback(() => {
|
||||
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 (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
@@ -208,23 +242,6 @@ const SensorsAnalogDialog = ({
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.DIGITAL_IN && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
name="f"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.f}
|
||||
sx={{ width: '15ch' }}
|
||||
fullWidth
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={0}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.ADC && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
@@ -430,7 +447,7 @@ const SensorsAnalogDialog = ({
|
||||
name="o"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.o}
|
||||
sx={{ width: '15ch' }}
|
||||
sx={{ width: '11ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
@@ -462,7 +479,7 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
</Grid>
|
||||
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box mt={1}>
|
||||
{Object.values(fieldErrors).map((errArr, idx) =>
|
||||
Array.isArray(errArr)
|
||||
? errArr.map((err, j) => (
|
||||
@@ -470,7 +487,7 @@ const SensorsAnalogDialog = ({
|
||||
key={`${idx}-${j}`}
|
||||
color="error"
|
||||
variant="caption"
|
||||
sx={{ display: 'block' }}
|
||||
display="block"
|
||||
>
|
||||
{err.message}
|
||||
</Typography>
|
||||
@@ -481,7 +498,7 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
@@ -494,7 +511,7 @@ const SensorsAnalogDialog = ({
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box sx={{ flexGrow: 1, '& button': { mt: 0 } }}>
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
disabled={editItem.s}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
@@ -16,12 +17,12 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import { ValidatedTextField } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { validate } from 'validators';
|
||||
|
||||
import type { TemperatureSensor } from './types';
|
||||
|
||||
@@ -50,12 +51,16 @@ const SensorsTemperatureDialog = ({
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
||||
|
||||
const updateFormValue = updateValue(
|
||||
setEditItem as unknown as (
|
||||
updater: (
|
||||
prevState: Readonly<Record<string, unknown>>
|
||||
) => Record<string, unknown>
|
||||
) => void
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as (
|
||||
updater: (
|
||||
prevState: Readonly<Record<string, unknown>>
|
||||
) => Record<string, unknown>
|
||||
) => void
|
||||
),
|
||||
[setEditItem]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,29 +70,52 @@ const SensorsTemperatureDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason?: string) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [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 (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||
</Typography>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">
|
||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
@@ -103,29 +131,18 @@ const SensorsTemperatureDialog = ({
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.OFFSET()}
|
||||
value={numberValue(editItem.o)}
|
||||
value={offsetValue}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
||||
)
|
||||
},
|
||||
htmlInput: {
|
||||
min: OFFSET_MIN,
|
||||
max: OFFSET_MAX,
|
||||
step: OFFSET_STEP
|
||||
}
|
||||
}}
|
||||
slotProps={slotProps}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useContext } from 'react';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import {
|
||||
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
|
||||
|
||||
useLayoutTitle(LL.USER_PROFILE());
|
||||
|
||||
const handleSignOut = () => {
|
||||
const handleSignOut = useCallback(() => {
|
||||
signOut(true);
|
||||
};
|
||||
}, [signOut]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -41,12 +41,8 @@ const UserProfileComponent = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Box sx={{ mt: 2, mb: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
sx={{ mr: 2, textAlign: 'center' }}
|
||||
color="warning"
|
||||
variant="body1"
|
||||
>
|
||||
<Box mt={2} mb={2} display="flex" alignItems="center">
|
||||
<Typography mr={2} variant="body1" align="center">
|
||||
{LL.LANGUAGE()}:
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export interface Settings {
|
||||
system_name: string;
|
||||
locale: string;
|
||||
tx_mode: number;
|
||||
ems_bus_id: number;
|
||||
@@ -44,17 +43,7 @@ export interface Settings {
|
||||
modbus_port: number;
|
||||
modbus_max_clients: 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;
|
||||
disable_reset: boolean;
|
||||
}
|
||||
|
||||
export enum busConnectionStatus {
|
||||
@@ -207,19 +196,7 @@ export enum DeviceValueUOM {
|
||||
MBAR,
|
||||
LH,
|
||||
CTKWH,
|
||||
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';
|
||||
HZ
|
||||
}
|
||||
|
||||
export const DeviceValueUOM_s = [
|
||||
@@ -362,19 +339,21 @@ export enum DeviceEntityMask {
|
||||
}
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: number; // unique index for table
|
||||
id: number; // unique index
|
||||
active: boolean;
|
||||
deleted?: boolean;
|
||||
flags: number;
|
||||
time: string; // also used for Condition and On Change
|
||||
cmd_name: string; // references a named Command
|
||||
name: string;
|
||||
cmd: string;
|
||||
value: string;
|
||||
name: string; // can be empty
|
||||
o_id?: number;
|
||||
o_active?: boolean;
|
||||
o_deleted?: boolean;
|
||||
o_flags?: number;
|
||||
o_time?: string;
|
||||
o_cmd_name?: string;
|
||||
o_cmd?: string;
|
||||
o_value?: string;
|
||||
o_name?: string;
|
||||
}
|
||||
|
||||
@@ -382,23 +361,6 @@ export interface Schedule {
|
||||
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 {
|
||||
id: number; // unique index
|
||||
key: string;
|
||||
@@ -429,7 +391,8 @@ export enum ScheduleFlag {
|
||||
SCHEDULE_DAY = 0, // no bits set
|
||||
SCHEDULE_TIMER = 128, // bit 8
|
||||
SCHEDULE_ONCHANGE = 129, // bit 1
|
||||
SCHEDULE_CONDITION = 130 // bit 2
|
||||
SCHEDULE_CONDITION = 130, // bit 2
|
||||
SCHEDULE_IMMEDIATE = 132 // bit 3
|
||||
}
|
||||
|
||||
export interface EntityItem {
|
||||
@@ -472,7 +435,6 @@ export const enum DeviceType {
|
||||
ANALOGSENSOR = 2,
|
||||
SCHEDULER = 3,
|
||||
CUSTOM = 4,
|
||||
COMMAND = 5,
|
||||
BOILER,
|
||||
THERMOSTAT,
|
||||
MIXER,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import Schema from 'validators/schema';
|
||||
import type { InternalRuleItem } from 'validators/schema';
|
||||
import Schema from 'async-validator';
|
||||
import type { InternalRuleItem } from 'async-validator';
|
||||
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
|
||||
|
||||
import type {
|
||||
AnalogSensor,
|
||||
CommandItem,
|
||||
DeviceValue,
|
||||
EntityItem,
|
||||
ScheduleItem,
|
||||
@@ -233,29 +232,7 @@ export const schedulerItemValidation = (
|
||||
scheduleItem: ScheduleItem
|
||||
) =>
|
||||
new Schema({
|
||||
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)
|
||||
],
|
||||
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
||||
cmd: [
|
||||
{ required: true, message: 'Command is required' },
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
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 type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -20,10 +21,10 @@ import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { APSettingsType } from 'types';
|
||||
import { APProvisionMode } from 'types';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { createAPSettingsValidator, validate } from 'validators';
|
||||
|
||||
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||
|
||||
// Efficient range function without recursion
|
||||
@@ -62,16 +63,22 @@ const APSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -79,9 +86,9 @@ const APSettings = () => {
|
||||
await validate(createAPSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [data, saveData]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -101,6 +108,9 @@ const APSettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
>
|
||||
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
|
||||
{LL.AP_PROVIDE_TEXT_1()}
|
||||
</MenuItem>
|
||||
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
||||
{LL.AP_PROVIDE_TEXT_2()}
|
||||
</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 PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
@@ -19,6 +20,7 @@ import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import SystemMonitor from 'app/status/SystemMonitor';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -26,16 +28,12 @@ import {
|
||||
FormLoader,
|
||||
MessageBox,
|
||||
SectionContent,
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { validate } from 'validators';
|
||||
|
||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||
import { BOARD_PROFILES } from '../main/types';
|
||||
@@ -69,14 +67,6 @@ const ApplicationSettings = () => {
|
||||
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 { LL } = useI18nContext();
|
||||
@@ -116,79 +106,82 @@ const ApplicationSettings = () => {
|
||||
});
|
||||
});
|
||||
|
||||
const SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
// Memoized input props to prevent recreation on every render
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const MinutesInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
};
|
||||
const MinutesInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const HoursInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
};
|
||||
const HoursInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const doRestart = async () => {
|
||||
const doRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
}, [sendAPI]);
|
||||
|
||||
const updateBoardProfile = async (board_profile: string) => {
|
||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
const updateBoardProfile = useCallback(
|
||||
async (board_profile: string) => {
|
||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
},
|
||||
[readBoardProfile]
|
||||
);
|
||||
|
||||
useLayoutTitle(LL.APPLICATION());
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
} finally {
|
||||
await saveData();
|
||||
}
|
||||
};
|
||||
}, [data, saveData]);
|
||||
|
||||
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const boardProfile = event.target.value;
|
||||
updateFormValue(event);
|
||||
if (boardProfile === 'CUSTOM') {
|
||||
updateDataValue({
|
||||
...data,
|
||||
board_profile: boardProfile
|
||||
});
|
||||
} else {
|
||||
void updateBoardProfile(boardProfile);
|
||||
}
|
||||
};
|
||||
const changeBoardProfile = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const boardProfile = event.target.value;
|
||||
updateFormValue(event);
|
||||
if (boardProfile === 'CUSTOM') {
|
||||
updateDataValue({
|
||||
...data,
|
||||
board_profile: boardProfile
|
||||
});
|
||||
} else {
|
||||
void updateBoardProfile(boardProfile);
|
||||
}
|
||||
},
|
||||
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
||||
);
|
||||
|
||||
const restart = async () => {
|
||||
const restart = useCallback(async () => {
|
||||
await validateAndSubmit();
|
||||
await doRestart();
|
||||
};
|
||||
}, [validateAndSubmit, doRestart]);
|
||||
|
||||
const sendmail = async () => {
|
||||
await sendAPI({
|
||||
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();
|
||||
// Memoize board profile select items to prevent recreation
|
||||
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
||||
|
||||
const content = () => {
|
||||
if (!data || !hardwareData) {
|
||||
@@ -197,19 +190,7 @@ const ApplicationSettings = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" color="primary">
|
||||
{LL.SYSTEM(0)}
|
||||
{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">
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||
{LL.SERVICES()}
|
||||
</Typography>
|
||||
<Typography color="secondary">API</Typography>
|
||||
@@ -370,148 +351,6 @@ const ApplicationSettings = () => {
|
||||
</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">
|
||||
({LL.IS_REQUIRED('PSRAM')})
|
||||
</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">
|
||||
{LL.SENSORS()}
|
||||
</Typography>
|
||||
@@ -557,7 +396,7 @@ const ApplicationSettings = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value="cs">Česky (CS)</MenuItem>
|
||||
<MenuItem value="cz">Česky (CZ)</MenuItem>
|
||||
<MenuItem value="de">Deutsch (DE)</MenuItem>
|
||||
<MenuItem value="en">English (EN)</MenuItem>
|
||||
<MenuItem value="fr">Français (FR)</MenuItem>
|
||||
@@ -650,25 +489,17 @@ const ApplicationSettings = () => {
|
||||
name="board_profile"
|
||||
label={LL.BOARD_PROFILE()}
|
||||
value={data.board_profile}
|
||||
disabled={processingBoard}
|
||||
disabled={processingBoard || hardwareData.model.startsWith('BBQKees')}
|
||||
variant="outlined"
|
||||
onChange={changeBoardProfile}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
{hardwareData.model.startsWith('BBQKees') ? (
|
||||
<MenuItem key={hardwareData.board} value={hardwareData.board}>
|
||||
{BOARD_PROFILES[hardwareData.board as BoardProfileKey]}
|
||||
</MenuItem>
|
||||
) : (
|
||||
boardProfileItems
|
||||
)}
|
||||
{(data.board_profile === 'CUSTOM' || data.developer_mode) && <Divider />}
|
||||
{(data.board_profile === 'CUSTOM' || data.developer_mode) && (
|
||||
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
||||
{LL.CUSTOM()}…
|
||||
</MenuItem>
|
||||
)}
|
||||
{boardProfileItems}
|
||||
<Divider />
|
||||
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
||||
{LL.CUSTOM()}…
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
{data.board_profile === 'CUSTOM' && (
|
||||
<>
|
||||
@@ -838,7 +669,6 @@ const ApplicationSettings = () => {
|
||||
<MenuItem value={2}>EMS+</MenuItem>
|
||||
<MenuItem value={3}>HT3</MenuItem>
|
||||
<MenuItem value={4}>{LL.HARDWARE()}</MenuItem>
|
||||
<MenuItem value={5}>Auto</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
@@ -912,16 +742,6 @@ const ApplicationSettings = () => {
|
||||
}
|
||||
label={LL.DEVELOPER_MODE()}
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.disable_reset}
|
||||
onChange={updateFormValue}
|
||||
name="disable_reset"
|
||||
/>
|
||||
}
|
||||
label={LL.DISABLE_RESET()}
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -943,7 +763,7 @@ const ApplicationSettings = () => {
|
||||
label={LL.REMOTE_TIMEOUT_EN()}
|
||||
/>
|
||||
{data.remote_timeout_en && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box mt={2}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="remote_timeout"
|
||||
@@ -1076,12 +896,10 @@ const ApplicationSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
return restarting ? (
|
||||
<SystemMonitor />
|
||||
) : (
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content()}
|
||||
{restarting ? <SystemMonitor /> : content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { Box, Button, Grid, 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';
|
||||
@@ -26,15 +16,19 @@ import {
|
||||
SingleUpload,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { saveFile } from 'utils';
|
||||
|
||||
interface DownloadButton {
|
||||
key: string;
|
||||
type: string;
|
||||
label: string | number;
|
||||
isGridButton: boolean;
|
||||
}
|
||||
|
||||
const DownloadUpload = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const [confirmBackup, setConfirmBackup] = useState<boolean>(false);
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
|
||||
const { send: sendExportData } = useRequest(
|
||||
@@ -57,7 +51,7 @@ const DownloadUpload = () => {
|
||||
|
||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||
|
||||
const doRestart = async () => {
|
||||
const doRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||
@@ -65,18 +59,52 @@ const DownloadUpload = () => {
|
||||
toast.error((error as Error).message);
|
||||
setRestarting(false);
|
||||
}
|
||||
};
|
||||
}, [sendAPI]);
|
||||
|
||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||
|
||||
const handleCloseBackupDialog = () => {
|
||||
setConfirmBackup(false);
|
||||
};
|
||||
const downloadButtons: DownloadButton[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'settings',
|
||||
type: 'settings',
|
||||
label: LL.SETTINGS_OF(LL.APPLICATION()),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'customizations',
|
||||
type: 'customizations',
|
||||
label: LL.CUSTOMIZATIONS(),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'entities',
|
||||
type: 'entities',
|
||||
label: LL.CUSTOM_ENTITIES(0),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
type: 'schedule',
|
||||
label: LL.SCHEDULE(0),
|
||||
isGridButton: true
|
||||
},
|
||||
{
|
||||
key: 'allvalues',
|
||||
type: 'allvalues',
|
||||
label: LL.ALLVALUES(),
|
||||
isGridButton: false
|
||||
}
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
|
||||
const handleDownload = (type: string) => () => {
|
||||
void sendExportData(type);
|
||||
setConfirmBackup(false);
|
||||
};
|
||||
const handleDownload = useCallback(
|
||||
(type: string) => () => {
|
||||
void sendExportData(type);
|
||||
},
|
||||
[sendExportData]
|
||||
);
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
@@ -90,86 +118,58 @@ const DownloadUpload = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
|
||||
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmBackup}
|
||||
onClose={handleCloseBackupDialog}
|
||||
>
|
||||
<DialogTitle>{LL.DOWNLOAD_SYSTEM_BACKUP()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<WarningIcon color="warning" sx={{ fontSize: 18 }} />
|
||||
|
||||
{LL.WARNING_SYSTEM_BACKUP()}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseBackupDialog}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleDownload('systembackup')}
|
||||
color="primary"
|
||||
>
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}:
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setConfirmBackup(true)}
|
||||
>
|
||||
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||
</Button>
|
||||
<Typography mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{gridButtons.map((button) => (
|
||||
<Grid key={button.key}>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload(button.type)}
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
|
||||
<Typography variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}:
|
||||
</Typography>
|
||||
<Typography mt={2} mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
|
||||
</Typography>
|
||||
|
||||
{standaloneButton && (
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleDownload('allvalues')}
|
||||
onClick={handleDownload(standaloneButton.type)}
|
||||
>
|
||||
{LL.ALLVALUES()}
|
||||
{standaloneButton.label}
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ pb: 2 }} color="warning" variant="body1">
|
||||
{LL.UPLOAD_TEXT()}:
|
||||
</Typography>
|
||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||
</Box>
|
||||
|
||||
<SingleUpload doRestart={doRestart} />
|
||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
|
||||
import * as MqttApi from 'api/mqtt';
|
||||
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -26,12 +28,10 @@ import {
|
||||
ValidatedTextField,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { MqttSettingsType } from 'types';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { createMqttSettingsValidator, validate } from 'validators';
|
||||
|
||||
import { callAction } from '../../api/app';
|
||||
|
||||
@@ -57,7 +57,7 @@ const MqttSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const sendResetMQTT = () => {
|
||||
const sendResetMQTT = useCallback(() => {
|
||||
void callAction({ action: 'resetMQTT' })
|
||||
.then(() => {
|
||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
||||
@@ -65,44 +65,56 @@ const MqttSettings = () => {
|
||||
.catch((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
);
|
||||
|
||||
const SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
const emptyFieldErrors = useMemo(() => ({}), []);
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createMqttSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [data, saveData]);
|
||||
|
||||
const publishIntervalFields = [
|
||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||
{
|
||||
name: 'publish_time_thermostat',
|
||||
label: LL.MQTT_INT_THERMOSTATS(),
|
||||
validated: false
|
||||
},
|
||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||
];
|
||||
const publishIntervalFields = useMemo(
|
||||
() => [
|
||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||
{
|
||||
name: 'publish_time_thermostat',
|
||||
label: LL.MQTT_INT_THERMOSTATS(),
|
||||
validated: false
|
||||
},
|
||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||
],
|
||||
[LL]
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -117,7 +129,7 @@ const MqttSettings = () => {
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
|
||||
<Box display="flex" gap={2} mb={1}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -142,7 +154,7 @@ const MqttSettings = () => {
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="host"
|
||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||
multiline
|
||||
@@ -154,7 +166,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="port"
|
||||
label="Port"
|
||||
variant="outlined"
|
||||
@@ -166,7 +178,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="base"
|
||||
label={LL.BASE_TOPIC()}
|
||||
variant="outlined"
|
||||
@@ -207,7 +219,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="keep_alive"
|
||||
label="Keep Alive"
|
||||
slotProps={{
|
||||
@@ -333,91 +345,75 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{/* <Grid container spacing={2} rowSpacing={0}> */}
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="ha_enabled"
|
||||
checked={data.ha_enabled}
|
||||
onChange={updateFormValue}
|
||||
disabled={data.publish_single}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_3()}
|
||||
/>
|
||||
</Grid>
|
||||
{data.ha_enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_type"
|
||||
label={LL.MQTT_PUBLISH_TEXT_5()}
|
||||
value={data.discovery_type}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>Home Assistant</MenuItem>
|
||||
<MenuItem value={1}>Domoticz</MenuItem>
|
||||
<MenuItem value={2}>Domoticz (latest)</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_prefix"
|
||||
label={LL.MQTT_PUBLISH_TEXT_4()}
|
||||
variant="outlined"
|
||||
value={data.discovery_prefix}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="entity_format"
|
||||
label={LL.MQTT_ENTITY_FORMAT()}
|
||||
value={data.entity_format}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||
<MenuItem value={3}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.5)
|
||||
</MenuItem>
|
||||
<MenuItem value={4}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.5)
|
||||
</MenuItem>
|
||||
<MenuItem value={1}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (latest)
|
||||
</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (latest)
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
{data.discovery_type === 0 && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="ha_enabled"
|
||||
checked={data.ha_enabled}
|
||||
onChange={updateFormValue}
|
||||
disabled={data.publish_single}
|
||||
/>
|
||||
}
|
||||
label={LL.MQTT_PUBLISH_TEXT_3()}
|
||||
/>
|
||||
</Grid>
|
||||
{data.ha_enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="ha_number_mode"
|
||||
label={LL.MQTT_INPUT_NUMBER_FORMAT()}
|
||||
value={data.ha_number_mode}
|
||||
name="discovery_type"
|
||||
label={LL.MQTT_PUBLISH_TEXT_5()}
|
||||
value={data.discovery_type}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
sx={{ width: '20ch' }}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>Box</MenuItem>
|
||||
<MenuItem value={1}>Slider</MenuItem>
|
||||
<MenuItem value={0}>Home Assistant</MenuItem>
|
||||
<MenuItem value={1}>Domoticz</MenuItem>
|
||||
<MenuItem value={2}>Domoticz (latest)</MenuItem>
|
||||
</TextField>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="discovery_prefix"
|
||||
label={LL.MQTT_PUBLISH_TEXT_4()}
|
||||
variant="outlined"
|
||||
value={data.discovery_prefix}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="entity_format"
|
||||
label={LL.MQTT_ENTITY_FORMAT()}
|
||||
value={data.entity_format}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||
<MenuItem value={3}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.5)
|
||||
</MenuItem>
|
||||
<MenuItem value={4}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.5)
|
||||
</MenuItem>
|
||||
<MenuItem value={1}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (latest)
|
||||
</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (latest)
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
)}
|
||||
</Grid>
|
||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
||||
{LL.MQTT_PUBLISH_INTERVALS()} (0=auto)
|
||||
</Typography>
|
||||
@@ -426,7 +422,7 @@ const MqttSettings = () => {
|
||||
<Grid key={field.name}>
|
||||
{field.validated ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
slotProps={{
|
||||
|
||||
@@ -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 CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
@@ -23,6 +23,7 @@ import { readNTPSettings } from 'api/ntp';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import { updateState } from 'alova/client';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -32,20 +33,13 @@ import {
|
||||
ValidatedTextField,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { NTPSettingsType, Time } from 'types';
|
||||
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import { validate } from 'validators';
|
||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import {
|
||||
TIME_ZONES,
|
||||
selectedTimeZone,
|
||||
timeZoneSelectItemsT,
|
||||
useTimeZoneSelectItems
|
||||
} from './TZ';
|
||||
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
|
||||
|
||||
const NTPSettings = () => {
|
||||
const {
|
||||
@@ -67,16 +61,15 @@ const NTPSettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('NTP');
|
||||
|
||||
// Memoized timezone select items for better performance
|
||||
const timeZoneItems = useTimeZoneSelectItems();
|
||||
const timeZoneItemsT = timeZoneSelectItemsT();
|
||||
|
||||
const selectedTzValue = data
|
||||
? selectedTimeZone(data.tz_label, data.tz_format)
|
||||
: undefined;
|
||||
// Memoized selected timezone value
|
||||
const selectedTzValue = useMemo(
|
||||
() => (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)
|
||||
: undefined;
|
||||
const [localTime, setLocalTime] = useState<string>('');
|
||||
const [settingTime, setSettingTime] = useState<boolean>(false);
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
@@ -89,22 +82,32 @@ const NTPSettings = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
// Memoize updateFormValue to prevent recreation on every render
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValueDirty(
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
),
|
||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||
);
|
||||
|
||||
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setLocalTime(event.target.value);
|
||||
// Memoize updateLocalTime handler
|
||||
const updateLocalTime = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
||||
[]
|
||||
);
|
||||
|
||||
const openSetTime = () => {
|
||||
// Memoize openSetTime handler
|
||||
const openSetTime = useCallback(() => {
|
||||
setLocalTime(formatLocalDateTime(new Date()));
|
||||
setSettingTime(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const configureTime = async () => {
|
||||
// Memoize configureTime handler
|
||||
const configureTime = useCallback(async () => {
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
@@ -117,40 +120,38 @@ const NTPSettings = () => {
|
||||
} finally {
|
||||
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;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [data, saveData]);
|
||||
|
||||
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||
...settings,
|
||||
tz_label: event.target.value,
|
||||
tz_format: TIME_ZONES[event.target.value]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
};
|
||||
// Memoize timezone change handler
|
||||
const changeTimeZone = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||
...settings,
|
||||
tz_label: event.target.value,
|
||||
tz_format: TIME_ZONES[event.target.value]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
},
|
||||
[updateFormValue]
|
||||
);
|
||||
|
||||
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||
...settings,
|
||||
tz_label_t: event.target.value,
|
||||
tz_format_t: TIME_ZONES[event.target.value]
|
||||
}));
|
||||
updateFormValue(event);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
// Memoize render content to prevent unnecessary re-renders
|
||||
const renderContent = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
@@ -173,7 +174,6 @@ const NTPSettings = () => {
|
||||
label={LL.NTP_SERVER()}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={!data.enabled}
|
||||
value={data.server}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
@@ -192,50 +192,10 @@ const NTPSettings = () => {
|
||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||
{timeZoneItems}
|
||||
</ValidatedTextField>
|
||||
{data.enabled && (
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="thermostat_sync"
|
||||
label="Sync EMS-Thermostat"
|
||||
variant="outlined"
|
||||
sx={{ width: '30ch' }}
|
||||
value={data.thermostat_sync}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.USE()} {LL.TIME_ZONE()}
|
||||
</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
{data.thermostat_sync === 2 && (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="tz_label_t"
|
||||
label={LL.TIME_ZONE() + ' Thermostat'}
|
||||
sx={{ width: '30ch' }}
|
||||
variant="outlined"
|
||||
value={selectedTzValueT}
|
||||
onChange={changeTimeZoneT}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||
{timeZoneItemsT}
|
||||
</ValidatedTextField>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
{!data.enabled && !dirtyFlags.length && (
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<ButtonRow>
|
||||
<Button
|
||||
onClick={openSetTime}
|
||||
@@ -276,18 +236,32 @@ const NTPSettings = () => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
data,
|
||||
errorMessage,
|
||||
loadData,
|
||||
updateFormValue,
|
||||
fieldErrors,
|
||||
selectedTzValue,
|
||||
changeTimeZone,
|
||||
timeZoneItems,
|
||||
dirtyFlags,
|
||||
openSetTime,
|
||||
saving,
|
||||
validateAndSubmit,
|
||||
LL
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{renderContent()}
|
||||
{renderContent}
|
||||
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography color="warning" variant="body2">
|
||||
{LL.SET_TIME_TEXT()}
|
||||
</Typography>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
label={LL.LOCAL_TIME(0)}
|
||||
type="datetime-local"
|
||||
|
||||
@@ -1,108 +1,188 @@
|
||||
import { useContext } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
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 ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import { 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 ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import SystemMonitor from '../status/SystemMonitor';
|
||||
|
||||
const Settings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { versions } = useContext(AuthenticatedContext);
|
||||
useLayoutTitle(LL.SETTINGS(0));
|
||||
|
||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||
const firmwareText = versions?.current?.version
|
||||
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
|
||||
: '';
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label="EMS-ESP Firmware"
|
||||
text={firmwareText}
|
||||
to="/settings/version"
|
||||
badge={upgradeAvailable}
|
||||
/>
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
const doFormat = useCallback(async () => {
|
||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||
setRestarting(true);
|
||||
setConfirmFactoryReset(false);
|
||||
});
|
||||
}, [sendAPI]);
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
const handleFactoryResetClose = useCallback(() => {
|
||||
setConfirmFactoryReset(false);
|
||||
}, []);
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
const handleFactoryResetClick = useCallback(() => {
|
||||
setConfirmFactoryReset(true);
|
||||
}, []);
|
||||
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor="#68374d"
|
||||
label="MQTT"
|
||||
text={LL.CONFIGURE('MQTT')}
|
||||
to="mqtt"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={LockIcon}
|
||||
label={LL.SECURITY(0)}
|
||||
text={LL.SECURITY_1()}
|
||||
to="security"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ViewModuleIcon}
|
||||
bgcolor="#efc34b"
|
||||
label={LL.MODULES()}
|
||||
text={LL.MODULES_1()}
|
||||
to="modules"
|
||||
/>
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={ImportExportIcon}
|
||||
bgcolor="#5d89f7"
|
||||
label={LL.DOWNLOAD_UPLOAD()}
|
||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||
to="downloadUpload"
|
||||
/>
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
<ListMenuItem
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor="#68374d"
|
||||
label="MQTT"
|
||||
text={LL.CONFIGURE('MQTT')}
|
||||
to="mqtt"
|
||||
/>
|
||||
|
||||
<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
|
||||
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 <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MenuItem } from '@mui/material';
|
||||
|
||||
export const TIME_ZONES: Record<string, string> = {
|
||||
@@ -470,20 +472,26 @@ export function selectedTimeZone(label: string, format: string) {
|
||||
return TIME_ZONES[label] === format ? label : undefined;
|
||||
}
|
||||
|
||||
// Memoized version for use in components
|
||||
export function useTimeZoneSelectItems() {
|
||||
return useMemo(
|
||||
() =>
|
||||
TIME_ZONE_LABELS.map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback export for backward compatibility - now memoized
|
||||
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
export function useTimeZoneSelectItems() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
export function timeZoneSelectItems() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
export function timeZoneSelectItemsT() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
({data.build_flags})
|
||||
</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">
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CloseIcon
|
||||
color="error"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
PSRAM)
|
||||
</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()}…
|
||||
</Typography>
|
||||
)}
|
||||
{me.admin && (
|
||||
<>
|
||||
<VersionInfoDialog
|
||||
showVersionInfo={showVersionInfo}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
partitionVersion={partitionVersion}
|
||||
locale={locale}
|
||||
partition={partition}
|
||||
currentPartition={data?.partition ?? ''}
|
||||
size={firmwareSize}
|
||||
LL={LL}
|
||||
onClose={handleVersionInfoClose}
|
||||
/>
|
||||
<InstallDialog
|
||||
openInstallDialog={openInstallDialog}
|
||||
fetchDevVersion={fetchDevVersion}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
upgradeImportantMessageType={upgradeImportantMessageType}
|
||||
platform={platform}
|
||||
LL={LL}
|
||||
onClose={closeInstallDialog}
|
||||
onInstall={installFirmwareURL}
|
||||
/>
|
||||
<InstallPartitionDialog
|
||||
openInstallPartitionDialog={openInstallPartitionDialog}
|
||||
version={partitionVersion?.version || ''}
|
||||
partition={partition}
|
||||
LL={LL}
|
||||
onClose={closeInstallPartitionDialog}
|
||||
onInstall={installPartitionFirmware}
|
||||
/>
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
<SingleUpload doRestart={doRestart} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{me.admin && (
|
||||
<>
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmFactoryReset}
|
||||
onClose={handleFactoryResetClose}
|
||||
>
|
||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={doFormat}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmRestart}
|
||||
onClose={handleRestartClose}
|
||||
>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="success"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
whiteSpace: 'nowrap',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
{!data.disable_reset && (
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleRestartClick}
|
||||
color="success"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Version);
|
||||
@@ -1,5 +1,12 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Outlet, useMatch, useNavigate } from 'react-router';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigate
|
||||
} from 'react-router';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
|
||||
@@ -7,32 +14,52 @@ import { RouterTabs, useLayoutTitle } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { WiFiNetwork } from 'types';
|
||||
|
||||
import NetworkSettings from './NetworkSettings';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
import WiFiNetworkScanner from './WiFiNetworkScanner';
|
||||
|
||||
const Network = () => {
|
||||
const { LL } = useI18nContext();
|
||||
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 [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
||||
|
||||
const selectNetwork = (network: WiFiNetwork) => {
|
||||
setSelectedNetwork(network);
|
||||
void navigate('/settings/network/settings');
|
||||
};
|
||||
const selectNetwork = useCallback(
|
||||
(network: WiFiNetwork) => {
|
||||
setSelectedNetwork(network);
|
||||
void navigate('/settings/network/settings');
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const deselectNetwork = () => {
|
||||
const deselectNetwork = useCallback(() => {
|
||||
setSelectedNetwork(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = {
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
};
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
}),
|
||||
[selectedNetwork, selectNetwork, deselectNetwork]
|
||||
);
|
||||
|
||||
return (
|
||||
<WiFiConnectionContext.Provider value={contextValue}>
|
||||
@@ -43,7 +70,14 @@ const Network = () => {
|
||||
/>
|
||||
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
@@ -25,6 +26,7 @@ import { API } from 'api/app';
|
||||
|
||||
import { updateState, useRequest } from 'alova/client';
|
||||
import type { APIcall } from 'app/main/types';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
BlockNavigation,
|
||||
@@ -35,13 +37,11 @@ import {
|
||||
ValidatedPasswordField,
|
||||
ValidatedTextField
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { NetworkSettingsType } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import { validate } from 'validators';
|
||||
import { createNetworkSettingsValidator } from 'validators/network';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
|
||||
import SystemMonitor from '../../status/SystemMonitor';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
@@ -89,7 +89,7 @@ const NetworkSettings = () => {
|
||||
static_ip_config: false,
|
||||
bandwidth20: false,
|
||||
tx_power: 0,
|
||||
nosleep: true,
|
||||
nosleep: false,
|
||||
enableMDNS: true,
|
||||
enableCORS: false,
|
||||
CORSOrigin: '*'
|
||||
@@ -116,24 +116,24 @@ const NetworkSettings = () => {
|
||||
await validate(createNetworkSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
deselectNetwork();
|
||||
}, [data, saveData, deselectNetwork]);
|
||||
|
||||
const setCancel = async () => {
|
||||
const setCancel = useCallback(async () => {
|
||||
deselectNetwork();
|
||||
await loadData();
|
||||
};
|
||||
}, [deselectNetwork, loadData]);
|
||||
|
||||
const doRestart = async () => {
|
||||
const doRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
}, [sendAPI]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="ssid"
|
||||
label="SSID"
|
||||
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.ssid}
|
||||
@@ -397,12 +397,10 @@ const NetworkSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
return restarting ? (
|
||||
<SystemMonitor />
|
||||
) : (
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content()}
|
||||
{restarting ? <SystemMonitor /> : content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { Button } from '@mui/material';
|
||||
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const renderNetworkScanner = () => {
|
||||
const renderNetworkScanner = useCallback(() => {
|
||||
if (!networkList) {
|
||||
return <FormLoader errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
return <WiFiNetworkSelector networkList={networkList} />;
|
||||
};
|
||||
}, [networkList, errorMessage]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useContext } from 'react';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
@@ -63,31 +63,34 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
||||
|
||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||
|
||||
const renderNetwork = (network: WiFiNetwork) => (
|
||||
<ListItem
|
||||
key={network.bssid}
|
||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={network.ssid}
|
||||
secondary={
|
||||
'Security: ' +
|
||||
networkSecurityMode(network) +
|
||||
', Ch: ' +
|
||||
network.channel +
|
||||
', bssid: ' +
|
||||
network.bssid
|
||||
}
|
||||
/>
|
||||
<ListItemIcon>
|
||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
const renderNetwork = useCallback(
|
||||
(network: WiFiNetwork) => (
|
||||
<ListItem
|
||||
key={network.bssid}
|
||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={network.ssid}
|
||||
secondary={
|
||||
'Security: ' +
|
||||
networkSecurityMode(network) +
|
||||
', Ch: ' +
|
||||
network.channel +
|
||||
', bssid: ' +
|
||||
network.bssid
|
||||
}
|
||||
/>
|
||||
<ListItemIcon>
|
||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
),
|
||||
[wifiConnectionContext, theme]
|
||||
);
|
||||
|
||||
if (networkList.networks.length === 0) {
|
||||
|
||||
@@ -54,27 +54,19 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
||||
<DialogContent dividers>
|
||||
{token ? (
|
||||
<>
|
||||
<MessageBox
|
||||
message={LL.ACCESS_TOKEN_TEXT()}
|
||||
level="info"
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<MessageBox message={LL.ACCESS_TOKEN_TEXT()} level="info" my={2} />
|
||||
<Box mt={2} mb={2}>
|
||||
<TextField
|
||||
label="Token"
|
||||
multiline
|
||||
value={token.token}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true
|
||||
}
|
||||
}}
|
||||
contentEditable={false}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ m: 4, textAlign: 'center' }}>
|
||||
<Box m={4} textAlign="center">
|
||||
<LinearProgress />
|
||||
<Typography variant="h6">{LL.GENERATING_TOKEN()}…</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -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 CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -55,14 +55,16 @@ const ManageUsers = () => {
|
||||
const blocker = useBlocker(changed !== 0);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const table_theme = useTheme({
|
||||
Table: `
|
||||
const table_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -72,7 +74,7 @@ const ManageUsers = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #565656;
|
||||
@@ -85,7 +87,7 @@ const ManageUsers = () => {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -93,36 +95,44 @@ const ManageUsers = () => {
|
||||
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) => {
|
||||
if (!data) return;
|
||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||
updateDataValue({ ...data, users });
|
||||
setChanged(changed + 1);
|
||||
};
|
||||
const removeUser = useCallback(
|
||||
(toRemove: UserType) => {
|
||||
if (!data) return;
|
||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||
updateDataValue({ ...data, users });
|
||||
setChanged(changed + 1);
|
||||
},
|
||||
[data, updateDataValue, changed]
|
||||
);
|
||||
|
||||
const createUser = () => {
|
||||
const createUser = useCallback(() => {
|
||||
setCreating(true);
|
||||
setUser({
|
||||
username: '',
|
||||
password: '',
|
||||
admin: true
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const editUser = (toEdit: UserType) => {
|
||||
const editUser = useCallback((toEdit: UserType) => {
|
||||
setCreating(false);
|
||||
setUser({ ...toEdit });
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelEditingUser = () => {
|
||||
const cancelEditingUser = useCallback(() => {
|
||||
setUser(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const doneEditingUser = () => {
|
||||
const doneEditingUser = useCallback(() => {
|
||||
if (user && data) {
|
||||
const users = [
|
||||
...data.users.filter(
|
||||
@@ -134,26 +144,26 @@ const ManageUsers = () => {
|
||||
setUser(undefined);
|
||||
setChanged(changed + 1);
|
||||
}
|
||||
};
|
||||
}, [user, data, updateDataValue, changed]);
|
||||
|
||||
const closeGenerateToken = useCallback(() => {
|
||||
setGeneratingToken(undefined);
|
||||
}, []);
|
||||
|
||||
const generateTokenForUser = (username: string) => {
|
||||
const generateTokenForUser = useCallback((username: string) => {
|
||||
setGeneratingToken(username);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const onSubmit = useCallback(async () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
setChanged(0);
|
||||
};
|
||||
}, [saveData, authenticatedContext]);
|
||||
|
||||
const onCancelSubmit = async () => {
|
||||
const onCancelSubmit = useCallback(async () => {
|
||||
await loadData();
|
||||
setChanged(0);
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -167,10 +177,15 @@ const ManageUsers = () => {
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
const user_table = data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[];
|
||||
// add id to the type, needed for the table
|
||||
const user_table = useMemo(
|
||||
() =>
|
||||
data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[],
|
||||
[data.users]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -225,16 +240,12 @@ const ManageUsers = () => {
|
||||
</Table>
|
||||
|
||||
{noAdminConfigured() && (
|
||||
<MessageBox
|
||||
level="warning"
|
||||
message={LL.USER_WARNING()}
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
<MessageBox level="warning" message={LL.USER_WARNING()} my={2} />
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
{changed !== 0 && (
|
||||
<Box sx={{ flexGrow: 1, '& button': { mt: 2 } }}>
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
@@ -259,7 +270,7 @@ const ManageUsers = () => {
|
||||
</ButtonRow>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<PersonAddIcon />}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { memo } from 'react';
|
||||
import { Outlet, useMatch } from 'react-router';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useLayoutTitle } from 'components';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import ManageUsers from './ManageUsers';
|
||||
import SecuritySettings from './SecuritySettings';
|
||||
|
||||
const Security = () => {
|
||||
const { LL } = useI18nContext();
|
||||
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 (
|
||||
<>
|
||||
@@ -21,7 +40,14 @@ const Security = () => {
|
||||
/>
|
||||
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
||||
</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 type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockNavigation,
|
||||
ButtonRow,
|
||||
@@ -18,8 +19,7 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { SecuritySettingsType } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
|
||||
|
||||
const SecuritySettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
@@ -58,7 +58,7 @@ const SecuritySettings = () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData, authenticatedContext]);
|
||||
|
||||
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
|
||||
onChange={updateFormValue}
|
||||
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 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '@mui/material';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import type Schema from 'async-validator';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
import {
|
||||
BlockFormControlLabel,
|
||||
ValidatedPasswordField,
|
||||
@@ -22,9 +24,7 @@ import {
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { UserType } from 'types';
|
||||
import { updateValue } from 'utils';
|
||||
import { ValidationError, validate } from 'validators';
|
||||
import type Schema from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import { validate } from 'validators';
|
||||
|
||||
interface UserFormProps {
|
||||
creating: boolean;
|
||||
@@ -62,17 +62,17 @@ const User: FC<UserFormProps> = ({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const validateAndDone = async () => {
|
||||
const validateAndDone = useCallback(async () => {
|
||||
if (user) {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, user);
|
||||
onDoneEditing();
|
||||
} catch (error) {
|
||||
setFieldErrors((error as ValidationError).fieldErrors);
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [user, validator, onDoneEditing]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Cell,
|
||||
@@ -34,14 +36,16 @@ const SystemActivity = () => {
|
||||
|
||||
useLayoutTitle(LL.DATA_TRAFFIC());
|
||||
|
||||
const stats_theme = tableTheme({
|
||||
Table: `
|
||||
const stats_theme = tableTheme(
|
||||
useMemo(
|
||||
() => ({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -51,7 +55,7 @@ const SystemActivity = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #565656;
|
||||
@@ -65,20 +69,26 @@ const SystemActivity = () => {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:not(:first-of-type) {
|
||||
text-align: center;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
const showName = (id: number) => {
|
||||
const name: keyof Translation['STATUS_NAMES'] =
|
||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||
return LL.STATUS_NAMES[name]();
|
||||
};
|
||||
const showName = useCallback(
|
||||
(id: number) => {
|
||||
const name: keyof Translation['STATUS_NAMES'] =
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -90,18 +100,14 @@ const SystemActivity = () => {
|
||||
} else {
|
||||
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 (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<Table
|
||||
data={{ nodes: data.stats }}
|
||||
theme={stats_theme}
|
||||
@@ -130,8 +136,10 @@ const SystemActivity = () => {
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
</SectionContent>
|
||||
);
|
||||
);
|
||||
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
|
||||
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default SystemActivity;
|
||||
|
||||
@@ -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 DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
@@ -127,15 +127,16 @@ const MqttStatus = () => {
|
||||
void loadData();
|
||||
});
|
||||
|
||||
// Memoize error message separately to avoid re-renders on error object changes
|
||||
const errorMessage = error?.message || '';
|
||||
|
||||
const mqttStatusText = !data
|
||||
? ''
|
||||
: !data.enabled
|
||||
? LL.NOT_ENABLED()
|
||||
: data.connected
|
||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||
const mqttStatusText = useMemo(() => {
|
||||
if (!data) return '';
|
||||
if (!data.enabled) return LL.NOT_ENABLED();
|
||||
return data.connected
|
||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||
}, [data, LL]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
|
||||
@@ -65,16 +67,12 @@ const NTPStatus = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
return (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -123,8 +121,10 @@ const NTPStatus = () => {
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
);
|
||||
}, [data, error, loadData, LL, theme]);
|
||||
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default NTPStatus;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import GiteIcon from '@mui/icons-material/Gite';
|
||||
@@ -122,20 +124,16 @@ const NetworkStatus = () => {
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -229,8 +227,10 @@ const NetworkStatus = () => {
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
);
|
||||
}, [data, error, loadData, LL, theme]);
|
||||
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default NetworkStatus;
|
||||
|
||||
+276
-111
@@ -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 BuildIcon from '@mui/icons-material/Build';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import RouterIcon from '@mui/icons-material/Router';
|
||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
@@ -18,10 +27,12 @@ import {
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
|
||||
import { API } from 'api/app';
|
||||
import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
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 ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
@@ -30,6 +41,9 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
|
||||
import { useInterval } from 'utils';
|
||||
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 formatDurationSec = (
|
||||
@@ -58,7 +72,24 @@ const SystemStatus = () => {
|
||||
|
||||
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(() => {
|
||||
void loadData();
|
||||
@@ -66,8 +97,10 @@ const SystemStatus = () => {
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const busStatus = (() => {
|
||||
// Memoize derived status values to avoid recalculation on every render
|
||||
const busStatus = useMemo(() => {
|
||||
if (!data) return 'EMS state unknown';
|
||||
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
||||
@@ -78,10 +111,12 @@ const SystemStatus = () => {
|
||||
default:
|
||||
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 '??';
|
||||
|
||||
switch (data.status) {
|
||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
||||
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
||||
@@ -94,12 +129,14 @@ const SystemStatus = () => {
|
||||
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
||||
return LL.GPIO_OF(LL.FAILED(0));
|
||||
default:
|
||||
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||
return 'OK';
|
||||
}
|
||||
})();
|
||||
}, [data?.status, LL]);
|
||||
|
||||
const busStatusHighlight = (() => {
|
||||
const busStatusHighlight = useMemo(() => {
|
||||
if (!data) return theme.palette.warning.main;
|
||||
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||
return theme.palette.warning.main;
|
||||
@@ -110,10 +147,11 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
})();
|
||||
}, [data?.bus_status, theme.palette]);
|
||||
|
||||
const ntpStatus = (() => {
|
||||
const ntpStatus = useMemo(() => {
|
||||
if (!data) return LL.UNKNOWN();
|
||||
|
||||
switch (data.ntp_status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return LL.NOT_ENABLED();
|
||||
@@ -126,10 +164,11 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
})();
|
||||
}, [data?.ntp_status, data?.ntp_time, LL]);
|
||||
|
||||
const ntpStatusHighlight = (() => {
|
||||
const ntpStatusHighlight = useMemo(() => {
|
||||
if (!data) return theme.palette.error.main;
|
||||
|
||||
switch (data.ntp_status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return theme.palette.info.main;
|
||||
@@ -140,10 +179,11 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
})();
|
||||
}, [data?.ntp_status, theme.palette]);
|
||||
|
||||
const networkStatusHighlight = (() => {
|
||||
const networkStatusHighlight = useMemo(() => {
|
||||
if (!data) return theme.palette.warning.main;
|
||||
|
||||
switch (data.network_status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
@@ -158,10 +198,11 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
})();
|
||||
}, [data?.network_status, theme.palette]);
|
||||
|
||||
const networkStatus = (() => {
|
||||
const networkStatus = useMemo(() => {
|
||||
if (!data) return LL.UNKNOWN();
|
||||
|
||||
switch (data.network_status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return LL.INACTIVE(1);
|
||||
@@ -182,103 +223,227 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
})();
|
||||
}, [data?.network_status, data?.wifi_rssi, LL]);
|
||||
|
||||
const activeHighlight = (value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main;
|
||||
|
||||
if (!data || !LL) {
|
||||
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 activeHighlight = useCallback(
|
||||
(value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main,
|
||||
[theme.palette]
|
||||
);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
setConfirmRestart(false);
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
|
||||
const handleCloseRestartDialog = useCallback(() => {
|
||||
setConfirmRestart(false);
|
||||
}, []);
|
||||
|
||||
const renderRestartDialog = useMemo(
|
||||
() => (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmRestart}
|
||||
onClose={handleCloseRestartDialog}
|
||||
>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseRestartDialog}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
),
|
||||
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
|
||||
);
|
||||
|
||||
// Memoize formatted values
|
||||
const firmwareVersion = useMemo(
|
||||
() => `v${data?.emsesp_version || ''}`,
|
||||
[data?.emsesp_version]
|
||||
);
|
||||
|
||||
const uptimeText = useMemo(
|
||||
() => (data ? formatDurationSec(data.uptime, LL) : ''),
|
||||
[data?.uptime, LL]
|
||||
);
|
||||
|
||||
const freeMemoryText = useMemo(
|
||||
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
|
||||
[data?.free_heap, LL]
|
||||
);
|
||||
|
||||
const networkIcon = useMemo(
|
||||
() =>
|
||||
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||
? WifiIcon
|
||||
: RouterIcon,
|
||||
[data?.network_status]
|
||||
);
|
||||
|
||||
const mqttStatusText = useMemo(
|
||||
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
|
||||
[data?.mqtt_status, LL]
|
||||
);
|
||||
|
||||
const apStatusText = useMemo(
|
||||
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
|
||||
[data?.ap_status, LL]
|
||||
);
|
||||
|
||||
const handleRestartClick = useCallback(() => {
|
||||
setConfirmRestart(true);
|
||||
}, []);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!data || !LL) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
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 <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default SystemStatus;
|
||||
|
||||
@@ -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 PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
@@ -25,12 +33,13 @@ import {
|
||||
SectionContent,
|
||||
useLayoutTitle
|
||||
} from 'components';
|
||||
import { toast } from 'components/toast';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import type { LogEntry, LogSettings } from 'types';
|
||||
import { LogLevel } from 'types';
|
||||
import { updateValueDirty, useRest } from 'utils';
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues
|
||||
|
||||
const TextColors: Record<LogLevel, string> = {
|
||||
[LogLevel.ERROR]: '#ff0000', // red
|
||||
[LogLevel.WARNING]: '#ff0000', // red
|
||||
@@ -178,7 +187,8 @@ const SystemLog = () => {
|
||||
};
|
||||
}, [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 logentry = JSON.parse(rawData) as LogEntry;
|
||||
setLogEntries((log) => {
|
||||
@@ -190,9 +200,13 @@ const SystemLog = () => {
|
||||
}
|
||||
}
|
||||
const newLog = [...log, logentry];
|
||||
// Limit log entries to prevent memory issues - only slice when necessary
|
||||
if (newLog.length > MAX_LOG_ENTRIES) {
|
||||
return newLog.slice(-MAX_LOG_ENTRIES);
|
||||
}
|
||||
return newLog;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useSSE(fetchLogES, {
|
||||
immediate: true,
|
||||
@@ -203,7 +217,7 @@ const SystemLog = () => {
|
||||
toast.error('No connection to Log service');
|
||||
});
|
||||
|
||||
const onDownload = () => {
|
||||
const onDownload = useCallback(() => {
|
||||
const result = logEntries
|
||||
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
||||
.join('\n');
|
||||
@@ -217,11 +231,11 @@ const SystemLog = () => {
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
}, [logEntries]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
const saveSettings = useCallback(async () => {
|
||||
await saveData();
|
||||
};
|
||||
}, [saveData]);
|
||||
|
||||
// handle scrolling - optimized to only scroll when needed
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -238,7 +252,7 @@ const SystemLog = () => {
|
||||
}
|
||||
}, [logEntries.length, autoscroll]);
|
||||
|
||||
const sendReadCommand = () => {
|
||||
const sendReadCommand = useCallback(() => {
|
||||
if (readValue === '') {
|
||||
setReadOpen(!readOpen);
|
||||
return;
|
||||
@@ -249,7 +263,7 @@ const SystemLog = () => {
|
||||
setReadOpen(false);
|
||||
setReadValue('');
|
||||
}
|
||||
};
|
||||
}, [readValue, readOpen, send]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -258,7 +272,7 @@ const SystemLog = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid>
|
||||
<TextField
|
||||
name="level"
|
||||
@@ -294,8 +308,6 @@ const SystemLog = () => {
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value={75}>75</MenuItem>
|
||||
<MenuItem value={100}>100</MenuItem>
|
||||
<MenuItem value={500}>500</MenuItem>
|
||||
<MenuItem value={1000}>1000</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
@@ -57,31 +57,41 @@ const SystemMonitor = () => {
|
||||
void send();
|
||||
}, 1000); // check every 1 second
|
||||
|
||||
const status = data?.status;
|
||||
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
||||
const status = data?.status;
|
||||
|
||||
const statusMessage =
|
||||
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||
? LL.WAIT_FIRMWARE()
|
||||
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
||||
? LL.APPLICATION_RESTARTING()
|
||||
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||
? LL.RESTARTING_PRE()
|
||||
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||
? 'Upload Failed'
|
||||
: LL.RESTARTING_POST();
|
||||
let message = '';
|
||||
if (status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING) {
|
||||
message = LL.WAIT_FIRMWARE();
|
||||
} else if (status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART) {
|
||||
message = LL.APPLICATION_RESTARTING();
|
||||
} else if (status === SystemStatusCodes.SYSTEM_STATUS_NORMAL) {
|
||||
message = LL.RESTARTING_PRE();
|
||||
} else if (status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD) {
|
||||
message = 'Upload Failed';
|
||||
} else {
|
||||
message = LL.RESTARTING_POST();
|
||||
}
|
||||
|
||||
const isUploading =
|
||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||
const progressValue =
|
||||
isUploading && status
|
||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||
: 0;
|
||||
const uploading =
|
||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||
const progress =
|
||||
uploading && status
|
||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||
: 0;
|
||||
|
||||
const onCancel = async () => {
|
||||
return {
|
||||
statusMessage: message,
|
||||
isUploading: uploading,
|
||||
progressValue: progress
|
||||
};
|
||||
}, [data?.status, LL]);
|
||||
|
||||
const onCancel = useCallback(async () => {
|
||||
setErrorMessage(undefined);
|
||||
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
||||
document.location.href = '/';
|
||||
};
|
||||
}, [setSystemStatus]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -93,8 +103,8 @@ const SystemMonitor = () => {
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
// backdropFilter: 'blur(8px)'
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -110,15 +120,17 @@ const SystemMonitor = () => {
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" flexDirection="column">
|
||||
<img
|
||||
src="/app/icon.png"
|
||||
alt="EMS-ESP"
|
||||
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{ color: 'secondary', fontWeight: 400, textAlign: 'center' }}
|
||||
color="secondary"
|
||||
variant="h6"
|
||||
fontWeight={400}
|
||||
textAlign="center"
|
||||
>
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
@@ -138,28 +150,13 @@ const SystemMonitor = () => {
|
||||
</MessageBox>
|
||||
) : (
|
||||
<>
|
||||
<Typography
|
||||
sx={{ mt: 2, fontWeight: 400, textAlign: 'center' }}
|
||||
variant="h6"
|
||||
>
|
||||
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
||||
{LL.PLEASE_WAIT()}…
|
||||
</Typography>
|
||||
{isUploading && (
|
||||
<>
|
||||
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
||||
<LinearProgressWithLabel value={progressValue} />
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
sx={{ ml: 2, mt: 2 }}
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
</>
|
||||
<Box width="100%" pl={2} pr={2} py={2}>
|
||||
<LinearProgressWithLabel value={progressValue} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
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,
|
||||
Link,
|
||||
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,
|
||||
downloadOnly,
|
||||
platform,
|
||||
LL,
|
||||
onClose,
|
||||
onInstall
|
||||
}: {
|
||||
openInstallDialog: boolean;
|
||||
fetchDevVersion: boolean;
|
||||
latestVersion?: VersionInfo;
|
||||
latestDevVersion?: VersionInfo;
|
||||
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 mb={2}>
|
||||
{LL.INSTALL_VERSION(
|
||||
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
|
||||
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
|
||||
)}
|
||||
</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 underline="none" target="_blank" href={binURL} color="primary">
|
||||
{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 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
|
||||
});
|
||||
|
||||
// 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);
|
||||
setOpenInstallDialog(true);
|
||||
}, []);
|
||||
|
||||
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 p={2} border="1px solid grey" borderRadius={2}>
|
||||
<Typography 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">
|
||||
({data.build_flags})
|
||||
</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">
|
||||
(
|
||||
{data.psram ? (
|
||||
<CheckIcon
|
||||
color="success"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CloseIcon
|
||||
color="error"
|
||||
sx={{
|
||||
fontSize: '1.5em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
PSRAM)
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{internetLive ? (
|
||||
<>
|
||||
<Typography 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} 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 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}
|
||||
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 text={LL.UPLOAD_DROP_TEXT()} 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 <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default memo(Version);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FC, type PropsWithChildren, memo } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
import type { BoxProps } from '@mui/material';
|
||||
|
||||
const ButtonRow: FC<PropsWithChildren<BoxProps>> = memo(({ children, ...rest }) => (
|
||||
const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
||||
<Box
|
||||
sx={{
|
||||
'& button, & a, & .MuiCard-root': {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, type PropsWithChildren, memo } from 'react';
|
||||
import { type FC, memo, useMemo } from 'react';
|
||||
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
@@ -12,7 +12,6 @@ type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||
export interface MessageBoxProps extends BoxProps {
|
||||
level: MessageBoxLevel;
|
||||
message?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
|
||||
@@ -29,7 +28,7 @@ const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
|
||||
error: 'error.dark'
|
||||
};
|
||||
|
||||
const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
||||
const MessageBox: FC<MessageBoxProps> = ({
|
||||
level,
|
||||
message,
|
||||
sx,
|
||||
@@ -38,30 +37,27 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||
const [paletteKeyName, shade] = palettePath.split('.') as [
|
||||
keyof typeof theme.palette,
|
||||
string
|
||||
];
|
||||
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const backgroundColor = paletteKey[shade];
|
||||
const { Icon, backgroundColor } = useMemo(() => {
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||
const [key, shade] = palettePath.split('.') as [
|
||||
keyof typeof theme.palette,
|
||||
string
|
||||
];
|
||||
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
||||
const backgroundColor = paletteKey[shade];
|
||||
|
||||
return { Icon, backgroundColor };
|
||||
}, [level, theme]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={2}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderRadius={1}
|
||||
sx={{ backgroundColor, color: 'white', ...sx }}
|
||||
{...rest}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
backgroundColor,
|
||||
color: 'white',
|
||||
p: 2,
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
{(message || children) && (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useContext } from 'react';
|
||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
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 FRflag from 'i18n/FR.svg';
|
||||
import GBflag from 'i18n/GB.svg';
|
||||
@@ -28,7 +28,7 @@ interface LanguageOption {
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ key: 'cs', flag: CZflag, label: 'CS' },
|
||||
{ key: 'cz', flag: CZflag, label: 'CZ' },
|
||||
{ key: 'de', flag: DEflag, label: 'DE' },
|
||||
{ key: 'en', flag: GBflag, label: 'EN' },
|
||||
{ key: 'fr', flag: FRflag, label: 'FR' },
|
||||
@@ -44,14 +44,27 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
const LanguageSelector = () => {
|
||||
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||
target
|
||||
}) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
};
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
async ({ target }) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
},
|
||||
[setLocale]
|
||||
);
|
||||
|
||||
// Memoize menu items to prevent recreation on every render
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<img src={flag} style={flagStyle} alt={label} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
)),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
@@ -63,12 +76,7 @@ const LanguageSelector = () => {
|
||||
size="small"
|
||||
select
|
||||
>
|
||||
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<img src={flag} style={flagStyle} alt={label} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
{menuItems}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
setShowPassword((prev) => !prev);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ValidatedTextField
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FC } from 'react';
|
||||
import { FormHelperText, TextField } from '@mui/material';
|
||||
import type { TextFieldProps } from '@mui/material';
|
||||
|
||||
import type { ValidateFieldsError } from 'validators/schema';
|
||||
import type { ValidateFieldsError } from 'async-validator';
|
||||
|
||||
interface ValidatedFieldProps {
|
||||
fieldErrors?: ValidateFieldsError;
|
||||
|
||||
@@ -18,6 +18,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const [title, setTitle] = useState(PROJECT_NAME);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Memoize drawer toggle handler to prevent unnecessary re-renders
|
||||
const handleDrawerToggle = useCallback(() => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
}, []);
|
||||
@@ -27,6 +28,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Memoize context value to prevent unnecessary re-renders
|
||||
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
@@ -39,11 +39,14 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
|
||||
const navigate = useNavigate();
|
||||
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]);
|
||||
};
|
||||
}, [navigate, pathnames]);
|
||||
|
||||
return (
|
||||
<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 { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { PROJECT_NAME } from 'env';
|
||||
|
||||
import { DRAWER_WIDTH } from './Layout';
|
||||
@@ -25,33 +24,22 @@ interface LayoutDrawerProps {
|
||||
}
|
||||
|
||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
const { systemName } = useContext(AuthenticatedContext);
|
||||
|
||||
const drawer = (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography>{PROJECT_NAME}</Typography>
|
||||
{systemName && (
|
||||
<Typography color="secondary" variant="body2">
|
||||
{systemName}
|
||||
</Typography>
|
||||
)}
|
||||
// Memoize drawer content to prevent unnecessary re-renders
|
||||
const drawer = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box display="flex" alignItems="center" px={2}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 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 MoreTimeIcon from '@mui/icons-material/MoreTime';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
|
||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
@@ -19,15 +18,13 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const LayoutMenuComponent = () => {
|
||||
const { me, versions } = useContext(AuthenticatedContext);
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
const [menuOpen, setMenuOpen] = useState(true);
|
||||
|
||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
setMenuOpen((prev) => !prev);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,7 +51,9 @@ const LayoutMenuComponent = () => {
|
||||
sx={{ my: 0 }}
|
||||
slotProps={{
|
||||
primary: {
|
||||
sx: { fontWeight: 600, mb: '2px', color: 'lightblue' }
|
||||
fontWeight: '600',
|
||||
mb: '2px',
|
||||
color: 'lightblue'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -81,12 +80,6 @@ const LayoutMenuComponent = () => {
|
||||
disabled={!me.admin}
|
||||
to={`/customizations`}
|
||||
/>
|
||||
<LayoutMenuItem
|
||||
icon={PlaylistPlayIcon}
|
||||
label={LL.COMMANDS()}
|
||||
disabled={!me.admin}
|
||||
to={`/commands`}
|
||||
/>
|
||||
<LayoutMenuItem
|
||||
icon={MoreTimeIcon}
|
||||
label={LL.SCHEDULER()}
|
||||
@@ -114,7 +107,6 @@ const LayoutMenuComponent = () => {
|
||||
label={LL.SETTINGS(0)}
|
||||
disabled={!me.admin}
|
||||
to="/settings"
|
||||
badge={upgradeAvailable}
|
||||
/>
|
||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||
<Divider />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
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 { routeMatches } from 'utils';
|
||||
@@ -11,52 +11,61 @@ interface LayoutMenuItemProps {
|
||||
label: string;
|
||||
to: string;
|
||||
disabled?: boolean;
|
||||
badge?: boolean;
|
||||
}
|
||||
|
||||
const LayoutMenuItemComponent = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
to,
|
||||
disabled,
|
||||
badge
|
||||
disabled
|
||||
}: LayoutMenuItemProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const selected = routeMatches(to, pathname);
|
||||
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
|
||||
|
||||
const buttonStyles: SxProps<Theme> = {
|
||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
margin: '2px 8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: selected ? '3px' : '0px',
|
||||
backgroundColor: '#90caf9',
|
||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||
}
|
||||
};
|
||||
// Memoize dynamic styles based on selected state
|
||||
const buttonStyles: SxProps<Theme> = useMemo(
|
||||
() => ({
|
||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
margin: '2px 8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: selected ? '4px' : '0px',
|
||||
backgroundColor: '#90caf9',
|
||||
borderRadius: '0 2px 2px 0',
|
||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||
}
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
|
||||
const iconStyles: SxProps<Theme> = {
|
||||
color: selected ? '#90caf9' : '#9e9e9e',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||
transitionProperty: 'color, transform'
|
||||
};
|
||||
const iconStyles: SxProps<Theme> = useMemo(
|
||||
() => ({
|
||||
color: selected ? '#90caf9' : '#9e9e9e',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||
transitionProperty: 'color, transform'
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
|
||||
const textStyles: SxProps<Theme> = {
|
||||
color: selected ? '#90caf9' : '#f5f5f5',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transitionProperty: 'color, font-weight'
|
||||
};
|
||||
const textStyles: SxProps<Theme> = useMemo(
|
||||
() => ({
|
||||
color: selected ? '#90caf9' : '#f5f5f5',
|
||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||
transitionProperty: 'color, font-weight'
|
||||
}),
|
||||
[selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
@@ -70,20 +79,6 @@ const LayoutMenuItemComponent = ({
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user