mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-09 17:29:50 +03:00
Compare commits
421 Commits
2b2c86ba5a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9889b1b5c4 | ||
|
|
bffccc585a | ||
|
|
a01f10b042 | ||
|
|
64058b0f61 | ||
|
|
d7b5c81b0e | ||
|
|
02e8dba971 | ||
|
|
59878fb190 | ||
|
|
9ff0f83af9 | ||
|
|
e6f825371e | ||
|
|
45f3f23033 | ||
|
|
ffd27db208 | ||
|
|
a452d6131b | ||
|
|
03ef981765 | ||
|
|
9ca9f25fd3 | ||
|
|
41122dddb2 | ||
|
|
1e0c94d007 | ||
|
|
3e42a7fb4c | ||
|
|
a8fcc1fb44 | ||
|
|
e43416019d | ||
|
|
9f467ecec1 | ||
|
|
273d87dbf1 | ||
|
|
befd21f8cb | ||
|
|
15e05c4abc | ||
|
|
748a2f5fcf | ||
|
|
37ba42faf8 | ||
|
|
19e343e517 | ||
|
|
8f39129bf8 | ||
|
|
9c3521caf2 | ||
|
|
40fc0fd2f9 | ||
|
|
ff8566498f | ||
|
|
dd06882860 | ||
|
|
fb2294c945 | ||
|
|
26ea8320ce | ||
|
|
0f4963d91e | ||
|
|
91020abc90 | ||
|
|
64906f3ea0 | ||
|
|
28f85b4c5a | ||
|
|
b44a0d6813 | ||
|
|
8af7cde2d6 | ||
|
|
3fcd656bb6 | ||
|
|
76c827257e | ||
|
|
8ca9f7ee30 | ||
|
|
da7a06646a | ||
|
|
0a36f1df7a | ||
|
|
80e5d30781 | ||
|
|
9c4beba3b1 | ||
|
|
0cf932f57e | ||
|
|
5cb9f3b014 | ||
|
|
3eb581142a | ||
|
|
d6c460e7fd | ||
|
|
a2baa50530 | ||
|
|
6569b8c038 | ||
|
|
48b4bf02a3 | ||
|
|
693054a92a | ||
|
|
a738cc36dd | ||
|
|
9b6f9aeda3 | ||
|
|
8ea8c1821d | ||
|
|
53a1a8826e | ||
|
|
237551d9f6 | ||
|
|
f7c7bc65f2 | ||
|
|
badbd9c6fe | ||
|
|
cc7ac5b911 | ||
|
|
5957300c4e | ||
|
|
2e343ce0c3 | ||
|
|
3113a4be2b | ||
|
|
7d9cb2932f | ||
|
|
026c2caea0 | ||
|
|
eb058b688d | ||
|
|
2a7a0ce3f6 | ||
|
|
a330110eef | ||
|
|
feed534be5 | ||
|
|
2375633bca | ||
|
|
7018139289 | ||
|
|
5f67934f26 | ||
|
|
574cc8ad33 | ||
|
|
658c613c4e | ||
|
|
c2ce54c2a7 | ||
|
|
58da0d9778 | ||
|
|
8b90036c11 | ||
|
|
65678ea739 | ||
|
|
34ddc9b7ff | ||
|
|
97f9914d33 | ||
|
|
8b64851f6f | ||
|
|
c00f50238e | ||
|
|
25ea57e02f | ||
|
|
a951afe205 | ||
|
|
44c7954ce7 | ||
|
|
ef315b6dde | ||
|
|
935a9dcbb7 | ||
|
|
2bda432d70 | ||
|
|
d567ea3cf0 | ||
|
|
7b5e386595 | ||
|
|
1c65a7caba | ||
|
|
12ebacf1a7 | ||
|
|
f2c176111f | ||
|
|
cc242e5eba | ||
|
|
4888808ad0 | ||
|
|
cd1b5e1d57 | ||
|
|
9661c9a0eb | ||
|
|
f6ccf6da44 | ||
|
|
b691488240 | ||
|
|
eb71996e6a | ||
|
|
b9566ae1d6 | ||
|
|
8016fc4287 | ||
|
|
c95b43ea69 | ||
|
|
d767a503cd | ||
|
|
221131f9d3 | ||
|
|
f307cccabb | ||
|
|
2f21d0d94c | ||
|
|
132d1292ce | ||
|
|
df9a10cb53 | ||
|
|
95d564901b | ||
|
|
6424d9b1c0 | ||
|
|
38a3d20acf | ||
|
|
89b117bbc2 | ||
|
|
eeba7a3a6b | ||
|
|
23a660aabb | ||
|
|
c9bddba446 | ||
|
|
3a508a3ec4 | ||
|
|
6bf33f6447 | ||
|
|
a02054ceb6 | ||
|
|
8422521975 | ||
|
|
fbfacc5ed5 | ||
|
|
b9d96620a4 | ||
|
|
8c61735579 | ||
|
|
56e8ccdfc4 | ||
|
|
5454e7dd16 | ||
|
|
901a27140c | ||
|
|
37db2b9504 | ||
|
|
5e7768f912 | ||
|
|
80dd16740d | ||
|
|
24421a0224 | ||
|
|
26d26cf088 | ||
|
|
615c5e8439 | ||
|
|
090387ef37 | ||
|
|
25ea7d8b0c | ||
|
|
68f067f2c4 | ||
|
|
e20fa5ab39 | ||
|
|
bee307a91d | ||
|
|
f85226ce55 | ||
|
|
f112e6f6cc | ||
|
|
5df82b7e2c | ||
|
|
ea75a34c82 | ||
|
|
07d0de0151 | ||
|
|
0a75dd7e3c | ||
|
|
88afd3f453 | ||
|
|
9669a044ba | ||
|
|
e31ebab12b | ||
|
|
e68a3a0d3a | ||
|
|
4ae09c8766 | ||
|
|
a693e96248 | ||
|
|
60b0de79b3 | ||
|
|
86277396f7 | ||
|
|
0b65d55601 | ||
|
|
23782ec773 | ||
|
|
0b33497842 | ||
|
|
6cd4bcd41c | ||
|
|
8d8db3ba85 | ||
|
|
d9c2066035 | ||
|
|
af5cbf045d | ||
|
|
99d67cdd42 | ||
|
|
a74910ddf6 | ||
|
|
d9678d04dd | ||
|
|
c7acf89d84 | ||
|
|
24f1fe13cc | ||
|
|
11fcd8bcfe | ||
|
|
bcde5bad63 | ||
|
|
d2a8fbaf1e | ||
|
|
f9d2b18959 | ||
|
|
bdba2ae6e4 | ||
|
|
f068ed97f1 | ||
|
|
7ab2e782ac | ||
|
|
88a7d12306 | ||
|
|
dffe8b2648 | ||
|
|
daa98b106e | ||
|
|
83796341c5 | ||
|
|
ea24cd8a0f | ||
|
|
0e7ff32d02 | ||
|
|
31706bfc21 | ||
|
|
993e2fdc22 | ||
|
|
0d05cf489b | ||
|
|
0961b814d5 | ||
|
|
999c407f12 | ||
|
|
7c137d71ca | ||
|
|
ad26768d6d | ||
|
|
5ed7d60ae8 | ||
|
|
a67379fcb2 | ||
|
|
cccae3fbcc | ||
|
|
10d85c6547 | ||
|
|
21af1adefe | ||
|
|
fe02258a00 | ||
|
|
3d8ec8e295 | ||
|
|
d5b496aa67 | ||
|
|
8458cfc468 | ||
|
|
f10d2ef48d | ||
|
|
2ee433bb89 | ||
|
|
237cf91f5a | ||
|
|
8db7e4a5b9 | ||
|
|
a3703514b1 | ||
|
|
db8ed34dc4 | ||
|
|
21b89cf84d | ||
|
|
fc0f29337b | ||
|
|
84b9e6ae18 | ||
|
|
cbabaa7d91 | ||
|
|
4cfe704242 | ||
|
|
25cae61cc1 | ||
|
|
d96b3e6d05 | ||
|
|
1f261476d5 | ||
|
|
3171fc7375 | ||
|
|
ba843c1589 | ||
|
|
f7f01ea875 | ||
|
|
8ea1dfc7d2 | ||
|
|
9616a113b0 | ||
|
|
4aaf6e95cf | ||
|
|
edfdfb1016 | ||
|
|
3067149357 | ||
|
|
d5548c8bdc | ||
|
|
cf66dc99b4 | ||
|
|
891619fa26 | ||
|
|
1f6462be38 | ||
|
|
e74dc1fd78 | ||
|
|
f59768ce17 | ||
|
|
22d015615d | ||
|
|
0ef0ca8518 | ||
|
|
1917c5d7cb | ||
|
|
ff0fe593d3 | ||
|
|
12e8d64ec2 | ||
|
|
c0c13eb687 | ||
|
|
91b78f9a23 | ||
|
|
32474d10ce | ||
|
|
7c3de25c20 | ||
|
|
f75b7b1a59 | ||
|
|
a3e01b8a3b | ||
|
|
6c9a9b8632 | ||
|
|
3fba75868f | ||
|
|
8dee390d75 | ||
|
|
dc838639b2 | ||
|
|
3fd05c8eb7 | ||
|
|
5f0df140b0 | ||
|
|
b98cbd3ec5 | ||
|
|
18d67d088e | ||
|
|
0bf60394fe | ||
|
|
cec5ffd547 | ||
|
|
026ea4450e | ||
|
|
9a1dd5bb98 | ||
|
|
fbc42fbb15 | ||
|
|
5613cde00f | ||
|
|
d65d6f49cd | ||
|
|
c9bc18cf4b | ||
|
|
9179127bce | ||
|
|
16b6bef393 | ||
|
|
52159ac596 | ||
|
|
0dc3fd43e9 | ||
|
|
b0d490036f | ||
|
|
c28b098c65 | ||
|
|
4a6ccce09a | ||
|
|
5053ad08dd | ||
|
|
72b4809ed8 | ||
|
|
6799fe5189 | ||
|
|
12635ff4a5 | ||
|
|
5908fd9d9c | ||
|
|
5c07a2c0cc | ||
|
|
f5048abae7 | ||
|
|
76acffdb2e | ||
|
|
1ac96aa02e | ||
|
|
3386ac7f8a | ||
|
|
1e4c157c28 | ||
|
|
a1c5297eef | ||
|
|
cda04bef26 | ||
|
|
1edf60b617 | ||
|
|
32cf4dd6c9 | ||
|
|
f0caeb089d | ||
|
|
23f1e7569c | ||
|
|
5087fdb5d6 | ||
|
|
b654a42229 | ||
|
|
d312c8e592 | ||
|
|
319787bae3 | ||
|
|
d124b04a2c | ||
|
|
4ef8a3a163 | ||
|
|
41b5cdddf2 | ||
|
|
e10453b2fd | ||
|
|
d2f665ab70 | ||
|
|
4083478b65 | ||
|
|
01136a19a5 | ||
|
|
0b2df96461 | ||
|
|
f66c3ff322 | ||
|
|
5d8fe89e5a | ||
|
|
22eb5436ab | ||
|
|
17bdd87576 | ||
|
|
8f7c0a1d97 | ||
|
|
3b453b18bc | ||
|
|
a1fc5bf54b | ||
|
|
0cb413a579 | ||
|
|
d326d345f4 | ||
|
|
1de647e8a5 | ||
|
|
c562b88b32 | ||
|
|
f2e38330ea | ||
|
|
f2dca2a90a | ||
|
|
9f1cd04d45 | ||
|
|
fe67f3a982 | ||
|
|
2fd6e9ebf5 | ||
|
|
0f1195de82 | ||
|
|
267042f74f | ||
|
|
ea9783dbfc | ||
|
|
67655c4b06 | ||
|
|
2970aa5ba3 | ||
|
|
99a3ffcf17 | ||
|
|
9bc5bde3c2 | ||
|
|
0edb844225 | ||
|
|
e3feb8f11e | ||
|
|
6b7534b7fb | ||
|
|
ca1506de8b | ||
|
|
1cb535dea3 | ||
|
|
5d32b6d383 | ||
|
|
c203b8e6d2 | ||
|
|
e6dbe020c1 | ||
|
|
63cf1603b0 | ||
|
|
c184a0af10 | ||
|
|
8a4e6d5ed5 | ||
|
|
aadb67fa79 | ||
|
|
4949471518 | ||
|
|
9504723ef2 | ||
|
|
3abfb7bb9c | ||
|
|
3ef6bf8681 | ||
|
|
1647a10b1b | ||
|
|
55b893362c | ||
|
|
48f1928327 | ||
|
|
dc793f145d | ||
|
|
77e0b7d89c | ||
|
|
cec19860bc | ||
|
|
898acb90d0 | ||
|
|
a074ac732d | ||
|
|
c71526b95c | ||
|
|
f035e31dcf | ||
|
|
35192b9dde | ||
|
|
b5ac637231 | ||
|
|
4e0ef8b0e3 | ||
|
|
c32ee4dfb5 | ||
|
|
accfeab6fa | ||
|
|
d98f3cc8c5 | ||
|
|
342f238983 | ||
|
|
73f8ea0fc5 | ||
|
|
f2bd1ff575 | ||
|
|
b3ec23d6bd | ||
|
|
1fc7fa4720 | ||
|
|
58ae058465 | ||
|
|
7ece395d1b | ||
|
|
4bb876031e | ||
|
|
e4f129db04 | ||
|
|
90038e08dc | ||
|
|
4c30e930cf | ||
|
|
dd69e02f6b | ||
|
|
f2ccf1953d | ||
|
|
0e133840c9 | ||
|
|
e12b277472 | ||
|
|
dc7b1809c1 | ||
|
|
8c2146ff55 | ||
|
|
882d412409 | ||
|
|
3f88a9469c | ||
|
|
3ac7d43a81 | ||
|
|
d12446b6d9 | ||
|
|
865fb3a967 | ||
|
|
48538222b2 | ||
|
|
8d44f61517 | ||
|
|
0e4f6f4209 | ||
|
|
a2823563bf | ||
|
|
353cdb324d | ||
|
|
216f799db1 | ||
|
|
4d6f080263 | ||
|
|
b9d124618c | ||
|
|
ab5d9e8d36 | ||
|
|
4cdeecd952 | ||
|
|
59b3933cb6 | ||
|
|
179ddcb348 | ||
|
|
efa2c8fc4b | ||
|
|
c958c7d61a | ||
|
|
35fca9c450 | ||
|
|
61962fbc07 | ||
|
|
39e724befe | ||
|
|
43bb77b095 | ||
|
|
28e1e46586 | ||
|
|
2f5b879652 | ||
|
|
16930fe8ca | ||
|
|
1db1b6e524 | ||
|
|
43eba7a010 | ||
|
|
49278bdea4 | ||
|
|
2405e11af2 | ||
|
|
23b6894484 | ||
|
|
a837c9398c | ||
|
|
ea484e15f9 | ||
|
|
cce99a8b1d | ||
|
|
f29faafd78 | ||
|
|
c3eafbcd85 | ||
|
|
65e18ab4e2 | ||
|
|
8bb16ed3a7 | ||
|
|
e685284f72 | ||
|
|
86d2805642 | ||
|
|
29b98a15a4 | ||
|
|
ba9df92b12 | ||
|
|
0eac1c9bf9 | ||
|
|
515feb9f9e | ||
|
|
0b84b79e1d | ||
|
|
8fd129f4fe | ||
|
|
ba334930fe | ||
|
|
982d64ddca | ||
|
|
3577300361 | ||
|
|
0478e0ff7c | ||
|
|
c2cfd0a1b0 | ||
|
|
637ba30df1 | ||
|
|
fa8421b297 | ||
|
|
61c23a57d9 | ||
|
|
43f61fd2df | ||
|
|
85eed64fe9 | ||
|
|
43634a4312 | ||
|
|
9757db4438 | ||
|
|
17a2ba7f1a | ||
|
|
03c7417888 | ||
|
|
99b43b0379 | ||
|
|
6fb8fbba18 | ||
|
|
09c750e622 | ||
|
|
b4f174f2f4 |
27
.devcontainer/devcontainer.json
Normal file
27
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "EMS-ESP Devcontainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {},
|
||||
"ghcr.io/devcontainers/features/python:1": {},
|
||||
"ghcr.io/shyim/devcontainers-features/bun:0": {}
|
||||
},
|
||||
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3080
|
||||
],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cd mock-api && pnpm install && cd .. && cd interface && pnpm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"platformio.platformio-ide"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
36
.github/workflows/dev_release.yml
vendored
36
.github/workflows/dev_release.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -45,20 +45,26 @@ jobs:
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build the WebUI
|
||||
- name: Build webUI
|
||||
run: |
|
||||
cd interface
|
||||
pnpm install
|
||||
pnpm typesafe-i18n --no-watch
|
||||
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
|
||||
pnpm build
|
||||
pnpm webUI
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build all PIO target environments
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
env:
|
||||
NO_BUILD_WEBUI: true
|
||||
|
||||
- name: Commit the generated files
|
||||
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'
|
||||
|
||||
@@ -7,9 +7,11 @@ on:
|
||||
jobs:
|
||||
github-releases-to-discord:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: GitHub Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
|
||||
25
.github/workflows/pr_check.yml
vendored
25
.github/workflows/pr_check.yml
vendored
@@ -1,37 +1,32 @@
|
||||
name: 'Pre-check on PR'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: dev
|
||||
paths:
|
||||
- '**.c'
|
||||
- '**.cpp'
|
||||
- '**.h'
|
||||
- '**.hpp'
|
||||
- '**.json'
|
||||
- '**.py'
|
||||
- '**.md'
|
||||
- '.github/workflows/pr_check.yml'
|
||||
|
||||
- 'src/**'
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Automatic pre-release build'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: |
|
||||
pip install wheel
|
||||
pip install -U platformio
|
||||
|
||||
- name: Build native
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
platformio run -e native
|
||||
platformio run -e native-test -t exec
|
||||
|
||||
10
.github/workflows/sonar_check.yml
vendored
10
.github/workflows/sonar_check.yml
vendored
@@ -1,12 +1,14 @@
|
||||
# see https://github.com/marketplace/actions/sonarcloud-scan-for-c-and-c#usage
|
||||
name: Sonar Check
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
# pull_request:
|
||||
# types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -17,7 +19,7 @@ jobs:
|
||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Build Wrapper
|
||||
|
||||
31
.github/workflows/stable_release.yml
vendored
31
.github/workflows/stable_release.yml
vendored
@@ -16,17 +16,17 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -37,20 +37,21 @@ jobs:
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build the WebUI
|
||||
- name: Build webUI
|
||||
run: |
|
||||
cd interface
|
||||
pnpm install
|
||||
pnpm typesafe-i18n --no-watch
|
||||
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
|
||||
pnpm build
|
||||
pnpm webUI
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build all PIO target environments
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
env:
|
||||
NO_BUILD_WEBUI: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: emsesp/action-automatic-releases@v1.0.0
|
||||
|
||||
6
.github/workflows/stale_issues.yml
vendored
6
.github/workflows/stale_issues.yml
vendored
@@ -1,4 +1,8 @@
|
||||
name: "Mark or close stale issues and PRs"
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -8,7 +12,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
33
.github/workflows/test_release.yml
vendored
33
.github/workflows/test_release.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'dev2'
|
||||
- 'test'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,17 +18,17 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Install python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
- name: Install Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable pnpm
|
||||
@@ -45,20 +45,21 @@ jobs:
|
||||
pip install -U platformio
|
||||
python -m pip install intelhex
|
||||
|
||||
- name: Build the WebUI
|
||||
- name: Build webUI
|
||||
run: |
|
||||
cd interface
|
||||
pnpm install
|
||||
pnpm typesafe-i18n --no-watch
|
||||
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
|
||||
pnpm build
|
||||
pnpm webUI
|
||||
platformio run -e build_webUI
|
||||
|
||||
- name: Build all target environments
|
||||
- name: Build modbus
|
||||
run: |
|
||||
platformio run -e build_modbus
|
||||
|
||||
- name: Build standalone
|
||||
run: |
|
||||
platformio run -e build_standalone
|
||||
|
||||
- name: Build all PIO target environments, from default_envs
|
||||
run: |
|
||||
platformio run
|
||||
env:
|
||||
NO_BUILD_WEBUI: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: 'automatic_releases'
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,7 +2,6 @@
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/extensions.json
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
|
||||
# c++ compiling
|
||||
.clang_complete
|
||||
@@ -72,3 +71,7 @@ CMakeLists.txt
|
||||
logs/*
|
||||
sdkconfig.*
|
||||
sdkconfig_tasmota_esp32
|
||||
pnpm-lock.yaml
|
||||
.cache/
|
||||
interface/.tsbuildinfo
|
||||
test/test_api/package-lock.json
|
||||
|
||||
@@ -22,12 +22,20 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
- Internal sensors of E32V2_2
|
||||
- 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)
|
||||
- Fuse settings for BBQKees boards
|
||||
- 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)
|
||||
- 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)
|
||||
- new boiler entities VR0,VR1, compressor speed [#2669](https://github.com/emsesp/EMS-ESP32/issues/2669)
|
||||
- solar temperature TS16 [#2690](https://github.com/emsesp/EMS-ESP32/issues/2690)
|
||||
- pumpmode enum for HT3 boilers, add commands for manual defrost, chimneysweeper [#2727](https://github.com/emsesp/EMS-ESP32/issues/2727)
|
||||
- pid settings [#2735](https://github.com/emsesp/EMS-ESP32/issues/2735)
|
||||
- refresh MQTT button added to MQTT Settings page
|
||||
- added LWT (Last Will and Testament) to MQTT entities in Home Assistant
|
||||
- added api/metrics endpoint for prometheus integration by @gr3enk
|
||||
[#2774](https://github.com/emsesp/EMS-ESP32/pull/2774)
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -46,6 +54,9 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
- fix wwMaxPower on Junkers ZBS14 [#2609](https://github.com/emsesp/EMS-ESP32/issues/2609)
|
||||
- ventilation bypass state from telegram 0x55C [#1197](https://github.com/emsesp/EMS-ESP32/issues/1197)
|
||||
- set selflowtemp for ems+ boilers [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
|
||||
- syslog timestamp [#2704](https://github.com/emsesp/EMS-ESP32/issues/2704)
|
||||
- fixed FS format command [#2720](https://github.com/emsesp/EMS-ESP32/discussions/2720)
|
||||
- dhw priority setting to boiler and mixer, telegrams 0x2CC, 0x2CD, etc.
|
||||
|
||||
## Changed
|
||||
|
||||
@@ -54,3 +65,13 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
||||
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
|
||||
- remove command `scan deep`
|
||||
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
|
||||
- optimized web for better performance by adding lazy loading and caching
|
||||
- internal system analog sensors (core_voltage, supply_voltage and gateway_temperature) cannot be accidentally removed
|
||||
- double click button reconnects EMS-ESP to AP
|
||||
- place system message command in side scheduler loop to reduce stack memory usage by 2KB
|
||||
- syslog mark interval set to 1 hour
|
||||
- handle process_telegram in oneloop
|
||||
- improved GPIO validation for Analog Sensors and System GPIOs
|
||||
- entities with no values are greyed out in the Web UI in the Customization page
|
||||
- added System Status to Web Status page
|
||||
- show number on entities and supported languages in log on boot
|
||||
|
||||
54
Makefile
54
Makefile
@@ -19,17 +19,20 @@ C = $(words $N)$(eval N := x $N)
|
||||
ECHO = python3 $(I)/scripts/echo_progress.py --stepno=$C --nsteps=$T
|
||||
endif
|
||||
|
||||
# determine number of parallel compiles based on OS
|
||||
# Optimize parallel build configuration
|
||||
UNAME_S := $(shell uname -s)
|
||||
JOBS ?= 1
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
EXTRA_CPPFLAGS = -D LINUX
|
||||
JOBS ?= $(shell nproc)
|
||||
JOBS := $(shell nproc)
|
||||
endif
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
|
||||
JOBS ?= $(shell sysctl -n hw.ncpu)
|
||||
JOBS := $(shell sysctl -n hw.ncpu)
|
||||
endif
|
||||
MAKEFLAGS += -j $(JOBS) -l $(JOBS)
|
||||
|
||||
# Set optimal parallel build settings
|
||||
MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
||||
|
||||
# $(info Number of jobs: $(JOBS))
|
||||
|
||||
@@ -72,16 +75,21 @@ DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DE
|
||||
OUTPUT := $(CURDIR)/$(TARGET)
|
||||
SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
||||
|
||||
CSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.c))
|
||||
CXXSOURCES := $(foreach dir,$(SOURCES),$(wildcard $(dir)/*.cpp))
|
||||
# Optimize source discovery - use shell find for better performance
|
||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
||||
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)) )
|
||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||
|
||||
INCLUDE += $(addprefix -I,$(foreach dir,$(INCLUDES), $(wildcard $(dir))))
|
||||
INCLUDE += $(addprefix -I,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/include)))
|
||||
# Optimize include path discovery
|
||||
INCLUDE_DIRS := $(shell find $(INCLUDES) -type d 2>/dev/null)
|
||||
LIBRARY_INCLUDES := $(shell find $(LIBRARIES) -name "include" -type d 2>/dev/null)
|
||||
INCLUDE += $(addprefix -I,$(INCLUDE_DIRS) $(LIBRARY_INCLUDES))
|
||||
|
||||
LDLIBS += $(addprefix -L,$(foreach dir,$(LIBRARIES),$(wildcard $(dir)/lib)))
|
||||
# Optimize library path discovery
|
||||
LIBRARY_DIRS := $(shell find $(LIBRARIES) -name "lib" -type d 2>/dev/null)
|
||||
LDLIBS += $(addprefix -L,$(LIBRARY_DIRS))
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Compiler & Linker
|
||||
@@ -99,9 +107,11 @@ CXX := /usr/bin/g++
|
||||
#----------------------------------------------------------------------
|
||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||
CPPFLAGS += -ggdb -g3 -MMD
|
||||
CPPFLAGS += -flto=auto -fno-lto
|
||||
CPPFLAGS += -flto=auto
|
||||
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
|
||||
|
||||
CPPFLAGS += $(EXTRA_CPPFLAGS)
|
||||
|
||||
@@ -122,7 +132,8 @@ else
|
||||
LD := $(CXX)
|
||||
endif
|
||||
|
||||
#DEPFLAGS += -MF $(BUILD)/$*.d
|
||||
# Dependency file generation
|
||||
DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
|
||||
|
||||
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
||||
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
@@ -139,7 +150,10 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||
.SUFFIXES:
|
||||
.INTERMEDIATE:
|
||||
.PRECIOUS: $(OBJS) $(DEPS)
|
||||
.PHONY: all clean help
|
||||
.PHONY: all clean help cppcheck run
|
||||
|
||||
# Enable second expansion for more flexible rules
|
||||
.SECONDEXPANSION:
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
# Targets
|
||||
@@ -154,7 +168,6 @@ $(OUTPUT): $(OBJS)
|
||||
@mkdir -p $(@D)
|
||||
@$(ECHO) Linking $@
|
||||
$(LINK.o)
|
||||
$(SYMBOLS.out)
|
||||
|
||||
$(BUILD)/%.o: %.c
|
||||
@mkdir -p $(@D)
|
||||
@@ -182,8 +195,15 @@ clean:
|
||||
@$(RM) -rf $(BUILD) $(OUTPUT)
|
||||
|
||||
help:
|
||||
@echo available targets: all run clean
|
||||
@echo $(OUTPUT)
|
||||
@echo "Available targets:"
|
||||
@echo " all - Build the project (default)"
|
||||
@echo " run - Build and run the executable"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " cppcheck - Run static analysis"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Output: $(OUTPUT)"
|
||||
@echo "Jobs: $(JOBS)"
|
||||
|
||||
-include $(DEPS)
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -70,7 +70,7 @@ Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install a
|
||||
|
||||
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 [here](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request.
|
||||
|
||||
## 🎥 **Live Demo**
|
||||
|
||||
@@ -82,13 +82,19 @@ EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned an
|
||||
|
||||
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**
|
||||
|
||||
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**
|
||||
|
||||
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified
|
||||
- [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified
|
||||
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries
|
||||
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing
|
||||
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client
|
||||
- [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server and TCP backends
|
||||
- [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server
|
||||
|
||||
## 📜 **License**
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"upload": {
|
||||
"flash_size": "32MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"maximum_size": 33554432,
|
||||
"require_upload_port": true,
|
||||
"speed": 460800
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
11683
docs/dump_entities.csv
11683
docs/dump_entities.csv
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
telegram_type_id,name,is_fetched
|
||||
0x04,UBAFactory,fetched
|
||||
0x06,RCTime,
|
||||
0x0A,EasyMonitor,fetched
|
||||
0x10,UBAErrorMessage1,
|
||||
0x11,UBAErrorMessage2,
|
||||
0x12,RCErrorMessage,
|
||||
0x13,RCErrorMessage2,
|
||||
0x14,UBATotalUptime,fetched
|
||||
0x15,UBAMaintenanceData,
|
||||
0x16,UBAParameters,fetched
|
||||
0x18,UBAMonitorFast,
|
||||
0x19,UBAMonitorSlow,
|
||||
0x1A,UBASetPoints,
|
||||
0x1C,UBAMaintenanceStatus,
|
||||
0x1E,WM10TempMessage,
|
||||
0x23,JunkersSetMixer,fetched
|
||||
0x27,UBASettingsWW,fetched
|
||||
0x28,WeatherComp,fetched
|
||||
0x2A,MC110Status,
|
||||
0x2E,Meters,
|
||||
0x33,UBAParameterWW,fetched
|
||||
0x34,UBAMonitorWW,
|
||||
0x35,UBAFlags,
|
||||
0x37,WWSettings,fetched
|
||||
0x38,WWTimer,fetched
|
||||
0x39,WWCircTimer,fetched
|
||||
0x3A,RC30WWSettings,fetched
|
||||
0x3B,Energy,
|
||||
0x3D,RC35Set,
|
||||
0x3E,RC35Monitor,
|
||||
0x3F,RC35Timer,
|
||||
0x40,RC30Temp,
|
||||
0x41,RC30Monitor,
|
||||
0x42,RC35Timer2,
|
||||
0x47,RC35Set,
|
||||
0x48,RC35Monitor,
|
||||
0x49,RC35Timer,
|
||||
0x4C,RC35Timer2,
|
||||
0x51,RC35Set,
|
||||
0x52,RC35Monitor,
|
||||
0x53,RC35Timer,
|
||||
0x56,RC35Timer2,
|
||||
0x5B,RC35Set,
|
||||
0x5C,RC35Monitor,
|
||||
0x5D,RC35Timer,
|
||||
0x60,RC35Timer2,
|
||||
0x96,SM10Config,fetched
|
||||
0x97,SM10Monitor,
|
||||
0x9C,WM10MonitorMessage,
|
||||
0x9D,WM10SetMessage,
|
||||
0xA2,RCError,
|
||||
0xA3,RCOutdoorTemp,
|
||||
0xA5,IBASettings,fetched
|
||||
0xA7,RC30Set,
|
||||
0xA9,RC30Vacation,fetched
|
||||
0xAA,MMConfigMessage,fetched
|
||||
0xAB,MMStatusMessage,
|
||||
0xAC,MMSetMessage,
|
||||
0xAF,RC20Remote,
|
||||
0xB0,RC10Set,
|
||||
0xB1,RC10Monitor,
|
||||
0xBB,HybridSettings,fetched
|
||||
0xBF,ErrorMessage,
|
||||
0xC0,RCErrorMessage,
|
||||
0xC2,UBAErrorMessage3,
|
||||
0xC6,UBAErrorMessage3,
|
||||
0xD1,UBAOutdoorTemp,
|
||||
0xE3,UBAMonitorSlowPlus2,
|
||||
0xE4,UBAMonitorFastPlus,
|
||||
0xE5,UBAMonitorSlowPlus,
|
||||
0xE6,UBAParametersPlus,fetched
|
||||
0xE9,UBAMonitorWWPlus,
|
||||
0xEA,UBAParameterWWPlus,fetched
|
||||
0x0101,ISM1Set,fetched
|
||||
0x0103,ISM1StatusMessage,fetched
|
||||
0x0104,ISM2StatusMessage,
|
||||
0x010C,IPMStatusMessage,
|
||||
0x011E,JunkersDisp,fetched
|
||||
0x012E,HPEnergy1,
|
||||
0x013B,HPEnergy2,
|
||||
0x0165,JunkersSet,
|
||||
0x0166,JunkersSet,
|
||||
0x0167,JunkersSet,
|
||||
0x0168,JunkersSet,
|
||||
0x016E,Absent,fetched
|
||||
0x016F,JunkersMonitor,
|
||||
0x0170,JunkersMonitor,
|
||||
0x0171,JunkersMonitor,
|
||||
0x0172,JunkersMonitor,
|
||||
0x0179,JunkersSet,
|
||||
0x017A,JunkersSet,
|
||||
0x017B,JunkersSet,
|
||||
0x017C,JunkersSet,
|
||||
0x01D3,JunkersDhw,fetched
|
||||
0x023A,RC300OutdoorTemp,fetched
|
||||
0x023E,PVSettings,fetched
|
||||
0x0240,RC300Settings,fetched
|
||||
0x0241,RC300Settings,fetched
|
||||
0x0267,RC300Floordry,
|
||||
0x0269,RC300Holiday,fetched
|
||||
0x0291,HPMode,fetched
|
||||
0x0292,HPMode,fetched
|
||||
0x0293,HPMode,fetched
|
||||
0x0294,HPMode,fetched
|
||||
0x029B,RC300Curves,
|
||||
0x029C,RC300Curves,
|
||||
0x029D,RC300Curves,
|
||||
0x029E,RC300Curves,
|
||||
0x029F,RC300Curves,
|
||||
0x02A0,RC300Curves,
|
||||
0x02A1,RC300Curves,
|
||||
0x02A2,RC300Curves,
|
||||
0x02A5,RC300Monitor,fetched
|
||||
0x02A6,RC300Monitor,
|
||||
0x02A7,RC300Monitor,
|
||||
0x02A8,RC300Monitor,
|
||||
0x02A9,RC300Monitor,
|
||||
0x02AA,RC300Monitor,
|
||||
0x02AB,RC300Monitor,
|
||||
0x02AC,RC300Monitor,
|
||||
0x02AF,RC300Summer,
|
||||
0x02B0,RC300Summer,
|
||||
0x02B1,RC300Summer,
|
||||
0x02B2,RC300Summer,
|
||||
0x02B3,RC300Summer,
|
||||
0x02B4,RC300Summer,
|
||||
0x02B5,RC300Summer,
|
||||
0x02B6,RC300Summer,
|
||||
0x02B9,RC300Set,
|
||||
0x02BA,RC300Set,
|
||||
0x02BB,RC300Set,
|
||||
0x02BC,RC300Set,
|
||||
0x02BD,RC300Set,
|
||||
0x02BE,RC300Set,
|
||||
0x02BF,RC300Set,
|
||||
0x02C0,RC300Set,
|
||||
0x02CC,HPPressure,fetched
|
||||
0x02CD,MMPLUSConfigMessage,
|
||||
0x02D6,HPPump2,fetched
|
||||
0x02D7,MMPLUSStatusMessage,
|
||||
0x02E0,UBASetPoints,
|
||||
0x02F5,RC300WWmode,fetched
|
||||
0x02F6,RC300WW2mode,fetched
|
||||
0x0313,MMPLUSConfigMessage_WWC,fetched
|
||||
0x031B,RC300WWtemp,fetched
|
||||
0x031D,RC300WWmode2,
|
||||
0x031E,RC300WWmode2,
|
||||
0x0331,MMPLUSStatusMessage_WWC,
|
||||
0x0358,SM100SystemConfig,fetched
|
||||
0x035A,SM100CircuitConfig,fetched
|
||||
0x035C,SM100HeatAssist,fetched
|
||||
0x035D,SM100Circuit2Config,fetched
|
||||
0x035F,SM100Config1,fetched
|
||||
0x0361,SM100Differential,fetched
|
||||
0x0362,SM100Monitor,
|
||||
0x0363,SM100Monitor2,
|
||||
0x0364,SM100Status,
|
||||
0x0366,SM100Config,
|
||||
0x036A,SM100Status2,
|
||||
0x0380,SM100CollectorConfig,fetched
|
||||
0x038E,SM100Energy,fetched
|
||||
0x0391,SM100Time,fetched
|
||||
0x0421,RC300Set2,
|
||||
0x0422,RC300Set2,
|
||||
0x0423,RC300Set2,
|
||||
0x0424,RC300Set2,
|
||||
0x043F,CRHolidays,fetched
|
||||
0x0467,HPSet,
|
||||
0x0468,HPSet,
|
||||
0x0469,HPSet,
|
||||
0x046A,HPSet,
|
||||
0x0471,RC300Summer2,
|
||||
0x0472,RC300Summer2,
|
||||
0x0473,RC300Summer2,
|
||||
0x0474,RC300Summer2,
|
||||
0x0475,RC300Summer2,
|
||||
0x0476,RC300Summer2,
|
||||
0x0477,RC300Summer2,
|
||||
0x0478,RC300Summer2,
|
||||
0x047B,HP2,
|
||||
0x0484,HPSilentMode,fetched
|
||||
0x0485,HpCooling,fetched
|
||||
0x0486,HpInConfig,fetched
|
||||
0x0488,HPValve,fetched
|
||||
0x048A,HpPool,fetched
|
||||
0x048B,HPPumps,fetched
|
||||
0x048D,HpPower,fetched
|
||||
0x048F,HpTemperatures,
|
||||
0x0491,HPAdditionalHeater,fetched
|
||||
0x0492,HpHeaterConfig,fetched
|
||||
0x0494,UBAEnergySupplied,
|
||||
0x0495,UBAInformation,
|
||||
0x0499,HPDhwSettings,fetched
|
||||
0x049C,HPSettings2,fetched
|
||||
0x049D,HPSettings3,fetched
|
||||
0x04A2,HpInput,fetched
|
||||
0x04A5,HPFan,fetched
|
||||
0x04A7,HPPowerLimit,fetched
|
||||
0x04AA,HPPower2,fetched
|
||||
0x04AE,HPEnergy,fetched
|
||||
0x04AF,HPMeters,fetched
|
||||
0x055C,VentilationSet,fetched
|
||||
0x056B,VentilationMode,fetched
|
||||
0x0583,VentilationMonitor,
|
||||
0x0585,Blowerspeed,
|
||||
0x0587,Bypass,
|
||||
0x05BA,HpPoolStatus,fetched
|
||||
0x05D9,Airquality,
|
||||
0x0772,HIUSettings,
|
||||
0x0779,HIUMonitor,
|
||||
0x07A5,SM100wwCirc,fetched
|
||||
0x07A6,SM100wwParam,fetched
|
||||
0x07AA,SM100wwStatus,
|
||||
0x07AB,SM100wwCommand,
|
||||
0x07AC,SM100wwParam1,
|
||||
0x07AD,SM100ValveStatus,
|
||||
0x07AE,SM100wwKeepWarm,fetched
|
||||
0x07D6,SM100wwTemperature,
|
||||
0x07E0,SM100wwStatus2,fetched
|
||||
0x0935,EM100SetMessage,fetched
|
||||
0x0936,EM100OutMessage,
|
||||
0x0937,EM100TempMessage,
|
||||
0x0938,EM100InputMessage,
|
||||
0x0939,EM100MonitorMessage,
|
||||
0x093A,EM100ConfigMessage,
|
||||
0x0998,HPSettings,fetched
|
||||
0x0999,HPFunctionTest,fetched
|
||||
0x099A,HPStarts,
|
||||
0x099B,HPFlowTemp,
|
||||
0x099C,HPComp,
|
||||
0x09A0,HPTemperature,
|
||||
|
||||
|
@@ -13,55 +13,59 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
||||
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
|
||||
"mock-rest": "bun --watch ../mock-api/restServer.ts",
|
||||
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite\"",
|
||||
"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",
|
||||
"webUI": "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"
|
||||
"lint": "eslint . --fix",
|
||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/adapter-xhr": "2.2.1",
|
||||
"@alova/adapter-xhr": "2.3.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@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.3.4",
|
||||
"alova": "3.4.0",
|
||||
"async-validator": "^4.2.5",
|
||||
"etag": "^1.8.1",
|
||||
"formidable": "^3.5.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"magic-string": "^0.30.19",
|
||||
"mime-types": "^3.0.1",
|
||||
"preact": "^10.27.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mime-types": "^3.0.2",
|
||||
"preact": "^10.28.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.9.4",
|
||||
"react-router": "^7.10.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.4",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@preact/compat": "^18.3.1",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axe-core": "^4.11.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.7.4",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"terser": "^5.44.0",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vite": "^7.1.11",
|
||||
"terser": "^5.44.1",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.6",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
|
||||
}
|
||||
|
||||
1358
interface/pnpm-lock.yaml
generated
1358
interface/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
import etag from 'etag';
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
unlinkSync
|
||||
} from 'fs';
|
||||
import mime from 'mime-types';
|
||||
@@ -36,7 +35,7 @@ const generateWWWClass =
|
||||
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')}
|
||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
|
||||
${INDENT.repeat(2)}}
|
||||
};
|
||||
`;
|
||||
@@ -71,7 +70,8 @@ const writeFile = (relativeFilePath, buffer) => {
|
||||
writeStream.write(`const uint8_t ${variable}[] = {`);
|
||||
|
||||
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
||||
const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
|
||||
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
|
||||
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
|
||||
|
||||
zipBuffer.forEach((b) => {
|
||||
if (!(size % bytesPerLine)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { ToastContainer, Zoom } from 'react-toastify';
|
||||
|
||||
import AppRouting from 'AppRouting';
|
||||
@@ -8,7 +8,7 @@ import type { Locales } from 'i18n/i18n-types';
|
||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||
|
||||
const availableLocales = [
|
||||
const AVAILABLE_LOCALES = [
|
||||
'de',
|
||||
'en',
|
||||
'it',
|
||||
@@ -20,47 +20,56 @@ const availableLocales = [
|
||||
'sv',
|
||||
'tr',
|
||||
'cz'
|
||||
];
|
||||
] as Locales[];
|
||||
|
||||
const App = () => {
|
||||
// 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(() => {
|
||||
// determine locale, take from session if set other default to browser language
|
||||
const browserLocale = detectLocale('en', availableLocales, navigatorDetector);
|
||||
// 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);
|
||||
void loadLocaleAsync(newLocale).then(() => setWasLoaded(true));
|
||||
await loadLocaleAsync(newLocale);
|
||||
setWasLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void initializeLocale();
|
||||
}, [initializeLocale]);
|
||||
|
||||
if (!wasLoaded) return null;
|
||||
|
||||
return (
|
||||
<TypesafeI18n locale={locale}>
|
||||
<CustomTheme>
|
||||
<AppRouting />
|
||||
<ToastContainer
|
||||
position="bottom-left"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable={false}
|
||||
pauseOnHover={false}
|
||||
transition={Zoom}
|
||||
closeButton={false}
|
||||
theme="dark"
|
||||
toastStyle={{
|
||||
border: '1px solid #177ac9'
|
||||
}}
|
||||
/>
|
||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||
</CustomTheme>
|
||||
</TypesafeI18n>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,60 +1,80 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AuthenticatedRouting from 'AuthenticatedRouting';
|
||||
import SignIn from 'SignIn';
|
||||
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
|
||||
import {
|
||||
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 {
|
||||
message: string;
|
||||
signOut?: boolean;
|
||||
readonly message: string;
|
||||
readonly signOut?: boolean;
|
||||
}
|
||||
|
||||
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
useEffect(() => {
|
||||
signOut && authenticationContext.signOut(false);
|
||||
toast.success(message);
|
||||
}, [message, signOut, authenticationContext]);
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
||||
({ message, signOut = false }) => {
|
||||
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
||||
const hasShownToast = useRef(false);
|
||||
|
||||
const AppRouting = () => {
|
||||
useEffect(() => {
|
||||
// Prevent duplicate toasts on strict mode or re-renders
|
||||
if (!hasShownToast.current) {
|
||||
hasShownToast.current = true;
|
||||
if (signOut) {
|
||||
contextSignOut(false);
|
||||
}
|
||||
toast.success(message);
|
||||
}
|
||||
// Only run once on mount - using ref to track execution
|
||||
}, []);
|
||||
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
);
|
||||
|
||||
const AppRouting: FC = memo(() => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<Authentication>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/unauthorized"
|
||||
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
||||
/>
|
||||
<Route
|
||||
path="/fileUpdated"
|
||||
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireUnauthenticated>
|
||||
<SignIn />
|
||||
</RequireUnauthenticated>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuthenticated>
|
||||
<AuthenticatedRouting />
|
||||
</RequireAuthenticated>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<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;
|
||||
|
||||
@@ -1,77 +1,88 @@
|
||||
import { useContext } from 'react';
|
||||
import { Suspense, lazy, memo, useContext } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router';
|
||||
|
||||
import CustomEntities from 'app/main/CustomEntities';
|
||||
import Customizations from 'app/main/Customizations';
|
||||
import Dashboard from 'app/main/Dashboard';
|
||||
import Devices from 'app/main/Devices';
|
||||
import Help from 'app/main/Help';
|
||||
import Modules from 'app/main/Modules';
|
||||
import Scheduler from 'app/main/Scheduler';
|
||||
import Sensors from 'app/main/Sensors';
|
||||
import APSettings from 'app/settings/APSettings';
|
||||
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
||||
import DownloadUpload from 'app/settings/DownloadUpload';
|
||||
import MqttSettings from 'app/settings/MqttSettings';
|
||||
import NTPSettings from 'app/settings/NTPSettings';
|
||||
import Settings from 'app/settings/Settings';
|
||||
import Network from 'app/settings/network/Network';
|
||||
import Security from 'app/settings/security/Security';
|
||||
import APStatus from 'app/status/APStatus';
|
||||
import Activity from 'app/status/Activity';
|
||||
import HardwareStatus from 'app/status/HardwareStatus';
|
||||
import MqttStatus from 'app/status/MqttStatus';
|
||||
import NTPStatus from 'app/status/NTPStatus';
|
||||
import NetworkStatus from 'app/status/NetworkStatus';
|
||||
import Status from 'app/status/Status';
|
||||
import SystemLog from 'app/status/SystemLog';
|
||||
import Version from 'app/status/Version';
|
||||
import { Layout } from 'components';
|
||||
import { Layout, LoadingSpinner } from 'components';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
|
||||
const AuthenticatedRouting = () => {
|
||||
// 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>
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/devices/*" element={<Devices />} />
|
||||
<Route path="/sensors/*" element={<Sensors />} />
|
||||
<Route path="/help/*" element={<Help />} />
|
||||
<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 />} />
|
||||
<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 />} />
|
||||
{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="/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="/customizations" element={<Customizations />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customentities" element={<CustomEntities />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default AuthenticatedRouting;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { CssBaseline, ThemeProvider, responsiveFontSizes } from '@mui/material';
|
||||
import {
|
||||
CssBaseline,
|
||||
ThemeProvider,
|
||||
responsiveFontSizes,
|
||||
tooltipClasses
|
||||
} from '@mui/material';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
import type { RequiredChildrenProps } from 'utils';
|
||||
@@ -10,9 +16,9 @@ export const dialogStyle = {
|
||||
borderRadius: '8px',
|
||||
borderColor: '#565656',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '1px'
|
||||
borderWidth: '2px'
|
||||
}
|
||||
};
|
||||
} as const;
|
||||
|
||||
const theme = responsiveFontSizes(
|
||||
createTheme({
|
||||
@@ -30,15 +36,45 @@ const theme = responsiveFontSizes(
|
||||
text: {
|
||||
disabled: '#eee' // white
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: {
|
||||
fontSize: 14
|
||||
},
|
||||
secondary: {
|
||||
color: '#9e9e9e' // grey[500]
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTooltip: {
|
||||
defaultProps: {
|
||||
placement: 'top',
|
||||
arrow: true
|
||||
},
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
backgroundColor: '#4caf50', // MUI success.main default color
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
[`& .${tooltipClasses.arrow}`]: {
|
||||
color: '#4caf50'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
|
||||
const CustomTheme: FC<RequiredChildrenProps> = memo(({ children }) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
));
|
||||
|
||||
export default CustomTheme;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
@@ -19,7 +19,7 @@ import type { SignInRequest } from 'types';
|
||||
import { onEnterCallback, updateValue } from 'utils';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
||||
|
||||
const SignIn = () => {
|
||||
const SignIn = memo(() => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
@@ -42,9 +42,18 @@ const SignIn = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||
// 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());
|
||||
@@ -53,9 +62,9 @@ const SignIn = () => {
|
||||
}
|
||||
setProcessing(false);
|
||||
});
|
||||
};
|
||||
}, [callSignIn, signInRequest, LL]);
|
||||
|
||||
const validateAndSignIn = async () => {
|
||||
const validateAndSignIn = useCallback(async () => {
|
||||
setProcessing(true);
|
||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||
required: LL.IS_REQUIRED('%s')
|
||||
@@ -67,9 +76,19 @@ const SignIn = () => {
|
||||
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(() => {
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -92,23 +111,27 @@ const SignIn = () => {
|
||||
width: '100%'
|
||||
})}
|
||||
>
|
||||
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||
|
||||
<Typography mb={1} variant="h4">
|
||||
{PROJECT_NAME}
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<Box
|
||||
mt={1}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
disabled={processing}
|
||||
sx={{
|
||||
width: 240
|
||||
width: '32ch'
|
||||
}}
|
||||
name="username"
|
||||
label={LL.USERNAME(0)}
|
||||
value={signInRequest.username}
|
||||
onChange={updateLoginRequestValue}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
input: {
|
||||
autoCapitalize: 'none',
|
||||
@@ -120,14 +143,13 @@ const SignIn = () => {
|
||||
fieldErrors={fieldErrors || {}}
|
||||
disabled={processing}
|
||||
sx={{
|
||||
width: 240
|
||||
width: '32ch'
|
||||
}}
|
||||
name="password"
|
||||
label={LL.PASSWORD()}
|
||||
value={signInRequest.password}
|
||||
onChange={updateLoginRequestValue}
|
||||
onKeyDown={submitOnEnter}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -144,6 +166,6 @@ const SignIn = () => {
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default SignIn;
|
||||
|
||||
@@ -20,19 +20,18 @@ import type {
|
||||
WriteTemperatureSensor
|
||||
} from '../app/main/types';
|
||||
|
||||
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
|
||||
|
||||
// Dashboard
|
||||
export const readDashboard = () =>
|
||||
alovaInstance.Get<DashboardData>('/rest/dashboardData', {
|
||||
responseType: 'arraybuffer' // uses msgpack
|
||||
});
|
||||
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
|
||||
|
||||
// Devices
|
||||
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
|
||||
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
|
||||
export const readDeviceData = (id: number) =>
|
||||
alovaInstance.Get<DeviceData>('/rest/deviceData', {
|
||||
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
|
||||
params: { id },
|
||||
responseType: 'arraybuffer' // uses msgpack
|
||||
...MSGPACK_CONFIG
|
||||
});
|
||||
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
|
||||
alovaInstance.Post('/rest/writeDeviceValue', data);
|
||||
@@ -66,13 +65,13 @@ export const callAction = (action: Action) =>
|
||||
|
||||
// SettingsCustomization
|
||||
export const readDeviceEntities = (id: number) =>
|
||||
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
|
||||
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
|
||||
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
|
||||
params: { id },
|
||||
responseType: 'arraybuffer',
|
||||
...MSGPACK_CONFIG,
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
transform(data) {
|
||||
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
|
||||
const entities = data as DeviceEntity[];
|
||||
return entities.map((de) => ({
|
||||
...de,
|
||||
o_m: de.m,
|
||||
o_cn: de.cn,
|
||||
@@ -95,7 +94,8 @@ export const readSchedule = () =>
|
||||
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
transform(data) {
|
||||
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
|
||||
const schedule = (data as Schedule).schedule;
|
||||
return schedule.map((si) => ({
|
||||
...si,
|
||||
o_id: si.id,
|
||||
o_active: si.active,
|
||||
@@ -115,7 +115,8 @@ export const writeSchedule = (data: Schedule) =>
|
||||
export const readModules = () =>
|
||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
||||
transform(data) {
|
||||
return (data as Modules).modules.map((mi: ModuleItem) => ({
|
||||
const modules = (data as Modules).modules;
|
||||
return modules.map((mi) => ({
|
||||
...mi,
|
||||
o_enabled: mi.enabled,
|
||||
o_license: mi.license
|
||||
@@ -133,7 +134,8 @@ export const readCustomEntities = () =>
|
||||
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
|
||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||
transform(data) {
|
||||
return (data as Entities).entities.map((ei: EntityItem) => ({
|
||||
const entities = (data as Entities).entities;
|
||||
return entities.map((ei) => ({
|
||||
...ei,
|
||||
o_id: ei.id,
|
||||
o_ram: ei.ram,
|
||||
|
||||
@@ -4,55 +4,57 @@ import ReactHook from 'alova/react';
|
||||
|
||||
import { unpack } from './unpack';
|
||||
|
||||
export const ACCESS_TOKEN = 'access_token';
|
||||
export const ACCESS_TOKEN = 'access_token' as const;
|
||||
|
||||
// Cached token to avoid repeated localStorage access
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
const getAccessToken = (): string | null => {
|
||||
if (cachedToken === null) {
|
||||
cachedToken = localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
return cachedToken;
|
||||
};
|
||||
|
||||
// Clear token cache when needed (e.g., on logout)
|
||||
export const clearTokenCache = (): void => {
|
||||
cachedToken = null;
|
||||
};
|
||||
|
||||
const handleResponse = async (response: AlovaXHRResponse) => {
|
||||
// Handle various HTTP status codes
|
||||
if (response.status === 205) {
|
||||
throw new Error('Reboot required');
|
||||
}
|
||||
if (response.status === 400) {
|
||||
throw new Error('Request Failed');
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
const data = (await response.data) as ArrayBuffer;
|
||||
|
||||
// Unpack MessagePack data if ArrayBuffer
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return unpack(data) as ArrayBuffer;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const alovaInstance = createAlova({
|
||||
statesHook: ReactHook,
|
||||
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
|
||||
cacheFor: null, // disable cache
|
||||
// cacheFor: {
|
||||
// GET: {
|
||||
// mode: 'memory',
|
||||
// expire: 60 * 10 * 1000 // 60 seconds in cache
|
||||
// }
|
||||
// },
|
||||
requestAdapter: xhrRequestAdapter(),
|
||||
beforeRequest(method) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
method.config.headers.Authorization =
|
||||
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
method.config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// for simulating very slow networks
|
||||
// return new Promise((resolve) => {
|
||||
// const random = 3000 + Math.random() * 2000;
|
||||
// setTimeout(resolve, Math.floor(random));
|
||||
// });
|
||||
},
|
||||
|
||||
responded: {
|
||||
onSuccess: async (response: AlovaXHRResponse) => {
|
||||
// if (response.status === 202) {
|
||||
// throw new Error('Wait'); // wifi scan in progress
|
||||
// } else
|
||||
if (response.status === 205) {
|
||||
throw new Error('Reboot required');
|
||||
} else if (response.status === 400) {
|
||||
throw new Error('Request Failed');
|
||||
} else if (response.status >= 400) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
|
||||
if (response.data instanceof ArrayBuffer) {
|
||||
return unpack(data) as ArrayBuffer;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
|
||||
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
|
||||
// onError: (error, method) => {
|
||||
// alert(error.message);
|
||||
// }
|
||||
onSuccess: handleResponse
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
|
||||
|
||||
import { alovaInstance } from './endpoints';
|
||||
|
||||
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
|
||||
|
||||
export const readNetworkStatus = () =>
|
||||
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
|
||||
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
||||
export const listNetworks = () =>
|
||||
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
||||
timeout: 20000 // 20 seconds
|
||||
timeout: LIST_NETWORKS_TIMEOUT
|
||||
});
|
||||
export const readNetworkSettings = () =>
|
||||
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
|
||||
|
||||
@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
|
||||
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
|
||||
|
||||
export const readNTPSettings = () =>
|
||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
|
||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
|
||||
export const updateNTPSettings = (data: NTPSettingsType) =>
|
||||
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const readSystemStatus = () =>
|
||||
|
||||
// SystemLog
|
||||
export const readLogSettings = () =>
|
||||
alovaInstance.Get<LogSettings>(`/rest/logSettings`);
|
||||
alovaInstance.Get<LogSettings>('/rest/logSettings');
|
||||
export const updateLogSettings = (data: LogSettings) =>
|
||||
alovaInstance.Post('/rest/logSettings', data);
|
||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||
@@ -36,10 +36,12 @@ export const getDevVersion = () =>
|
||||
}
|
||||
});
|
||||
|
||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||
|
||||
export const uploadFile = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return alovaInstance.Post('/rest/uploadFile', formData, {
|
||||
timeout: 60000 // override timeout for uploading firmware - 1 minute
|
||||
timeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export class Unpackr {
|
||||
}
|
||||
Object.assign(this, options);
|
||||
}
|
||||
unpack(source, options?: any) {
|
||||
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
|
||||
if (src) {
|
||||
return saveState(() => {
|
||||
clearSource();
|
||||
@@ -184,7 +184,7 @@ export class Unpackr {
|
||||
function getPosition() {
|
||||
return position;
|
||||
}
|
||||
function checkedRead(options: any) {
|
||||
function checkedRead(options?: { lazy?: boolean }) {
|
||||
try {
|
||||
if (!currentUnpackr.trusted && !sequentialMode) {
|
||||
const sharedLength = currentStructures.sharedLength || 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { Entities, EntityItem } from './types';
|
||||
import { entityItemValidation } from './validators';
|
||||
|
||||
const MIN_ID = -100;
|
||||
const MAX_ID = 100;
|
||||
const ICON_SIZE = 12;
|
||||
|
||||
const CustomEntities = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
@@ -53,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 }
|
||||
);
|
||||
|
||||
function hasEntityChanged(ei: EntityItem) {
|
||||
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
||||
return (
|
||||
ei.id !== ei.o_id ||
|
||||
ei.ram !== ei.o_ram ||
|
||||
@@ -80,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;
|
||||
}
|
||||
@@ -112,7 +120,7 @@ const CustomEntities = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -121,7 +129,7 @@ const CustomEntities = () => {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -132,9 +140,11 @@ const CustomEntities = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const saveEntities = async () => {
|
||||
const saveEntities = useCallback(async () => {
|
||||
await writeEntities({
|
||||
entities: entities
|
||||
.filter((ei: EntityItem) => !ei.deleted)
|
||||
@@ -163,7 +173,7 @@ const CustomEntities = () => {
|
||||
await fetchEntities();
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
}, [entities, writeEntities, LL, fetchEntities]);
|
||||
|
||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
||||
setCreating(false);
|
||||
@@ -171,36 +181,39 @@ const CustomEntities = () => {
|
||||
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() * (Math.floor(200) - 100) + 100),
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
name: item.name + '_',
|
||||
ram: item.ram,
|
||||
device_id: item.device_id,
|
||||
@@ -215,12 +228,12 @@ const CustomEntities = () => {
|
||||
value: item.value
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addEntityItem = () => {
|
||||
const addEntityItem = useCallback(() => {
|
||||
setCreating(true);
|
||||
setSelectedEntityItem({
|
||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
name: '',
|
||||
ram: 0,
|
||||
device_id: '0',
|
||||
@@ -235,22 +248,30 @@ const CustomEntities = () => {
|
||||
value: ''
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function 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]);
|
||||
}
|
||||
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
||||
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
||||
}, []);
|
||||
|
||||
function showHex(value: number, digit: number) {
|
||||
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
|
||||
}
|
||||
const showHex = useCallback((value: number, digit: number) => {
|
||||
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||
}, []);
|
||||
|
||||
const renderEntity = () => {
|
||||
const filteredAndSortedEntities = useMemo(
|
||||
() =>
|
||||
entities
|
||||
?.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
||||
[entities]
|
||||
);
|
||||
|
||||
const renderEntity = useCallback(() => {
|
||||
if (!entities) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||
@@ -260,9 +281,7 @@ const CustomEntities = () => {
|
||||
return (
|
||||
<Table
|
||||
data={{
|
||||
nodes: entities
|
||||
.filter((ei: EntityItem) => !ei.deleted)
|
||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name))
|
||||
nodes: filteredAndSortedEntities
|
||||
}}
|
||||
theme={entity_theme}
|
||||
layout={{ custom: true }}
|
||||
@@ -285,7 +304,10 @@ const CustomEntities = () => {
|
||||
<Cell>
|
||||
{ei.name}
|
||||
{ei.writeable && (
|
||||
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
<EditOutlinedIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: ICON_SIZE }}
|
||||
/>
|
||||
)}
|
||||
</Cell>
|
||||
<Cell>
|
||||
@@ -304,7 +326,17 @@ const CustomEntities = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
entities,
|
||||
error,
|
||||
fetchEntities,
|
||||
entity_theme,
|
||||
editEntityItem,
|
||||
LL,
|
||||
filteredAndSortedEntities,
|
||||
showHex,
|
||||
formatValue
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -327,7 +359,7 @@ const CustomEntities = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={1} display="flex" flexWrap="wrap">
|
||||
<Box mt={2} display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges > 0 && (
|
||||
<ButtonRow>
|
||||
|
||||
@@ -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';
|
||||
@@ -33,6 +33,19 @@ import { validate } from 'validators';
|
||||
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||
import type { EntityItem } from './types';
|
||||
|
||||
// Constant value type options for the dropdown
|
||||
const VALUE_TYPE_OPTIONS = [
|
||||
DeviceValueType.BOOL,
|
||||
DeviceValueType.INT8,
|
||||
DeviceValueType.UINT8,
|
||||
DeviceValueType.INT16,
|
||||
DeviceValueType.UINT16,
|
||||
DeviceValueType.UINT24,
|
||||
DeviceValueType.TIME,
|
||||
DeviceValueType.UINT32,
|
||||
DeviceValueType.STRING
|
||||
] as const;
|
||||
|
||||
interface CustomEntitiesDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
@@ -55,64 +68,97 @@ const CustomEntitiesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as React.Dispatch<
|
||||
React.SetStateAction<Record<string, unknown>>
|
||||
>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
setEditItem(selectedItem);
|
||||
// convert to hex strings straight away
|
||||
// Convert to hex strings - combined into single setEditItem call
|
||||
const deviceIdHex =
|
||||
typeof selectedItem.device_id === 'number'
|
||||
? selectedItem.device_id.toString(16).toUpperCase()
|
||||
: selectedItem.device_id;
|
||||
const typeIdHex =
|
||||
typeof selectedItem.type_id === 'number'
|
||||
? selectedItem.type_id.toString(16).toUpperCase()
|
||||
: selectedItem.type_id;
|
||||
const factorValue =
|
||||
selectedItem.value_type === DeviceValueType.BOOL &&
|
||||
typeof selectedItem.factor === 'number'
|
||||
? selectedItem.factor.toString(16).toUpperCase()
|
||||
: selectedItem.factor;
|
||||
|
||||
setEditItem({
|
||||
...selectedItem,
|
||||
device_id: selectedItem.device_id.toString(16).toUpperCase(),
|
||||
type_id: selectedItem.type_id.toString(16).toUpperCase(),
|
||||
factor:
|
||||
selectedItem.value_type === DeviceValueType.BOOL
|
||||
? selectedItem.factor.toString(16).toUpperCase()
|
||||
: selectedItem.factor
|
||||
device_id: deviceIdHex,
|
||||
type_id: typeIdHex,
|
||||
factor: factorValue
|
||||
});
|
||||
}
|
||||
}, [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);
|
||||
if (typeof editItem.device_id === 'string') {
|
||||
editItem.device_id = parseInt(editItem.device_id, 16);
|
||||
|
||||
// Create a copy to avoid mutating the state directly
|
||||
const processedItem: EntityItem = { ...editItem };
|
||||
|
||||
if (typeof processedItem.device_id === 'string') {
|
||||
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
|
||||
}
|
||||
if (typeof editItem.type_id === 'string') {
|
||||
editItem.type_id = parseInt(editItem.type_id, 16);
|
||||
if (typeof processedItem.type_id === 'string') {
|
||||
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
|
||||
}
|
||||
if (
|
||||
editItem.value_type === DeviceValueType.BOOL &&
|
||||
typeof editItem.factor === 'string'
|
||||
processedItem.value_type === DeviceValueType.BOOL &&
|
||||
typeof processedItem.factor === 'string'
|
||||
) {
|
||||
editItem.factor = parseInt(editItem.factor, 16);
|
||||
processedItem.factor = Number.parseInt(processedItem.factor, 16);
|
||||
}
|
||||
onSave(editItem);
|
||||
onSave(processedItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const remove = () => {
|
||||
editItem.deleted = true;
|
||||
onSave(editItem);
|
||||
};
|
||||
const remove = useCallback(() => {
|
||||
const itemWithDeleted = { ...editItem, deleted: true };
|
||||
onSave(itemWithDeleted);
|
||||
}, [editItem, onSave]);
|
||||
|
||||
const dup = () => {
|
||||
const dup = useCallback(() => {
|
||||
onDup(editItem);
|
||||
};
|
||||
}, [editItem, onDup]);
|
||||
|
||||
// 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}>
|
||||
@@ -120,9 +166,6 @@ const CustomEntitiesDialog = ({
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box display="flex" flexWrap="wrap" mb={1}>
|
||||
<Box flexWrap="nowrap" whiteSpace="nowrap" />
|
||||
</Box>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid size={12}>
|
||||
<ValidatedTextField
|
||||
@@ -187,11 +230,7 @@ const CustomEntitiesDialog = ({
|
||||
onChange={updateFormValue}
|
||||
select
|
||||
>
|
||||
{DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
{uomMenuItems}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
@@ -275,33 +314,11 @@ const CustomEntitiesDialog = ({
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={DeviceValueType.BOOL}>
|
||||
{DeviceValueTypeNames[DeviceValueType.BOOL]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.INT8}>
|
||||
{DeviceValueTypeNames[DeviceValueType.INT8]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT8}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT8]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.INT16}>
|
||||
{DeviceValueTypeNames[DeviceValueType.INT16]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT16}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT16]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT24}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT24]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.TIME}>
|
||||
{DeviceValueTypeNames[DeviceValueType.TIME]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.UINT32}>
|
||||
{DeviceValueTypeNames[DeviceValueType.UINT32]}
|
||||
</MenuItem>
|
||||
<MenuItem value={DeviceValueType.STRING}>
|
||||
{DeviceValueTypeNames[DeviceValueType.STRING]}
|
||||
</MenuItem>
|
||||
{VALUE_TYPE_OPTIONS.map((valueType) => (
|
||||
<MenuItem key={valueType} value={valueType}>
|
||||
{DeviceValueTypeNames[valueType]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
|
||||
@@ -333,11 +350,7 @@ const CustomEntitiesDialog = ({
|
||||
onChange={updateFormValue}
|
||||
select
|
||||
>
|
||||
{DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
{uomMenuItems}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBlocker, useLocation } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon';
|
||||
import { DeviceEntityMask } from './types';
|
||||
import type { APIcall, Device, DeviceEntity } from './types';
|
||||
|
||||
export const APIURL = window.location.origin + '/api/';
|
||||
export const APIURL = `${window.location.origin}/api/`;
|
||||
|
||||
const MAX_BUFFER_SIZE = 2000;
|
||||
|
||||
// Helper function to create masked entity ID - extracted to avoid duplication
|
||||
const createMaskedEntityId = (de: DeviceEntity): string => {
|
||||
const maskHex = de.m.toString(16).padStart(2, '0');
|
||||
const hasCustomizations = !!(de.cn || de.mi || de.ma);
|
||||
const customizations = [
|
||||
de.cn || '',
|
||||
de.mi ? `>${de.mi}` : '',
|
||||
de.ma ? `<${de.ma}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
|
||||
};
|
||||
|
||||
const Customizations = () => {
|
||||
const { LL } = useI18nContext();
|
||||
@@ -153,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;
|
||||
}
|
||||
@@ -174,7 +193,7 @@ const Customizations = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
@@ -186,7 +205,7 @@ const Customizations = () => {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -202,7 +221,7 @@ const Customizations = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -216,7 +235,9 @@ const Customizations = () => {
|
||||
padding-right: 8px;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
function hasEntityChanged(de: DeviceEntity) {
|
||||
return (
|
||||
@@ -229,19 +250,8 @@ const Customizations = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceEntities.length) {
|
||||
setNumChanges(
|
||||
deviceEntities
|
||||
.filter((de) => hasEntityChanged(de))
|
||||
.map(
|
||||
(new_de) =>
|
||||
new_de.m.toString(16).padStart(2, '0') +
|
||||
new_de.id +
|
||||
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||
(new_de.cn ? new_de.cn : '') +
|
||||
(new_de.mi ? '>' + new_de.mi : '') +
|
||||
(new_de.ma ? '<' + new_de.ma : '')
|
||||
).length
|
||||
);
|
||||
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
|
||||
setNumChanges(changedEntities.length);
|
||||
}
|
||||
}, [deviceEntities]);
|
||||
|
||||
@@ -275,18 +285,26 @@ const Customizations = () => {
|
||||
return value as string;
|
||||
}
|
||||
|
||||
const formatName = (de: DeviceEntity, withShortname: boolean) =>
|
||||
(de.n && de.n[0] === '!'
|
||||
? de.t
|
||||
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
|
||||
: LL.COMMAND(1) + ': ' + de.n.slice(1)
|
||||
: de.cn && de.cn !== ''
|
||||
? de.t
|
||||
? de.t + ' ' + de.cn
|
||||
: de.cn
|
||||
: de.t
|
||||
? de.t + ' ' + de.n
|
||||
: de.n) + (withShortname ? ' ' + de.id : '');
|
||||
const isCommand = useCallback((de: DeviceEntity) => {
|
||||
return de.n && de.n[0] === '!';
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -316,34 +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(
|
||||
deviceEntities.map(function (de) {
|
||||
if (filter_entity(de)) {
|
||||
return {
|
||||
...de,
|
||||
m: set
|
||||
? de.m |
|
||||
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE)
|
||||
: de.m &
|
||||
~(
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||
)
|
||||
};
|
||||
} else {
|
||||
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());
|
||||
@@ -351,25 +368,30 @@ const Customizations = () => {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setConfirmReset(false);
|
||||
setRestarting(true);
|
||||
}
|
||||
};
|
||||
}, [sendResetCustomizations, LL]);
|
||||
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||
setDeviceEntities(
|
||||
deviceEntities?.map((de) =>
|
||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||
)
|
||||
(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 = useCallback((de: DeviceEntity) => {
|
||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||
@@ -384,60 +406,54 @@ const Customizations = () => {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const saveCustomization = async () => {
|
||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
||||
const masked_entities = deviceEntities
|
||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||
.map(
|
||||
(new_de) =>
|
||||
new_de.m.toString(16).padStart(2, '0') +
|
||||
new_de.id +
|
||||
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||
(new_de.cn ? new_de.cn : '') +
|
||||
(new_de.mi ? '>' + new_de.mi : '') +
|
||||
(new_de.ma ? '<' + new_de.ma : '')
|
||||
);
|
||||
|
||||
// check size in bytes to match buffer in CPP, which is 2048
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||
if (bytes > 2000) {
|
||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||
return;
|
||||
}
|
||||
|
||||
await sendCustomizationEntities({
|
||||
id: selectedDevice,
|
||||
entity_ids: masked_entities
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.CUSTOMIZATIONS_SAVED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error.message === 'Reboot required') {
|
||||
setRestartNeeded(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setOriginalSettings(deviceEntities);
|
||||
});
|
||||
const saveCustomization = useCallback(async () => {
|
||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const renameDevice = async () => {
|
||||
const masked_entities = deviceEntities
|
||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||
.map((new_de) => createMaskedEntityId(new_de));
|
||||
|
||||
// check size in bytes to match buffer in CPP, which is 2048
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||
if (bytes > MAX_BUFFER_SIZE) {
|
||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||
return;
|
||||
}
|
||||
|
||||
await sendCustomizationEntities({
|
||||
id: selectedDevice,
|
||||
entity_ids: masked_entities
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.CUSTOMIZATIONS_SAVED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error.message === 'Reboot required') {
|
||||
setRestartNeeded(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setOriginalSettings(deviceEntities);
|
||||
});
|
||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||
|
||||
const renameDevice = useCallback(async () => {
|
||||
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
||||
.then(() => {
|
||||
toast.success(LL.UPDATED_OF(LL.NAME(1)));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1));
|
||||
toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
|
||||
})
|
||||
.finally(async () => {
|
||||
setRename(false);
|
||||
await fetchCoreData();
|
||||
});
|
||||
};
|
||||
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
|
||||
|
||||
const renderDeviceList = () => (
|
||||
<>
|
||||
@@ -500,25 +516,38 @@ const Customizations = () => {
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setRename(true)}
|
||||
>
|
||||
{LL.RENAME()}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setRename(true)}
|
||||
>
|
||||
{LL.RENAME()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmReset(true)}
|
||||
>
|
||||
{LL.REMOVE_ALL()}
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderDeviceData = () => {
|
||||
const shown_data = deviceEntities.filter((de) => filter_entity(de));
|
||||
const filteredEntities = useMemo(
|
||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
||||
[deviceEntities, filter_entity]
|
||||
);
|
||||
|
||||
const renderDeviceData = () => {
|
||||
return (
|
||||
<>
|
||||
<Box color="warning.main">
|
||||
<Typography variant="body2" mt={1}>
|
||||
<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()}
|
||||
@@ -544,6 +573,7 @@ const Customizations = () => {
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder={LL.SEARCH()}
|
||||
aria-label={LL.SEARCH()}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
@@ -612,13 +642,13 @@ const Customizations = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography variant="subtitle2" color="grey">
|
||||
{LL.SHOWING()} {shown_data.length}/{deviceEntities.length}
|
||||
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
||||
{LL.ENTITIES(deviceEntities.length)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Table
|
||||
data={{ nodes: shown_data }}
|
||||
data={{ nodes: filteredEntities }}
|
||||
theme={entities_theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
@@ -640,14 +670,27 @@ const Customizations = () => {
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
{formatName(de, false)} (
|
||||
<Link
|
||||
target="_blank"
|
||||
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
|
||||
}}
|
||||
>
|
||||
{de.id}
|
||||
</Link>
|
||||
)
|
||||
{formatName(de, false)} (
|
||||
<Link
|
||||
style={{
|
||||
color:
|
||||
de.v === undefined && !isCommand(de)
|
||||
? 'grey'
|
||||
: 'primary'
|
||||
}}
|
||||
target="_blank"
|
||||
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
|
||||
>
|
||||
{de.id}
|
||||
</Link>
|
||||
)
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
|
||||
@@ -672,7 +715,7 @@ const Customizations = () => {
|
||||
open={confirmReset}
|
||||
onClose={() => setConfirmReset(false)}
|
||||
>
|
||||
<DialogTitle>{LL.RESET(1)}</DialogTitle>
|
||||
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
@@ -689,7 +732,7 @@ const Customizations = () => {
|
||||
onClick={resetCustomization}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESET(0)}
|
||||
{LL.REMOVE_ALL()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -700,8 +743,9 @@ const Customizations = () => {
|
||||
{devices && renderDeviceList()}
|
||||
{selectedDevice !== -1 && !rename && renderDeviceData()}
|
||||
{restartNeeded ? (
|
||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="contained"
|
||||
color="error"
|
||||
@@ -719,7 +763,11 @@ const Customizations = () => {
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => devices && sendDeviceEntities(selectedDevice)}
|
||||
onClick={() => {
|
||||
if (devices) {
|
||||
void sendDeviceEntities(selectedDevice);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
@@ -734,18 +782,6 @@ const Customizations = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
{!rename && (
|
||||
<ButtonRow mt={1}>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmReset(true)}
|
||||
>
|
||||
{LL.RESET(0)}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{renderResetDialog()}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { 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';
|
||||
@@ -30,6 +30,23 @@ interface SettingsCustomizationsDialogProps {
|
||||
selectedItem: DeviceEntity;
|
||||
}
|
||||
|
||||
interface LabelValueProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
const LabelValue = memo(({ label, value }: LabelValueProps) => (
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{label}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Grid>
|
||||
));
|
||||
LabelValue.displayName = 'LabelValue';
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
|
||||
const CustomizationsDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -40,12 +57,23 @@ const CustomizationsDialog = ({
|
||||
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
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) {
|
||||
@@ -54,66 +82,59 @@ 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 &&
|
||||
editItem.ma &&
|
||||
editItem.mi > editItem?.ma
|
||||
editItem.mi > editItem.ma
|
||||
) {
|
||||
setError(true);
|
||||
} else {
|
||||
onSave(editItem);
|
||||
}
|
||||
};
|
||||
}, [isWriteableNumber, editItem, onSave]);
|
||||
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
setEditItem({ ...editItem, m: updatedItem.m });
|
||||
};
|
||||
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>
|
||||
<Grid container>
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{LL.ID_OF(LL.ENTITY())}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{editItem.id}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{editItem.n}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid container direction="row">
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{LL.WRITEABLE()}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{editItem.w ? (
|
||||
<DoneIcon color="success" sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<CloseIcon color="error" sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<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={writeableIcon} />
|
||||
|
||||
<Box mt={1} mb={2}>
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<TextField
|
||||
@@ -149,12 +170,14 @@ const CustomizationsDialog = ({
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{error && (
|
||||
<Typography variant="body2" color="error" mt={2}>
|
||||
Error: Check min and max values
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, 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';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconButton,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
} from './types';
|
||||
import { deviceValueItemValidation } from './validators';
|
||||
|
||||
const Dashboard = () => {
|
||||
const Dashboard = memo(() => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
@@ -81,7 +82,7 @@ const Dashboard = () => {
|
||||
if (!selectedDashboardItem) {
|
||||
return;
|
||||
}
|
||||
const id = selectedDashboardItem.id; // this is the parent ID
|
||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||
.then(() => {
|
||||
toast.success(LL.WRITE_CMD_SENT());
|
||||
@@ -132,7 +133,7 @@ const Dashboard = () => {
|
||||
);
|
||||
|
||||
const tree = useTree(
|
||||
{ nodes: data.nodes },
|
||||
{ nodes: [...data.nodes] },
|
||||
{
|
||||
onChange: () => {} // not used but needed
|
||||
},
|
||||
@@ -163,9 +164,14 @@ const Dashboard = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const nodeIds = useMemo(
|
||||
() => data.nodes.map((item: DashboardItem) => item.id),
|
||||
[data.nodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
showAll
|
||||
? tree.fns.onAddAll(data.nodes.map((item: DashboardItem) => item.id)) // expand tree
|
||||
? tree.fns.onAddAll(nodeIds) // expand tree
|
||||
: tree.fns.onRemoveAll(); // collapse tree
|
||||
}, [parentNodes]);
|
||||
|
||||
@@ -195,27 +201,32 @@ const Dashboard = () => {
|
||||
[LL]
|
||||
);
|
||||
|
||||
const showName = (di: DashboardItem) => {
|
||||
if (di.id < 100) {
|
||||
// if its a device (parent node) and has entities
|
||||
if (di.nodes?.length) {
|
||||
return (
|
||||
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>
|
||||
<DeviceIcon type_id={di.t ?? 0} />
|
||||
{showType(di.n, di.t)}
|
||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||
</span>
|
||||
);
|
||||
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;
|
||||
};
|
||||
if (di.dv) {
|
||||
return <span>{di.dv.id.slice(2)}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[showType]
|
||||
);
|
||||
|
||||
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 editDashboardValue = useCallback(
|
||||
(di: DashboardItem) => {
|
||||
@@ -237,6 +248,11 @@ const Dashboard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasFavEntities = useMemo(
|
||||
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
||||
[data.nodes]
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -244,14 +260,10 @@ const Dashboard = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const hasFavEntities = data.nodes.filter(
|
||||
(item: DashboardItem) => item.id <= 90
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data.connected && (
|
||||
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
)}
|
||||
|
||||
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
||||
@@ -271,105 +283,121 @@ const Dashboard = () => {
|
||||
</MessageBox>
|
||||
)}
|
||||
|
||||
{data.nodes.length > 0 && (
|
||||
<>
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
size="small"
|
||||
value={showAll}
|
||||
exclusive
|
||||
onChange={handleShowAll}
|
||||
>
|
||||
<ButtonTooltip title={LL.ALLVALUES()} arrow>
|
||||
<ToggleButton value={true}>
|
||||
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
|
||||
</ToggleButton>
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip title={LL.COMPACT()} arrow>
|
||||
<ToggleButton value={false}>
|
||||
<UnfoldLessIcon sx={{ fontSize: 18 }} />
|
||||
</ToggleButton>
|
||||
</ButtonTooltip>
|
||||
</ToggleButtonGroup>
|
||||
<ButtonTooltip title={LL.DASHBOARD_1()} arrow>
|
||||
<HelpOutlineIcon color="primary" sx={{ ml: 1, fontSize: 20 }} />
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
flexWrap="nowrap"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="primary"
|
||||
value={showAll}
|
||||
exclusive
|
||||
onChange={handleShowAll}
|
||||
>
|
||||
<ButtonTooltip title={LL.ALLVALUES()}>
|
||||
<ToggleButton value={true}>
|
||||
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
|
||||
</ToggleButton>
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip title={LL.COMPACT()}>
|
||||
<ToggleButton value={false}>
|
||||
<UnfoldLessIcon sx={{ fontSize: 18 }} />
|
||||
</ToggleButton>
|
||||
</ButtonTooltip>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
padding={1}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: '1px solid grey'
|
||||
{data.nodes.length > 0 ? (
|
||||
<Box mt={1} justifyContent="center" flexDirection="column">
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
>
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
<Table
|
||||
data={{ nodes: data.nodes }}
|
||||
theme={dashboard_theme}
|
||||
layout={{ custom: true }}
|
||||
tree={tree}
|
||||
>
|
||||
<Table
|
||||
data={{ nodes: data.nodes }}
|
||||
theme={dashboard_theme}
|
||||
layout={{ custom: true }}
|
||||
tree={tree}
|
||||
>
|
||||
{(tableList: DashboardItem[]) => (
|
||||
<Body>
|
||||
{tableList.map((di: DashboardItem) => (
|
||||
<Row
|
||||
key={di.id}
|
||||
item={di}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
{di.id > 99 ? (
|
||||
<>
|
||||
<Cell>{showName(di)}</Cell>
|
||||
<Cell>
|
||||
<ButtonTooltip
|
||||
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||
>
|
||||
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
|
||||
</ButtonTooltip>
|
||||
</Cell>
|
||||
{(tableList: DashboardItem[]) => (
|
||||
<Body>
|
||||
{tableList.map((di: DashboardItem) => (
|
||||
<Row
|
||||
key={di.id}
|
||||
item={di}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
{di.id > 99 ? (
|
||||
<>
|
||||
<Cell>{showName(di)}</Cell>
|
||||
<Cell>
|
||||
<ButtonTooltip
|
||||
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||
>
|
||||
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
|
||||
</ButtonTooltip>
|
||||
</Cell>
|
||||
|
||||
<Cell>
|
||||
{me.admin &&
|
||||
di.dv?.c &&
|
||||
!hasMask(
|
||||
di.dv.id,
|
||||
DeviceEntityMask.DV_READONLY
|
||||
) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
<EditIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Cell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CellTree item={di}>{showName(di)}</CellTree>
|
||||
<Cell />
|
||||
<Cell />
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
)}
|
||||
</Table>
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
</>
|
||||
<Cell>
|
||||
{me.admin &&
|
||||
di.dv?.c &&
|
||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={
|
||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
||||
}
|
||||
onClick={() => editDashboardValue(di)}
|
||||
>
|
||||
<EditIcon
|
||||
color="primary"
|
||||
sx={{ fontSize: 16 }}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Cell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CellTree item={di}>{showName(di)}</CellTree>
|
||||
<Cell />
|
||||
<Cell />
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
)}
|
||||
</Table>
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
) : (
|
||||
<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()}>
|
||||
<HelpOutlineIcon
|
||||
sx={{
|
||||
ml: 1,
|
||||
mt: 1,
|
||||
fontSize: 20,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -391,6 +419,6 @@ const Dashboard = () => {
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
|
||||
import { CgSmartHomeBoiler } from 'react-icons/cg';
|
||||
import { FaSolarPanel } from 'react-icons/fa';
|
||||
import { GiHeatHaze, GiTap } from 'react-icons/gi';
|
||||
import { MdPlaylistAdd } from 'react-icons/md';
|
||||
import { MdMoreTime } from 'react-icons/md';
|
||||
import {
|
||||
MdMoreTime,
|
||||
MdOutlineDevices,
|
||||
MdOutlinePool,
|
||||
MdOutlineSensors,
|
||||
MdPlaylistAdd,
|
||||
MdThermostatAuto
|
||||
} from 'react-icons/md';
|
||||
import { PiFan, PiGauge } from 'react-icons/pi';
|
||||
@@ -18,9 +19,10 @@ import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
import { DeviceType } from './types';
|
||||
|
||||
const deviceIconLookup: {
|
||||
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
|
||||
} = {
|
||||
const deviceIconLookup: Record<
|
||||
DeviceType,
|
||||
React.ComponentType<SvgIconProps> | null
|
||||
> = {
|
||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
||||
@@ -39,15 +41,19 @@ const deviceIconLookup: {
|
||||
[DeviceType.POOL]: MdOutlinePool,
|
||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||
[DeviceType.SYSTEM]: undefined,
|
||||
[DeviceType.SYSTEM]: null,
|
||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||
[DeviceType.VENTILATION]: PiFan
|
||||
};
|
||||
|
||||
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
|
||||
interface DeviceIconProps {
|
||||
type_id: DeviceType;
|
||||
}
|
||||
|
||||
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
|
||||
const Icon = deviceIconLookup[type_id];
|
||||
return Icon ? <Icon /> : null;
|
||||
};
|
||||
});
|
||||
|
||||
export default DeviceIcon;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { IconContext } from 'react-icons';
|
||||
@@ -75,7 +77,7 @@ import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
|
||||
import type { Device, DeviceValue } from './types';
|
||||
import { deviceValueItemValidation } from './validators';
|
||||
|
||||
const Devices = () => {
|
||||
const Devices = memo(() => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
@@ -91,7 +93,7 @@ const Devices = () => {
|
||||
|
||||
useLayoutTitle(LL.DEVICES());
|
||||
|
||||
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
|
||||
const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
|
||||
initialData: {
|
||||
connected: true,
|
||||
devices: []
|
||||
@@ -116,36 +118,36 @@ const Devices = () => {
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function updateSize() {
|
||||
setSize([window.innerWidth, window.innerHeight]);
|
||||
}
|
||||
let raf = 0;
|
||||
const updateSize = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
setSize([window.innerWidth, window.innerHeight]);
|
||||
});
|
||||
};
|
||||
window.addEventListener('resize', updateSize);
|
||||
updateSize();
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSize);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftOffset = () => {
|
||||
const leftOffset = useCallback(() => {
|
||||
const devicesWindow = document.getElementById('devices-window');
|
||||
if (!devicesWindow) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const clientRect = devicesWindow.getBoundingClientRect();
|
||||
const left = clientRect.left;
|
||||
const right = clientRect.right;
|
||||
|
||||
if (!left || !right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -153,7 +155,7 @@ const Devices = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
cursor: pointer;
|
||||
background-color: #1E1E1E;
|
||||
.td {
|
||||
@@ -163,30 +165,47 @@ const Devices = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const device_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
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: `
|
||||
HeaderRow: `
|
||||
.th {
|
||||
padding: 8px;
|
||||
`,
|
||||
Row: `
|
||||
font-weight: bold;
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
},
|
||||
`
|
||||
}
|
||||
]);
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
|
||||
const data_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
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%;
|
||||
@@ -195,12 +214,12 @@ const Devices = () => {
|
||||
display:none;
|
||||
}
|
||||
`,
|
||||
BaseRow: `
|
||||
BaseRow: `
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
BaseCell: `
|
||||
&:nth-of-type(1) {
|
||||
border-left: 1px solid #177ac9;
|
||||
},
|
||||
@@ -211,12 +230,12 @@ const Devices = () => {
|
||||
border-right: 1px solid #177ac9;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
HeaderRow: `
|
||||
.th {
|
||||
border-top: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
},
|
||||
@@ -224,8 +243,10 @@ const Devices = () => {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
}
|
||||
]);
|
||||
}
|
||||
]),
|
||||
[common_theme]
|
||||
);
|
||||
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
@@ -238,7 +259,7 @@ const Devices = () => {
|
||||
};
|
||||
|
||||
const dv_sort = useSort(
|
||||
{ nodes: deviceData.nodes },
|
||||
{ nodes: [...deviceData.nodes] },
|
||||
{},
|
||||
{
|
||||
sortIcon: {
|
||||
@@ -268,7 +289,7 @@ const Devices = () => {
|
||||
}
|
||||
|
||||
const device_select = useRowSelect(
|
||||
{ nodes: coreData.devices },
|
||||
{ nodes: [...coreData.devices] },
|
||||
{
|
||||
onChange: onSelectChange
|
||||
}
|
||||
@@ -324,8 +345,10 @@ const Devices = () => {
|
||||
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(
|
||||
@@ -510,55 +533,55 @@ const Devices = () => {
|
||||
|
||||
const renderCoreData = () => (
|
||||
<>
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
>
|
||||
{!coreData.connected && (
|
||||
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
)}
|
||||
|
||||
{coreData.connected && (
|
||||
<Table
|
||||
data={{ nodes: coreData.devices }}
|
||||
select={device_select}
|
||||
theme={device_theme}
|
||||
layout={{ custom: true }}
|
||||
{!coreData.connected ? (
|
||||
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
|
||||
) : (
|
||||
<Box justifyContent="center" flexDirection="column">
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
color: 'lightblue',
|
||||
size: '18',
|
||||
style: { verticalAlign: 'middle' }
|
||||
}}
|
||||
>
|
||||
{(tableList: Device[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.length === 0 && (
|
||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||
)}
|
||||
{tableList.map((device: Device) => (
|
||||
<Row key={device.id} item={device}>
|
||||
<Cell>
|
||||
<DeviceIcon type_id={device.t} />
|
||||
|
||||
{device.n}
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell stiff>{device.tn}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</IconContext.Provider>
|
||||
<Table
|
||||
data={{ nodes: [...coreData.devices] }}
|
||||
select={device_select}
|
||||
theme={device_theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: Device[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.length === 0 && (
|
||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||
)}
|
||||
{tableList.map((device: Device) => (
|
||||
<Row key={device.id} item={device}>
|
||||
<Cell>
|
||||
<DeviceIcon type_id={device.t} />
|
||||
|
||||
{device.n}
|
||||
<span style={{ color: 'lightblue' }}>
|
||||
({device.e})
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell stiff>{device.tn}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
</IconContext.Provider>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -574,35 +597,41 @@ const Devices = () => {
|
||||
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
|
||||
@@ -621,7 +650,7 @@ const Devices = () => {
|
||||
sx={{
|
||||
backgroundColor: 'black',
|
||||
position: 'absolute',
|
||||
left: () => leftOffset(),
|
||||
left: leftOffset,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: 64,
|
||||
@@ -638,7 +667,7 @@ const Devices = () => {
|
||||
</Typography>
|
||||
<Grid justifyContent="flex-end">
|
||||
<ButtonTooltip title={LL.CLOSE()}>
|
||||
<IconButton onClick={resetDeviceSelect}>
|
||||
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
||||
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ButtonTooltip>
|
||||
@@ -650,6 +679,7 @@ const Devices = () => {
|
||||
variant="outlined"
|
||||
sx={{ width: '22ch' }}
|
||||
placeholder={LL.SEARCH()}
|
||||
aria-label={LL.SEARCH()}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
@@ -664,19 +694,22 @@ const Devices = () => {
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
|
||||
<IconButton onClick={() => setShowDeviceInfo(true)}>
|
||||
<IconButton
|
||||
onClick={() => setShowDeviceInfo(true)}
|
||||
aria-label={LL.DEVICE_DETAILS()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ButtonTooltip>
|
||||
{me.admin && (
|
||||
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
|
||||
<IconButton onClick={customize}>
|
||||
<IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
|
||||
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
<ButtonTooltip title={LL.EXPORT()}>
|
||||
<IconButton onClick={handleDownloadCsv}>
|
||||
<IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
|
||||
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ButtonTooltip>
|
||||
@@ -711,7 +744,7 @@ const Devices = () => {
|
||||
</Box>
|
||||
|
||||
<Table
|
||||
data={{ nodes: shown_data }}
|
||||
data={{ nodes: Array.from(shown_data) }}
|
||||
theme={data_theme}
|
||||
sort={dv_sort}
|
||||
layout={{ custom: true, fixedHeader: true }}
|
||||
@@ -795,6 +828,6 @@ const Devices = () => {
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Devices;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
@@ -52,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) {
|
||||
@@ -61,11 +61,7 @@ const DevicesDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const close = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
@@ -73,46 +69,66 @@ const DevicesDialog = ({
|
||||
} catch (error) {
|
||||
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) =>
|
||||
dv.h ? (
|
||||
dv.h
|
||||
) : dv.l ? (
|
||||
dv.l.join(' | ')
|
||||
) : dv.m !== undefined && dv.x !== undefined ? (
|
||||
<>
|
||||
{dv.m} → {dv.x}
|
||||
</>
|
||||
) : undefined;
|
||||
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) {
|
||||
return (
|
||||
<>
|
||||
{dv.m} → {dv.x}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const isCommand = useMemo(
|
||||
() => selectedItem.v === '' && selectedItem.c,
|
||||
[selectedItem.v, selectedItem.c]
|
||||
);
|
||||
|
||||
const dialogTitle = useMemo(() => {
|
||||
if (isCommand) return LL.RUN_COMMAND();
|
||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||
}, [isCommand, writeable, LL]);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||
}, [isCommand, LL]);
|
||||
|
||||
const helperText = useMemo(
|
||||
() => showHelperText(editItem),
|
||||
[editItem, showHelperText]
|
||||
);
|
||||
|
||||
const valueLabel = LL.VALUE(0);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={close}>
|
||||
<DialogTitle>
|
||||
{selectedItem.v === '' && selectedItem.c
|
||||
? LL.RUN_COMMAND()
|
||||
: writeable
|
||||
? LL.CHANGE_VALUE()
|
||||
: LL.VALUE(0)}
|
||||
</DialogTitle>
|
||||
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
||||
</Box>
|
||||
<Grid container>
|
||||
@@ -120,8 +136,8 @@ const DevicesDialog = ({
|
||||
{editItem.l ? (
|
||||
<TextField
|
||||
name="v"
|
||||
// label={LL.VALUE(0)}
|
||||
value={editItem.v}
|
||||
aria-label={valueLabel}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
select
|
||||
@@ -137,7 +153,7 @@ const DevicesDialog = ({
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={LL.VALUE(0)}
|
||||
label={valueLabel}
|
||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||
autoFocus
|
||||
disabled={!writeable}
|
||||
@@ -161,7 +177,7 @@ const DevicesDialog = ({
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="v"
|
||||
label={LL.VALUE(0)}
|
||||
label={valueLabel}
|
||||
value={editItem.v}
|
||||
disabled={!writeable}
|
||||
sx={{ width: '30ch' }}
|
||||
@@ -170,9 +186,9 @@ const DevicesDialog = ({
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{writeable && (
|
||||
{writeable && helperText && (
|
||||
<Grid>
|
||||
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
|
||||
<FormHelperText>{helperText}</FormHelperText>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
@@ -191,7 +207,7 @@ const DevicesDialog = ({
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={close}
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
@@ -202,7 +218,7 @@ const DevicesDialog = ({
|
||||
onClick={save}
|
||||
color="primary"
|
||||
>
|
||||
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
{progress && (
|
||||
<CircularProgress
|
||||
@@ -217,7 +233,7 @@ const DevicesDialog = ({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={close} color="secondary">
|
||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||
{LL.CLOSE()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
import OptionIcon from './OptionIcon';
|
||||
@@ -9,92 +11,132 @@ interface EntityMaskToggleProps {
|
||||
de: DeviceEntity;
|
||||
}
|
||||
|
||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||
const getMaskNumber = (newMask: string[]) => {
|
||||
let new_mask = 0;
|
||||
for (const entry of newMask) {
|
||||
new_mask |= Number(entry);
|
||||
}
|
||||
return new_mask;
|
||||
};
|
||||
// Available mask values
|
||||
const MASK_VALUES = [
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||
DeviceEntityMask.DV_READONLY, // 4
|
||||
DeviceEntityMask.DV_FAVORITE, // 8
|
||||
DeviceEntityMask.DV_DELETED // 128
|
||||
];
|
||||
|
||||
const getMaskString = (m: number) => {
|
||||
const new_masks: string[] = [];
|
||||
if ((m & 1) === 1) {
|
||||
new_masks.push('1');
|
||||
}
|
||||
if ((m & 2) === 2) {
|
||||
new_masks.push('2');
|
||||
}
|
||||
if ((m & 4) === 4) {
|
||||
new_masks.push('4');
|
||||
}
|
||||
if ((m & 8) === 8) {
|
||||
new_masks.push('8');
|
||||
}
|
||||
if ((m & 128) === 128) {
|
||||
new_masks.push('128');
|
||||
}
|
||||
return new_masks;
|
||||
};
|
||||
/**
|
||||
* Converts an array of mask strings to a bitmask number
|
||||
*/
|
||||
const getMaskNumber = (newMask: string[]): number => {
|
||||
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = useCallback(
|
||||
(_event: unknown, mask: string[]) => {
|
||||
// Convert selected masks to a number
|
||||
const newMask = getMaskNumber(mask);
|
||||
const updatedDe = { ...de };
|
||||
|
||||
// Apply business logic for mask interactions
|
||||
// If entity has no name and is set to readonly, also exclude from web
|
||||
if (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;
|
||||
}
|
||||
|
||||
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)}
|
||||
onChange={(_event, mask: string[]) => {
|
||||
de.m = getMaskNumber(mask);
|
||||
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
|
||||
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
}
|
||||
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
|
||||
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||
}
|
||||
onUpdate(de);
|
||||
}}
|
||||
value={maskStringValue}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
|
||||
<OptionIcon
|
||||
type="favorite"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
|
||||
}
|
||||
/>
|
||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
|
||||
<OptionIcon
|
||||
type="readonly"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
|
||||
}
|
||||
/>
|
||||
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
|
||||
<OptionIcon
|
||||
type="api_mqtt_exclude"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
|
||||
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 || (de.m & 0x80) !== 0}>
|
||||
<OptionIcon
|
||||
type="web_exclude"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||
}
|
||||
/>
|
||||
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="128">
|
||||
<OptionIcon
|
||||
type="deleted"
|
||||
isSet={
|
||||
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
|
||||
}
|
||||
/>
|
||||
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { 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';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Stack,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import { useRequest } from 'alova/client';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
@@ -29,26 +31,62 @@ import { saveFile } from 'utils';
|
||||
import { API, callAction } from '../../api/app';
|
||||
import type { APIcall } from './types';
|
||||
|
||||
const Help = () => {
|
||||
interface HelpLink {
|
||||
href: string;
|
||||
icon: ReactElement;
|
||||
label: () => string;
|
||||
}
|
||||
|
||||
interface CustomSupport {
|
||||
img_url: string | null;
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
// Constants moved outside component to prevent recreation
|
||||
const DEFAULT_IMAGE_URL = 'https://docs.emsesp.org/_media/images/installer.jpeg';
|
||||
|
||||
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
|
||||
borderRadius: 3,
|
||||
border: '1px solid lightblue',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center'
|
||||
};
|
||||
|
||||
const IMAGE_STYLES: SxProps<Theme> = {
|
||||
maxHeight: { xs: 100, md: 250 }
|
||||
};
|
||||
|
||||
const AVATAR_STYLES: SxProps<Theme> = {
|
||||
bgcolor: '#72caf9'
|
||||
};
|
||||
|
||||
const HelpComponent = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.HELP());
|
||||
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
|
||||
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
const [customSupport, setCustomSupport] = useState<CustomSupport>({
|
||||
img_url: null,
|
||||
html: null
|
||||
});
|
||||
const [imgError, setImgError] = useState<boolean>(false);
|
||||
|
||||
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
||||
if (event && event.data && Object.keys(event.data).length !== 0) {
|
||||
const data = (event.data as { Support: { img_url?: string; html?: string[] } })
|
||||
.Support;
|
||||
if (data.img_url) {
|
||||
setCustomSupportIMG(data.img_url);
|
||||
}
|
||||
if (data.html) {
|
||||
setCustomSupportHTML(data.html.join('<br/>'));
|
||||
}
|
||||
// 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[] };
|
||||
};
|
||||
setCustomSupport({
|
||||
img_url: Support.img_url || null,
|
||||
html: Support.html?.join('<br/>') || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,90 +101,88 @@ const Help = () => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
|
||||
// Optimize API call memoization
|
||||
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
||||
|
||||
const handleDownloadSystemInfo = useCallback(() => {
|
||||
void sendAPI(apiCall);
|
||||
}, [sendAPI, apiCall]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImgError(true);
|
||||
}, []);
|
||||
|
||||
// Memoize help links to prevent recreation on every render
|
||||
const helpLinks: HelpLink[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
href: 'https://docs.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>
|
||||
{customSupportHTML && (
|
||||
{customSupport.html && (
|
||||
<Stack
|
||||
padding={1}
|
||||
mb={2}
|
||||
direction="row"
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '2px solid grey',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
sx={SUPPORT_BOX_STYLES}
|
||||
>
|
||||
<Typography variant="subtitle1">
|
||||
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
referrerPolicy="no-referrer"
|
||||
sx={{
|
||||
maxHeight: { xs: 100, md: 250 }
|
||||
}}
|
||||
onError={() => setNotFound(true)}
|
||||
src={
|
||||
notFound
|
||||
? ''
|
||||
: customSupportIMG ||
|
||||
'https://docs.emsesp.org/_media/images/installer.jpeg'
|
||||
}
|
||||
sx={IMAGE_STYLES}
|
||||
onError={handleImageError}
|
||||
src={imageSrc}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{me.admin && (
|
||||
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://docs.emsesp.org"
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||
<MenuBookIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.HELP_INFORMATION_1()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://discord.gg/3J3GgnzpyT"
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||
<CommentIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.HELP_INFORMATION_2()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
||||
<GitHubIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.HELP_INFORMATION_3()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{isAdmin && (
|
||||
<List>
|
||||
{helpLinks.map(({ href, icon, label }) => (
|
||||
<ListItem key={href}>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={href}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={label()} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
@@ -158,7 +194,7 @@ const Help = () => {
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
|
||||
onClick={handleDownloadSystemInfo}
|
||||
>
|
||||
{LL.SUPPORT_INFORMATION(0)}
|
||||
</Button>
|
||||
@@ -174,11 +210,14 @@ const Help = () => {
|
||||
href="https://emsesp.org"
|
||||
color="primary"
|
||||
>
|
||||
{'emsesp.org'}
|
||||
emsesp.org
|
||||
</Link>
|
||||
</Typography>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
const Help = memo(HelpComponent);
|
||||
|
||||
export default Help;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -31,6 +31,19 @@ import { readModules, writeModules } from '../../api/app';
|
||||
import ModulesDialog from './ModulesDialog';
|
||||
import type { ModuleItem } from './types';
|
||||
|
||||
const PENDING_COLOR = 'red';
|
||||
const ACTIVATED_COLOR = '#00FF7F';
|
||||
|
||||
const hasModulesChanged = (mi: ModuleItem): boolean =>
|
||||
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
|
||||
|
||||
const ColorStatus = memo(({ status }: { status: number }) => {
|
||||
if (status === 1) {
|
||||
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
|
||||
}
|
||||
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
|
||||
});
|
||||
|
||||
const Modules = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
@@ -56,105 +69,107 @@ 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 onDialogSave = (updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
};
|
||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||
const new_data = data.map((mi) =>
|
||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||
);
|
||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
||||
return new_data;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: ModuleItem) => {
|
||||
setDialogOpen(false);
|
||||
updateModuleItem(updatedItem);
|
||||
},
|
||||
[updateModuleItem]
|
||||
);
|
||||
|
||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
||||
setSelectedModuleItem(mi);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCancel = async () => {
|
||||
const onCancel = useCallback(async () => {
|
||||
await fetchModules().then(() => {
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
}, [fetchModules]);
|
||||
|
||||
function hasModulesChanged(mi: ModuleItem) {
|
||||
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
|
||||
}
|
||||
|
||||
const updateModuleItem = (updatedItem: ModuleItem) => {
|
||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||
const new_data = data.map((mi) =>
|
||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||
const saveModules = useCallback(async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
modules.map((condensed_mi: ModuleItem) =>
|
||||
updateModules({
|
||||
key: condensed_mi.key,
|
||||
enabled: condensed_mi.enabled,
|
||||
license: condensed_mi.license
|
||||
})
|
||||
)
|
||||
);
|
||||
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
|
||||
return new_data;
|
||||
});
|
||||
};
|
||||
toast.success(LL.MODULES_UPDATED());
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
await fetchModules();
|
||||
setNumChanges(0);
|
||||
}
|
||||
}, [modules, updateModules, LL, fetchModules]);
|
||||
|
||||
const saveModules = async () => {
|
||||
await Promise.all(
|
||||
modules.map((condensed_mi: ModuleItem) =>
|
||||
updateModules({
|
||||
key: condensed_mi.key,
|
||||
enabled: condensed_mi.enabled,
|
||||
license: condensed_mi.license
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(LL.MODULES_UPDATED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(async () => {
|
||||
await fetchModules();
|
||||
setNumChanges(0);
|
||||
});
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
const content = useMemo(() => {
|
||||
if (!modules) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||
@@ -169,13 +184,6 @@ const Modules = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const colorStatus = (status: number) => {
|
||||
if (status === 1) {
|
||||
return <div style={{ color: 'red' }}>Pending Activation</div>;
|
||||
}
|
||||
return <div style={{ color: '#00FF7F' }}>Activated</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={2} color="warning.main">
|
||||
@@ -218,7 +226,9 @@ const Modules = () => {
|
||||
<Cell>{mi.author}</Cell>
|
||||
<Cell>{mi.version}</Cell>
|
||||
<Cell>{mi.message}</Cell>
|
||||
<Cell>{colorStatus(mi.status)}</Cell>
|
||||
<Cell>
|
||||
<ColorStatus status={mi.status} />
|
||||
</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
@@ -252,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,25 +37,35 @@ const ModulesDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEditItem(selectedItem);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const close = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
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
|
||||
@@ -85,7 +95,7 @@ const ModulesDialog = ({
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={close}
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
@@ -93,7 +103,7 @@ const ModulesDialog = ({
|
||||
<Button
|
||||
startIcon={<DoneIcon />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
onClick={handleSave}
|
||||
color="primary"
|
||||
>
|
||||
{LL.UPDATE()}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
@@ -10,33 +12,39 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
|
||||
type OptionType =
|
||||
export type OptionType =
|
||||
| 'deleted'
|
||||
| 'readonly'
|
||||
| 'web_exclude'
|
||||
| 'api_mqtt_exclude'
|
||||
| 'favorite';
|
||||
|
||||
const OPTION_ICONS: {
|
||||
[type in OptionType]: [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
React.ComponentType<SvgIconProps>
|
||||
];
|
||||
} = {
|
||||
type IconPair = [
|
||||
React.ComponentType<SvgIconProps>,
|
||||
React.ComponentType<SvgIconProps>
|
||||
];
|
||||
|
||||
const OPTION_ICONS: Record<OptionType, IconPair> = {
|
||||
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
|
||||
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
|
||||
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
|
||||
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
|
||||
favorite: [StarIcon, StarOutlineIcon]
|
||||
} as const;
|
||||
|
||||
const ICON_SIZE = 16;
|
||||
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
|
||||
|
||||
export interface OptionIconProps {
|
||||
readonly type: OptionType;
|
||||
readonly isSet: boolean;
|
||||
}
|
||||
|
||||
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
|
||||
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
|
||||
const Icon = isSet ? SetIcon : UnsetIcon;
|
||||
|
||||
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
|
||||
};
|
||||
|
||||
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
|
||||
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
|
||||
return isSet ? (
|
||||
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
||||
) : (
|
||||
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionIcon;
|
||||
export default memo(OptionIcon);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBlocker } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -35,6 +35,76 @@ import { ScheduleFlag } from './types';
|
||||
import type { Schedule, ScheduleItem } from './types';
|
||||
import { schedulerItemValidation } from './validators';
|
||||
|
||||
// Constants
|
||||
const INTERVAL_DELAY = 30000; // 30 seconds
|
||||
const MIN_ID = -100;
|
||||
const MAX_ID = 100;
|
||||
const ICON_SIZE = 16;
|
||||
const SCHEDULE_FLAG_THRESHOLD = 127;
|
||||
const REFERENCE_YEAR = 2017;
|
||||
const REFERENCE_MONTH = '01';
|
||||
const LOG_2 = Math.log(2);
|
||||
|
||||
// Days of week starting from Monday (1-7)
|
||||
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: ScheduleFlag.SCHEDULE_DAY,
|
||||
time: '',
|
||||
cmd: '',
|
||||
value: '',
|
||||
name: ''
|
||||
};
|
||||
|
||||
const scheduleTheme = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
&: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-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
const scheduleTypeLabels: Record<number, string> = {
|
||||
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
|
||||
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
||||
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
||||
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
||||
};
|
||||
|
||||
const Scheduler = () => {
|
||||
const { LL, locale } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
@@ -61,7 +131,7 @@ const Scheduler = () => {
|
||||
}
|
||||
);
|
||||
|
||||
function hasScheduleChanged(si: ScheduleItem) {
|
||||
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
||||
return (
|
||||
si.id !== si.o_id ||
|
||||
(si.name || '') !== (si.o_name || '') ||
|
||||
@@ -72,91 +142,56 @@ const Scheduler = () => {
|
||||
si.cmd !== si.o_cmd ||
|
||||
si.value !== si.o_value
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useInterval(() => {
|
||||
const intervalCallback = useCallback(() => {
|
||||
if (numChanges === 0) {
|
||||
void fetchSchedule();
|
||||
}
|
||||
});
|
||||
}, [numChanges, fetchSchedule]);
|
||||
|
||||
useInterval(intervalCallback, INTERVAL_DELAY);
|
||||
|
||||
useEffect(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
|
||||
const dd = day < 10 ? `0${day}` : day;
|
||||
return new Date(`2017-01-${dd}T00:00:00+00:00`);
|
||||
const days = WEEK_DAYS.map((day) => {
|
||||
const dayStr = String(day).padStart(2, '0');
|
||||
return new Date(
|
||||
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
|
||||
);
|
||||
});
|
||||
setDow(days.map((date) => formatter.format(date)));
|
||||
}, [locale]);
|
||||
|
||||
const schedule_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: center;
|
||||
}
|
||||
&: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-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`
|
||||
});
|
||||
const schedule_theme = useTheme(scheduleTheme);
|
||||
|
||||
const saveSchedule = async () => {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.map((condensed_si: ScheduleItem) => ({
|
||||
id: condensed_si.id,
|
||||
active: condensed_si.active,
|
||||
flags: condensed_si.flags,
|
||||
time: condensed_si.time,
|
||||
cmd: condensed_si.cmd,
|
||||
value: condensed_si.value,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(LL.SCHEDULE_UPDATED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(async () => {
|
||||
await fetchSchedule();
|
||||
setNumChanges(0);
|
||||
const saveSchedule = useCallback(async () => {
|
||||
try {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.map((condensed_si: ScheduleItem) => ({
|
||||
id: condensed_si.id,
|
||||
active: condensed_si.active,
|
||||
flags: condensed_si.flags,
|
||||
time: condensed_si.time,
|
||||
cmd: condensed_si.cmd,
|
||||
value: condensed_si.value,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
});
|
||||
};
|
||||
toast.success(LL.SCHEDULE_UPDATED());
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
await fetchSchedule();
|
||||
setNumChanges(0);
|
||||
}
|
||||
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
||||
|
||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
||||
setCreating(false);
|
||||
@@ -167,95 +202,93 @@ const Scheduler = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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.filter((si) => creating || si.o_id !== updatedItem.o_id),
|
||||
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);
|
||||
setSelectedScheduleItem({
|
||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||
active: false,
|
||||
deleted: false,
|
||||
flags: ScheduleFlag.SCHEDULE_DAY,
|
||||
time: '',
|
||||
cmd: '',
|
||||
value: '',
|
||||
name: ''
|
||||
});
|
||||
const newItem: ScheduleItem = {
|
||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||
...DEFAULT_SCHEDULE_ITEM
|
||||
};
|
||||
setSelectedScheduleItem(newItem);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderSchedule = () => {
|
||||
const filteredAndSortedSchedule = useMemo(
|
||||
() =>
|
||||
schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
||||
[schedule]
|
||||
);
|
||||
|
||||
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>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[dow]
|
||||
);
|
||||
|
||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
||||
const label = scheduleTypeLabels[si.flags];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color="primary">
|
||||
{label || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderSchedule = useCallback(() => {
|
||||
if (!schedule) {
|
||||
return (
|
||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||
);
|
||||
}
|
||||
|
||||
const dayBox = (si: ScheduleItem, flag: number) => (
|
||||
<>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{ fontSize: 11 }}
|
||||
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[Math.log(flag) / Math.log(2)]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
</>
|
||||
);
|
||||
|
||||
const scheduleType = (si: ScheduleItem) => (
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 11 }} color="primary">
|
||||
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
|
||||
<>Immediate</>
|
||||
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
|
||||
<>Timer</>
|
||||
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
|
||||
<>Condition</>
|
||||
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
|
||||
<>On Change</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={{
|
||||
nodes: schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags)
|
||||
}}
|
||||
data={{ nodes: filteredAndSortedSchedule }}
|
||||
theme={schedule_theme}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
@@ -275,22 +308,15 @@ const Scheduler = () => {
|
||||
{tableList.map((si: ScheduleItem) => (
|
||||
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
|
||||
<Cell stiff>
|
||||
{si.active ? (
|
||||
<CircleIcon
|
||||
color="success"
|
||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
||||
/>
|
||||
) : (
|
||||
<CircleIcon
|
||||
color="error"
|
||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
||||
/>
|
||||
)}
|
||||
<CircleIcon
|
||||
color={si.active ? 'success' : 'error'}
|
||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell stiff>
|
||||
<Stack spacing={0.5} direction="row">
|
||||
<Divider orientation="vertical" flexItem />
|
||||
{si.flags > 127 ? (
|
||||
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
||||
scheduleType(si)
|
||||
) : (
|
||||
<>
|
||||
@@ -316,7 +342,17 @@ const Scheduler = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
schedule,
|
||||
error,
|
||||
fetchSchedule,
|
||||
filteredAndSortedSchedule,
|
||||
schedule_theme,
|
||||
editScheduleItem,
|
||||
LL,
|
||||
dayBox,
|
||||
scheduleType
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
@@ -338,7 +374,7 @@ const Scheduler = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={1} display="flex" flexWrap="wrap">
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
|
||||
@@ -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';
|
||||
@@ -31,6 +31,34 @@ import { validate } from 'validators';
|
||||
import { ScheduleFlag } from './types';
|
||||
import type { ScheduleItem } from './types';
|
||||
|
||||
// Constants
|
||||
const FLAG_MASK_127 = 127;
|
||||
const SCHEDULE_TYPE_THRESHOLD = 128;
|
||||
const DEFAULT_TIME = '00:00';
|
||||
const TYPOGRAPHY_FONT_SIZE = 10;
|
||||
|
||||
// Day of week flag configuration (static, defined outside component)
|
||||
const DAY_FLAGS = [
|
||||
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
|
||||
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
|
||||
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
|
||||
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
|
||||
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
|
||||
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
|
||||
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
|
||||
] as const;
|
||||
|
||||
// Day of week flag values array (static)
|
||||
const FLAG_VALUES = [
|
||||
ScheduleFlag.SCHEDULE_SUN,
|
||||
ScheduleFlag.SCHEDULE_MON,
|
||||
ScheduleFlag.SCHEDULE_TUE,
|
||||
ScheduleFlag.SCHEDULE_WED,
|
||||
ScheduleFlag.SCHEDULE_THU,
|
||||
ScheduleFlag.SCHEDULE_FRI,
|
||||
ScheduleFlag.SCHEDULE_SAT
|
||||
] as const;
|
||||
|
||||
interface SchedulerDialogProps {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
@@ -53,110 +81,163 @@ const SchedulerDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
||||
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
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...
|
||||
// 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 < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
|
||||
selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
|
||||
? ScheduleFlag.SCHEDULE_DAY
|
||||
: selectedItem.flags
|
||||
);
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
const saveandactivate = async () => {
|
||||
editItem.active = true;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(validator, editItem);
|
||||
onSave(editItem);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
editItem.deleted = true;
|
||||
onSave(editItem);
|
||||
};
|
||||
|
||||
const getFlagDOWnumber = (newFlag: string[]) => {
|
||||
let new_flag = 0;
|
||||
for (const entry of newFlag) {
|
||||
new_flag |= Number(entry);
|
||||
}
|
||||
return new_flag & 127;
|
||||
};
|
||||
|
||||
const getFlagDOWstring = (f: number) => {
|
||||
const new_flags: string[] = [];
|
||||
if ((f & 129) === 1) {
|
||||
new_flags.push('1');
|
||||
}
|
||||
if ((f & 130) === 2) {
|
||||
new_flags.push('2');
|
||||
}
|
||||
if ((f & 4) === 4) {
|
||||
new_flags.push('4');
|
||||
}
|
||||
if ((f & 8) === 8) {
|
||||
new_flags.push('8');
|
||||
}
|
||||
if ((f & 16) === 16) {
|
||||
new_flags.push('16');
|
||||
}
|
||||
if ((f & 32) === 32) {
|
||||
new_flags.push('32');
|
||||
}
|
||||
if ((f & 64) === 64) {
|
||||
new_flags.push('64');
|
||||
}
|
||||
|
||||
return new_flags;
|
||||
};
|
||||
|
||||
const showDOW = (si: ScheduleItem, flag: number) => (
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[Math.log(flag) / Math.log(2)]}
|
||||
</Typography>
|
||||
// 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 handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
const save = useCallback(async () => {
|
||||
await handleSave(editItem);
|
||||
}, [editItem, handleSave]);
|
||||
|
||||
const saveandactivate = useCallback(async () => {
|
||||
await handleSave({ ...editItem, active: true });
|
||||
}, [editItem, handleSave]);
|
||||
|
||||
const remove = useCallback(() => {
|
||||
onSave({ ...editItem, deleted: true });
|
||||
}, [editItem, onSave]);
|
||||
|
||||
// Optimize DOW flag conversion
|
||||
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
||||
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||
}, []);
|
||||
|
||||
const getFlagDOWstring = useCallback((f: number) => {
|
||||
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
||||
String(flag)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Day of week display component
|
||||
const DayOfWeekButton = useCallback(
|
||||
(flag: number) => {
|
||||
const dayIndex = Math.log2(flag);
|
||||
const isSelected = (editItem.flags & flag) === flag;
|
||||
return (
|
||||
<Typography
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isSelected ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[dayIndex]}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
[editItem.flags, dow]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||
if (reason !== 'backdropClick') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const handleScheduleTypeChange = useCallback(
|
||||
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
|
||||
if (flag !== null) {
|
||||
setFieldErrors(undefined); // clear any validation errors
|
||||
setScheduleType(flag);
|
||||
// wipe the time field when changing the schedule type
|
||||
// set the flags based on type
|
||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 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 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}>
|
||||
<DialogTitle>
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
||||
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
||||
{LL.SCHEDULE(1)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
@@ -166,47 +247,27 @@ const SchedulerDialog = ({
|
||||
value={scheduleType}
|
||||
exclusive
|
||||
disabled={!creating}
|
||||
onChange={(_event, flag: ScheduleFlag) => {
|
||||
if (flag !== null) {
|
||||
setFieldErrors(undefined); // clear any validation errors
|
||||
setScheduleType(flag);
|
||||
// wipe the time field when changing the schedule type
|
||||
setEditItem({ ...editItem, time: '' });
|
||||
// set the flags based on type
|
||||
// 0-127 is day schedule
|
||||
// 128 is timer
|
||||
// 129 is on change
|
||||
// 130 is on condition
|
||||
// 132 is immediate
|
||||
setEditItem(
|
||||
flag === ScheduleFlag.SCHEDULE_DAY
|
||||
? { ...editItem, flags: 0 }
|
||||
: { ...editItem, flags: flag }
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={handleScheduleTypeChange}
|
||||
>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isDaySchedule ? 'primary' : 'grey'}
|
||||
>
|
||||
{LL.SCHEDULE(0)}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
|
||||
}
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isTimerSchedule ? 'primary' : 'grey'}
|
||||
>
|
||||
{LL.TIMER(0)}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
||||
}
|
||||
@@ -216,7 +277,7 @@ const SchedulerDialog = ({
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
||||
}
|
||||
@@ -226,50 +287,30 @@ const SchedulerDialog = ({
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||
<Typography
|
||||
sx={{ fontSize: 10 }}
|
||||
color={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
|
||||
}
|
||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||
color={isImmediateSchedule ? 'primary' : 'grey'}
|
||||
>
|
||||
{LL.IMMEDIATE()}
|
||||
</Typography>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
|
||||
{isDaySchedule && (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={getFlagDOWstring(editItem.flags)}
|
||||
onChange={(_event, flag: string[]) => {
|
||||
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
|
||||
}}
|
||||
value={dowFlags}
|
||||
onChange={handleDOWChange}
|
||||
>
|
||||
<ToggleButton value="2">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="4">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="8">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="16">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="32">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="64">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="1">
|
||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
|
||||
</ToggleButton>
|
||||
{DAY_FLAGS.map(({ value, flag }) => (
|
||||
<ToggleButton key={value} value={value}>
|
||||
{DayOfWeekButton(flag)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
|
||||
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
|
||||
{!isImmediateSchedule && (
|
||||
<>
|
||||
<Grid container>
|
||||
<BlockFormControlLabel
|
||||
@@ -284,22 +325,17 @@ const SchedulerDialog = ({
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container>
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
|
||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
|
||||
{needsTimeField ? (
|
||||
<>
|
||||
<TextField
|
||||
name="time"
|
||||
type="time"
|
||||
label={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER
|
||||
? LL.TIMER(1)
|
||||
: LL.TIME(1)
|
||||
}
|
||||
value={editItem.time === '' ? '00:00' : editItem.time}
|
||||
label={timeFieldLabel}
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
|
||||
{isTimerSchedule && (
|
||||
<Box color="warning.main" ml={2} mt={4}>
|
||||
<Typography variant="body2">
|
||||
{LL.SCHEDULER_HELP_2()}
|
||||
@@ -310,16 +346,10 @@ const SchedulerDialog = ({
|
||||
) : (
|
||||
<TextField
|
||||
name="time"
|
||||
label={
|
||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
|
||||
? LL.CONDITION()
|
||||
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
|
||||
? LL.ONCHANGE()
|
||||
: LL.IMMEDIATE()
|
||||
}
|
||||
label={timeFieldLabel}
|
||||
multiline
|
||||
fullWidth
|
||||
value={editItem.time === '00:00' ? '' : editItem.time}
|
||||
value={timeFieldValue}
|
||||
margin="normal"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
@@ -386,7 +416,7 @@ const SchedulerDialog = ({
|
||||
>
|
||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||
</Button>
|
||||
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
|
||||
{isImmediateSchedule && editItem.cmd !== '' && (
|
||||
<Button
|
||||
startIcon={<PlayArrowIcon />}
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||
@@ -49,6 +49,74 @@ import {
|
||||
temperatureSensorItemValidation
|
||||
} from './validators';
|
||||
|
||||
// Constants
|
||||
const MS_PER_SECOND = 1000;
|
||||
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MIN_TEMP_ID = -100;
|
||||
const MAX_TEMP_ID = 100;
|
||||
const GPIO_25 = 25;
|
||||
const GPIO_26 = 26;
|
||||
|
||||
const HEADER_BUTTON_STYLE: React.CSSProperties = {
|
||||
fontSize: '14px',
|
||||
justifyContent: 'flex-start'
|
||||
};
|
||||
|
||||
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
|
||||
fontSize: '14px',
|
||||
justifyContent: 'flex-end'
|
||||
};
|
||||
|
||||
const common_theme = {
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
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 {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
color: white;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:last-of-type {
|
||||
text-align: right;
|
||||
},
|
||||
`
|
||||
};
|
||||
|
||||
const temperature_theme_config = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
||||
`
|
||||
};
|
||||
|
||||
const analog_theme_config = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
|
||||
`
|
||||
};
|
||||
|
||||
const Sensors = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
@@ -59,18 +127,22 @@ const Sensors = () => {
|
||||
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
|
||||
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
||||
const [creating, setCreating] = useState<boolean>(false);
|
||||
const firstAvailableGPIO = useRef<number>(undefined);
|
||||
|
||||
const { data: sensorData, send: fetchSensorData } = useRequest(
|
||||
() => readSensorData(),
|
||||
{
|
||||
initialData: {
|
||||
ts: [],
|
||||
as: [],
|
||||
analog_enabled: false,
|
||||
platform: 'ESP32'
|
||||
}
|
||||
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
|
||||
initialData: {
|
||||
ts: [],
|
||||
as: [],
|
||||
analog_enabled: false,
|
||||
available_gpios: [] as number[],
|
||||
platform: 'ESP32'
|
||||
}
|
||||
);
|
||||
}).onSuccess((event) => {
|
||||
// store the first available GPIO in a ref
|
||||
if (event.data.available_gpios.length > 0) {
|
||||
firstAvailableGPIO.current = event.data.available_gpios[0];
|
||||
}
|
||||
});
|
||||
|
||||
const { send: sendTemperatureSensor } = useRequest(
|
||||
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
|
||||
@@ -86,118 +158,18 @@ const Sensors = () => {
|
||||
}
|
||||
);
|
||||
|
||||
useInterval(() => {
|
||||
const intervalCallback = useCallback(() => {
|
||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||
void fetchSensorData();
|
||||
}
|
||||
});
|
||||
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
||||
|
||||
const common_theme = useTheme({
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
.th {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&:hover .td {
|
||||
background-color: #177ac9;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:last-of-type {
|
||||
text-align: right;
|
||||
},
|
||||
`
|
||||
});
|
||||
useInterval(intervalCallback);
|
||||
|
||||
const temperature_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
||||
`
|
||||
}
|
||||
]);
|
||||
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
||||
|
||||
const analog_theme = useTheme([
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
|
||||
`
|
||||
}
|
||||
]);
|
||||
|
||||
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={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||
onClick={() =>
|
||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||
}
|
||||
>
|
||||
{LL.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-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 key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
|
||||
<Cell>{ts.n}</Cell>
|
||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
|
||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
||||
if (state.sortKey === sortKey && state.reverse) {
|
||||
return <KeyboardArrowDownOutlinedIcon />;
|
||||
}
|
||||
@@ -205,7 +177,7 @@ const Sensors = () => {
|
||||
return <KeyboardArrowUpOutlinedIcon />;
|
||||
}
|
||||
return <UnfoldMoreOutlinedIcon />;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const analog_sort = useSort(
|
||||
{ nodes: sensorData.as },
|
||||
@@ -218,11 +190,20 @@ const Sensors = () => {
|
||||
},
|
||||
sortToggleType: SortToggleType.AlternateWithReset,
|
||||
sortFns: {
|
||||
GPIO: (array) => array.sort((a, b) => a.g - b.g),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
||||
TYPE: (array) => array.sort((a, b) => a.t - b.t),
|
||||
VALUE: (array) => array.sort((a, b) => a.v - b.v)
|
||||
GPIO: (array) =>
|
||||
[...array].sort(
|
||||
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
|
||||
),
|
||||
NAME: (array) =>
|
||||
[...array].sort((a, b) =>
|
||||
((a as AnalogSensor)?.n ?? '').localeCompare(
|
||||
(b as AnalogSensor)?.n ?? ''
|
||||
)
|
||||
),
|
||||
TYPE: (array) =>
|
||||
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
|
||||
VALUE: (array) =>
|
||||
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -238,227 +219,349 @@ const Sensors = () => {
|
||||
},
|
||||
sortToggleType: SortToggleType.AlternateWithReset,
|
||||
sortFns: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
||||
VALUE: (array) => array.sort((a, b) => a.t - b.t)
|
||||
NAME: (array) =>
|
||||
[...array].sort((a, b) =>
|
||||
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
|
||||
),
|
||||
VALUE: (array) =>
|
||||
[...array].sort(
|
||||
(a, b) =>
|
||||
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useLayoutTitle(LL.SENSORS());
|
||||
|
||||
const formatDurationMin = (duration_min: number) => {
|
||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 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;
|
||||
|
||||
let formatted = '';
|
||||
if (days) {
|
||||
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
||||
}
|
||||
if (hours) {
|
||||
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
|
||||
}
|
||||
if (minutes) {
|
||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
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]
|
||||
);
|
||||
|
||||
function 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 })
|
||||
.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('No available GPIO found');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setSelectedAnalogSensor({
|
||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
|
||||
n: '',
|
||||
g: 21, // default GPIO 21 which is safe for all platforms
|
||||
u: 0,
|
||||
g: firstAvailableGPIO.current,
|
||||
u: DeviceValueUOM.NONE,
|
||||
v: 0,
|
||||
o: 0,
|
||||
t: 0,
|
||||
f: 1,
|
||||
t: AnalogType.DIGITAL_IN, // default to digital in 1
|
||||
d: false,
|
||||
s: false,
|
||||
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
|
||||
})
|
||||
.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();
|
||||
});
|
||||
};
|
||||
.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 RenderAnalogSensors = () => (
|
||||
<Table
|
||||
data={{ nodes: sensorData.as }}
|
||||
theme={analog_theme}
|
||||
sort={analog_sort}
|
||||
layout={{ custom: true }}
|
||||
>
|
||||
{(tableList: AnalogSensor[]) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||
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)}
|
||||
>
|
||||
GPIO
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
<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.NAME(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff>
|
||||
<Button
|
||||
fullWidth
|
||||
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||
>
|
||||
{LL.VALUE(0)}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((a: AnalogSensor) => (
|
||||
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
|
||||
<Cell stiff>{a.g}</Cell>
|
||||
<Cell>{a.n}</Cell>
|
||||
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
|
||||
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
|
||||
a.t === AnalogType.DIGITAL_IN ||
|
||||
a.t === AnalogType.PULSE ? (
|
||||
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
|
||||
) : (
|
||||
<Cell stiff>{a.t ? formatValue(a.v, a.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 (
|
||||
<SectionContent>
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
|
||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||
{LL.TEMP_SENSORS()}
|
||||
</Typography>
|
||||
<RenderTemperatureSensors />
|
||||
{RenderTemperatureSensors}
|
||||
{selectedTemperatureSensor && (
|
||||
<DashboardSensorsTemperatureDialog
|
||||
open={temperatureDialogOpen}
|
||||
@@ -471,10 +574,10 @@ const Sensors = () => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
|
||||
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
|
||||
{LL.ANALOG_SENSORS()}
|
||||
</Typography>
|
||||
<RenderAnalogSensors />
|
||||
{RenderAnalogSensors}
|
||||
{selectedAnalogSensor && (
|
||||
<DashboardSensorsAnalogDialog
|
||||
open={analogDialogOpen}
|
||||
@@ -482,16 +585,12 @@ const Sensors = () => {
|
||||
onSave={onAnalogDialogSave}
|
||||
creating={creating}
|
||||
selectedItem={selectedAnalogSensor}
|
||||
validator={analogSensorItemValidation(
|
||||
sensorData.as,
|
||||
selectedAnalogSensor,
|
||||
creating,
|
||||
sensorData.platform
|
||||
)}
|
||||
analogGPIOList={sensorData.available_gpios}
|
||||
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
|
||||
/>
|
||||
)}
|
||||
{sensorData?.analog_enabled === true && me.admin && (
|
||||
<Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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/RemoveCircleOutline';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Grid,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
@@ -34,6 +34,7 @@ interface DashboardSensorsAnalogDialogProps {
|
||||
onSave: (as: AnalogSensor) => void;
|
||||
creating: boolean;
|
||||
selectedItem: AnalogSensor;
|
||||
analogGPIOList: number[];
|
||||
validator: Schema;
|
||||
}
|
||||
|
||||
@@ -43,13 +44,95 @@ const SensorsAnalogDialog = ({
|
||||
onSave,
|
||||
creating,
|
||||
selectedItem,
|
||||
analogGPIOList,
|
||||
validator
|
||||
}: DashboardSensorsAnalogDialogProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue((updater) =>
|
||||
setEditItem(
|
||||
(prev) =>
|
||||
updater(
|
||||
prev as unknown as Record<string, unknown>
|
||||
) as unknown as AnalogSensor
|
||||
)
|
||||
),
|
||||
[setEditItem]
|
||||
);
|
||||
|
||||
// Memoize helper functions to check sensor type conditions
|
||||
const isCounterOrRate = useMemo(
|
||||
() => editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE,
|
||||
[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 isDigitalOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26),
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
const isDigitalOutNonGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
editItem.g !== 25 &&
|
||||
editItem.g !== 26,
|
||||
[editItem.t, editItem.g]
|
||||
);
|
||||
|
||||
// Memoize menu items to avoid recreation on each render
|
||||
const analogTypeMenuItems = useMemo(
|
||||
() =>
|
||||
AnalogTypeNames.map((val, i) => (
|
||||
<MenuItem key={val} value={i + 1}>
|
||||
{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
|
||||
[
|
||||
...(analogGPIOList?.includes(selectedItem.g) || selectedItem.g === undefined
|
||||
? analogGPIOList
|
||||
: [selectedItem.g, ...analogGPIOList])
|
||||
]
|
||||
.filter((gpio, idx, arr) => arr.indexOf(gpio) === idx)
|
||||
.sort((a, b) => a - b)
|
||||
.map((gpio: number) => {
|
||||
return (
|
||||
<MenuItem key={gpio} value={gpio}>
|
||||
{gpio}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
// Reset form when dialog opens or selectedItem changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFieldErrors(undefined);
|
||||
@@ -57,16 +140,16 @@ 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);
|
||||
@@ -74,97 +157,81 @@ const SensorsAnalogDialog = ({
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
}, [validator, editItem, onSave]);
|
||||
|
||||
const remove = () => {
|
||||
editItem.d = true;
|
||||
onSave(editItem);
|
||||
};
|
||||
const remove = useCallback(() => {
|
||||
onSave({ ...editItem, d: true });
|
||||
}, [editItem, onSave]);
|
||||
|
||||
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}>
|
||||
<DialogTitle>
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
||||
{LL.ANALOG_SENSOR(0)}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<ValidatedTextField
|
||||
name="g"
|
||||
label="GPIO"
|
||||
value={editItem.g}
|
||||
sx={{ width: '9ch' }}
|
||||
disabled={editItem.s || !creating}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
{analogGPIOMenuItems()}
|
||||
</ValidatedTextField>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="g"
|
||||
label="GPIO"
|
||||
sx={{ width: '11ch' }}
|
||||
value={numberValue(editItem.g)}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</Grid>
|
||||
{creating && (
|
||||
<Grid>
|
||||
<Box color="warning.main" mt={2}>
|
||||
<Typography variant="body2">{LL.WARN_GPIO()}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="n"
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.n}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="t"
|
||||
label={LL.TYPE(0)}
|
||||
value={editItem.t}
|
||||
fullWidth
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
{AnalogTypeNames.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{analogTypeMenuItems}
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
{((editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE) ||
|
||||
(editItem.t >= AnalogType.FREQ_0 &&
|
||||
editItem.t <= AnalogType.FREQ_2)) && (
|
||||
{(isCounterOrRate || isFreqType) && (
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="u"
|
||||
label={LL.UNIT()}
|
||||
value={editItem.u}
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
{DeviceValueUOM_s.map((val, i) => (
|
||||
<MenuItem key={val} value={i}>
|
||||
{val}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{uomMenuItems}
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.ADC && (
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.OFFSET()}
|
||||
value={numberValue(editItem.o)}
|
||||
type="number"
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -178,14 +245,14 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
{editItem.t === AnalogType.NTC && (
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.OFFSET()}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -199,14 +266,14 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
{editItem.t === AnalogType.COUNTER && (
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.STARTVALUE()}
|
||||
value={numberValue(editItem.o)}
|
||||
type="number"
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
htmlInput: { step: '0.001' }
|
||||
}}
|
||||
@@ -215,113 +282,110 @@ const SensorsAnalogDialog = ({
|
||||
)}
|
||||
{editItem.t === AnalogType.RGB && (
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={'RGB ' + LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
type="number"
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
|
||||
{isCounterOrRate && (
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="f"
|
||||
label={LL.FACTOR()}
|
||||
value={numberValue(editItem.f)}
|
||||
sx={{ width: '11ch' }}
|
||||
sx={{ width: '14ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
htmlInput: { step: '0.001' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26) && (
|
||||
{isDigitalOutGPIO && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
htmlInput: { min: '0', max: '255', step: '1' }
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{isDigitalOutNonGPIO && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
sx={{ width: '11ch' }}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
slotProps={{
|
||||
htmlInput: { min: '0', max: '255', step: '1' }
|
||||
}}
|
||||
/>
|
||||
disabled={editItem.s}
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
)}
|
||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
editItem.g !== 25 &&
|
||||
editItem.g !== 26 && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="o"
|
||||
label={LL.VALUE(0)}
|
||||
value={numberValue(editItem.o)}
|
||||
select
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="f"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.f}
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="u"
|
||||
label={LL.STARTVALUE()}
|
||||
sx={{ width: '15ch' }}
|
||||
value={editItem.u}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
>
|
||||
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
||||
<MenuItem value={1}>
|
||||
{LL.ALWAYS()} {LL.OFF()}
|
||||
</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.ALWAYS()} {LL.ON()}
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{(editItem.t === AnalogType.PWM_0 ||
|
||||
editItem.t === AnalogType.PWM_1 ||
|
||||
editItem.t === AnalogType.PWM_2) && (
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
name="f"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.f}
|
||||
sx={{ width: '15ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
name="u"
|
||||
label={LL.STARTVALUE()}
|
||||
sx={{ width: '15ch' }}
|
||||
value={editItem.u}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
||||
<MenuItem value={1}>
|
||||
{LL.ALWAYS()} {LL.OFF()}
|
||||
</MenuItem>
|
||||
<MenuItem value={2}>
|
||||
{LL.ALWAYS()} {LL.ON()}
|
||||
</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{isPWM && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="f"
|
||||
label={LL.FREQ()}
|
||||
value={numberValue(editItem.f)}
|
||||
type="number"
|
||||
variant="outlined"
|
||||
sx={{ width: '11ch' }}
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -333,14 +397,14 @@ const SensorsAnalogDialog = ({
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.DUTY_CYCLE()}
|
||||
value={numberValue(editItem.o)}
|
||||
type="number"
|
||||
sx={{ width: '11ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -356,27 +420,28 @@ const SensorsAnalogDialog = ({
|
||||
{editItem.t === AnalogType.PULSE && (
|
||||
<>
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label={LL.POLARITY()}
|
||||
value={editItem.o}
|
||||
sx={{ width: '11ch' }}
|
||||
select
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
>
|
||||
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
|
||||
</TextField>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
<ValidatedTextField
|
||||
name="f"
|
||||
label="Pulse"
|
||||
value={numberValue(editItem.f)}
|
||||
type="number"
|
||||
sx={{ width: '15ch' }}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
disabled={editItem.s}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -390,12 +455,43 @@ const SensorsAnalogDialog = ({
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
|
||||
<Box mt={1}>
|
||||
{Object.values(fieldErrors).map((errArr, idx) =>
|
||||
Array.isArray(errArr)
|
||||
? errArr.map((err, j) => (
|
||||
<Typography
|
||||
key={`${idx}-${j}`}
|
||||
color="error"
|
||||
variant="caption"
|
||||
display="block"
|
||||
>
|
||||
{err.message}
|
||||
</Typography>
|
||||
))
|
||||
: null
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
color="warning"
|
||||
/>
|
||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!creating && (
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
||||
<Button
|
||||
startIcon={<RemoveIcon />}
|
||||
disabled={editItem.s}
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={remove}
|
||||
@@ -413,7 +509,7 @@ const SensorsAnalogDialog = ({
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
startIcon={<DoneIcon />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -33,6 +34,12 @@ interface SensorsTemperatureDialogProps {
|
||||
validator: Schema;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const OFFSET_MIN = -5;
|
||||
const OFFSET_MAX = 5;
|
||||
const OFFSET_STEP = 0.1;
|
||||
const TEMP_UNIT = '°C';
|
||||
|
||||
const SensorsTemperatureDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -43,7 +50,18 @@ const SensorsTemperatureDialog = ({
|
||||
const { LL } = useI18nContext();
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
||||
const updateFormValue = updateValue(setEditItem);
|
||||
|
||||
const updateFormValue = useMemo(
|
||||
() =>
|
||||
updateValue(
|
||||
setEditItem as unknown as (
|
||||
updater: (
|
||||
prevState: Readonly<Record<string, unknown>>
|
||||
) => Record<string, unknown>
|
||||
) => void
|
||||
),
|
||||
[setEditItem]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -52,16 +70,16 @@ const SensorsTemperatureDialog = ({
|
||||
}
|
||||
}, [open, selectedItem]);
|
||||
|
||||
const handleClose = (
|
||||
_event: React.SyntheticEvent,
|
||||
reason: 'backdropClick' | 'escapeKeyDown'
|
||||
) => {
|
||||
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);
|
||||
@@ -69,15 +87,31 @@ const SensorsTemperatureDialog = ({
|
||||
} catch (error) {
|
||||
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>
|
||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
||||
<Box color="warning.main" mb={2}>
|
||||
<Typography variant="body2">
|
||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||
</Typography>
|
||||
@@ -85,7 +119,7 @@ const SensorsTemperatureDialog = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors ?? {}}
|
||||
name="n"
|
||||
label={LL.NAME(0)}
|
||||
value={editItem.n}
|
||||
@@ -97,22 +131,27 @@ 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">°C</InputAdornment>
|
||||
)
|
||||
},
|
||||
htmlInput: { min: '-5', max: '5', step: '0.1' }
|
||||
}}
|
||||
slotProps={slotProps}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{editItem.s && (
|
||||
<Grid>
|
||||
<Typography mt={1} color="warning.main" variant="body2">
|
||||
<WarningIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||
color="warning"
|
||||
/>
|
||||
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
@@ -124,7 +163,7 @@ const SensorsTemperatureDialog = ({
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
startIcon={<DoneIcon />}
|
||||
variant="outlined"
|
||||
onClick={save}
|
||||
color="primary"
|
||||
|
||||
65
interface/src/app/main/UserProfile.tsx
Normal file
65
interface/src/app/main/UserProfile.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import { AuthenticatedContext } from '@/contexts/authentication';
|
||||
import { SectionContent, useLayoutTitle } from 'components';
|
||||
import { LanguageSelector } from 'components/inputs';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const UserProfileComponent = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const { me, signOut } = useContext(AuthenticatedContext);
|
||||
|
||||
useLayoutTitle(LL.USER_PROFILE());
|
||||
|
||||
const handleSignOut = useCallback(() => {
|
||||
signOut(true);
|
||||
}, [signOut]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List sx={{ flexGrow: 1 }}>
|
||||
<ListItem disablePadding>
|
||||
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
<ListItemText
|
||||
sx={{ pl: 2, color: '#2196f3' }}
|
||||
primary={me.username}
|
||||
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Box mt={2} mb={2} display="flex" alignItems="center">
|
||||
<Typography mr={2} variant="body1" align="center">
|
||||
{LL.LANGUAGE()}:
|
||||
</Typography>
|
||||
<LanguageSelector />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
{LL.SIGN_OUT()}
|
||||
</Button>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
const UserProfile = memo(UserProfileComponent);
|
||||
|
||||
export default UserProfile;
|
||||
@@ -2,27 +2,30 @@ import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||
|
||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||
|
||||
// Cache NumberFormat instances for better performance
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
});
|
||||
|
||||
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
|
||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
||||
const totalMs = duration_min * 60000;
|
||||
const days = Math.trunc(totalMs / 86400000);
|
||||
const hours = Math.trunc(totalMs / 3600000) % 24;
|
||||
const minutes = Math.trunc(duration_min) % 60;
|
||||
|
||||
let formatted = '';
|
||||
const parts: string[] = [];
|
||||
if (days) {
|
||||
formatted += LL.NUM_DAYS({ num: days });
|
||||
parts.push(LL.NUM_DAYS({ num: days }));
|
||||
}
|
||||
|
||||
if (hours) {
|
||||
if (formatted) formatted += ' ';
|
||||
formatted += LL.NUM_HOURS({ num: hours });
|
||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
}
|
||||
|
||||
if (minutes) {
|
||||
if (formatted) formatted += ' ';
|
||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
}
|
||||
|
||||
return formatted;
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
export function formatValue(
|
||||
@@ -30,18 +33,21 @@ export function formatValue(
|
||||
value?: unknown,
|
||||
uom?: DeviceValueUOM
|
||||
) {
|
||||
// Handle non-numeric values or missing data
|
||||
if (typeof value !== 'number' || uom === undefined || value === undefined) {
|
||||
if (value === undefined || typeof value === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
// Type assertion is safe here since we know it's not a number, boolean, or undefined
|
||||
return (
|
||||
(value as string) +
|
||||
(value === '' || uom === undefined || uom === 0
|
||||
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
|
||||
? ''
|
||||
: ' ' + DeviceValueUOM_s[uom])
|
||||
);
|
||||
}
|
||||
|
||||
// Handle numeric values
|
||||
switch (uom) {
|
||||
case DeviceValueUOM.HOURS:
|
||||
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||
@@ -50,18 +56,12 @@ export function formatValue(
|
||||
case DeviceValueUOM.SECONDS:
|
||||
return LL.NUM_SECONDS({ num: value });
|
||||
case DeviceValueUOM.NONE:
|
||||
return new Intl.NumberFormat().format(value);
|
||||
return numberFormatter.format(value);
|
||||
case DeviceValueUOM.DEGREES:
|
||||
case DeviceValueUOM.DEGREES_R:
|
||||
case DeviceValueUOM.FAHRENHEIT:
|
||||
return (
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 1
|
||||
}).format(value) +
|
||||
' ' +
|
||||
DeviceValueUOM_s[uom]
|
||||
);
|
||||
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
default:
|
||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface Stat {
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
stats: Stat[];
|
||||
readonly stats: readonly Stat[];
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
@@ -82,38 +82,42 @@ export interface TemperatureSensor {
|
||||
t?: number; // temp, optional
|
||||
o: number; // offset
|
||||
u: number; // uom
|
||||
s: boolean; // system sensor flag
|
||||
o_n?: string;
|
||||
}
|
||||
|
||||
export interface AnalogSensor {
|
||||
id: number;
|
||||
g: number; // GPIO
|
||||
n: string;
|
||||
v: number;
|
||||
u: number;
|
||||
o: number;
|
||||
f: number;
|
||||
t: number;
|
||||
n: string; // name
|
||||
v: number; // value
|
||||
u: number; // uom
|
||||
o: number; // offset
|
||||
f: number; // factor
|
||||
t: number; // type
|
||||
d: boolean; // deleted flag
|
||||
o_n?: string;
|
||||
s: boolean; // system sensor flag
|
||||
o_n?: string; // original name
|
||||
}
|
||||
|
||||
export interface WriteTemperatureSensor {
|
||||
id: string;
|
||||
name: string;
|
||||
offset: number;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface SensorData {
|
||||
ts: TemperatureSensor[];
|
||||
as: AnalogSensor[];
|
||||
analog_enabled: boolean;
|
||||
available_gpios: number[];
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface CoreData {
|
||||
connected: boolean;
|
||||
devices: Device[];
|
||||
readonly connected: boolean;
|
||||
readonly devices: readonly Device[];
|
||||
}
|
||||
|
||||
export interface DashboardItem {
|
||||
@@ -122,11 +126,12 @@ export interface DashboardItem {
|
||||
n?: string; // name, optional
|
||||
dv?: DeviceValue; // device value, optional
|
||||
nodes?: DashboardItem[]; // children nodes, optional
|
||||
parentNode: DashboardItem; // to stop lint errors
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
connected: boolean; // true if connected to EMS bus
|
||||
nodes: DashboardItem[];
|
||||
readonly connected: boolean; // true if connected to EMS bus
|
||||
readonly nodes: readonly DashboardItem[];
|
||||
}
|
||||
|
||||
export interface DeviceValue {
|
||||
@@ -139,10 +144,11 @@ export interface DeviceValue {
|
||||
s?: string; // steps for up/down, optional
|
||||
m?: number; // min, optional
|
||||
x?: number; // max, optional
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DeviceData {
|
||||
nodes: DeviceValue[];
|
||||
readonly nodes: readonly DeviceValue[];
|
||||
}
|
||||
|
||||
export interface DeviceEntity {
|
||||
@@ -195,7 +201,7 @@ export enum DeviceValueUOM {
|
||||
export const DeviceValueUOM_s = [
|
||||
'',
|
||||
'°C',
|
||||
'°C',
|
||||
'°C Rel',
|
||||
'%',
|
||||
'l/min',
|
||||
'kWh',
|
||||
@@ -221,11 +227,10 @@ export const DeviceValueUOM_s = [
|
||||
'l/h',
|
||||
'ct/kWh',
|
||||
'Hz'
|
||||
];
|
||||
] as const;
|
||||
|
||||
export enum AnalogType {
|
||||
REMOVED = -1,
|
||||
NOTUSED = 0,
|
||||
DIGITAL_IN = 1,
|
||||
COUNTER = 2,
|
||||
ADC = 3,
|
||||
@@ -244,27 +249,24 @@ export enum AnalogType {
|
||||
}
|
||||
|
||||
export const AnalogTypeNames = [
|
||||
'(disabled)',
|
||||
'Digital In',
|
||||
'Counter',
|
||||
'ADC In',
|
||||
'Timer',
|
||||
'Rate',
|
||||
'Digital Out',
|
||||
'PWM 0',
|
||||
'PWM 1',
|
||||
'PWM 2',
|
||||
'NTC Temp.',
|
||||
'RGB Led',
|
||||
'Pulse',
|
||||
'Freq 0',
|
||||
'Freq 1',
|
||||
'Freq 2'
|
||||
];
|
||||
'Digital In', // 1
|
||||
'Counter', // 2
|
||||
'ADC In', // 3
|
||||
'Timer', // 4
|
||||
'Rate', // 5
|
||||
'Digital Out', // 6
|
||||
'PWM 0', // 7
|
||||
'PWM 1', // 8
|
||||
'PWM 2', // 9
|
||||
'NTC Temp.', // 10
|
||||
'RGB Led', // 11
|
||||
'Pulse', // 12
|
||||
'Freq 0', // 13
|
||||
'Freq 1', // 14
|
||||
'Freq 2' // 15
|
||||
] as const;
|
||||
|
||||
type BoardProfiles = Record<string, string>;
|
||||
|
||||
export const BOARD_PROFILES: BoardProfiles = {
|
||||
export const BOARD_PROFILES = {
|
||||
S32: 'BBQKees Gateway S32',
|
||||
S32S3: 'BBQKees Gateway S3',
|
||||
E32: 'BBQKees Gateway E32',
|
||||
@@ -278,7 +280,9 @@ export const BOARD_PROFILES: BoardProfiles = {
|
||||
C3MINI: 'Wemos C3 Mini',
|
||||
S2MINI: 'Wemos S2 Mini',
|
||||
S3MINI: 'Liligo S3'
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
|
||||
|
||||
export interface BoardProfile {
|
||||
board_profile: string;
|
||||
@@ -315,6 +319,7 @@ export interface WriteAnalogSensor {
|
||||
uom: number;
|
||||
type: number;
|
||||
deleted: boolean;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export enum DeviceEntityMask {
|
||||
@@ -346,7 +351,7 @@ export interface ScheduleItem {
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
schedule: ScheduleItem[];
|
||||
readonly schedule: readonly ScheduleItem[];
|
||||
}
|
||||
|
||||
export interface ModuleItem {
|
||||
@@ -364,7 +369,7 @@ export interface ModuleItem {
|
||||
}
|
||||
|
||||
export interface Modules {
|
||||
modules: ModuleItem[];
|
||||
readonly modules: readonly ModuleItem[];
|
||||
}
|
||||
|
||||
export enum ScheduleFlag {
|
||||
@@ -413,7 +418,7 @@ export interface EntityItem {
|
||||
}
|
||||
|
||||
export interface Entities {
|
||||
entities: EntityItem[];
|
||||
readonly entities: readonly EntityItem[];
|
||||
}
|
||||
|
||||
// matches emsdevice.h DeviceType
|
||||
@@ -469,4 +474,4 @@ export const DeviceValueTypeNames = [
|
||||
'ENUM',
|
||||
'RAW',
|
||||
'CMD'
|
||||
];
|
||||
] as const;
|
||||
|
||||
@@ -11,273 +11,158 @@ import type {
|
||||
TemperatureSensor
|
||||
} from './types';
|
||||
|
||||
export const GPIO_VALIDATOR = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
value &&
|
||||
(value === 1 ||
|
||||
(value >= 6 && value <= 11) ||
|
||||
value === 20 ||
|
||||
value === 24 ||
|
||||
(value >= 28 && value <= 31) ||
|
||||
value > 40 ||
|
||||
value < 0)
|
||||
) {
|
||||
callback('Must be an valid GPIO port');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
// Constants
|
||||
const ERROR_MESSAGES = {
|
||||
GPIO_INVALID: 'Must be an valid GPIO port',
|
||||
NAME_DUPLICATE: 'Name already in use',
|
||||
GPIO_DUPLICATE: 'GPIO already in use',
|
||||
VALUE_OUT_OF_RANGE: 'Value out of range',
|
||||
HEX_REQUIRED: 'Is required and must be in hex format'
|
||||
} as const;
|
||||
|
||||
const VALIDATION_LIMITS = {
|
||||
PORT_MIN: 0,
|
||||
PORT_MAX: 65535,
|
||||
MODBUS_MAX_CLIENTS_MIN: 0,
|
||||
MODBUS_MAX_CLIENTS_MAX: 50,
|
||||
MODBUS_TIMEOUT_MIN: 100,
|
||||
MODBUS_TIMEOUT_MAX: 20000,
|
||||
SYSLOG_MARK_INTERVAL_MIN: 0,
|
||||
SYSLOG_MARK_INTERVAL_MAX: 3600,
|
||||
SHOWER_MIN_DURATION_MIN: 10,
|
||||
SHOWER_MIN_DURATION_MAX: 360,
|
||||
SHOWER_ALERT_TRIGGER_MIN: 1,
|
||||
SHOWER_ALERT_TRIGGER_MAX: 20,
|
||||
SHOWER_ALERT_COLDSHOT_MIN: 1,
|
||||
SHOWER_ALERT_COLDSHOT_MAX: 10,
|
||||
REMOTE_TIMEOUT_MIN: 1,
|
||||
REMOTE_TIMEOUT_MAX: 240,
|
||||
OFFSET_MIN: 0,
|
||||
OFFSET_MAX: 255,
|
||||
COMMAND_MIN: 1,
|
||||
COMMAND_MAX: 300,
|
||||
NAME_MAX_LENGTH: 19,
|
||||
HEX_BASE: 16
|
||||
} as const;
|
||||
|
||||
type ValidationRules = Array<{
|
||||
required?: boolean;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
|
||||
export const createSettingsValidator = (settings: Settings) => {
|
||||
const schema: Record<string, ValidationRules> = {};
|
||||
|
||||
// Syslog validations
|
||||
if (settings.syslog_enabled) {
|
||||
schema.syslog_host = [
|
||||
{ required: true, message: 'Host is required' },
|
||||
IP_OR_HOSTNAME_VALIDATOR
|
||||
];
|
||||
schema.syslog_port = [
|
||||
{ required: true, message: 'Port is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.PORT_MIN,
|
||||
max: VALIDATION_LIMITS.PORT_MAX,
|
||||
message: 'Invalid Port'
|
||||
}
|
||||
];
|
||||
schema.syslog_mark_interval = [
|
||||
{ required: true, message: 'Mark interval is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
|
||||
max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
|
||||
message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Modbus validations
|
||||
if (settings.modbus_enabled) {
|
||||
schema.modbus_max_clients = [
|
||||
{ required: true, message: 'Max clients is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
|
||||
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
|
||||
message: 'Invalid number'
|
||||
}
|
||||
];
|
||||
schema.modbus_port = [
|
||||
{ required: true, message: 'Port is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.PORT_MIN,
|
||||
max: VALIDATION_LIMITS.PORT_MAX,
|
||||
message: 'Invalid Port'
|
||||
}
|
||||
];
|
||||
schema.modbus_timeout = [
|
||||
{ required: true, message: 'Timeout is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
|
||||
max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
|
||||
message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Shower timer validations
|
||||
if (settings.shower_timer) {
|
||||
schema.shower_min_duration = [
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
|
||||
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
|
||||
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Shower alert validations
|
||||
if (settings.shower_alert) {
|
||||
schema.shower_alert_trigger = [
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
|
||||
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
|
||||
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
|
||||
}
|
||||
];
|
||||
schema.shower_alert_coldshot = [
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
|
||||
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
|
||||
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Remote timeout validations
|
||||
if (settings.remote_timeout_en) {
|
||||
schema.remote_timeout = [
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
|
||||
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
|
||||
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return new Schema(schema);
|
||||
};
|
||||
|
||||
export const GPIO_VALIDATORR = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
value &&
|
||||
(value === 1 ||
|
||||
(value >= 6 && value <= 11) ||
|
||||
(value >= 16 && value <= 17) ||
|
||||
value === 20 ||
|
||||
value === 24 ||
|
||||
(value >= 28 && value <= 31) ||
|
||||
value > 40 ||
|
||||
value < 0)
|
||||
) {
|
||||
callback('Must be an valid GPIO port');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const GPIO_VALIDATORC3 = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
|
||||
callback('Must be an valid GPIO port');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const GPIO_VALIDATORS2 = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
value &&
|
||||
((value >= 19 && value <= 20) ||
|
||||
(value >= 22 && value <= 32) ||
|
||||
value > 40 ||
|
||||
value < 0)
|
||||
) {
|
||||
callback('Must be an valid GPIO port');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const GPIO_VALIDATORS3 = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
value &&
|
||||
((value >= 19 && value <= 20) ||
|
||||
(value >= 22 && value <= 37) ||
|
||||
(value >= 39 && value <= 42) ||
|
||||
value > 48 ||
|
||||
value < 0)
|
||||
) {
|
||||
callback('Must be an valid GPIO port');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createSettingsValidator = (settings: Settings) =>
|
||||
new Schema({
|
||||
...(settings.board_profile === 'CUSTOM' &&
|
||||
settings.platform === 'ESP32' && {
|
||||
led_gpio: [
|
||||
{ required: true, message: 'LED GPIO is required' },
|
||||
GPIO_VALIDATOR
|
||||
],
|
||||
dallas_gpio: [
|
||||
{ required: true, message: 'GPIO is required' },
|
||||
GPIO_VALIDATOR
|
||||
],
|
||||
pbutton_gpio: [
|
||||
{ required: true, message: 'Button GPIO is required' },
|
||||
GPIO_VALIDATOR
|
||||
],
|
||||
tx_gpio: [
|
||||
{ required: true, message: 'Tx GPIO is required' },
|
||||
GPIO_VALIDATOR
|
||||
],
|
||||
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
|
||||
}),
|
||||
...(settings.board_profile === 'CUSTOM' &&
|
||||
settings.platform === 'ESP32C3' && {
|
||||
led_gpio: [
|
||||
{ required: true, message: 'LED GPIO is required' },
|
||||
GPIO_VALIDATORC3
|
||||
],
|
||||
dallas_gpio: [
|
||||
{ required: true, message: 'GPIO is required' },
|
||||
GPIO_VALIDATORC3
|
||||
],
|
||||
pbutton_gpio: [
|
||||
{ required: true, message: 'Button GPIO is required' },
|
||||
GPIO_VALIDATORC3
|
||||
],
|
||||
tx_gpio: [
|
||||
{ required: true, message: 'Tx GPIO is required' },
|
||||
GPIO_VALIDATORC3
|
||||
],
|
||||
rx_gpio: [
|
||||
{ required: true, message: 'Rx GPIO is required' },
|
||||
GPIO_VALIDATORC3
|
||||
]
|
||||
}),
|
||||
...(settings.board_profile === 'CUSTOM' &&
|
||||
settings.platform === 'ESP32S2' && {
|
||||
led_gpio: [
|
||||
{ required: true, message: 'LED GPIO is required' },
|
||||
GPIO_VALIDATORS2
|
||||
],
|
||||
dallas_gpio: [
|
||||
{ required: true, message: 'GPIO is required' },
|
||||
GPIO_VALIDATORS2
|
||||
],
|
||||
pbutton_gpio: [
|
||||
{ required: true, message: 'Button GPIO is required' },
|
||||
GPIO_VALIDATORS2
|
||||
],
|
||||
tx_gpio: [
|
||||
{ required: true, message: 'Tx GPIO is required' },
|
||||
GPIO_VALIDATORS2
|
||||
],
|
||||
rx_gpio: [
|
||||
{ required: true, message: 'Rx GPIO is required' },
|
||||
GPIO_VALIDATORS2
|
||||
]
|
||||
}),
|
||||
...(settings.board_profile === 'CUSTOM' &&
|
||||
settings.platform === 'ESP32S3' && {
|
||||
led_gpio: [
|
||||
{ required: true, message: 'LED GPIO is required' },
|
||||
GPIO_VALIDATORS3
|
||||
],
|
||||
dallas_gpio: [
|
||||
{ required: true, message: 'GPIO is required' },
|
||||
GPIO_VALIDATORS3
|
||||
],
|
||||
pbutton_gpio: [
|
||||
{ required: true, message: 'Button GPIO is required' },
|
||||
GPIO_VALIDATORS3
|
||||
],
|
||||
tx_gpio: [
|
||||
{ required: true, message: 'Tx GPIO is required' },
|
||||
GPIO_VALIDATORS3
|
||||
],
|
||||
rx_gpio: [
|
||||
{ required: true, message: 'Rx GPIO is required' },
|
||||
GPIO_VALIDATORS3
|
||||
]
|
||||
}),
|
||||
...(settings.syslog_enabled && {
|
||||
syslog_host: [
|
||||
{ required: true, message: 'Host is required' },
|
||||
IP_OR_HOSTNAME_VALIDATOR
|
||||
],
|
||||
syslog_port: [
|
||||
{ required: true, message: 'Port is required' },
|
||||
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
|
||||
],
|
||||
syslog_mark_interval: [
|
||||
{ required: true, message: 'Mark interval is required' },
|
||||
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
|
||||
]
|
||||
}),
|
||||
...(settings.modbus_enabled && {
|
||||
modbus_max_clients: [
|
||||
{ required: true, message: 'Max clients is required' },
|
||||
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
|
||||
],
|
||||
modbus_port: [
|
||||
{ required: true, message: 'Port is required' },
|
||||
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
|
||||
],
|
||||
modbus_timeout: [
|
||||
{ required: true, message: 'Timeout is required' },
|
||||
{
|
||||
type: 'number',
|
||||
min: 100,
|
||||
max: 20000,
|
||||
message: 'Must be between 100 and 20000'
|
||||
}
|
||||
]
|
||||
}),
|
||||
...(settings.shower_timer && {
|
||||
shower_min_duration: [
|
||||
{
|
||||
type: 'number',
|
||||
min: 10,
|
||||
max: 360,
|
||||
message: 'Time must be between 10 and 360 seconds'
|
||||
}
|
||||
]
|
||||
}),
|
||||
...(settings.shower_alert && {
|
||||
shower_alert_trigger: [
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 20,
|
||||
message: 'Time must be between 1 and 20 minutes'
|
||||
}
|
||||
],
|
||||
shower_alert_coldshot: [
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 10,
|
||||
message: 'Time must be between 1 and 10 seconds'
|
||||
}
|
||||
]
|
||||
}),
|
||||
...(settings.remote_timeout_en && {
|
||||
remote_timeout: [
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 240,
|
||||
message: 'Timeout must be between 1 and 240 hours'
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
|
||||
// Generic unique name validator factory
|
||||
const createUniqueNameValidator = <T extends { name: string }>(
|
||||
items: T[],
|
||||
originalName?: string
|
||||
) => ({
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
name: string,
|
||||
@@ -285,43 +170,22 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =
|
||||
) {
|
||||
if (
|
||||
name !== '' &&
|
||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
||||
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
|
||||
(originalName === undefined ||
|
||||
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
export const schedulerItemValidation = (
|
||||
schedule: ScheduleItem[],
|
||||
scheduleItem: ScheduleItem
|
||||
) =>
|
||||
new Schema({
|
||||
name: [
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
|
||||
],
|
||||
cmd: [
|
||||
{ required: true, message: 'Command is required' },
|
||||
{
|
||||
type: 'string',
|
||||
min: 1,
|
||||
max: 300,
|
||||
message: 'Command must be 1-300 characters'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const uniqueCustomNameValidator = (
|
||||
entity: EntityItem[],
|
||||
o_name?: string
|
||||
// Generic field name validator (for cases where the name field has different property names)
|
||||
const createUniqueFieldNameValidator = <T>(
|
||||
items: T[],
|
||||
getName: (item: T) => string,
|
||||
originalName?: string
|
||||
) => ({
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
@@ -329,58 +193,91 @@ export const uniqueCustomNameValidator = (
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (
|
||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
||||
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
|
||||
name !== '' &&
|
||||
(originalName === undefined ||
|
||||
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
|
||||
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
|
||||
|
||||
const NAME_PATTERN = {
|
||||
type: 'string' as const,
|
||||
pattern: new RegExp(
|
||||
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
|
||||
),
|
||||
message: NAME_PATTERN_MESSAGE
|
||||
};
|
||||
|
||||
const NAME_PATTERN_REQUIRED = {
|
||||
type: 'string' as const,
|
||||
pattern: new RegExp(
|
||||
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
|
||||
),
|
||||
message: NAME_PATTERN_MESSAGE
|
||||
};
|
||||
|
||||
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
|
||||
createUniqueNameValidator(schedule, o_name);
|
||||
|
||||
export const schedulerItemValidation = (
|
||||
schedule: ScheduleItem[],
|
||||
scheduleItem: ScheduleItem
|
||||
) =>
|
||||
new Schema({
|
||||
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
||||
cmd: [
|
||||
{ required: true, message: 'Command is required' },
|
||||
{
|
||||
type: 'string',
|
||||
min: VALIDATION_LIMITS.COMMAND_MIN,
|
||||
max: VALIDATION_LIMITS.COMMAND_MAX,
|
||||
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
|
||||
createUniqueNameValidator(entity, o_name);
|
||||
|
||||
const hexValidator = {
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: string,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
|
||||
callback(ERROR_MESSAGES.HEX_REQUIRED);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
|
||||
new Schema({
|
||||
name: [
|
||||
{ required: true, message: 'Name is required' },
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{1,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
|
||||
],
|
||||
device_id: [
|
||||
{
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: string,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (isNaN(parseInt(value, 16))) {
|
||||
callback('Is required and must be in hex format');
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
],
|
||||
type_id: [
|
||||
{
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
value: string,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (isNaN(parseInt(value, 16))) {
|
||||
callback('Is required and must be in hex format');
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
NAME_PATTERN_REQUIRED,
|
||||
uniqueCustomNameValidator(entity, entityItem.o_name)
|
||||
],
|
||||
device_id: [hexValidator],
|
||||
type_id: [hexValidator],
|
||||
offset: [
|
||||
{ required: true, message: 'Offset is required' },
|
||||
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
|
||||
{
|
||||
type: 'number',
|
||||
min: VALIDATION_LIMITS.OFFSET_MIN,
|
||||
max: VALIDATION_LIMITS.OFFSET_MAX,
|
||||
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
|
||||
}
|
||||
],
|
||||
factor: [{ required: true, message: 'is required' }]
|
||||
});
|
||||
@@ -388,93 +285,34 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
|
||||
export const uniqueTemperatureNameValidator = (
|
||||
sensors: TemperatureSensor[],
|
||||
o_name?: string
|
||||
) => ({
|
||||
validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
|
||||
if (
|
||||
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
|
||||
n !== '' &&
|
||||
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
||||
|
||||
export const temperatureSensorItemValidation = (
|
||||
sensors: TemperatureSensor[],
|
||||
sensor: TemperatureSensor
|
||||
) =>
|
||||
new Schema({
|
||||
n: [
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
||||
]
|
||||
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
||||
});
|
||||
|
||||
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
|
||||
validator(
|
||||
_rule: InternalRuleItem,
|
||||
gpio: number,
|
||||
callback: (error?: string) => void
|
||||
) {
|
||||
if (sensors.find((as) => as.g === gpio)) {
|
||||
callback('GPIO already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const uniqueAnalogNameValidator = (
|
||||
sensors: AnalogSensor[],
|
||||
o_name?: string
|
||||
) => ({
|
||||
validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
|
||||
if (
|
||||
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
|
||||
n !== '' &&
|
||||
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
|
||||
) {
|
||||
callback('Name already in use');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
||||
|
||||
export const analogSensorItemValidation = (
|
||||
sensors: AnalogSensor[],
|
||||
sensor: AnalogSensor,
|
||||
creating: boolean,
|
||||
platform: string
|
||||
) =>
|
||||
new Schema({
|
||||
sensor: AnalogSensor
|
||||
) => {
|
||||
return new Schema({
|
||||
// name is required and must be unique
|
||||
n: [
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||
message: "Must be <20 characters: alphanumeric or '_'"
|
||||
},
|
||||
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
|
||||
],
|
||||
g: [
|
||||
{ required: true, message: 'GPIO is required' },
|
||||
platform === 'ESP32S3'
|
||||
? GPIO_VALIDATORS3
|
||||
: platform === 'ESP32S2'
|
||||
? GPIO_VALIDATORS2
|
||||
: platform === 'ESP32C3'
|
||||
? GPIO_VALIDATORC3
|
||||
: GPIO_VALIDATOR,
|
||||
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
|
||||
{ required: true, message: 'Name is required' },
|
||||
NAME_PATTERN,
|
||||
uniqueAnalogNameValidator(sensors, sensor.o_n)
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||
new Schema({
|
||||
@@ -488,11 +326,12 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||
) {
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
dv.m &&
|
||||
dv.x &&
|
||||
dv.m !== undefined &&
|
||||
dv.x !== undefined &&
|
||||
(value < dv.m || value > dv.x)
|
||||
) {
|
||||
callback('Value out of range');
|
||||
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -27,6 +27,19 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||
|
||||
// Efficient range function without recursion
|
||||
const createRange = (start: number, end: number): number[] => {
|
||||
const result: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Pre-computed ranges for better performance
|
||||
const CHANNEL_RANGE = createRange(1, 14);
|
||||
const MAX_CLIENTS_RANGE = createRange(1, 9);
|
||||
|
||||
const APSettings = () => {
|
||||
const {
|
||||
loadData,
|
||||
@@ -50,33 +63,38 @@ const APSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
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]
|
||||
);
|
||||
|
||||
// Memoize AP enabled state
|
||||
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
|
||||
|
||||
// Memoize validation and submit handler
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createAPSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createAPSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
// no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/
|
||||
function range(a: number, b: number): number[] {
|
||||
return a < b ? [a, ...range(a + 1, b)] : [b];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ValidatedTextField
|
||||
@@ -100,7 +118,7 @@ const APSettings = () => {
|
||||
{LL.AP_PROVIDE_TEXT_3()}
|
||||
</MenuItem>
|
||||
</ValidatedTextField>
|
||||
{isAPEnabled(data) && (
|
||||
{apEnabled && (
|
||||
<>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
@@ -134,7 +152,7 @@ const APSettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
>
|
||||
{range(1, 14).map((i) => (
|
||||
{CHANNEL_RANGE.map((i) => (
|
||||
<MenuItem key={i} value={i}>
|
||||
{i}
|
||||
</MenuItem>
|
||||
@@ -162,7 +180,7 @@ const APSettings = () => {
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
>
|
||||
{range(1, 9).map((i) => (
|
||||
{MAX_CLIENTS_RANGE.map((i) => (
|
||||
<MenuItem key={i} value={i}>
|
||||
{i}
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -37,13 +37,13 @@ import { validate } from 'validators';
|
||||
|
||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||
import { BOARD_PROFILES } from '../main/types';
|
||||
import type { APIcall, Settings } from '../main/types';
|
||||
import type { APIcall, BoardProfileKey, Settings } from '../main/types';
|
||||
import { createSettingsValidator } from '../main/validators';
|
||||
|
||||
export function boardProfileSelectItems() {
|
||||
return Object.keys(BOARD_PROFILES).map((code) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{BOARD_PROFILES[code]}
|
||||
{BOARD_PROFILES[code as BoardProfileKey]}
|
||||
</MenuItem>
|
||||
));
|
||||
}
|
||||
@@ -72,7 +72,7 @@ const ApplicationSettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
@@ -106,50 +106,61 @@ const ApplicationSettings = () => {
|
||||
});
|
||||
});
|
||||
|
||||
const doRestart = async () => {
|
||||
// Memoized input props to prevent recreation on every render
|
||||
const SecondsInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const MinutesInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
const HoursInputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
}),
|
||||
[LL]
|
||||
);
|
||||
|
||||
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 SecondsInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||
};
|
||||
const MinutesInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||
};
|
||||
const HoursInputProps = {
|
||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data || !hardwareData) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createSettingsValidator(data), data);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
} finally {
|
||||
await saveData();
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createSettingsValidator(data), data);
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
} finally {
|
||||
await saveData();
|
||||
}
|
||||
};
|
||||
|
||||
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const changeBoardProfile = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const boardProfile = event.target.value;
|
||||
updateFormValue(event);
|
||||
if (boardProfile === 'CUSTOM') {
|
||||
@@ -160,12 +171,22 @@ const ApplicationSettings = () => {
|
||||
} else {
|
||||
void updateBoardProfile(boardProfile);
|
||||
}
|
||||
};
|
||||
},
|
||||
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
||||
);
|
||||
|
||||
const restart = async () => {
|
||||
await validateAndSubmit();
|
||||
await doRestart();
|
||||
};
|
||||
const restart = useCallback(async () => {
|
||||
await validateAndSubmit();
|
||||
await doRestart();
|
||||
}, [validateAndSubmit, doRestart]);
|
||||
|
||||
// Memoize board profile select items to prevent recreation
|
||||
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
||||
|
||||
const content = () => {
|
||||
if (!data || !hardwareData) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -474,7 +495,7 @@ const ApplicationSettings = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
{boardProfileSelectItems()}
|
||||
{boardProfileItems}
|
||||
<Divider />
|
||||
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
||||
{LL.CUSTOM()}…
|
||||
@@ -836,8 +857,9 @@ const ApplicationSettings = () => {
|
||||
</Grid>
|
||||
|
||||
{restartNeeded && (
|
||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="contained"
|
||||
color="error"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
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();
|
||||
|
||||
@@ -44,95 +51,126 @@ const DownloadUpload = () => {
|
||||
|
||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||
|
||||
const doRestart = async () => {
|
||||
const doRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
try {
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [sendAPI]);
|
||||
|
||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
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 = useCallback(
|
||||
(type: string) => () => {
|
||||
void sendExportData(type);
|
||||
},
|
||||
[sendExportData]
|
||||
);
|
||||
|
||||
if (restarting) {
|
||||
return <SystemMonitor />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Typography>
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
<Typography mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportData('settings')}
|
||||
>
|
||||
{LL.SETTINGS_OF(LL.APPLICATION())}
|
||||
</Button>
|
||||
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
|
||||
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
|
||||
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportData('customizations')}
|
||||
>
|
||||
{LL.CUSTOMIZATIONS()}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportData('entities')}
|
||||
>
|
||||
{LL.CUSTOM_ENTITIES(0)}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportData('schedule')}
|
||||
>
|
||||
{LL.SCHEDULE(0)}
|
||||
</Button>
|
||||
</Grid>
|
||||
return (
|
||||
<SectionContent>
|
||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||
{LL.DOWNLOAD(0)}
|
||||
</Typography>
|
||||
|
||||
<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>
|
||||
|
||||
<Typography mt={2} mb={1} variant="body1" color="warning">
|
||||
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
|
||||
</Typography>
|
||||
|
||||
{standaloneButton && (
|
||||
<Button
|
||||
sx={{ ml: 2, mt: 2 }}
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => sendExportData('allvalues')}
|
||||
onClick={handleDownload(standaloneButton.type)}
|
||||
>
|
||||
{LL.ALLVALUES()}
|
||||
{standaloneButton.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
|
||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||
</Box>
|
||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
||||
</Box>
|
||||
|
||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
|
||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Grid,
|
||||
@@ -30,6 +33,8 @@ import type { MqttSettingsType } from 'types';
|
||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||
import { createMqttSettingsValidator, validate } from 'validators';
|
||||
|
||||
import { callAction } from '../../api/app';
|
||||
|
||||
const MqttSettings = () => {
|
||||
const {
|
||||
loadData,
|
||||
@@ -52,48 +57,104 @@ const MqttSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
const sendResetMQTT = useCallback(() => {
|
||||
void callAction({ action: 'resetMQTT' })
|
||||
.then(() => {
|
||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(String(error.error?.message || 'An error occurred'));
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
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 ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createMqttSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
<>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="enabled"
|
||||
checked={data.enabled}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
}
|
||||
label={LL.ENABLE_MQTT()}
|
||||
/>
|
||||
<Box display="flex" gap={2} mb={1}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="enabled"
|
||||
checked={data.enabled}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
}
|
||||
label={LL.ENABLE_MQTT()}
|
||||
/>
|
||||
{data.enabled && (
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
onClick={sendResetMQTT}
|
||||
>
|
||||
{LL.REFRESH() + ' MQTT'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="host"
|
||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||
multiline
|
||||
@@ -105,7 +166,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="port"
|
||||
label="Port"
|
||||
variant="outlined"
|
||||
@@ -117,7 +178,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="base"
|
||||
label={LL.BASE_TOPIC()}
|
||||
variant="outlined"
|
||||
@@ -129,7 +190,7 @@ const MqttSettings = () => {
|
||||
<Grid>
|
||||
<TextField
|
||||
name="client_id"
|
||||
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
|
||||
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
|
||||
variant="outlined"
|
||||
value={data.client_id}
|
||||
onChange={updateFormValue}
|
||||
@@ -158,7 +219,7 @@ const MqttSettings = () => {
|
||||
</Grid>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name="keep_alive"
|
||||
label="Keep Alive"
|
||||
slotProps={{
|
||||
@@ -205,6 +266,7 @@ const MqttSettings = () => {
|
||||
label={LL.CERT()}
|
||||
variant="outlined"
|
||||
value={data.rootCA}
|
||||
sx={{ width: '50ch' }}
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
@@ -336,13 +398,17 @@ const MqttSettings = () => {
|
||||
>
|
||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||
<MenuItem value={3}>
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.6)
|
||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.5)
|
||||
</MenuItem>
|
||||
<MenuItem value={4}>
|
||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.6)
|
||||
{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>
|
||||
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
|
||||
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -352,119 +418,42 @@ const MqttSettings = () => {
|
||||
{LL.MQTT_PUBLISH_INTERVALS()} (0=auto)
|
||||
</Typography>
|
||||
<Grid container spacing={2} rowSpacing={0}>
|
||||
<Grid>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors || {}}
|
||||
name="publish_time_heartbeat"
|
||||
label="Heartbeat"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_heartbeat)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_boiler"
|
||||
label={LL.MQTT_INT_BOILER()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_boiler)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_thermostat"
|
||||
label={LL.MQTT_INT_THERMOSTATS()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_thermostat)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_solar"
|
||||
label={LL.MQTT_INT_SOLAR()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_solar)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_mixer"
|
||||
label={LL.MQTT_INT_MIXER()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_mixer)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_water"
|
||||
label={LL.MQTT_INT_WATER()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_water)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_sensor"
|
||||
label={LL.SENSORS()}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_sensor)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<TextField
|
||||
name="publish_time_other"
|
||||
label={LL.DEFAULT(0)}
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_other)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{publishIntervalFields.map((field) => (
|
||||
<Grid key={field.name}>
|
||||
{field.validated ? (
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
variant="outlined"
|
||||
value={numberValue(
|
||||
data[field.name as keyof MqttSettingsType] as number
|
||||
)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
variant="outlined"
|
||||
value={numberValue(
|
||||
data[field.name as keyof MqttSettingsType] as number
|
||||
)}
|
||||
type="number"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
slotProps={{
|
||||
input: SecondsInputProps
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||
<ButtonRow>
|
||||
@@ -491,13 +480,6 @@ const MqttSettings = () => {
|
||||
</ButtonRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content()}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
@@ -39,7 +39,7 @@ import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
||||
import { validate } from 'validators';
|
||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||
|
||||
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
|
||||
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
|
||||
|
||||
const NTPSettings = () => {
|
||||
const {
|
||||
@@ -61,9 +61,19 @@ const NTPSettings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('NTP');
|
||||
|
||||
// Memoized timezone select items for better performance
|
||||
const timeZoneItems = useTimeZoneSelectItems();
|
||||
|
||||
// Memoized selected timezone value
|
||||
const selectedTzValue = useMemo(
|
||||
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
|
||||
[data?.tz_label, data?.tz_format]
|
||||
);
|
||||
|
||||
const [localTime, setLocalTime] = useState<string>('');
|
||||
const [settingTime, setSettingTime] = useState<boolean>(false);
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
const { send: updateTime } = useRequest(
|
||||
(local_time: Time) => NTPApi.updateTime(local_time),
|
||||
@@ -72,110 +82,79 @@ const NTPSettings = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
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 [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
// Memoize updateLocalTime handler
|
||||
const updateLocalTime = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
||||
[]
|
||||
);
|
||||
|
||||
const updateLocalTime = (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);
|
||||
|
||||
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
|
||||
.then(async () => {
|
||||
toast.success(LL.TIME_SET());
|
||||
setSettingTime(false);
|
||||
await loadData();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(LL.PROBLEM_UPDATING());
|
||||
})
|
||||
.finally(() => {
|
||||
setProcessing(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renderSetTimeDialog = () => (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={settingTime}
|
||||
onClose={() => setSettingTime(false)}
|
||||
>
|
||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<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"
|
||||
value={localTime}
|
||||
onChange={updateLocalTime}
|
||||
disabled={processing}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setSettingTime(false)}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<AccessTimeIcon />}
|
||||
variant="outlined"
|
||||
onClick={configureTime}
|
||||
disabled={processing}
|
||||
color="primary"
|
||||
>
|
||||
{LL.UPDATE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
try {
|
||||
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
|
||||
toast.success(LL.TIME_SET());
|
||||
setSettingTime(false);
|
||||
await loadData();
|
||||
} catch {
|
||||
toast.error(LL.PROBLEM_UPDATING());
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [localTime, updateTime, LL, loadData]);
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
// Memoize close dialog handler
|
||||
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
|
||||
|
||||
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// 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 ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData]);
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
// Memoize render content to prevent unnecessary re-renders
|
||||
const renderContent = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -205,13 +184,13 @@ const NTPSettings = () => {
|
||||
label={LL.TIME_ZONE()}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={selectedTimeZone(data.tz_label, data.tz_format)}
|
||||
value={selectedTzValue}
|
||||
onChange={changeTimeZone}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||
{timeZoneSelectItems()}
|
||||
{timeZoneItems}
|
||||
</ValidatedTextField>
|
||||
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
@@ -230,7 +209,6 @@ const NTPSettings = () => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{renderSetTimeDialog()}
|
||||
|
||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||
<ButtonRow>
|
||||
@@ -258,12 +236,66 @@ const NTPSettings = () => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
data,
|
||||
errorMessage,
|
||||
loadData,
|
||||
updateFormValue,
|
||||
fieldErrors,
|
||||
selectedTzValue,
|
||||
changeTimeZone,
|
||||
timeZoneItems,
|
||||
dirtyFlags,
|
||||
openSetTime,
|
||||
saving,
|
||||
validateAndSubmit,
|
||||
LL
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{content()}
|
||||
{renderContent}
|
||||
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<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"
|
||||
value={localTime}
|
||||
onChange={updateLocalTime}
|
||||
disabled={processing}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleCloseSetTime}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<AccessTimeIcon />}
|
||||
variant="outlined"
|
||||
onClick={configureTime}
|
||||
disabled={processing}
|
||||
color="primary"
|
||||
>
|
||||
{LL.UPDATE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
List
|
||||
} from '@mui/material';
|
||||
|
||||
@@ -29,134 +30,159 @@ import { SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
import SystemMonitor from '../status/SystemMonitor';
|
||||
|
||||
const Settings = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.SETTINGS(0));
|
||||
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||
const [restarting, setRestarting] = useState<boolean>();
|
||||
|
||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const doFormat = async () => {
|
||||
const doFormat = useCallback(async () => {
|
||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||
setRestarting(true);
|
||||
setConfirmFactoryReset(false);
|
||||
});
|
||||
};
|
||||
}, [sendAPI]);
|
||||
|
||||
const renderFactoryResetDialog = () => (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmFactoryReset}
|
||||
onClose={() => setConfirmFactoryReset(false)}
|
||||
>
|
||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setConfirmFactoryReset(false)}
|
||||
color="secondary"
|
||||
const handleFactoryResetClose = useCallback(() => {
|
||||
setConfirmFactoryReset(false);
|
||||
}, []);
|
||||
|
||||
const handleFactoryResetClick = useCallback(() => {
|
||||
setConfirmFactoryReset(true);
|
||||
}, []);
|
||||
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
|
||||
<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}
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={doFormat}
|
||||
color="error"
|
||||
<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"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFactoryResetClick}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
LL,
|
||||
handleFactoryResetClick,
|
||||
handleFactoryResetClose,
|
||||
doFormat,
|
||||
confirmFactoryReset,
|
||||
restarting
|
||||
]);
|
||||
|
||||
const content = () => (
|
||||
<>
|
||||
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
|
||||
<ListMenuItem
|
||||
icon={TuneIcon}
|
||||
bgcolor="#134ba2"
|
||||
label={LL.APPLICATION()}
|
||||
text={LL.APPLICATION_SETTINGS_1()}
|
||||
to="application"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsEthernetIcon}
|
||||
bgcolor="#40828f"
|
||||
label={LL.NETWORK(0)}
|
||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||
to="network"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor="#5f9a5f"
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||
to="ap"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor="#c5572c"
|
||||
label="NTP"
|
||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||
to="ntp"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{renderFactoryResetDialog()}
|
||||
|
||||
<Box mt={2} display="flex" flexWrap="wrap">
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setConfirmFactoryReset(true)}
|
||||
color="error"
|
||||
>
|
||||
{LL.FACTORY_RESET()}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MenuItem } from '@mui/material';
|
||||
|
||||
type TimeZones = Record<string, string>;
|
||||
|
||||
export const TIME_ZONES: TimeZones = {
|
||||
export const TIME_ZONES: Readonly<TimeZones> = {
|
||||
'Africa/Abidjan': 'GMT0',
|
||||
'Africa/Accra': 'GMT0',
|
||||
'Africa/Addis_Ababa': 'EAT-3',
|
||||
@@ -465,14 +467,33 @@ export const TIME_ZONES: TimeZones = {
|
||||
'Pacific/Wallis': 'UNK-12'
|
||||
};
|
||||
|
||||
// Pre-compute sorted timezone labels for better performance
|
||||
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
|
||||
|
||||
export function selectedTimeZone(label: string, format: string) {
|
||||
return TIME_ZONES[label] === format ? label : undefined;
|
||||
}
|
||||
|
||||
export function timeZoneSelectItems() {
|
||||
return Object.keys(TIME_ZONES).map((label) => (
|
||||
<MenuItem key={label} value={label}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
));
|
||||
// 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 timeZoneSelectItems() {
|
||||
return precomputedTimeZoneItems;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
@@ -28,8 +28,7 @@ const Network = () => {
|
||||
[
|
||||
{
|
||||
path: '/settings/network/settings',
|
||||
element: <NetworkSettings />,
|
||||
dog: 'woof'
|
||||
element: <NetworkSettings />
|
||||
},
|
||||
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
|
||||
],
|
||||
@@ -53,14 +52,17 @@ const Network = () => {
|
||||
setSelectedNetwork(undefined);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
}),
|
||||
[selectedNetwork, selectNetwork, deselectNetwork]
|
||||
);
|
||||
|
||||
return (
|
||||
<WiFiConnectionContext.Provider
|
||||
value={{
|
||||
...(selectedNetwork && { selectedNetwork }),
|
||||
selectNetwork,
|
||||
deselectNetwork
|
||||
}}
|
||||
>
|
||||
<WiFiConnectionContext.Provider value={contextValue}>
|
||||
<RouterTabs value={routerTab}>
|
||||
<Tab
|
||||
value="/settings/network/settings"
|
||||
@@ -80,4 +82,4 @@ const Network = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Network;
|
||||
export default memo(Network);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -109,38 +109,37 @@ const NetworkSettings = () => {
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
|
||||
useEffect(() => deselectNetwork, [deselectNetwork]);
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createNetworkSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
deselectNetwork();
|
||||
}, [data, saveData, deselectNetwork]);
|
||||
|
||||
const setCancel = useCallback(async () => {
|
||||
deselectNetwork();
|
||||
await loadData();
|
||||
}, [deselectNetwork, loadData]);
|
||||
|
||||
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) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(createNetworkSettingsValidator(data), data);
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
deselectNetwork();
|
||||
};
|
||||
|
||||
const setCancel = async () => {
|
||||
deselectNetwork();
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const doRestart = async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" color="primary">
|
||||
@@ -165,7 +164,7 @@ const NetworkSettings = () => {
|
||||
selectedNetwork.bssid
|
||||
}
|
||||
/>
|
||||
<IconButton onClick={setCancel}>
|
||||
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
@@ -356,8 +355,9 @@ const NetworkSettings = () => {
|
||||
</>
|
||||
)}
|
||||
{restartNeeded && (
|
||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="contained"
|
||||
color="error"
|
||||
@@ -405,4 +405,4 @@ const NetworkSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkSettings;
|
||||
export default memo(NetworkSettings);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { 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>
|
||||
@@ -73,4 +73,4 @@ const WiFiNetworkScanner = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default WiFiNetworkScanner;
|
||||
export default memo(WiFiNetworkScanner);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
@@ -63,38 +63,41 @@ 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) {
|
||||
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
|
||||
return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
|
||||
}
|
||||
|
||||
return <List>{networkList.networks.map(renderNetwork)}</List>;
|
||||
};
|
||||
|
||||
export default WiFiNetworkSelector;
|
||||
export default memo(WiFiNetworkSelector);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import {
|
||||
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
||||
if (open) {
|
||||
void generateToken();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, generateToken]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -86,4 +86,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateToken;
|
||||
export default memo(GenerateToken);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { 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,72 +95,81 @@ const ManageUsers = () => {
|
||||
text-align: right;
|
||||
}
|
||||
`
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const noAdminConfigured = useCallback(
|
||||
() => !data?.users.find((u) => u.admin),
|
||||
[data]
|
||||
);
|
||||
|
||||
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 = useCallback(() => {
|
||||
setCreating(true);
|
||||
setUser({
|
||||
username: '',
|
||||
password: '',
|
||||
admin: true
|
||||
});
|
||||
}, []);
|
||||
|
||||
const editUser = useCallback((toEdit: UserType) => {
|
||||
setCreating(false);
|
||||
setUser({ ...toEdit });
|
||||
}, []);
|
||||
|
||||
const cancelEditingUser = useCallback(() => {
|
||||
setUser(undefined);
|
||||
}, []);
|
||||
|
||||
const doneEditingUser = useCallback(() => {
|
||||
if (user && data) {
|
||||
const users = [
|
||||
...data.users.filter(
|
||||
(u: { username: string }) => u.username !== user.username
|
||||
),
|
||||
user
|
||||
];
|
||||
updateDataValue({ ...data, users });
|
||||
setUser(undefined);
|
||||
setChanged(changed + 1);
|
||||
}
|
||||
}, [user, data, updateDataValue, changed]);
|
||||
|
||||
const closeGenerateToken = useCallback(() => {
|
||||
setGeneratingToken(undefined);
|
||||
}, []);
|
||||
|
||||
const generateTokenForUser = useCallback((username: string) => {
|
||||
setGeneratingToken(username);
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
setChanged(0);
|
||||
}, [saveData, authenticatedContext]);
|
||||
|
||||
const onCancelSubmit = useCallback(async () => {
|
||||
await loadData();
|
||||
setChanged(0);
|
||||
}, [loadData]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
|
||||
const noAdminConfigured = () => !data.users.find((u) => u.admin);
|
||||
|
||||
const removeUser = (toRemove: UserType) => {
|
||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||
updateDataValue({ ...data, users });
|
||||
setChanged(changed + 1);
|
||||
};
|
||||
|
||||
const createUser = () => {
|
||||
setCreating(true);
|
||||
setUser({
|
||||
username: '',
|
||||
password: '',
|
||||
admin: true
|
||||
});
|
||||
};
|
||||
|
||||
const editUser = (toEdit: UserType) => {
|
||||
setCreating(false);
|
||||
setUser({ ...toEdit });
|
||||
};
|
||||
|
||||
const cancelEditingUser = () => {
|
||||
setUser(undefined);
|
||||
};
|
||||
|
||||
const doneEditingUser = () => {
|
||||
if (user) {
|
||||
const users = [
|
||||
...data.users.filter(
|
||||
(u: { username: string }) => u.username !== user.username
|
||||
),
|
||||
user
|
||||
];
|
||||
updateDataValue({ ...data, users });
|
||||
setUser(undefined);
|
||||
setChanged(changed + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const closeGenerateToken = () => {
|
||||
setGeneratingToken(undefined);
|
||||
};
|
||||
|
||||
const generateToken = (username: string) => {
|
||||
setGeneratingToken(username);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
setChanged(0);
|
||||
};
|
||||
|
||||
const onCancelSubmit = async () => {
|
||||
await loadData();
|
||||
setChanged(0);
|
||||
};
|
||||
|
||||
interface UserType2 {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -167,10 +178,14 @@ const ManageUsers = () => {
|
||||
}
|
||||
|
||||
// add id to the type, needed for the table
|
||||
const user_table = data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[];
|
||||
const user_table = useMemo(
|
||||
() =>
|
||||
data.users.map((u) => ({
|
||||
...u,
|
||||
id: u.username
|
||||
})) as UserType2[],
|
||||
[data.users]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,15 +211,24 @@ const ManageUsers = () => {
|
||||
<Cell stiff>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={LL.GENERATING_TOKEN()}
|
||||
disabled={!authenticatedContext.me.admin}
|
||||
onClick={() => generateToken(u.username)}
|
||||
onClick={() => generateTokenForUser(u.username)}
|
||||
>
|
||||
<VpnKeyIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => removeUser(u)}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => removeUser(u)}
|
||||
aria-label={LL.REMOVE()}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => editUser(u)}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editUser(u)}
|
||||
aria-label={LL.EDIT()}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Cell>
|
||||
@@ -286,4 +310,4 @@ const ManageUsers = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageUsers;
|
||||
export default memo(ManageUsers);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
||||
|
||||
import { Tab } from '@mui/material';
|
||||
@@ -12,12 +13,21 @@ const Security = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.SECURITY(0));
|
||||
|
||||
const matchedRoutes = matchRoutes(
|
||||
[
|
||||
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' },
|
||||
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
||||
],
|
||||
useLocation()
|
||||
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;
|
||||
|
||||
@@ -42,4 +52,4 @@ const Security = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Security;
|
||||
export default memo(Security);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { memo, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
@@ -44,28 +44,29 @@ const SecuritySettings = () => {
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
);
|
||||
|
||||
const validateAndSubmit = useCallback(async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(SECURITY_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}, [data, saveData, authenticatedContext]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||
}
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
await validate(SECURITY_SETTINGS_VALIDATOR, data);
|
||||
await saveData();
|
||||
await authenticatedContext.refresh();
|
||||
} catch (error) {
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ValidatedPasswordField
|
||||
@@ -115,4 +116,4 @@ const SecuritySettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SecuritySettings;
|
||||
export default memo(SecuritySettings);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
@@ -45,7 +45,14 @@ const User: FC<UserFormProps> = ({
|
||||
}) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const updateFormValue = updateValue(setUser);
|
||||
const updateFormValue = updateValue((updater) => {
|
||||
setUser((prevState) => {
|
||||
if (!prevState) return prevState;
|
||||
return updater(
|
||||
prevState as unknown as Record<string, unknown>
|
||||
) as unknown as UserType;
|
||||
});
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||
const open = !!user;
|
||||
|
||||
@@ -55,7 +62,7 @@ const User: FC<UserFormProps> = ({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const validateAndDone = async () => {
|
||||
const validateAndDone = useCallback(async () => {
|
||||
if (user) {
|
||||
try {
|
||||
setFieldErrors(undefined);
|
||||
@@ -65,7 +72,7 @@ const User: FC<UserFormProps> = ({
|
||||
setFieldErrors(error as ValidateFieldsError);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [user, validator, onDoneEditing]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -137,4 +144,4 @@ const User: FC<UserFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
export default memo(User);
|
||||
|
||||
@@ -34,37 +34,43 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getApStatusText = (
|
||||
status: APNetworkStatus,
|
||||
LL: ReturnType<typeof useI18nContext>['LL']
|
||||
) => {
|
||||
switch (status) {
|
||||
case APNetworkStatus.ACTIVE:
|
||||
return LL.ACTIVE();
|
||||
case APNetworkStatus.INACTIVE:
|
||||
return LL.INACTIVE(0);
|
||||
case APNetworkStatus.LINGERING:
|
||||
return 'Lingering until idle';
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
|
||||
const APStatus = () => {
|
||||
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
|
||||
const { LL } = useI18nContext();
|
||||
const theme = useTheme();
|
||||
|
||||
useLayoutTitle(LL.ACCESS_POINT(0));
|
||||
|
||||
useInterval(() => {
|
||||
void loadData();
|
||||
});
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.ACCESS_POINT(0));
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const apStatus = ({ status }: APStatusType) => {
|
||||
switch (status) {
|
||||
case APNetworkStatus.ACTIVE:
|
||||
return LL.ACTIVE();
|
||||
case APNetworkStatus.INACTIVE:
|
||||
return LL.INACTIVE(0);
|
||||
case APNetworkStatus.LINGERING:
|
||||
return 'Lingering until idle';
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -72,19 +78,26 @@ const APStatus = () => {
|
||||
<SettingsInputAntennaIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
|
||||
<ListItemText
|
||||
primary={LL.STATUS_OF('')}
|
||||
secondary={getApStatusText(data.status, LL)}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>IP</Avatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<DeviceHubIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
@@ -93,21 +106,22 @@ const APStatus = () => {
|
||||
secondary={data.mac_address}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<ComputerIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
|
||||
</ListItem>
|
||||
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default APStatus;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Cell,
|
||||
@@ -17,6 +19,12 @@ import { useInterval } from 'utils';
|
||||
import { readActivity } from '../../api/app';
|
||||
import type { Stat } from '../main/types';
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
PERFECT: '#00FF7F',
|
||||
WARNING: 'orange',
|
||||
POOR: 'red'
|
||||
} as const;
|
||||
|
||||
const SystemActivity = () => {
|
||||
const { data, send: loadData, error } = useRequest(readActivity);
|
||||
|
||||
@@ -28,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;
|
||||
@@ -45,7 +55,7 @@ const SystemActivity = () => {
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
Row: `
|
||||
.td {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #565656;
|
||||
@@ -59,34 +69,40 @@ 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;
|
||||
}
|
||||
if (stat.q === 100) {
|
||||
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
|
||||
return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
|
||||
}
|
||||
if (stat.q >= 95) {
|
||||
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
|
||||
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
|
||||
} else {
|
||||
return <div style={{ color: 'red' }}>{stat.q}%</div>;
|
||||
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const content = () => {
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
@@ -121,9 +137,9 @@ const SystemActivity = () => {
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default SystemActivity;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import AppsIcon from '@mui/icons-material/Apps';
|
||||
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
|
||||
import DevicesIcon from '@mui/icons-material/Devices';
|
||||
@@ -24,10 +26,61 @@ import { useInterval } from 'utils';
|
||||
|
||||
import BBQKeesIcon from './bbqkees.svg';
|
||||
|
||||
// Constants
|
||||
const AVATAR_COLORS = {
|
||||
DEFAULT: '#5f9a5f',
|
||||
BBQKEES: '#003289'
|
||||
} as const;
|
||||
|
||||
const TEMP_THRESHOLD_CELSIUS = 90; // Temperature threshold to determine F vs C
|
||||
|
||||
function formatNumber(num: number) {
|
||||
return new Intl.NumberFormat().format(num);
|
||||
}
|
||||
|
||||
function formatTemperature(temp?: number): string {
|
||||
if (!temp) return '';
|
||||
const unit = temp > TEMP_THRESHOLD_CELSIUS ? 'F' : 'C';
|
||||
return `, T: ${temp} °${unit}`;
|
||||
}
|
||||
|
||||
function formatFlashSpeed(speed: number): string {
|
||||
return (speed / 1000000).toFixed(0) + ' MHz';
|
||||
}
|
||||
|
||||
function formatCPUCores(cores: number): string {
|
||||
return cores === 1 ? 'single-core)' : 'dual-core)';
|
||||
}
|
||||
|
||||
// Reusable component for hardware status list items
|
||||
interface HardwareListItemProps {
|
||||
icon: ReactElement;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
avatarColor?: string;
|
||||
customIcon?: ReactElement | undefined;
|
||||
}
|
||||
|
||||
const HardwareListItem = ({
|
||||
icon,
|
||||
primary,
|
||||
secondary,
|
||||
avatarColor = AVATAR_COLORS.DEFAULT,
|
||||
customIcon
|
||||
}: HardwareListItemProps) => (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: avatarColor, color: 'white' }}>
|
||||
{customIcon || icon}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={primary} secondary={secondary} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
);
|
||||
|
||||
const HardwareStatus = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
@@ -39,175 +92,72 @@ const HardwareStatus = () => {
|
||||
void loadData();
|
||||
});
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
{data.model ? (
|
||||
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
|
||||
<img
|
||||
alt="BBQKees"
|
||||
src={BBQKeesIcon}
|
||||
style={{ width: 16, verticalAlign: 'middle' }}
|
||||
/>
|
||||
</Avatar>
|
||||
) : (
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<TapAndPlayIcon />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
|
||||
secondary={data.model ? data.model : data.cpu_type}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<DevicesIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="SDK"
|
||||
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<DeveloperBoardIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="CPU"
|
||||
secondary={
|
||||
data.esp_platform +
|
||||
'/' +
|
||||
data.cpu_type +
|
||||
' (rev.' +
|
||||
data.cpu_rev +
|
||||
', ' +
|
||||
(data.cpu_cores === 1 ? 'single-core)' : 'dual-core)') +
|
||||
' @ ' +
|
||||
data.cpu_freq_mhz +
|
||||
' Mhz' +
|
||||
// bit of a hack : if the CPU temp is higher than 90 (=32 Fahrenheit if using Celsius), show F, otherwise C
|
||||
(data.temperature
|
||||
? ', T: ' +
|
||||
data.temperature +
|
||||
' °' +
|
||||
(data.temperature > 90 ? 'F' : 'C')
|
||||
: '')
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<MemoryIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.FREE_MEMORY()}
|
||||
secondary={
|
||||
formatNumber(data.free_heap) +
|
||||
' KB (' +
|
||||
formatNumber(data.max_alloc_heap) +
|
||||
' KB max alloc, ' +
|
||||
formatNumber(data.free_caps) +
|
||||
' KB caps)'
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{data.psram_size !== undefined && data.free_psram !== undefined && (
|
||||
<>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<AppsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.PSRAM()}
|
||||
secondary={
|
||||
formatNumber(data.psram_size) +
|
||||
' KB / ' +
|
||||
formatNumber(data.free_psram) +
|
||||
' KB'
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<SdStorageIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.FLASH()}
|
||||
secondary={
|
||||
formatNumber(data.flash_chip_size) +
|
||||
' KB , ' +
|
||||
(data.flash_chip_speed / 1000000).toFixed(0) +
|
||||
' MHz'
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<SdCardAlertIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.APPSIZE()}
|
||||
secondary={
|
||||
data.partition +
|
||||
': ' +
|
||||
formatNumber(data.app_used) +
|
||||
' KB / ' +
|
||||
formatNumber(data.app_free) +
|
||||
' KB'
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
|
||||
<FolderIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.FILESYSTEM()}
|
||||
secondary={
|
||||
formatNumber(data.fs_used) +
|
||||
' KB / ' +
|
||||
formatNumber(data.fs_free) +
|
||||
' KB'
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<HardwareListItem
|
||||
icon={<TapAndPlayIcon />}
|
||||
primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
|
||||
secondary={data.model || data.cpu_type}
|
||||
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
|
||||
customIcon={
|
||||
data.model ? (
|
||||
<img
|
||||
alt="BBQKees"
|
||||
src={BBQKeesIcon}
|
||||
style={{ width: 16, verticalAlign: 'middle' }}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<HardwareListItem
|
||||
icon={<DevicesIcon />}
|
||||
primary="SDK"
|
||||
secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
|
||||
/>
|
||||
<HardwareListItem
|
||||
icon={<DeveloperBoardIcon />}
|
||||
primary="CPU"
|
||||
secondary={`${data.esp_platform}/${data.cpu_type} (rev.${data.cpu_rev}, ${formatCPUCores(data.cpu_cores)} @ ${data.cpu_freq_mhz} Mhz${formatTemperature(data.temperature)}`}
|
||||
/>
|
||||
<HardwareListItem
|
||||
icon={<MemoryIcon />}
|
||||
primary={LL.FREE_MEMORY()}
|
||||
secondary={`${formatNumber(data.free_heap)} KB (${formatNumber(data.max_alloc_heap)} KB max alloc, ${formatNumber(data.free_caps)} KB caps)`}
|
||||
/>
|
||||
{data.psram_size !== undefined && data.free_psram !== undefined && (
|
||||
<HardwareListItem
|
||||
icon={<AppsIcon />}
|
||||
primary={LL.PSRAM()}
|
||||
secondary={`${formatNumber(data.psram_size)} KB / ${formatNumber(data.free_psram)} KB`}
|
||||
/>
|
||||
)}
|
||||
<HardwareListItem
|
||||
icon={<SdStorageIcon />}
|
||||
primary={LL.FLASH()}
|
||||
secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
|
||||
/>
|
||||
<HardwareListItem
|
||||
icon={<SdCardAlertIcon />}
|
||||
primary={LL.APPSIZE()}
|
||||
secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
|
||||
/>
|
||||
<HardwareListItem
|
||||
icon={<FolderIcon />}
|
||||
primary={LL.FILESYSTEM()}
|
||||
secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
|
||||
/>
|
||||
</List>
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default HardwareStatus;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type FC, memo, useMemo } from 'react';
|
||||
|
||||
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||
import ReportIcon from '@mui/icons-material/Report';
|
||||
@@ -22,17 +24,28 @@ import type { MqttStatusType } from 'types';
|
||||
import { MqttDisconnectReason } from 'types';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
// Disconnect reason lookup table - created once, reused across renders
|
||||
const DISCONNECT_REASONS: Record<MqttDisconnectReason, string> = {
|
||||
[MqttDisconnectReason.USER_OK]: 'User disconnected',
|
||||
[MqttDisconnectReason.TCP_DISCONNECTED]: 'TCP disconnected',
|
||||
[MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION]:
|
||||
'Unacceptable protocol version',
|
||||
[MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED]: 'Client ID rejected',
|
||||
[MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE]: 'Server unavailable',
|
||||
[MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS]: 'Malformed credentials',
|
||||
[MqttDisconnectReason.MQTT_NOT_AUTHORIZED]: 'Not authorized',
|
||||
[MqttDisconnectReason.TLS_BAD_FINGERPRINT]: 'TLS fingerprint invalid'
|
||||
};
|
||||
|
||||
const getDisconnectReason = (disconnect_reason: MqttDisconnectReason): string =>
|
||||
DISCONNECT_REASONS[disconnect_reason] ?? 'Unknown';
|
||||
|
||||
export const mqttStatusHighlight = (
|
||||
{ enabled, connected }: MqttStatusType,
|
||||
theme: Theme
|
||||
) => {
|
||||
if (!enabled) {
|
||||
return theme.palette.info.main;
|
||||
}
|
||||
if (connected) {
|
||||
return theme.palette.success.main;
|
||||
}
|
||||
return theme.palette.error.main;
|
||||
if (!enabled) return theme.palette.info.main;
|
||||
return connected ? theme.palette.success.main : theme.palette.error.main;
|
||||
};
|
||||
|
||||
export const mqttPublishHighlight = (
|
||||
@@ -41,114 +54,100 @@ export const mqttPublishHighlight = (
|
||||
) => {
|
||||
if (mqtt_fails === 0) return theme.palette.success.main;
|
||||
if (mqtt_fails < 10) return theme.palette.warning.main;
|
||||
|
||||
return theme.palette.error.main;
|
||||
};
|
||||
|
||||
export const mqttQueueHighlight = (
|
||||
{ mqtt_queued }: MqttStatusType,
|
||||
theme: Theme
|
||||
) => {
|
||||
if (mqtt_queued <= 1) return theme.palette.success.main;
|
||||
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
|
||||
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
|
||||
|
||||
return theme.palette.warning.main;
|
||||
};
|
||||
interface ConnectionStatusProps {
|
||||
data: MqttStatusType;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// Memoized component to prevent unnecessary re-renders when parent updates
|
||||
const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data.connected && (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<ReportIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.DISCONNECT_REASON()}
|
||||
secondary={getDisconnectReason(data.disconnect_reason)}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
)}
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>#</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
|
||||
<AutoAwesomeMotionIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
|
||||
<SpeakerNotesOffIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const MqttStatus = () => {
|
||||
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
|
||||
const { LL } = useI18nContext();
|
||||
const theme = useTheme();
|
||||
|
||||
useLayoutTitle('MQTT');
|
||||
|
||||
useInterval(() => {
|
||||
void loadData();
|
||||
});
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('MQTT');
|
||||
// Memoize error message separately to avoid re-renders on error object changes
|
||||
const errorMessage = error?.message || '';
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
|
||||
if (!enabled) {
|
||||
return LL.NOT_ENABLED();
|
||||
}
|
||||
if (connected) {
|
||||
return LL.CONNECTED(0) + ' (' + connect_count + ')';
|
||||
}
|
||||
return LL.DISCONNECTED() + ' (' + connect_count + ')';
|
||||
};
|
||||
|
||||
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
|
||||
switch (disconnect_reason) {
|
||||
case MqttDisconnectReason.TCP_DISCONNECTED:
|
||||
return 'TCP disconnected';
|
||||
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
|
||||
return 'Unacceptable protocol version';
|
||||
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
|
||||
return 'Client ID rejected';
|
||||
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
|
||||
return 'Server unavailable';
|
||||
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
|
||||
return 'Malformed credentials';
|
||||
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
|
||||
return 'Not authorized';
|
||||
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
|
||||
return 'TLS fingerprint invalid';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
const renderConnectionStatus = () => (
|
||||
<>
|
||||
{!data.connected && (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<ReportIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.DISCONNECT_REASON()}
|
||||
secondary={disconnectReason(data)}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
)}
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>#</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
|
||||
<AutoAwesomeMotionIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
|
||||
<SpeakerNotesOffIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
);
|
||||
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 (
|
||||
<SectionContent>
|
||||
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
@@ -156,15 +155,13 @@ const MqttStatus = () => {
|
||||
<DeviceHubIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
|
||||
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
{data.enabled && renderConnectionStatus()}
|
||||
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default MqttStatus;
|
||||
|
||||
@@ -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';
|
||||
@@ -23,6 +25,23 @@ import { NTPSyncStatus } from 'types';
|
||||
import { useInterval } from 'utils';
|
||||
import { formatDateTime } from 'utils';
|
||||
|
||||
// Utility functions
|
||||
const isNtpEnabled = ({ status }: NTPStatusType) =>
|
||||
status !== NTPSyncStatus.NTP_DISABLED;
|
||||
|
||||
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
|
||||
switch (status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return theme.palette.info.main;
|
||||
case NTPSyncStatus.NTP_INACTIVE:
|
||||
return theme.palette.error.main;
|
||||
case NTPSyncStatus.NTP_ACTIVE:
|
||||
return theme.palette.success.main;
|
||||
default:
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
};
|
||||
|
||||
const NTPStatus = () => {
|
||||
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
|
||||
|
||||
@@ -33,24 +52,6 @@ const NTPStatus = () => {
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle('NTP');
|
||||
|
||||
NTPApi.updateTime;
|
||||
|
||||
const isNtpEnabled = ({ status }: NTPStatusType) =>
|
||||
status !== NTPSyncStatus.NTP_DISABLED;
|
||||
|
||||
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
|
||||
switch (status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return theme.palette.info.main;
|
||||
case NTPSyncStatus.NTP_INACTIVE:
|
||||
return theme.palette.error.main;
|
||||
case NTPSyncStatus.NTP_ACTIVE:
|
||||
return theme.palette.success.main;
|
||||
default:
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const ntpStatus = ({ status }: NTPStatusType) => {
|
||||
@@ -66,66 +67,64 @@ const NTPStatus = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
|
||||
<UpdateIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
{isNtpEnabled(data) && (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<DnsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
)}
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AccessTimeIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.LOCAL_TIME(0)}
|
||||
secondary={formatDateTime(data.local_time)}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<SwapVerticalCircleIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.UTC_TIME()}
|
||||
secondary={formatDateTime(data.utc_time)}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
</>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
|
||||
<UpdateIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
{isNtpEnabled(data) && (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<DnsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</>
|
||||
)}
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AccessTimeIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.LOCAL_TIME(0)}
|
||||
secondary={formatDateTime(data.local_time)}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<SwapVerticalCircleIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.UTC_TIME()}
|
||||
secondary={formatDateTime(data.utc_time)}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
);
|
||||
};
|
||||
}, [data, error, loadData, LL, theme]);
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
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';
|
||||
@@ -25,10 +27,17 @@ import type { NetworkStatusType } from 'types';
|
||||
import { NetworkConnectionStatus } from 'types';
|
||||
import { useInterval } from 'utils';
|
||||
|
||||
// Utility functions
|
||||
const isConnected = ({ status }: NetworkStatusType) =>
|
||||
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
|
||||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
|
||||
|
||||
export const isWiFi = ({ status }: NetworkStatusType) =>
|
||||
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
|
||||
|
||||
export const isEthernet = ({ status }: NetworkStatusType) =>
|
||||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
|
||||
|
||||
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
|
||||
switch (status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||
@@ -55,11 +64,6 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
|
||||
return theme.palette.success.main;
|
||||
};
|
||||
|
||||
export const isWiFi = ({ status }: NetworkStatusType) =>
|
||||
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
|
||||
export const isEthernet = ({ status }: NetworkStatusType) =>
|
||||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
|
||||
|
||||
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
|
||||
if (!dns_ip_1) {
|
||||
return 'none';
|
||||
@@ -81,6 +85,33 @@ const IPs = (status: NetworkStatusType) => {
|
||||
return status.local_ip + ', ' + status.local_ipv6;
|
||||
};
|
||||
|
||||
const getNetworkStatusText = (
|
||||
status: NetworkConnectionStatus,
|
||||
reconnectCount: number,
|
||||
LL: ReturnType<typeof useI18nContext>['LL']
|
||||
) => {
|
||||
switch (status) {
|
||||
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
|
||||
return LL.CONNECTED(0) + ' (Ethernet)';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return LL.INACTIVE(1);
|
||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||
return LL.IDLE();
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
||||
return 'No SSID Available';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
|
||||
return LL.CONNECTED(0) + ' (WiFi) (' + reconnectCount + ')';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
||||
return LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + reconnectCount + ')';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + reconnectCount + ')';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
return LL.DISCONNECTED();
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
|
||||
const NetworkStatus = () => {
|
||||
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
|
||||
|
||||
@@ -93,51 +124,30 @@ const NetworkStatus = () => {
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const networkStatus = ({ status }: NetworkStatusType) => {
|
||||
switch (status) {
|
||||
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
|
||||
return LL.CONNECTED(0) + ' (Ethernet)';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return LL.INACTIVE(1);
|
||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||
return LL.IDLE();
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
||||
return 'No SSID Available';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
|
||||
return LL.CONNECTED(0) + ' (WiFi) (' + data.reconnect_count + ')';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
||||
return (
|
||||
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
|
||||
);
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
return LL.DISCONNECTED();
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
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 (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
|
||||
<Avatar sx={{ bgcolor: statusColor }}>
|
||||
{isWiFi(data) && <WifiIcon />}
|
||||
{isEthernet(data) && <RouterIcon />}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Status" secondary={networkStatus(data)} />
|
||||
<ListItemText primary="Status" secondary={statusText} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
|
||||
<Avatar sx={{ bgcolor: statusColor }}>
|
||||
<GiteIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
@@ -148,13 +158,13 @@ const NetworkStatus = () => {
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
|
||||
<Avatar sx={{ bgcolor: qualityColor }}>
|
||||
<SettingsInputAntennaIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="SSID (RSSI)"
|
||||
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
|
||||
secondary={`${data.ssid} (${data.rssi} dBm)`}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
@@ -218,9 +228,9 @@ const NetworkStatus = () => {
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
}, [data, error, loadData, LL, theme]);
|
||||
|
||||
return <SectionContent>{content()}</SectionContent>;
|
||||
return <SectionContent>{content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default NetworkStatus;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
@@ -8,10 +8,10 @@ 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 TimerIcon from '@mui/icons-material/Timer';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -37,12 +37,34 @@ import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
|
||||
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 = (
|
||||
duration_sec: number,
|
||||
LL: ReturnType<typeof useI18nContext>['LL']
|
||||
) => {
|
||||
const ms = duration_sec * 1000;
|
||||
const days = Math.trunc(ms / 86400000);
|
||||
const hours = Math.trunc(ms / 3600000) % 24;
|
||||
const minutes = Math.trunc(ms / 60000) % 60;
|
||||
const seconds = Math.trunc(ms / 1000) % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days) parts.push(LL.NUM_DAYS({ num: days }));
|
||||
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
|
||||
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||
parts.push(LL.NUM_SECONDS({ num: seconds }));
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const SystemStatus = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
@@ -62,7 +84,6 @@ const SystemStatus = () => {
|
||||
send: loadData,
|
||||
error
|
||||
} = useRequest(readSystemStatus, {
|
||||
initialData: [],
|
||||
async middleware(_, next) {
|
||||
if (!restarting) {
|
||||
await next();
|
||||
@@ -76,51 +97,46 @@ const SystemStatus = () => {
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const formatDurationSec = (duration_sec: number) => {
|
||||
const days = Math.trunc((duration_sec * 1000) / 86400000);
|
||||
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
|
||||
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
|
||||
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
|
||||
// Memoize derived status values to avoid recalculation on every render
|
||||
const busStatus = useMemo(() => {
|
||||
if (!data) return 'EMS state unknown';
|
||||
|
||||
let formatted = '';
|
||||
if (days) {
|
||||
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||
return 'EMS ' + LL.TX_ISSUES();
|
||||
case busConnectionStatus.BUS_STATUS_OFFLINE:
|
||||
return 'EMS ' + LL.DISCONNECTED();
|
||||
default:
|
||||
return 'EMS state unknown';
|
||||
}
|
||||
if (hours) {
|
||||
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
|
||||
}
|
||||
if (minutes) {
|
||||
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
|
||||
}
|
||||
formatted += LL.NUM_SECONDS({ num: seconds });
|
||||
return formatted;
|
||||
};
|
||||
}, [data?.bus_status, data?.bus_uptime, LL]);
|
||||
|
||||
function formatNumber(num: number) {
|
||||
return new Intl.NumberFormat().format(num);
|
||||
}
|
||||
// Memoize derived status values to avoid recalculation on every render
|
||||
const systemStatus = useMemo(() => {
|
||||
if (!data) return '??';
|
||||
|
||||
const busStatus = () => {
|
||||
if (data) {
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||
return (
|
||||
'EMS ' +
|
||||
LL.CONNECTED(0) +
|
||||
' (' +
|
||||
formatDurationSec(data.bus_uptime) +
|
||||
')'
|
||||
);
|
||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||
return 'EMS ' + LL.TX_ISSUES();
|
||||
case busConnectionStatus.BUS_STATUS_OFFLINE:
|
||||
return 'EMS ' + LL.DISCONNECTED();
|
||||
}
|
||||
switch (data.status) {
|
||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
||||
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
||||
return LL.WAIT_FIRMWARE();
|
||||
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
|
||||
return LL.ERROR();
|
||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
|
||||
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
|
||||
return LL.RESTARTING_PRE();
|
||||
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
||||
return LL.GPIO_OF(LL.FAILED(0));
|
||||
default:
|
||||
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||
return 'OK';
|
||||
}
|
||||
return 'EMS state unknown';
|
||||
};
|
||||
}, [data?.status, LL]);
|
||||
|
||||
const busStatusHighlight = useMemo(() => {
|
||||
if (!data) return theme.palette.warning.main;
|
||||
|
||||
const busStatusHighlight = () => {
|
||||
switch (data.bus_status) {
|
||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||
return theme.palette.warning.main;
|
||||
@@ -131,27 +147,28 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
};
|
||||
}, [data?.bus_status, theme.palette]);
|
||||
|
||||
const ntpStatus = useMemo(() => {
|
||||
if (!data) return LL.UNKNOWN();
|
||||
|
||||
const ntpStatus = () => {
|
||||
switch (data.ntp_status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return LL.NOT_ENABLED();
|
||||
case NTPSyncStatus.NTP_INACTIVE:
|
||||
return LL.INACTIVE(0);
|
||||
case NTPSyncStatus.NTP_ACTIVE:
|
||||
return (
|
||||
LL.ACTIVE() +
|
||||
(data.ntp_time !== undefined
|
||||
? ' (' + formatDateTime(data.ntp_time) + ')'
|
||||
: '')
|
||||
);
|
||||
return data.ntp_time
|
||||
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
|
||||
: LL.ACTIVE();
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
}, [data?.ntp_status, data?.ntp_time, LL]);
|
||||
|
||||
const ntpStatusHighlight = useMemo(() => {
|
||||
if (!data) return theme.palette.error.main;
|
||||
|
||||
const ntpStatusHighlight = () => {
|
||||
switch (data.ntp_status) {
|
||||
case NTPSyncStatus.NTP_DISABLED:
|
||||
return theme.palette.info.main;
|
||||
@@ -162,9 +179,11 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.error.main;
|
||||
}
|
||||
};
|
||||
}, [data?.ntp_status, theme.palette]);
|
||||
|
||||
const networkStatusHighlight = useMemo(() => {
|
||||
if (!data) return theme.palette.warning.main;
|
||||
|
||||
const networkStatusHighlight = () => {
|
||||
switch (data.network_status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
@@ -179,9 +198,11 @@ const SystemStatus = () => {
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
};
|
||||
}, [data?.network_status, theme.palette]);
|
||||
|
||||
const networkStatus = useMemo(() => {
|
||||
if (!data) return LL.UNKNOWN();
|
||||
|
||||
const networkStatus = () => {
|
||||
switch (data.network_status) {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return LL.INACTIVE(1);
|
||||
@@ -190,24 +211,27 @@ const SystemStatus = () => {
|
||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
||||
return 'No SSID Available';
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
|
||||
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)';
|
||||
return `${LL.CONNECTED(0)} (WiFi, ${data.wifi_rssi} dBm)`;
|
||||
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
|
||||
return LL.CONNECTED(0) + ' (Ethernet)';
|
||||
return `${LL.CONNECTED(0)} (Ethernet)`;
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
||||
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
|
||||
return `${LL.CONNECTED(1)} ${LL.FAILED(0)}`;
|
||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||
return LL.CONNECTED(1) + ' ' + LL.LOST();
|
||||
return `${LL.CONNECTED(1)} ${LL.LOST()}`;
|
||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
return LL.DISCONNECTED();
|
||||
default:
|
||||
return LL.UNKNOWN();
|
||||
}
|
||||
};
|
||||
}, [data?.network_status, data?.wifi_rssi, LL]);
|
||||
|
||||
const activeHighlight = (value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main;
|
||||
const activeHighlight = useCallback(
|
||||
(value: boolean) =>
|
||||
value ? theme.palette.success.main : theme.palette.info.main,
|
||||
[theme.palette]
|
||||
);
|
||||
|
||||
const doRestart = async () => {
|
||||
const doRestart = useCallback(async () => {
|
||||
setConfirmRestart(false);
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
@@ -215,69 +239,114 @@ const SystemStatus = () => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
}, [sendAPI]);
|
||||
|
||||
const renderRestartDialog = () => (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={confirmRestart}
|
||||
onClose={() => setConfirmRestart(false)}
|
||||
>
|
||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setConfirmRestart(false)}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
onClick={doRestart}
|
||||
color="error"
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
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]
|
||||
);
|
||||
|
||||
const content = () => {
|
||||
// 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 sx={{ borderRadius: 3, border: '2px solid grey' }}>
|
||||
<List>
|
||||
<ListMenuItem
|
||||
icon={BuildIcon}
|
||||
bgcolor="#72caf9"
|
||||
label="EMS-ESP Firmware"
|
||||
text={'v' + data.emsesp_version}
|
||||
text={firmwareVersion}
|
||||
to="version"
|
||||
/>
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||
<TimerIcon />
|
||||
<MonitorHeartIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={LL.UPTIME()}
|
||||
secondary={formatDurationSec(data.uptime)}
|
||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
|
||||
/>
|
||||
{me.admin && (
|
||||
<Button
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmRestart(true)}
|
||||
onClick={handleRestartClick}
|
||||
>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
@@ -289,29 +358,25 @@ const SystemStatus = () => {
|
||||
icon={MemoryIcon}
|
||||
bgcolor="#68374d"
|
||||
label={LL.HARDWARE()}
|
||||
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
|
||||
text={freeMemoryText}
|
||||
to="/status/hardwarestatus"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={DirectionsBusIcon}
|
||||
bgcolor={busStatusHighlight()}
|
||||
bgcolor={busStatusHighlight}
|
||||
label={LL.DATA_TRAFFIC()}
|
||||
text={busStatus()}
|
||||
text={busStatus}
|
||||
to="/status/activity"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={
|
||||
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||
? WifiIcon
|
||||
: RouterIcon
|
||||
}
|
||||
bgcolor={networkStatusHighlight()}
|
||||
icon={networkIcon}
|
||||
bgcolor={networkStatusHighlight}
|
||||
label={LL.NETWORK(1)}
|
||||
text={networkStatus()}
|
||||
text={networkStatus}
|
||||
to="/status/network"
|
||||
/>
|
||||
|
||||
@@ -320,16 +385,16 @@ const SystemStatus = () => {
|
||||
icon={DeviceHubIcon}
|
||||
bgcolor={activeHighlight(data.mqtt_status)}
|
||||
label="MQTT"
|
||||
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
||||
text={mqttStatusText}
|
||||
to="/status/mqtt"
|
||||
/>
|
||||
|
||||
<ListMenuItem
|
||||
disabled={!me.admin}
|
||||
icon={AccessTimeIcon}
|
||||
bgcolor={ntpStatusHighlight()}
|
||||
bgcolor={ntpStatusHighlight}
|
||||
label="NTP"
|
||||
text={ntpStatus()}
|
||||
text={ntpStatus}
|
||||
to="/status/ntp"
|
||||
/>
|
||||
|
||||
@@ -338,7 +403,7 @@ const SystemStatus = () => {
|
||||
icon={SettingsInputAntennaIcon}
|
||||
bgcolor={activeHighlight(data.ap_status)}
|
||||
label={LL.ACCESS_POINT(0)}
|
||||
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
||||
text={apStatusText}
|
||||
to="/status/ap"
|
||||
/>
|
||||
|
||||
@@ -352,14 +417,33 @@ const SystemStatus = () => {
|
||||
/>
|
||||
</List>
|
||||
|
||||
{renderRestartDialog()}
|
||||
{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>
|
||||
);
|
||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default SystemStatus;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, 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';
|
||||
@@ -31,6 +38,8 @@ 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
|
||||
@@ -47,11 +56,6 @@ const LogEntryLine = styled('span')(
|
||||
})
|
||||
);
|
||||
|
||||
const topOffset = () =>
|
||||
document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
|
||||
const leftOffset = () =>
|
||||
document.getElementById('log-window')?.getBoundingClientRect().left || 0;
|
||||
|
||||
const levelLabel = (level: LogLevel) => {
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
@@ -71,6 +75,39 @@ const levelLabel = (level: LogLevel) => {
|
||||
}
|
||||
};
|
||||
|
||||
const paddedLevelLabel = (level: LogLevel, compact: boolean) => {
|
||||
const label = levelLabel(level);
|
||||
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
|
||||
};
|
||||
|
||||
const paddedNameLabel = (name: string, compact: boolean) => {
|
||||
const label = '[' + name + ']';
|
||||
return compact ? label : label.padEnd(12, '\xa0');
|
||||
};
|
||||
|
||||
const paddedIDLabel = (id: number, compact: boolean) => {
|
||||
const label = id + ':';
|
||||
return compact ? label : label.padEnd(7, '\xa0');
|
||||
};
|
||||
|
||||
// Memoized log entry component to prevent unnecessary re-renders
|
||||
const LogEntryItem = memo(
|
||||
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
|
||||
return (
|
||||
<div style={{ font: '13px monospace', whiteSpace: 'nowrap' }}>
|
||||
<span>{entry.t}</span>
|
||||
<span>{paddedLevelLabel(entry.l, compact)} </span>
|
||||
<span>{paddedIDLabel(entry.i, compact)} </span>
|
||||
<span>{paddedNameLabel(entry.n, compact)} </span>
|
||||
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.entry.i === nextProps.entry.i &&
|
||||
prevProps.compact === nextProps.compact
|
||||
);
|
||||
|
||||
const SystemLog = () => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
@@ -102,54 +139,89 @@ const SystemLog = () => {
|
||||
const [readOpen, setReadOpen] = useState(false);
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const [lastId, setLastId] = useState<number>(-1);
|
||||
const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
|
||||
|
||||
const updateFormValue = updateValueDirty(
|
||||
origData,
|
||||
origData as unknown as Record<string, unknown>,
|
||||
dirtyFlags,
|
||||
setDirtyFlags,
|
||||
updateDataValue as (value: unknown) => void
|
||||
);
|
||||
|
||||
// Calculate box position after layout
|
||||
useLayoutEffect(() => {
|
||||
const logWindow = document.getElementById('log-window');
|
||||
if (!logWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
const windowElement = document.getElementById('log-window');
|
||||
if (!windowElement) {
|
||||
return;
|
||||
}
|
||||
const rect = windowElement.getBoundingClientRect();
|
||||
setBoxPosition({ top: rect.bottom, left: rect.left });
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Debounce resize events with requestAnimationFrame
|
||||
let rafId: number;
|
||||
const handleResize = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(updatePosition);
|
||||
};
|
||||
|
||||
// Update position on window resize
|
||||
window.addEventListener('resize', handleResize);
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(logWindow);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
resizeObserver.disconnect();
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [data]); // Recalculate when data changes (in case layout shifts)
|
||||
|
||||
// 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) => {
|
||||
// Skip if this is a duplicate entry (check last entry id)
|
||||
if (log.length > 0) {
|
||||
const lastEntry = log[log.length - 1];
|
||||
if (lastEntry && logentry.i <= lastEntry.i) {
|
||||
return log;
|
||||
}
|
||||
}
|
||||
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,
|
||||
interceptByGlobalResponded: false
|
||||
})
|
||||
.onMessage((message: { data: string }) => {
|
||||
const rawData = message.data;
|
||||
const logentry = JSON.parse(rawData) as LogEntry;
|
||||
if (lastId < logentry.i) {
|
||||
setLogEntries((log) => [...log, logentry]);
|
||||
setLastId(logentry.i);
|
||||
}
|
||||
})
|
||||
.onMessage(handleLogMessage)
|
||||
.onError(() => {
|
||||
toast.error('No connection to Log service');
|
||||
});
|
||||
|
||||
const paddedLevelLabel = (level: LogLevel) => {
|
||||
const label = levelLabel(level);
|
||||
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
|
||||
};
|
||||
const onDownload = useCallback(() => {
|
||||
const result = logEntries
|
||||
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
||||
.join('\n');
|
||||
|
||||
const paddedNameLabel = (name: string) => {
|
||||
const label = '[' + name + ']';
|
||||
return data?.compact ? label : label.padEnd(12, '\xa0');
|
||||
};
|
||||
|
||||
const paddedIDLabel = (id: number) => {
|
||||
const label = id + ':';
|
||||
return data?.compact ? label : label.padEnd(7, '\xa0');
|
||||
};
|
||||
|
||||
const onDownload = () => {
|
||||
let result = '';
|
||||
for (const i of logEntries) {
|
||||
result +=
|
||||
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute(
|
||||
'href',
|
||||
@@ -159,24 +231,28 @@ 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
|
||||
// handle scrolling - optimized to only scroll when needed
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const logWindowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (logEntries.length && autoscroll) {
|
||||
ref.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
});
|
||||
const container = logWindowRef.current;
|
||||
if (container) {
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [logEntries.length]);
|
||||
}, [logEntries.length, autoscroll]);
|
||||
|
||||
const sendReadCommand = () => {
|
||||
const sendReadCommand = useCallback(() => {
|
||||
if (readValue === '') {
|
||||
setReadOpen(!readOpen);
|
||||
return;
|
||||
@@ -187,7 +263,7 @@ const SystemLog = () => {
|
||||
setReadOpen(false);
|
||||
setReadValue('');
|
||||
}
|
||||
};
|
||||
}, [readValue, readOpen, send]);
|
||||
|
||||
const content = () => {
|
||||
if (!data) {
|
||||
@@ -279,6 +355,7 @@ const SystemLog = () => {
|
||||
>
|
||||
<IconButton
|
||||
disableRipple
|
||||
aria-label={LL.CANCEL()}
|
||||
onClick={() => {
|
||||
setReadOpen(false);
|
||||
setReadValue('');
|
||||
@@ -304,7 +381,7 @@ const SystemLog = () => {
|
||||
) : (
|
||||
<>
|
||||
{data.developer_mode && (
|
||||
<IconButton onClick={sendReadCommand}>
|
||||
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
|
||||
<PlayArrowIcon color="primary" />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -326,27 +403,20 @@ const SystemLog = () => {
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
ref={logWindowRef}
|
||||
sx={{
|
||||
backgroundColor: 'black',
|
||||
overflowY: 'scroll',
|
||||
position: 'absolute',
|
||||
right: 18,
|
||||
bottom: 18,
|
||||
left: () => leftOffset(),
|
||||
top: () => topOffset(),
|
||||
left: boxPosition.left,
|
||||
top: boxPosition.top,
|
||||
p: 1
|
||||
}}
|
||||
>
|
||||
{logEntries.map((e) => (
|
||||
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
|
||||
<span>{e.t}</span>
|
||||
<span>{paddedLevelLabel(e.l)} </span>
|
||||
<span>{paddedIDLabel(e.i)} </span>
|
||||
<span>{paddedNameLabel(e.n)} </span>
|
||||
<LogEntryLine details={{ level: e.l }} key={e.i}>
|
||||
{e.m}
|
||||
</LogEntryLine>
|
||||
</div>
|
||||
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
|
||||
))}
|
||||
|
||||
<div ref={ref} />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material';
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
|
||||
import { callAction } from 'api/app';
|
||||
import { readSystemStatus } from 'api/system';
|
||||
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import { useRequest } from 'alova/client';
|
||||
import MessageBox from 'components/MessageBox';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
@@ -17,11 +16,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
|
||||
|
||||
const SystemMonitor = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
const hasInitialized = useRef(false);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
let count = 0;
|
||||
|
||||
const { send: setSystemStatus } = useRequest(
|
||||
(status: string) => callAction({ action: 'systemStatus', param: status }),
|
||||
{
|
||||
@@ -32,10 +29,12 @@ const SystemMonitor = () => {
|
||||
const { data, send } = useRequest(readSystemStatus, {
|
||||
force: true,
|
||||
async middleware(_, next) {
|
||||
if (count++ >= 1) {
|
||||
// skip first request (1 second) to allow AsyncWS to send its response
|
||||
await next();
|
||||
// Skip first request to allow AsyncWS to send its response
|
||||
if (!hasInitialized.current) {
|
||||
hasInitialized.current = true;
|
||||
return; // Don't await next() on first call
|
||||
}
|
||||
await next();
|
||||
}
|
||||
})
|
||||
.onSuccess((event) => {
|
||||
@@ -58,40 +57,89 @@ const SystemMonitor = () => {
|
||||
void send();
|
||||
}, 1000); // check every 1 second
|
||||
|
||||
const onCancel = async () => {
|
||||
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
||||
const status = data?.status;
|
||||
|
||||
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 uploading =
|
||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||
const progress =
|
||||
uploading && status
|
||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
statusMessage: message,
|
||||
isUploading: uploading,
|
||||
progressValue: progress
|
||||
};
|
||||
}, [data?.status, LL]);
|
||||
|
||||
const onCancel = useCallback(async () => {
|
||||
setErrorMessage(undefined);
|
||||
await setSystemStatus(
|
||||
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
|
||||
);
|
||||
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
||||
document.location.href = '/';
|
||||
};
|
||||
}, [setSystemStatus]);
|
||||
|
||||
return (
|
||||
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
|
||||
<DialogContent dividers>
|
||||
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '30%',
|
||||
minWidth: '300px',
|
||||
maxWidth: '500px',
|
||||
backgroundColor: '#393939',
|
||||
border: 2,
|
||||
borderColor: '#565656',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" flexDirection="column">
|
||||
<img
|
||||
src="/app/icon.png"
|
||||
alt="EMS-ESP"
|
||||
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
|
||||
/>
|
||||
<Typography
|
||||
color="secondary"
|
||||
variant="h6"
|
||||
fontWeight={400}
|
||||
textAlign="center"
|
||||
>
|
||||
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||
? LL.WAIT_FIRMWARE()
|
||||
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
||||
? LL.APPLICATION_RESTARTING()
|
||||
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||
? LL.RESTARTING_PRE()
|
||||
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||
? 'Upload Failed'
|
||||
: LL.RESTARTING_POST()}
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
|
||||
{errorMessage ? (
|
||||
<MessageBox my={2} level="error" message={errorMessage}>
|
||||
<MessageBox level="error" message={errorMessage}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
variant="contained"
|
||||
color="error"
|
||||
@@ -105,20 +153,16 @@ const SystemMonitor = () => {
|
||||
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
||||
{LL.PLEASE_WAIT()}…
|
||||
</Typography>
|
||||
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
|
||||
{isUploading && (
|
||||
<Box width="100%" pl={2} pr={2} py={2}>
|
||||
<LinearProgressWithLabel
|
||||
value={Math.round(
|
||||
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||
)}
|
||||
/>
|
||||
<LinearProgressWithLabel value={progressValue} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
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,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Link,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
||||
@@ -36,168 +48,183 @@ import {
|
||||
} 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';
|
||||
|
||||
const Version = () => {
|
||||
const { LL, locale } = useI18nContext();
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
// 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';
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [openInstallDialog, setOpenInstallDialog] = 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);
|
||||
// Types for better type safety
|
||||
interface VersionData {
|
||||
emsesp_version: string;
|
||||
arduino_version: string;
|
||||
esp_platform: string;
|
||||
flash_chip_size: number;
|
||||
psram: boolean;
|
||||
build_flags?: string;
|
||||
}
|
||||
|
||||
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';
|
||||
interface UpgradeCheckData {
|
||||
emsesp_version: string;
|
||||
dev_upgradeable: boolean;
|
||||
stable_upgradeable: boolean;
|
||||
}
|
||||
|
||||
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';
|
||||
interface VersionInfo {
|
||||
name: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
const { send: sendCheckUpgrade } = useRequest(
|
||||
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
).onSuccess((event) => {
|
||||
const data = event.data as {
|
||||
emsesp_version: string;
|
||||
dev_upgradeable: boolean;
|
||||
stable_upgradeable: boolean;
|
||||
};
|
||||
setDevUpgradeAvailable(data.dev_upgradeable);
|
||||
setStableUpgradeAvailable(data.stable_upgradeable);
|
||||
});
|
||||
// Memoized components for better performance
|
||||
const VersionInfoDialog = memo(
|
||||
({
|
||||
showVersionInfo,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
locale,
|
||||
LL,
|
||||
onClose
|
||||
}: {
|
||||
showVersionInfo: number;
|
||||
latestVersion?: VersionInfo;
|
||||
latestDevVersion?: VersionInfo;
|
||||
locale: string;
|
||||
LL: TranslationFunctions;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
if (showVersionInfo === 0) return null;
|
||||
|
||||
const {
|
||||
data: data,
|
||||
send: loadData,
|
||||
error
|
||||
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
|
||||
// older version of EMS-ESP using ESP32 (not S3) and no PSRAM, can't use OTA because of SSL support in HttpClient
|
||||
if (event.data.arduino_version.startsWith('Tasmota')) {
|
||||
setDownloadOnly(true);
|
||||
}
|
||||
setUsingDevVersion(event.data.emsesp_version.includes('dev'));
|
||||
});
|
||||
const isStable = showVersionInfo === 1;
|
||||
const version = isStable ? latestVersion : latestDevVersion;
|
||||
const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
|
||||
|
||||
const { send: sendUploadURL } = useRequest(
|
||||
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
);
|
||||
|
||||
// called immediately to get the latest versions on page load
|
||||
const { data: latestVersion } = useRequest(getStableVersion);
|
||||
const { data: latestDevVersion } = useRequest(getDevVersion);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestVersion && latestDevVersion) {
|
||||
sendCheckUpgrade(latestDevVersion.name + ',' + latestVersion.name)
|
||||
.catch((error: Error) => {
|
||||
toast.error('Failed to check for upgrades: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setInternetLive(true);
|
||||
});
|
||||
}
|
||||
}, [latestVersion, latestDevVersion]);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
const DIVISIONS: Array<{ amount: number; name: string }> = [
|
||||
{ amount: 60, name: 'seconds' },
|
||||
{ amount: 60, name: 'minutes' },
|
||||
{ amount: 24, name: 'hours' },
|
||||
{ amount: 7, name: 'days' },
|
||||
{ amount: 4.34524, name: 'weeks' },
|
||||
{ amount: 12, name: 'months' },
|
||||
{ amount: Number.POSITIVE_INFINITY, name: 'years' }
|
||||
];
|
||||
function formatTimeAgo(date: Date) {
|
||||
let duration = (date.getTime() - new Date().getTime()) / 1000;
|
||||
for (let i = 0; i < DIVISIONS.length; i++) {
|
||||
const division = DIVISIONS[i];
|
||||
if (division && Math.abs(duration) < division.amount) {
|
||||
return rtf.format(
|
||||
Math.round(duration),
|
||||
division.name as Intl.RelativeTimeFormatUnit
|
||||
);
|
||||
}
|
||||
if (division) {
|
||||
duration /= division.amount;
|
||||
}
|
||||
}
|
||||
return rtf.format(0, 'seconds');
|
||||
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,
|
||||
width: 90
|
||||
}}
|
||||
>
|
||||
{LL.TYPE(0)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{isStable ? LL.STABLE() : LL.DEVELOPMENT()}
|
||||
</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
|
||||
}}
|
||||
>
|
||||
{LL.VERSION()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||
{version?.name}
|
||||
</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
|
||||
}}
|
||||
>
|
||||
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>
|
||||
<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 { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||
immediate: false
|
||||
});
|
||||
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 doRestart = async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
};
|
||||
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
||||
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
|
||||
|
||||
const getBinURL = (showingDev: boolean) => {
|
||||
if (!internetLive) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const filename =
|
||||
'EMS-ESP-' +
|
||||
(showingDev ? latestDevVersion.name : latestVersion.name).replaceAll(
|
||||
'.',
|
||||
'_'
|
||||
) +
|
||||
'-' +
|
||||
getPlatform() +
|
||||
'.bin';
|
||||
return showingDev
|
||||
? DEV_URL + filename
|
||||
: STABLE_URL + 'v' + latestVersion.name + '/' + filename;
|
||||
};
|
||||
|
||||
const getPlatform = () => {
|
||||
return (
|
||||
[data.esp_platform, data.flash_chip_size >= 16384 ? '16MB' : '4MB'].join('-') +
|
||||
(data.psram ? '+' : '')
|
||||
);
|
||||
};
|
||||
|
||||
const installFirmwareURL = async (url: string) => {
|
||||
await sendUploadURL(url).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
setRestarting(true);
|
||||
};
|
||||
|
||||
useLayoutTitle('EMS-ESP Firmware');
|
||||
|
||||
const renderInstallDialog = () => {
|
||||
const binURL = getBinURL(fetchDevVersion);
|
||||
return fetchDevVersion
|
||||
? `${DEV_URL}${filename}`
|
||||
: `${STABLE_URL}v${version.name}/${filename}`;
|
||||
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
sx={dialogStyle}
|
||||
open={openInstallDialog}
|
||||
onClose={() => closeInstallDialog()}
|
||||
>
|
||||
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||
<DialogTitle>
|
||||
{LL.UPDATE() +
|
||||
' ' +
|
||||
(fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()) +
|
||||
' Firmware'}
|
||||
{`${LL.UPDATE()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography mb={2}>
|
||||
@@ -211,7 +238,7 @@ const Version = () => {
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => closeInstallDialog()}
|
||||
onClick={onClose}
|
||||
color="secondary"
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
@@ -219,7 +246,7 @@ const Version = () => {
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => closeInstallDialog()}
|
||||
onClick={onClose}
|
||||
color="primary"
|
||||
>
|
||||
<Link underline="none" target="_blank" href={binURL} color="primary">
|
||||
@@ -230,7 +257,7 @@ const Version = () => {
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="outlined"
|
||||
onClick={() => installFirmwareURL(binURL)}
|
||||
onClick={() => onInstall(binURL)}
|
||||
color="primary"
|
||||
>
|
||||
{LL.INSTALL()}
|
||||
@@ -239,76 +266,186 @@ const Version = () => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const showFirmwareDialog = (useDevVersion: boolean) => {
|
||||
// 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 [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);
|
||||
|
||||
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 {
|
||||
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]);
|
||||
const isDev = useMemo(
|
||||
() => data?.emsesp_version.includes('dev') ?? false,
|
||||
[data?.emsesp_version]
|
||||
);
|
||||
|
||||
const doRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||
(error: Error) => {
|
||||
toast.error(error.message);
|
||||
}
|
||||
);
|
||||
}, [sendAPI]);
|
||||
|
||||
const installFirmwareURL = useCallback(
|
||||
async (url: string) => {
|
||||
await sendUploadURL(url).catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
setRestarting(true);
|
||||
},
|
||||
[sendUploadURL]
|
||||
);
|
||||
|
||||
const showFirmwareDialog = useCallback((useDevVersion: boolean) => {
|
||||
setFetchDevVersion(useDevVersion);
|
||||
setOpenInstallDialog(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeInstallDialog = () => {
|
||||
const closeInstallDialog = useCallback(() => {
|
||||
setOpenInstallDialog(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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;
|
||||
const handleVersionInfoClose = useCallback(() => {
|
||||
setShowVersionInfo(0);
|
||||
}, []);
|
||||
|
||||
// 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: 2 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{LL.REINSTALL()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me.admin) return null;
|
||||
|
||||
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: 2 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{LL.REINSTALL()}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
variant="outlined"
|
||||
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{choice}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
usingDevVersion,
|
||||
devUpgradeAvailable,
|
||||
stableUpgradeAvailable,
|
||||
me.admin,
|
||||
LL,
|
||||
showFirmwareDialog
|
||||
]
|
||||
);
|
||||
|
||||
if (!me.admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
variant="outlined"
|
||||
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
||||
size="small"
|
||||
onClick={() => showFirmwareDialog(showingDev)}
|
||||
>
|
||||
{choice}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const content = () => {
|
||||
const content = useMemo(() => {
|
||||
if (!data) {
|
||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||
}
|
||||
|
||||
const isDev = data.emsesp_version.includes('dev');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box p={2} border="1px solid grey" borderRadius={2}>
|
||||
@@ -344,7 +481,7 @@ const Version = () => {
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
{getPlatform()}
|
||||
{platform}
|
||||
<Typography variant="caption">
|
||||
(
|
||||
{data.psram ? (
|
||||
@@ -372,48 +509,11 @@ const Version = () => {
|
||||
<Grid size={{ xs: 4, md: 2 }}>
|
||||
<Typography color="secondary">{LL.RELEASE_TYPE()}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<FormControlLabel
|
||||
disabled={!isDev}
|
||||
control={
|
||||
<Checkbox
|
||||
sx={{
|
||||
'&.Mui-checked': {
|
||||
color: 'lightblue'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
slotProps={{
|
||||
typography: {
|
||||
color: 'grey'
|
||||
}
|
||||
}}
|
||||
checked={!isDev}
|
||||
label={LL.STABLE()}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 16 } }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
disabled={isDev}
|
||||
control={
|
||||
<Checkbox
|
||||
sx={{
|
||||
'&.Mui-checked': {
|
||||
color: 'lightblue'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
slotProps={{
|
||||
typography: {
|
||||
color: 'grey'
|
||||
}
|
||||
}}
|
||||
checked={isDev}
|
||||
label={LL.DEVELOPMENT()}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 16 } }}
|
||||
/>
|
||||
</Grid>
|
||||
{isDev ? (
|
||||
<Typography>{LL.DEVELOPMENT()}</Typography>
|
||||
) : (
|
||||
<Typography>{LL.STABLE()}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{internetLive ? (
|
||||
@@ -436,15 +536,13 @@ const Version = () => {
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
<Link target="_blank" href={STABLE_RELNOTES_URL} color="primary">
|
||||
{latestVersion.name}
|
||||
</Link>
|
||||
{latestVersion.published_at && (
|
||||
<Typography component="span" variant="caption">
|
||||
(
|
||||
{formatTimeAgo(new Date(latestVersion.published_at))})
|
||||
</Typography>
|
||||
)}
|
||||
{latestVersion?.name}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(1)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(false)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
@@ -454,15 +552,13 @@ const Version = () => {
|
||||
</Grid>
|
||||
<Grid size={{ xs: 8, md: 10 }}>
|
||||
<Typography>
|
||||
<Link target="_blank" href={DEV_RELNOTES_URL} color="primary">
|
||||
{latestDevVersion.name}
|
||||
</Link>
|
||||
{latestDevVersion.published_at && (
|
||||
<Typography component="span" variant="caption">
|
||||
(
|
||||
{formatTimeAgo(new Date(latestDevVersion.published_at))})
|
||||
</Typography>
|
||||
)}
|
||||
{latestDevVersion?.name}
|
||||
<IconButton
|
||||
onClick={() => setShowVersionInfo(2)}
|
||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||
>
|
||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{showButtons(true)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
@@ -476,7 +572,25 @@ const Version = () => {
|
||||
)}
|
||||
{me.admin && (
|
||||
<>
|
||||
{renderInstallDialog()}
|
||||
<VersionInfoDialog
|
||||
showVersionInfo={showVersionInfo}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
locale={locale}
|
||||
LL={LL}
|
||||
onClose={handleVersionInfoClose}
|
||||
/>
|
||||
<InstallDialog
|
||||
openInstallDialog={openInstallDialog}
|
||||
fetchDevVersion={fetchDevVersion}
|
||||
latestVersion={latestVersion}
|
||||
latestDevVersion={latestDevVersion}
|
||||
downloadOnly={downloadOnly}
|
||||
platform={platform}
|
||||
LL={LL}
|
||||
onClose={closeInstallDialog}
|
||||
onInstall={installFirmwareURL}
|
||||
/>
|
||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||
{LL.UPLOAD()}
|
||||
</Typography>
|
||||
@@ -486,11 +600,30 @@ const Version = () => {
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
data,
|
||||
error,
|
||||
loadData,
|
||||
LL,
|
||||
platform,
|
||||
isDev,
|
||||
internetLive,
|
||||
latestVersion,
|
||||
latestDevVersion,
|
||||
showVersionInfo,
|
||||
locale,
|
||||
openInstallDialog,
|
||||
fetchDevVersion,
|
||||
downloadOnly,
|
||||
me.admin,
|
||||
showButtons,
|
||||
handleVersionInfoClose,
|
||||
closeInstallDialog,
|
||||
installFirmwareURL,
|
||||
doRestart
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
|
||||
);
|
||||
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
|
||||
};
|
||||
|
||||
export default Version;
|
||||
export default memo(Version);
|
||||
|
||||
@@ -19,6 +19,4 @@ const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
||||
</Box>
|
||||
));
|
||||
|
||||
ButtonRow.displayName = 'ButtonRow';
|
||||
|
||||
export default ButtonRow;
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { Tooltip, type TooltipProps, styled, tooltipClasses } from '@mui/material';
|
||||
import { Tooltip, type TooltipProps } from '@mui/material';
|
||||
|
||||
export const ButtonTooltip = styled(({ className, ...props }: TooltipProps) => (
|
||||
<Tooltip
|
||||
{...props}
|
||||
placement="top"
|
||||
arrow
|
||||
classes={{ ...(className && { popper: className }) }}
|
||||
/>
|
||||
))(({ theme }) => ({
|
||||
[`& .${tooltipClasses.arrow}`]: {
|
||||
color: theme.palette.success.main
|
||||
},
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: theme.palette.success.main,
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
boxShadow: theme.shadows[1],
|
||||
fontSize: 10
|
||||
}
|
||||
}));
|
||||
export const ButtonTooltip = ({ children, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props}>{children}</Tooltip>
|
||||
);
|
||||
|
||||
export default ButtonTooltip;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { FC } from 'react';
|
||||
import { type FC, memo, useMemo } from 'react';
|
||||
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
|
||||
import type { BoxProps, SvgIconProps } from '@mui/material';
|
||||
|
||||
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||
|
||||
@@ -14,22 +14,18 @@ export interface MessageBoxProps extends BoxProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const LEVEL_ICONS: {
|
||||
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
|
||||
} = {
|
||||
const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
|
||||
success: CheckCircleOutlineOutlinedIcon,
|
||||
info: InfoOutlinedIcon,
|
||||
warning: ReportProblemOutlinedIcon,
|
||||
error: ErrorIcon
|
||||
};
|
||||
|
||||
const LEVEL_BACKGROUNDS: {
|
||||
[type in MessageBoxLevel]: (theme: Theme) => string;
|
||||
} = {
|
||||
success: (theme: Theme) => theme.palette.success.dark,
|
||||
info: (theme: Theme) => theme.palette.info.main,
|
||||
warning: (theme: Theme) => theme.palette.warning.dark,
|
||||
error: (theme: Theme) => theme.palette.error.dark
|
||||
const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
|
||||
success: 'success.dark',
|
||||
info: 'info.main',
|
||||
warning: 'warning.dark',
|
||||
error: 'error.dark'
|
||||
};
|
||||
|
||||
const MessageBox: FC<MessageBoxProps> = ({
|
||||
@@ -40,25 +36,38 @@ const MessageBox: FC<MessageBoxProps> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const Icon = LEVEL_ICONS[level];
|
||||
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
|
||||
const color = 'white';
|
||||
|
||||
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, ...sx }}
|
||||
sx={{ backgroundColor, color: 'white', ...sx }}
|
||||
{...rest}
|
||||
>
|
||||
<Icon />
|
||||
<Typography sx={{ ml: 2 }} variant="body1">
|
||||
{message ?? ''}
|
||||
</Typography>
|
||||
{children}
|
||||
{(message || children) && (
|
||||
<Typography sx={{ ml: 2 }} variant="body1">
|
||||
{message}
|
||||
{children}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBox;
|
||||
export default memo(MessageBox);
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { Divider, Paper } from '@mui/material';
|
||||
import { Paper } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import type { RequiredChildrenProps } from 'utils';
|
||||
|
||||
interface SectionContentProps extends RequiredChildrenProps {
|
||||
title?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const SectionContent: FC<SectionContentProps> = (props) => {
|
||||
const { children, title, id } = props;
|
||||
return (
|
||||
<Paper id={id} sx={{ p: 2, m: 2 }}>
|
||||
{title && (
|
||||
<Divider
|
||||
sx={{
|
||||
pb: 2,
|
||||
borderColor: 'primary.main',
|
||||
fontSize: 20,
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Divider>
|
||||
)}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
// Extract styles to avoid recreation on every render
|
||||
const paperStyles: SxProps<Theme> = {
|
||||
p: 1.5,
|
||||
m: 1.5,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgb(65, 65, 65)'
|
||||
};
|
||||
|
||||
export default SectionContent;
|
||||
const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
|
||||
<Paper id={id} sx={paperStyles}>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
// Memoize to prevent unnecessary re-renders
|
||||
export default memo(SectionContent);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Optimized exports - use direct exports to reduce bundle size
|
||||
// use direct exports to reduce bundle size
|
||||
export { default as SectionContent } from './SectionContent';
|
||||
export { default as ButtonRow } from './ButtonRow';
|
||||
export { default as MessageBox } from './MessageBox';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormControlLabel } from '@mui/material';
|
||||
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BlockFormControlLabel;
|
||||
export default memo(BlockFormControlLabel);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { type ChangeEventHandler, 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';
|
||||
|
||||
@@ -17,73 +19,66 @@ import { I18nContext } from 'i18n/i18n-react';
|
||||
import type { Locales } from 'i18n/i18n-types';
|
||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { setLocale, locale } = useContext(I18nContext);
|
||||
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
|
||||
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||
target
|
||||
}) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
};
|
||||
interface LanguageOption {
|
||||
key: Locales;
|
||||
flag: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ key: 'cz', flag: CZflag, label: 'CZ' },
|
||||
{ key: 'de', flag: DEflag, label: 'DE' },
|
||||
{ key: 'en', flag: GBflag, label: 'EN' },
|
||||
{ key: 'fr', flag: FRflag, label: 'FR' },
|
||||
{ key: 'it', flag: ITflag, label: 'IT' },
|
||||
{ key: 'nl', flag: NLflag, label: 'NL' },
|
||||
{ key: 'no', flag: NOflag, label: 'NO' },
|
||||
{ key: 'pl', flag: PLflag, label: 'PL' },
|
||||
{ key: 'sk', flag: SKflag, label: 'SK' },
|
||||
{ key: 'sv', flag: SVflag, label: 'SV' },
|
||||
{ key: 'tr', flag: TRflag, label: 'TR' }
|
||||
];
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||
|
||||
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
|
||||
name="locale"
|
||||
variant="outlined"
|
||||
aria-label={LL.LANGUAGE()}
|
||||
value={locale}
|
||||
onChange={onLocaleSelected}
|
||||
size="small"
|
||||
select
|
||||
>
|
||||
<MenuItem key="cz" value="cz">
|
||||
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
CZ
|
||||
</MenuItem>
|
||||
<MenuItem key="de" value="de">
|
||||
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
DE
|
||||
</MenuItem>
|
||||
<MenuItem key="en" value="en">
|
||||
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
EN
|
||||
</MenuItem>
|
||||
<MenuItem key="fr" value="fr">
|
||||
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
FR
|
||||
</MenuItem>
|
||||
<MenuItem key="it" value="it">
|
||||
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
IT
|
||||
</MenuItem>
|
||||
<MenuItem key="nl" value="nl">
|
||||
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
NL
|
||||
</MenuItem>
|
||||
<MenuItem key="no" value="no">
|
||||
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
NO
|
||||
</MenuItem>
|
||||
<MenuItem key="pl" value="pl">
|
||||
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
PL
|
||||
</MenuItem>
|
||||
<MenuItem key="sk" value="sk">
|
||||
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
SK
|
||||
</MenuItem>
|
||||
<MenuItem key="sv" value="sv">
|
||||
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
SV
|
||||
</MenuItem>
|
||||
<MenuItem key="tr" value="tr">
|
||||
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
||||
TR
|
||||
</MenuItem>
|
||||
{menuItems}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
export default memo(LanguageSelector);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
@@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
setShowPassword((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ValidatedTextField
|
||||
{...props}
|
||||
@@ -21,7 +25,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
<IconButton
|
||||
onClick={togglePasswordVisibility}
|
||||
edge="end"
|
||||
aria-label="Password visibility"
|
||||
>
|
||||
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
@@ -32,4 +40,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedPasswordField;
|
||||
export default memo(ValidatedPasswordField);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormHelperText, TextField } from '@mui/material';
|
||||
@@ -14,18 +15,42 @@ export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
|
||||
|
||||
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
|
||||
fieldErrors,
|
||||
sx,
|
||||
...rest
|
||||
}) => {
|
||||
const errors = fieldErrors?.[rest.name];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField error={!!errors} {...rest} />
|
||||
<TextField
|
||||
error={!!errors}
|
||||
{...rest}
|
||||
aria-label="Error"
|
||||
sx={{
|
||||
'& .MuiInputBase-input.Mui-disabled': {
|
||||
WebkitTextFillColor: 'grey'
|
||||
},
|
||||
...(sx || {})
|
||||
}}
|
||||
{...(rest.disabled && {
|
||||
slotProps: {
|
||||
select: {
|
||||
IconComponent: () => null
|
||||
},
|
||||
inputLabel: {
|
||||
style: { color: 'grey' }
|
||||
}
|
||||
}
|
||||
})}
|
||||
color={rest.disabled ? 'secondary' : 'primary'}
|
||||
/>
|
||||
{errors?.map((e) => (
|
||||
<FormHelperText key={e.message}>{e.message}</FormHelperText>
|
||||
<FormHelperText key={e.message} sx={{ color: 'rgb(250, 95, 84)' }}>
|
||||
{e.message}
|
||||
</FormHelperText>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedTextField;
|
||||
export default memo(ValidatedTextField);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -13,22 +13,26 @@ import { LayoutContext } from './context';
|
||||
|
||||
export const DRAWER_WIDTH = 210;
|
||||
|
||||
const Layout: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [title, setTitle] = useState(PROJECT_NAME);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
// Memoize drawer toggle handler to prevent unnecessary re-renders
|
||||
const handleDrawerToggle = useCallback(() => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => setMobileOpen(false), [pathname]);
|
||||
// Close drawer when route changes
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// cache the object to prevent unnecessary re-renders
|
||||
const obj = useMemo(() => ({ title, setTitle }), [title]);
|
||||
// Memoize context value to prevent unnecessary re-renders
|
||||
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={obj}>
|
||||
<LayoutContext.Provider value={contextValue}>
|
||||
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
|
||||
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
|
||||
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
|
||||
@@ -39,4 +43,6 @@ const Layout: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Layout = memo(LayoutComponent);
|
||||
|
||||
export default Layout;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
@@ -13,30 +15,47 @@ interface LayoutAppBarProps {
|
||||
onToggleDrawer: () => void;
|
||||
}
|
||||
|
||||
const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
// Extract static styles
|
||||
const appBarStyles: SxProps<Theme> = {
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
ml: { md: `${DRAWER_WIDTH}px` },
|
||||
boxShadow: 'none',
|
||||
backgroundColor: '#2e586a'
|
||||
};
|
||||
|
||||
const menuButtonStyles: SxProps<Theme> = {
|
||||
mr: 2,
|
||||
display: { md: 'none' }
|
||||
};
|
||||
|
||||
const backButtonStyles: SxProps<Theme> = {
|
||||
mr: 1,
|
||||
fontSize: 20,
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const pathnames = useLocation()
|
||||
.pathname.split('/')
|
||||
.filter((x) => x);
|
||||
const pathnames = useMemo(
|
||||
() => location.pathname.split('/').filter((x) => x),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const handleBackClick = useCallback(() => {
|
||||
void navigate('/' + pathnames[0]);
|
||||
}, [navigate, pathnames]);
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
ml: { md: `${DRAWER_WIDTH}px` },
|
||||
boxShadow: 'none',
|
||||
backgroundColor: '#2e586a'
|
||||
}}
|
||||
>
|
||||
<AppBar position="fixed" sx={appBarStyles}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={onToggleDrawer}
|
||||
sx={{ mr: 2, display: { md: 'none' } }}
|
||||
sx={menuButtonStyles}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
@@ -44,10 +63,10 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
{pathnames.length > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
sx={{ mr: 1, fontSize: 20, verticalAlign: 'middle' }}
|
||||
sx={backButtonStyles}
|
||||
color="primary"
|
||||
edge="start"
|
||||
onClick={() => navigate('/' + pathnames[0])}
|
||||
onClick={handleBackClick}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
@@ -70,4 +89,6 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutAppBar = memo(LayoutAppBarComponent);
|
||||
|
||||
export default LayoutAppBar;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||
|
||||
import { PROJECT_NAME } from 'env';
|
||||
@@ -21,19 +23,23 @@ interface LayoutDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
const drawer = (
|
||||
<>
|
||||
<Toolbar disableGutters>
|
||||
<Box display="flex" alignItems="center" px={2}>
|
||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||
</Box>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
// 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>
|
||||
<Divider absolute />
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<LayoutMenu />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -66,4 +72,6 @@ const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutDrawerProps;
|
||||
const LayoutDrawer = memo(LayoutDrawerComponent);
|
||||
|
||||
export default LayoutDrawer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { 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';
|
||||
@@ -7,47 +7,24 @@ import ConstructionIcon from '@mui/icons-material/Construction';
|
||||
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
||||
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
||||
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Popover
|
||||
} from '@mui/material';
|
||||
import { Box, Divider, List, ListItemButton, ListItemText } from '@mui/material';
|
||||
|
||||
import { LanguageSelector } from 'components/inputs';
|
||||
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
|
||||
import { AuthenticatedContext } from 'contexts/authentication';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
const LayoutMenu = () => {
|
||||
const { me, signOut } = useContext(AuthenticatedContext);
|
||||
const LayoutMenuComponent = () => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const id = anchorEl ? 'app-menu-popover' : undefined;
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(true);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
setMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,28 +41,13 @@ const LayoutMenu = () => {
|
||||
>
|
||||
<ListItemButton
|
||||
alignItems="flex-start"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
onClick={handleMenuToggle}
|
||||
sx={{
|
||||
pt: 2.5,
|
||||
pb: menuOpen ? 0 : 2.5,
|
||||
'&:hover, &:focus': { '& svg': { opacity: 1 } }
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={LL.MODULES()}
|
||||
// secondary={
|
||||
// LL.CUSTOMIZATIONS() +
|
||||
// ', ' +
|
||||
// LL.SCHEDULER() +
|
||||
// ', ' +
|
||||
// LL.CUSTOM_ENTITIES(0) +
|
||||
// '...'
|
||||
// }
|
||||
// secondaryTypographyProps={{
|
||||
// noWrap: true,
|
||||
// fontSize: 12,
|
||||
// color: menuOpen ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0.5)'
|
||||
// }}
|
||||
sx={{ my: 0 }}
|
||||
slotProps={{
|
||||
primary: {
|
||||
@@ -147,66 +109,13 @@ const LayoutMenu = () => {
|
||||
to="/settings"
|
||||
/>
|
||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||
<Divider />
|
||||
<LayoutMenuItem icon={AccountCircleIcon} label={me.username} to={`/user`} />
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component="button" onClick={handleClick}>
|
||||
<ListItemIcon sx={{ color: '#9e9e9e' }}>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText sx={{ color: '#2196f3' }}>{me.username}</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
padding={2}
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '3px solid grey'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
color="primary"
|
||||
onClick={() => signOut(true)}
|
||||
>
|
||||
{LL.SIGN_OUT()}
|
||||
</Button>
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
<ListItemText
|
||||
sx={{ pl: 2, color: '#2196f3' }}
|
||||
primary={me.username}
|
||||
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<LanguageSelector />
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutMenu = memo(LayoutMenuComponent);
|
||||
|
||||
export default LayoutMenu;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import type { SvgIconProps } from '@mui/material';
|
||||
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
||||
|
||||
import { routeMatches } from 'utils';
|
||||
|
||||
@@ -12,7 +13,7 @@ interface LayoutMenuItemProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LayoutMenuItem = ({
|
||||
const LayoutMenuItemComponent = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
to,
|
||||
@@ -20,7 +21,51 @@ const LayoutMenuItem = ({
|
||||
}: LayoutMenuItemProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const selected = routeMatches(to, pathname);
|
||||
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
|
||||
|
||||
// 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> = 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> = 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
|
||||
@@ -28,15 +73,16 @@ const LayoutMenuItem = ({
|
||||
to={to}
|
||||
disabled={disabled || false}
|
||||
selected={selected}
|
||||
sx={buttonStyles}
|
||||
>
|
||||
<ListItemIcon sx={{ color: selected ? '#90caf9' : '#9e9e9e' }}>
|
||||
<ListItemIcon sx={iconStyles}>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText sx={{ color: selected ? '#90caf9' : '#f5f5f5' }}>
|
||||
{label}
|
||||
</ListItemText>
|
||||
<ListItemText sx={textStyles}>{label}</ListItemText>
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutMenuItem = memo(LayoutMenuItemComponent);
|
||||
|
||||
export default LayoutMenuItem;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
@@ -20,8 +22,14 @@ interface ListMenuItemProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
|
||||
return (
|
||||
const iconStyles: CSSProperties = {
|
||||
justifyContent: 'right',
|
||||
color: 'lightblue',
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const RenderIcon = memo(
|
||||
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
|
||||
<>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||
@@ -30,8 +38,8 @@ function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={label} secondary={text} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const LayoutMenuItem = ({
|
||||
icon,
|
||||
@@ -46,13 +54,7 @@ const LayoutMenuItem = ({
|
||||
<ListItem
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<ListItemIcon
|
||||
style={{
|
||||
justifyContent: 'right',
|
||||
color: 'lightblue',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
<ListItemIcon style={iconStyles}>
|
||||
<NavigateNextIcon />
|
||||
</ListItemIcon>
|
||||
}
|
||||
@@ -79,4 +81,4 @@ const LayoutMenuItem = ({
|
||||
</>
|
||||
);
|
||||
|
||||
export default LayoutMenuItem;
|
||||
export default memo(LayoutMenuItem);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { Box, Button, CircularProgress } from '@mui/material';
|
||||
|
||||
@@ -9,12 +11,12 @@ interface FormLoaderProps {
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<MessageBox my={2} level="error" message={errorMessage}>
|
||||
<MessageBox level="error" message={errorMessage}>
|
||||
{onRetry && (
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
@@ -38,4 +40,6 @@ const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FormLoader = memo(FormLoaderComponent);
|
||||
|
||||
export default FormLoader;
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { memo } from 'react';
|
||||
|
||||
const LazyLoader = () => (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="200px"
|
||||
sx={{
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
const containerStyles: SxProps<Theme> = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '200px',
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
};
|
||||
|
||||
const LazyLoader = memo(() => (
|
||||
<Box sx={containerStyles}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
);
|
||||
));
|
||||
|
||||
export default LazyLoader;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import type { Theme } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const circularProgressStyles: SxProps<Theme> = (theme: Theme) => ({
|
||||
margin: theme.spacing(4),
|
||||
color: theme.palette.text.secondary
|
||||
});
|
||||
|
||||
const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
||||
return (
|
||||
<Box
|
||||
@@ -15,15 +22,9 @@ const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
|
||||
padding={2}
|
||||
height={height}
|
||||
>
|
||||
<CircularProgress
|
||||
sx={(theme: Theme) => ({
|
||||
margin: theme.spacing(4),
|
||||
color: theme.palette.text.secondary
|
||||
})}
|
||||
size={100}
|
||||
/>
|
||||
<CircularProgress sx={circularProgressStyles} size={100} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
export default memo(LoadingSpinner);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { Blocker } from 'react-router';
|
||||
|
||||
import {
|
||||
@@ -14,23 +15,23 @@ import { useI18nContext } from 'i18n/i18n-react';
|
||||
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
blocker.reset?.();
|
||||
}, [blocker]);
|
||||
|
||||
const handleProceed = useCallback(() => {
|
||||
blocker.proceed?.();
|
||||
}, [blocker]);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
||||
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
|
||||
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => blocker.reset?.()}
|
||||
color="secondary"
|
||||
>
|
||||
<Button variant="outlined" onClick={handleReset} color="secondary">
|
||||
{LL.STAY()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => blocker.proceed?.()}
|
||||
color="primary"
|
||||
>
|
||||
<Button variant="contained" onClick={handleProceed} color="primary">
|
||||
{LL.LEAVE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -38,4 +39,4 @@ const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockNavigation;
|
||||
export default memo(BlockNavigation);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
|
||||
@@ -14,4 +14,4 @@ const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireAdmin;
|
||||
export default memo(RequireAdmin);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { memo, useContext, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
|
||||
@@ -18,7 +18,7 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
if (!authenticationContext.me) {
|
||||
storeLoginRedirect(location);
|
||||
}
|
||||
});
|
||||
}, [authenticationContext.me, location]);
|
||||
|
||||
return authenticationContext.me ? (
|
||||
<AuthenticatedContext.Provider
|
||||
@@ -31,4 +31,4 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireAuthenticated;
|
||||
export default memo(RequireAuthenticated);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
|
||||
@@ -16,4 +16,4 @@ const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequireUnauthenticated;
|
||||
export default memo(RequireUnauthenticated);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user