mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-05-02 12:07:02 +00:00
Compare commits
1055 Commits
22eb5436ab
...
core3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3ed6cd3a | ||
|
|
23519a8a90 | ||
|
|
242708358e | ||
|
|
3cc3c74e5a | ||
|
|
cb4cb39396 | ||
|
|
363799c9c6 | ||
|
|
132f83aa79 | ||
|
|
f998714225 | ||
|
|
323fc1bb99 | ||
|
|
3062d3f0e3 | ||
|
|
8f37bb7623 | ||
|
|
a57ed90756 | ||
|
|
eaf8332d16 | ||
|
|
522286ff74 | ||
|
|
747047556e | ||
|
|
df3d75c702 | ||
|
|
e40beeadd4 | ||
|
|
751f10603d | ||
|
|
751c540cb3 | ||
|
|
d7bbc329bb | ||
|
|
41cd49a61c | ||
|
|
fd5a39702b | ||
|
|
4d3408254e | ||
|
|
2cbb5ec5f2 | ||
|
|
3b765b308e | ||
|
|
53ac82520e | ||
|
|
381fcf4080 | ||
|
|
a3f0faf022 | ||
|
|
b3a8737a71 | ||
|
|
6e76bcc9af | ||
|
|
6473c55317 | ||
|
|
1a880f14a0 | ||
|
|
e39af36589 | ||
|
|
c5b262af8a | ||
|
|
43ec5c1925 | ||
|
|
5e260f0239 | ||
|
|
ab67f97b40 | ||
|
|
9ac35e2e14 | ||
|
|
7c6259dddd | ||
|
|
1cff1abc33 | ||
|
|
d834d46586 | ||
|
|
1107e1bdf3 | ||
|
|
3a11327e7e | ||
|
|
74062bab57 | ||
|
|
6802336b6b | ||
|
|
a9db134d3a | ||
|
|
ee7be1d907 | ||
|
|
5ecda88457 | ||
|
|
7056c446fa | ||
|
|
147c09ae64 | ||
|
|
112adf9eb0 | ||
|
|
469d412951 | ||
|
|
6edbac86e2 | ||
|
|
0e08334132 | ||
|
|
3d51acf9e7 | ||
|
|
fd6ea5ed7e | ||
|
|
db2be70d66 | ||
|
|
c36f231990 | ||
|
|
d18e5b1f14 | ||
|
|
20327d817d | ||
|
|
26102121e1 | ||
|
|
8e64c6303e | ||
|
|
051c332426 | ||
|
|
a09258325e | ||
|
|
74c76eb90b | ||
|
|
daffdcf58e | ||
|
|
61dca0cbda | ||
|
|
2bff299193 | ||
|
|
4bc4fa903f | ||
|
|
1329b13db3 | ||
|
|
29380f0303 | ||
|
|
9dd894f0fe | ||
|
|
6b2370b79d | ||
|
|
dbc636c9bf | ||
|
|
30d1ae5642 | ||
|
|
79aceef382 | ||
|
|
a28e52210a | ||
|
|
0c0660c04b | ||
|
|
08eb294213 | ||
|
|
c9fd076394 | ||
|
|
888baed81a | ||
|
|
4de3955db2 | ||
|
|
25f08c7624 | ||
|
|
35550553be | ||
|
|
06ff219385 | ||
|
|
e705a5629f | ||
|
|
1e8013100c | ||
|
|
62c8f55568 | ||
|
|
cb3c9653ce | ||
|
|
0b5a83f6ae | ||
|
|
a079169005 | ||
|
|
845c51d5f9 | ||
|
|
c40d828749 | ||
|
|
d6d3a034ad | ||
|
|
84ad08887a | ||
|
|
ece08d96ee | ||
|
|
ed0a678020 | ||
|
|
854f4d559a | ||
|
|
f186f2a8f2 | ||
|
|
37107d8500 | ||
|
|
6b68cb7c61 | ||
|
|
a1e0288e09 | ||
|
|
e6c173bdf9 | ||
|
|
dde6a8c5db | ||
|
|
e2750b8572 | ||
|
|
acd23925b5 | ||
|
|
b0db054e11 | ||
|
|
d9b6de0652 | ||
|
|
c54da18822 | ||
|
|
51cea8e757 | ||
|
|
bbb086ea41 | ||
|
|
539e6ed080 | ||
|
|
555801dc5c | ||
|
|
1d33a26318 | ||
|
|
86a20fc97a | ||
|
|
d6e00c4534 | ||
|
|
6f81945da6 | ||
|
|
865c309475 | ||
|
|
77b8b21aea | ||
|
|
2f5edffec6 | ||
|
|
71de64502e | ||
|
|
6994d3559a | ||
|
|
a7d484d218 | ||
|
|
a810c41acd | ||
|
|
2fbfdf94ab | ||
|
|
2d7c8f0863 | ||
|
|
c3b734ab47 | ||
|
|
644abf105d | ||
|
|
5a8a451774 | ||
|
|
dae139aa01 | ||
|
|
b13fcd8939 | ||
|
|
26b42b4eea | ||
|
|
c9005e8aa9 | ||
|
|
6658b11adf | ||
|
|
e542f5809f | ||
|
|
ce1dd6233d | ||
|
|
fe488443da | ||
|
|
b264a39780 | ||
|
|
d2302eaa85 | ||
|
|
a813d38108 | ||
|
|
685a49c212 | ||
|
|
994706c9f2 | ||
|
|
2c8eb534af | ||
|
|
5210fab4cb | ||
|
|
49787d27f1 | ||
|
|
dfe7b46461 | ||
|
|
8a72ab42cb | ||
|
|
c4db8e3914 | ||
|
|
8d0225e595 | ||
|
|
f8257de0dd | ||
|
|
3b3ecc9f1d | ||
|
|
966049d0c9 | ||
|
|
907a65a701 | ||
|
|
f97b8e14e7 | ||
|
|
e65f634b21 | ||
|
|
fc71ed2b9d | ||
|
|
84105acf5d | ||
|
|
def5173692 | ||
|
|
6b31fef1af | ||
|
|
c9c059ca65 | ||
|
|
4d3b31e5a1 | ||
|
|
5a8195d430 | ||
|
|
24a7a607f3 | ||
|
|
061f9ffc52 | ||
|
|
9e17936bfc | ||
|
|
18bb2c4f39 | ||
|
|
7c3782a43f | ||
|
|
3ac807bdd5 | ||
|
|
1111458863 | ||
|
|
99c5e2230c | ||
|
|
3317aa845a | ||
|
|
97cd657336 | ||
|
|
3338f919bd | ||
|
|
7dd13bcab7 | ||
|
|
f226cb359f | ||
|
|
abbba0aa42 | ||
|
|
39b5a52b01 | ||
|
|
b6c3fc5bee | ||
|
|
909bea00df | ||
|
|
9522945e06 | ||
|
|
d6a9f2a731 | ||
|
|
0f30c81554 | ||
|
|
e514ba4bb5 | ||
|
|
38e63e3eaa | ||
|
|
0058324edd | ||
|
|
ac143d607a | ||
|
|
e9e3759db3 | ||
|
|
51d90095aa | ||
|
|
fb09e10f19 | ||
|
|
16c0370443 | ||
|
|
67bb38dcf4 | ||
|
|
049231a36e | ||
|
|
349d6b7375 | ||
|
|
b72b368d3c | ||
|
|
7f9fd44a02 | ||
|
|
a400c5974c | ||
|
|
afca995fe5 | ||
|
|
81504fedc5 | ||
|
|
3da3345683 | ||
|
|
c6c2889306 | ||
|
|
b60f0d260a | ||
|
|
cd750e4777 | ||
|
|
4e5d503b35 | ||
|
|
bd09e17e49 | ||
|
|
835eb743bb | ||
|
|
69a129d80e | ||
|
|
434bf483fd | ||
|
|
2b8e170b40 | ||
|
|
dc9b95f3e7 | ||
|
|
1616b0da0a | ||
|
|
91c457b22b | ||
|
|
70c60647c7 | ||
|
|
c0bea66d27 | ||
|
|
ed7cc078ed | ||
|
|
60b7d6d795 | ||
|
|
947f29cca0 | ||
|
|
d2a13ec0da | ||
|
|
cc39ba409e | ||
|
|
09473f17a0 | ||
|
|
ac9db6256e | ||
|
|
096f628d97 | ||
|
|
bbc2de08a5 | ||
|
|
22312812bb | ||
|
|
df808a2bcf | ||
|
|
d04e7c36f3 | ||
|
|
205d826adb | ||
|
|
3584975acb | ||
|
|
30b9ca4e6c | ||
|
|
7c6ff01ebe | ||
|
|
a54edcaf5b | ||
|
|
e446954844 | ||
|
|
4a2d0d6787 | ||
|
|
9725314135 | ||
|
|
4db8e43648 | ||
|
|
e610f0d57f | ||
|
|
8244af2940 | ||
|
|
cc60062678 | ||
|
|
40f371d23b | ||
|
|
817b791e59 | ||
|
|
25a7aac360 | ||
|
|
37115a174d | ||
|
|
1397f81fd0 | ||
|
|
56365cb403 | ||
|
|
dfd245ee7b | ||
|
|
9c81e4b34d | ||
|
|
67676df131 | ||
|
|
a73b129596 | ||
|
|
4600d886b5 | ||
|
|
e3305ab9db | ||
|
|
0fe45a2405 | ||
|
|
db87213242 | ||
|
|
b0157f288e | ||
|
|
5c3c010d5a | ||
|
|
c804cedd7a | ||
|
|
a9f50d9371 | ||
|
|
65a3226404 | ||
|
|
45690f5418 | ||
|
|
aa30ca99bf | ||
|
|
6836b6197f | ||
|
|
c0ca9d1069 | ||
|
|
5e79e1d57f | ||
|
|
8c732f9f1e | ||
|
|
5e94c2f636 | ||
|
|
69d4163b9d | ||
|
|
b1e974a82c | ||
|
|
34a2b20be8 | ||
|
|
f1fc8d9aae | ||
|
|
b04355e3e1 | ||
|
|
cd3ae5cdf2 | ||
|
|
a261ca23af | ||
|
|
cb96904a5c | ||
|
|
4a2d78f8e1 | ||
|
|
f5af4fb52f | ||
|
|
2037bc3a10 | ||
|
|
64d17d7c65 | ||
|
|
92e2633342 | ||
|
|
96a7ea8a02 | ||
|
|
5c4aaa4510 | ||
|
|
c05e1cb77b | ||
|
|
5879ce4090 | ||
|
|
ac3e5c793c | ||
|
|
64e5d29996 | ||
|
|
b320d8ded2 | ||
|
|
4326fb931b | ||
|
|
ced7051ce7 | ||
|
|
0be1b20996 | ||
|
|
6c55460622 | ||
|
|
421da246ed | ||
|
|
d627404dc2 | ||
|
|
148a721e17 | ||
|
|
f317123c26 | ||
|
|
e4df1887b0 | ||
|
|
34142c3e85 | ||
|
|
6e7f8bdf02 | ||
|
|
3dd9fcfb58 | ||
|
|
35e2954b8b | ||
|
|
59aa63db0f | ||
|
|
7a41a190f8 | ||
|
|
6741232450 | ||
|
|
a811670c5a | ||
|
|
72f08a86cf | ||
|
|
27c471f45f | ||
|
|
e303972d26 | ||
|
|
97bb03d703 | ||
|
|
e9f77c1bde | ||
|
|
81cba6c0a8 | ||
|
|
89029df25e | ||
|
|
3463b6818d | ||
|
|
349843e666 | ||
|
|
96ae3bbbba | ||
|
|
b153364b60 | ||
|
|
ccc40937fb | ||
|
|
909edf394d | ||
|
|
ac8ef646e9 | ||
|
|
3ef279ea10 | ||
|
|
769beeda37 | ||
|
|
f83404c216 | ||
|
|
c239658131 | ||
|
|
be82afd778 | ||
|
|
2156c96368 | ||
|
|
f7f078d82a | ||
|
|
95168cf514 | ||
|
|
6dc601c4a2 | ||
|
|
6b87bbb882 | ||
|
|
abdf2c5037 | ||
|
|
7beec1b80f | ||
|
|
3a0e46f064 | ||
|
|
85cc85a923 | ||
|
|
ca0079c0df | ||
|
|
28b662ad43 | ||
|
|
6bac6bbfeb | ||
|
|
958ec1002b | ||
|
|
438852ecaf | ||
|
|
92d82c0a00 | ||
|
|
7d37267f57 | ||
|
|
5641d53cc3 | ||
|
|
8fc6752290 | ||
|
|
cca6f87500 | ||
|
|
758d76051f | ||
|
|
95f7e66cff | ||
|
|
4e194287c9 | ||
|
|
3545830552 | ||
|
|
074f4c32ed | ||
|
|
b3fec5ed7d | ||
|
|
ffb90b8f9a | ||
|
|
584618043d | ||
|
|
d702c485b7 | ||
|
|
d3561da331 | ||
|
|
0e0aaf37df | ||
|
|
5ec068409f | ||
|
|
8796b6d340 | ||
|
|
bfbb18655d | ||
|
|
9088651e53 | ||
|
|
3e8f379502 | ||
|
|
265c2c4231 | ||
|
|
f671d79280 | ||
|
|
97c89d1d13 | ||
|
|
e0a26a38fa | ||
|
|
038f06e59f | ||
|
|
f4d2bae04f | ||
|
|
d443e275ea | ||
|
|
30d2057e01 | ||
|
|
d952b9aaae | ||
|
|
3402215e8d | ||
|
|
f01031dc26 | ||
|
|
01d4d116b9 | ||
|
|
bc7f82eef1 | ||
|
|
efdb355033 | ||
|
|
87c9fd010f | ||
|
|
930f0e615a | ||
|
|
4dbbdb3290 | ||
|
|
8379b3f5aa | ||
|
|
08124fa4db | ||
|
|
39414db732 | ||
|
|
da57a08005 | ||
|
|
b6b8700c3f | ||
|
|
8ee0789dad | ||
|
|
1eabe86015 | ||
|
|
5fe4b315f3 | ||
|
|
03ec96bf96 | ||
|
|
aa05e37fbb | ||
|
|
959a00c19a | ||
|
|
b42060be3a | ||
|
|
33bb433d7e | ||
|
|
28a5d4ef1a | ||
|
|
b78d47cbd0 | ||
|
|
8a7a1383a7 | ||
|
|
3f5163c1e4 | ||
|
|
fad82c8c68 | ||
|
|
6fc3bf30b6 | ||
|
|
43b3e74c08 | ||
|
|
64c9882d8c | ||
|
|
a690510903 | ||
|
|
c732ec301a | ||
|
|
cc1f16596a | ||
|
|
66c74f85a4 | ||
|
|
db667b9437 | ||
|
|
8799015f59 | ||
|
|
d71b3c64e1 | ||
|
|
6d3083fff4 | ||
|
|
56b2c111b8 | ||
|
|
12ce736580 | ||
|
|
debe90eb8d | ||
|
|
8d318143a4 | ||
|
|
59f90f5d1c | ||
|
|
0efbd0528e | ||
|
|
3218620a0e | ||
|
|
a93921c875 | ||
|
|
fb77b455be | ||
|
|
b64c392c58 | ||
|
|
7bc6cf3910 | ||
|
|
e19e76546e | ||
|
|
b9aaaae90a | ||
|
|
86b395d612 | ||
|
|
6204b9acc7 | ||
|
|
0777f420b0 | ||
|
|
4a6a662aa0 | ||
|
|
66837d399f | ||
|
|
34ff5f12ea | ||
|
|
82d160cabb | ||
|
|
2c85d3829b | ||
|
|
cc258dae16 | ||
|
|
577964befe | ||
|
|
f2500013ab | ||
|
|
98fb6941d2 | ||
|
|
7308b8e73d | ||
|
|
e97cfaf9ee | ||
|
|
fd0734d8d8 | ||
|
|
739f32f045 | ||
|
|
b66b49e812 | ||
|
|
d4b81a2909 | ||
|
|
fb57537e88 | ||
|
|
335b1274cf | ||
|
|
f6d1c87eaf | ||
|
|
5e07e9a11b | ||
|
|
dd0ea5df0e | ||
|
|
2b7f592957 | ||
|
|
db7b5df85d | ||
|
|
1c2534ed8f | ||
|
|
90535d7b94 | ||
|
|
0d3a8fc719 | ||
|
|
d624b9eac9 | ||
|
|
068cbf757c | ||
|
|
738d6f0b0f | ||
|
|
d9551bc4c3 | ||
|
|
722659325a | ||
|
|
927e7c80f4 | ||
|
|
39919b4ad8 | ||
|
|
b5defa552e | ||
|
|
e8fbbe5a1c | ||
|
|
949172128f | ||
|
|
35a8db4581 | ||
|
|
3a74abb4db | ||
|
|
bcc7687b1b | ||
|
|
13aa544214 | ||
|
|
696141721a | ||
|
|
4781eea665 | ||
|
|
2fd6bed485 | ||
|
|
db40d1d381 | ||
|
|
33bde8b407 | ||
|
|
bf5990a992 | ||
|
|
cff4bd0a71 | ||
|
|
28ee0834d8 | ||
|
|
9be1cb1d3e | ||
|
|
81d46fede2 | ||
|
|
664a8e9f5f | ||
|
|
def7501c62 | ||
|
|
1cc4dc52d4 | ||
|
|
978c738f27 | ||
|
|
feeb8500ac | ||
|
|
18a55d4622 | ||
|
|
29ea67f438 | ||
|
|
a3df77171b | ||
|
|
aa6f5c50b2 | ||
|
|
53a43ca147 | ||
|
|
da76fe3871 | ||
|
|
84af132e2c | ||
|
|
712a8537c9 | ||
|
|
bb22386f7f | ||
|
|
89dfe11ee3 | ||
|
|
be1e08af9c | ||
|
|
68ebcdded4 | ||
|
|
4afe041880 | ||
|
|
8b690d23da | ||
|
|
62c7fb671b | ||
|
|
5a82064a88 | ||
|
|
4ae4000944 | ||
|
|
616c73f658 | ||
|
|
b698485814 | ||
|
|
b4036bf8cd | ||
|
|
4b457d6cdb | ||
|
|
425b44e334 | ||
|
|
41bf293db3 | ||
|
|
af349edd54 | ||
|
|
5b303bd58a | ||
|
|
b992f90fe2 | ||
|
|
92c34dddba | ||
|
|
1475fc094d | ||
|
|
48f7b48216 | ||
|
|
cd054b293a | ||
|
|
ea6b7c0be0 | ||
|
|
a49a5537d3 | ||
|
|
c407ad04bf | ||
|
|
480e0951b8 | ||
|
|
a06c5bb297 | ||
|
|
77a54792dd | ||
|
|
d0d49397ca | ||
|
|
b51abeabac | ||
|
|
f0e4f17ab8 | ||
|
|
205da33fe5 | ||
|
|
9aa78111be | ||
|
|
c3f93d4aae | ||
|
|
5a5c0d7179 | ||
|
|
ba57942b7d | ||
|
|
c782deb581 | ||
|
|
566edfcd7b | ||
|
|
c3cf38c330 | ||
|
|
4953a41330 | ||
|
|
7ff4ed640d | ||
|
|
898a13fcb5 | ||
|
|
3fdc370466 | ||
|
|
39055ad0d2 | ||
|
|
21e73c973a | ||
|
|
4d03976032 | ||
|
|
eb7587270f | ||
|
|
3ea88a2be0 | ||
|
|
55778ba0b5 | ||
|
|
5d3694abd3 | ||
|
|
21d9ba0182 | ||
|
|
27848feddd | ||
|
|
ce261eeb65 | ||
|
|
8de3ae5468 | ||
|
|
367d27d48f | ||
|
|
1a9b6ab2a5 | ||
|
|
323b8ee67d | ||
|
|
5b95e1d41f | ||
|
|
a272d8e253 | ||
|
|
79285ca12e | ||
|
|
f90f676faf | ||
|
|
3224d8823d | ||
|
|
1a03b98670 | ||
|
|
80f32bfeb4 | ||
|
|
1b4693b981 | ||
|
|
535b760dd7 | ||
|
|
14775f6503 | ||
|
|
a856d249c9 | ||
|
|
484b547df5 | ||
|
|
d73ca2c890 | ||
|
|
6211bd8c69 | ||
|
|
e638a471d1 | ||
|
|
42ee21e883 | ||
|
|
263af58dc0 | ||
|
|
6727c0655a | ||
|
|
cba249938a | ||
|
|
9fbed47617 | ||
|
|
b5dd722888 | ||
|
|
11bef52568 | ||
|
|
d22a369333 | ||
|
|
dfe95296d9 | ||
|
|
8d39893e5e | ||
|
|
b8b8a501e1 | ||
|
|
2a36f378b4 | ||
|
|
6746df37a1 | ||
|
|
364f66b7d4 | ||
|
|
84bbd93216 | ||
|
|
85ef8d7d50 | ||
|
|
2b6606d8ad | ||
|
|
4772a61e7c | ||
|
|
d30375f3c4 | ||
|
|
8c831ac0e9 | ||
|
|
1ede7028a5 | ||
|
|
5b8dd0a693 | ||
|
|
cc041510be | ||
|
|
05baec85b7 | ||
|
|
fb698fd029 | ||
|
|
bbfec136e8 | ||
|
|
36271a2c24 | ||
|
|
bbe1f133dc | ||
|
|
d7b0614556 | ||
|
|
7e9f27a613 | ||
|
|
3bdc97d4d5 | ||
|
|
8a7511a941 | ||
|
|
a9511e6a29 | ||
|
|
ac37ead419 | ||
|
|
39fcda59da | ||
|
|
9c44e104bb | ||
|
|
18f8db7942 | ||
|
|
71281bc82d | ||
|
|
0557def0b6 | ||
|
|
1a3f7fbbee | ||
|
|
7bed8bf84e | ||
|
|
790371a9e2 | ||
|
|
537cf19e97 | ||
|
|
35ad43b7b3 | ||
|
|
3d70e8c1e6 | ||
|
|
7bb30d37ce | ||
|
|
0267f00b48 | ||
|
|
5a7cec91c5 | ||
|
|
642bf63abc | ||
|
|
6f3197f482 | ||
|
|
5c88968879 | ||
|
|
c4a43183b3 | ||
|
|
94b583d7f3 | ||
|
|
37012e55e3 | ||
|
|
ea4d613d12 | ||
|
|
0e6108b5a9 | ||
|
|
fdbaf7509f | ||
|
|
74182031ae | ||
|
|
6c80a34578 | ||
|
|
09f1c13d28 | ||
|
|
5668fe13ae | ||
|
|
78a02b6d85 | ||
|
|
ccab932e8d | ||
|
|
eaea1f383b | ||
|
|
de8309de4a | ||
|
|
bc3269037f | ||
|
|
31131427b8 | ||
|
|
9957bff62b | ||
|
|
f1841347a7 | ||
|
|
1b8b72c443 | ||
|
|
b4affbff6d | ||
|
|
c8b4a38bb6 | ||
|
|
eec373e2ae | ||
|
|
48b261317d | ||
|
|
e6beb01075 | ||
|
|
ecc6e9286a | ||
|
|
179351cb6b | ||
|
|
778fe43012 | ||
|
|
d84d52df4b | ||
|
|
11d4109915 | ||
|
|
0eddbac150 | ||
|
|
99afeb221a | ||
|
|
39d18b78a1 | ||
|
|
4ebe8cc0cc | ||
|
|
6dabfb7fe2 | ||
|
|
611b1d9aca | ||
|
|
2821f8e750 | ||
|
|
1cccd8dc2c | ||
|
|
4d13982594 | ||
|
|
10c63640c0 | ||
|
|
14ad1239db | ||
|
|
2a16cb6e64 | ||
|
|
89fa5947bd | ||
|
|
a81e56e3bf | ||
|
|
90ad2dde54 | ||
|
|
9c243cbe8d | ||
|
|
c1b444541f | ||
|
|
8527b16e9d | ||
|
|
a728420010 | ||
|
|
378d9e8634 | ||
|
|
1973081529 | ||
|
|
45ae6d802c | ||
|
|
c4297e2996 | ||
|
|
310afe3ab8 | ||
|
|
3215f36530 | ||
|
|
55621e12d9 | ||
|
|
458dc516f4 | ||
|
|
f2ae84bd22 | ||
|
|
f093df1cb9 | ||
|
|
05f15f7876 | ||
|
|
12f4a74094 | ||
|
|
15d895fd0a | ||
|
|
1890948924 | ||
|
|
fec246127f | ||
|
|
ecab30d4ac | ||
|
|
f8cc688241 | ||
|
|
f51b3528d5 | ||
|
|
42c94a1017 | ||
|
|
acccb56f07 | ||
|
|
3a2d3ac985 | ||
|
|
0ab18e6e08 | ||
|
|
44db5991e7 | ||
|
|
15a6c50326 | ||
|
|
911aa40ca1 | ||
|
|
2b679daabc | ||
|
|
71b956e613 | ||
|
|
79089a93bc | ||
|
|
da3ac1794e | ||
|
|
bc870b2aa2 | ||
|
|
6bc40ce2e1 | ||
|
|
84544979fa | ||
|
|
2446e4d1fd | ||
|
|
4a15b39945 | ||
|
|
df080bbad9 | ||
|
|
8632af8820 | ||
|
|
27047c0f39 | ||
|
|
95de3e339d | ||
|
|
99f44aece5 | ||
|
|
4e589aecbf | ||
|
|
ab013554bd | ||
|
|
5c966e291b | ||
|
|
068744b681 | ||
|
|
8c794baa7b | ||
|
|
f6a23723f8 | ||
|
|
4b3cc2e3ec | ||
|
|
4ee743d309 | ||
|
|
d3b4bab40a | ||
|
|
c71b54fb5b | ||
|
|
ecbdf01bdf | ||
|
|
c2e8a6c73d | ||
|
|
29037d19fb | ||
|
|
67d242d210 | ||
|
|
91a67def99 | ||
|
|
c8ab89ef37 | ||
|
|
17a9b7eb0a | ||
|
|
cdb592d744 | ||
|
|
af19941a07 | ||
|
|
00dce83096 | ||
|
|
597e60d6f1 | ||
|
|
4e4311cac0 | ||
|
|
c11402195f | ||
|
|
31ec15811f | ||
|
|
739ac7b42e | ||
|
|
ad3049ab0a | ||
|
|
d96c3f3ed7 | ||
|
|
dcfd0d5b11 | ||
|
|
b05712cf83 | ||
|
|
df15485d7c | ||
|
|
bb15fcdc46 | ||
|
|
e34291fbd5 | ||
|
|
55b8c2d04c | ||
|
|
8bd1cd03b4 | ||
|
|
bafb7c35fb | ||
|
|
9425558ff7 | ||
|
|
f20aad3813 | ||
|
|
7a683d3637 | ||
|
|
ac982cbb15 | ||
|
|
9889b1b5c4 | ||
|
|
7cb647e5f1 | ||
|
|
515b75160c | ||
|
|
a365dc7519 | ||
|
|
764520714b | ||
|
|
43e087ae91 | ||
|
|
bffccc585a | ||
|
|
a01f10b042 | ||
|
|
26a5f98aae | ||
|
|
67280546af | ||
|
|
64058b0f61 | ||
|
|
d7b5c81b0e | ||
|
|
1d03056784 | ||
|
|
dd0ab8f962 | ||
|
|
02e8dba971 | ||
|
|
59878fb190 | ||
|
|
9ff0f83af9 | ||
|
|
e6f825371e | ||
|
|
45f3f23033 | ||
|
|
ffd27db208 | ||
|
|
a452d6131b | ||
|
|
0bc478f9d3 | ||
|
|
03ef981765 | ||
|
|
9ca9f25fd3 | ||
|
|
41122dddb2 | ||
|
|
1e0c94d007 | ||
|
|
3e42a7fb4c | ||
|
|
a8fcc1fb44 | ||
|
|
e43416019d | ||
|
|
9f467ecec1 | ||
|
|
273d87dbf1 | ||
|
|
befd21f8cb | ||
|
|
882e3bc1cd | ||
|
|
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 | ||
|
|
1472e53410 | ||
|
|
5ed4970d62 | ||
|
|
056cf3cbd6 | ||
|
|
2bcd548747 | ||
|
|
9edcf47073 | ||
|
|
084d90e714 | ||
|
|
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 | ||
|
|
17bdd87576 | ||
|
|
8f7c0a1d97 | ||
|
|
3b453b18bc | ||
|
|
a1fc5bf54b | ||
|
|
0cb413a579 | ||
|
|
f2e38330ea | ||
|
|
9f1cd04d45 | ||
|
|
fe67f3a982 | ||
|
|
2fd6e9ebf5 | ||
|
|
67655c4b06 | ||
|
|
2970aa5ba3 | ||
|
|
99a3ffcf17 | ||
|
|
0edb844225 | ||
|
|
e3feb8f11e | ||
|
|
6b7534b7fb | ||
|
|
ca1506de8b | ||
|
|
1cb535dea3 | ||
|
|
5d32b6d383 | ||
|
|
e6dbe020c1 | ||
|
|
63cf1603b0 | ||
|
|
aadb67fa79 | ||
|
|
4949471518 | ||
|
|
9504723ef2 | ||
|
|
3abfb7bb9c |
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -21,8 +21,8 @@ _Make sure your have performed every step and checked the applicable boxes befor
|
|||||||
|
|
||||||
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
|
||||||
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
|
||||||
- [ ] Searched the issue in the [docs](https://docs.emsesp.org/Troubleshooting/)
|
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
|
||||||
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
|
- [ ] Searched the issue in the [chat](https://discord.gg/GP9DPSgeJq)
|
||||||
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +1,11 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: EMS-ESP Docs
|
- name: EMS-ESP Docs
|
||||||
url: https://docs.emsesp.org
|
url: https://emsesp.org
|
||||||
about: All the information related to EMS-ESP.
|
about: All the information related to EMS-ESP.
|
||||||
- name: EMS-ESP Discussions and Support
|
- name: EMS-ESP Discussions and Support
|
||||||
url: https://github.com/emsesp/EMS-ESP32/discussions
|
url: https://github.com/emsesp/EMS-ESP32/discussions
|
||||||
about: EMS-ESP usage Questions, Feature Requests and Projects.
|
about: EMS-ESP usage Questions, Feature Requests and Projects.
|
||||||
- name: EMS-ESP Users Chat
|
- name: EMS-ESP Users Chat
|
||||||
url: https://discord.gg/3J3GgnzpyT
|
url: https://discord.gg/GP9DPSgeJq
|
||||||
about: Chat for feedback, questions and troubleshooting.
|
about: Chat for feedback, questions and troubleshooting.
|
||||||
|
|||||||
52
.github/workflows/dev_release.yml
vendored
52
.github/workflows/dev_release.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build webUI
|
- name: Build webUI
|
||||||
run: |
|
run: |
|
||||||
platformio run -e build_webUI
|
platformio run -e build-webUI
|
||||||
|
|
||||||
- name: Build modbus
|
- name: Build modbus
|
||||||
run: |
|
run: |
|
||||||
@@ -62,35 +62,13 @@ jobs:
|
|||||||
platformio run
|
platformio run
|
||||||
|
|
||||||
- name: Commit the generated files
|
- name: Commit the generated files
|
||||||
uses: stefanzweifel/git-auto-commit-action@v5
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
with:
|
with:
|
||||||
commit_message: "chore: update generated files"
|
commit_message: "chore: update generated files for v${{steps.build_info.outputs.VERSION}}"
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config --local user.email "action@github.com"
|
|
||||||
git config --local user.name "GitHub Action"
|
|
||||||
|
|
||||||
- name: Check for changes and commit
|
|
||||||
run: |
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo "Changes detected, committing..."
|
|
||||||
git add .
|
|
||||||
git commit -m "Auto-commit build artifacts and configuration updates
|
|
||||||
|
|
||||||
- Updated build configurations
|
|
||||||
- Generated build artifacts
|
|
||||||
- Version: ${{steps.build_info.outputs.VERSION}}"
|
|
||||||
|
|
||||||
echo "Pushing changes to repository..."
|
|
||||||
git push origin dev
|
|
||||||
else
|
|
||||||
echo "No changes to commit"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
id: 'automatic_releases'
|
id: 'automatic_releases'
|
||||||
uses: emsesp/action-automatic-releases@v1.0.0
|
uses: emsesp/action-automatic-releases@v1.0.1
|
||||||
with:
|
with:
|
||||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
title: Development Build v${{steps.build_info.outputs.VERSION}}
|
title: Development Build v${{steps.build_info.outputs.VERSION}}
|
||||||
@@ -99,3 +77,23 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
CHANGELOG_LATEST.md
|
CHANGELOG_LATEST.md
|
||||||
./build/firmware/*.*
|
./build/firmware/*.*
|
||||||
|
|
||||||
|
- name: Update version in Cloudflare KV store
|
||||||
|
if: github.repository == 'emsesp/EMS-ESP32'
|
||||||
|
env:
|
||||||
|
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||||
|
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||||
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||||
|
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
||||||
|
run: |
|
||||||
|
JSON_DATA=$(jq -n \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'{version: $version, date: $date}')
|
||||||
|
echo "JSON_DATA: $JSON_DATA"
|
||||||
|
curl -sS --fail-with-body \
|
||||||
|
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
|
||||||
|
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$JSON_DATA"
|
||||||
|
echo
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: GitHub Releases To Discord
|
- name: GitHub Releases To Discord
|
||||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||||
|
|||||||
2
.github/workflows/pr_check.yml
vendored
2
.github/workflows/pr_check.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install python 3.13
|
- name: Install python 3.13
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
|
|||||||
2
.github/workflows/sonar_check.yml
vendored
2
.github/workflows/sonar_check.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
BUILD_WRAPPER_OUT_DIR: bw-output
|
BUILD_WRAPPER_OUT_DIR: bw-output
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install Build Wrapper
|
- name: Install Build Wrapper
|
||||||
|
|||||||
35
.github/workflows/stable_release.yml
vendored
35
.github/workflows/stable_release.yml
vendored
@@ -26,11 +26,18 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
|
|
||||||
|
|
||||||
|
- name: Get the EMS-ESP version
|
||||||
|
id: build_info
|
||||||
|
run: |
|
||||||
|
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
||||||
|
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Install PlatformIO
|
- name: Install PlatformIO
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
@@ -39,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build webUI
|
- name: Build webUI
|
||||||
run: |
|
run: |
|
||||||
platformio run -e build_webUI
|
platformio run -e build-webUI
|
||||||
|
|
||||||
- name: Build modbus
|
- name: Build modbus
|
||||||
run: |
|
run: |
|
||||||
@@ -54,10 +61,30 @@ jobs:
|
|||||||
platformio run
|
platformio run
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: emsesp/action-automatic-releases@v1.0.0
|
uses: emsesp/action-automatic-releases@v1.0.1
|
||||||
with:
|
with:
|
||||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
prerelease: false
|
prerelease: false
|
||||||
files: |
|
files: |
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
./build/firmware/*.*
|
./build/firmware/*.*
|
||||||
|
|
||||||
|
- name: Update version in Cloudflare KV store
|
||||||
|
if: github.repository == 'emsesp/EMS-ESP32'
|
||||||
|
env:
|
||||||
|
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||||
|
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||||
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||||
|
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
||||||
|
run: |
|
||||||
|
JSON_DATA=$(jq -n \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'{version: $version, date: $date}')
|
||||||
|
echo "JSON_DATA: $JSON_DATA"
|
||||||
|
curl -sS --fail-with-body \
|
||||||
|
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
|
||||||
|
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$JSON_DATA"
|
||||||
|
echo
|
||||||
|
|||||||
6
.github/workflows/test_release.yml
vendored
6
.github/workflows/test_release.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build webUI
|
- name: Build webUI
|
||||||
run: |
|
run: |
|
||||||
platformio run -e build_webUI
|
platformio run -e build-webUI
|
||||||
|
|
||||||
- name: Build modbus
|
- name: Build modbus
|
||||||
run: |
|
run: |
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
id: 'automatic_releases'
|
id: 'automatic_releases'
|
||||||
uses: emsesp/action-automatic-releases@v1.0.0
|
uses: emsesp/action-automatic-releases@v1.0.1
|
||||||
with:
|
with:
|
||||||
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
repo_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
title: Test Build v${{steps.build_info.outputs.VERSION}}
|
||||||
|
|||||||
40
.github/workflows/update_versions.yml
vendored
Normal file
40
.github/workflows/update_versions.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: 'Update versions'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-version:
|
||||||
|
name: 'Update versions in Cloudflare KV store'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Get and Send EMS-ESP version to Cloudflare
|
||||||
|
env:
|
||||||
|
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||||
|
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
||||||
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
|
||||||
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
|
KV_ENV="stable"
|
||||||
|
else
|
||||||
|
KV_ENV="dev"
|
||||||
|
fi
|
||||||
|
JSON_DATA=$(jq -n \
|
||||||
|
--arg version "$version" \
|
||||||
|
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'{version: $version, date: $date}')
|
||||||
|
echo "KV_ENV: $KV_ENV"
|
||||||
|
echo "JSON_DATA: $JSON_DATA"
|
||||||
|
curl -sS --fail-with-body \
|
||||||
|
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
|
||||||
|
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$JSON_DATA"
|
||||||
|
echo
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -64,7 +64,7 @@ words-found-verbose.txt
|
|||||||
# sonarlint
|
# sonarlint
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
|
|
||||||
# pioarduino + hybrid
|
# other files
|
||||||
managed_components
|
managed_components
|
||||||
dependencies.lock
|
dependencies.lock
|
||||||
CMakeLists.txt
|
CMakeLists.txt
|
||||||
@@ -73,4 +73,7 @@ logs/*
|
|||||||
sdkconfig.*
|
sdkconfig.*
|
||||||
sdkconfig_tasmota_esp32
|
sdkconfig_tasmota_esp32
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package.json
|
.cache/
|
||||||
|
interface/.tsbuildinfo
|
||||||
|
test/test_api/package-lock.json
|
||||||
|
.clangd
|
||||||
|
|||||||
101
.vscode/settings.json
vendored
101
.vscode/settings.json
vendored
@@ -1,101 +0,0 @@
|
|||||||
{
|
|
||||||
"search.exclude": {
|
|
||||||
"**/.yarn": true,
|
|
||||||
"**/.pnp.*": true
|
|
||||||
},
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit"
|
|
||||||
},
|
|
||||||
"eslint.validate": [
|
|
||||||
"typescript"
|
|
||||||
],
|
|
||||||
"eslint.codeActionsOnSave.rules": null,
|
|
||||||
"eslint.nodePath": "interface/.yarn/sdks",
|
|
||||||
"eslint.workingDirectories": ["interface"],
|
|
||||||
"prettier.prettierPath": "",
|
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
|
||||||
"files.associations": {
|
|
||||||
"*.tsx": "typescriptreact",
|
|
||||||
"*.tcc": "cpp",
|
|
||||||
"optional": "cpp",
|
|
||||||
"istream": "cpp",
|
|
||||||
"ostream": "cpp",
|
|
||||||
"ratio": "cpp",
|
|
||||||
"system_error": "cpp",
|
|
||||||
"array": "cpp",
|
|
||||||
"functional": "cpp",
|
|
||||||
"regex": "cpp",
|
|
||||||
"tuple": "cpp",
|
|
||||||
"type_traits": "cpp",
|
|
||||||
"utility": "cpp",
|
|
||||||
"string": "cpp",
|
|
||||||
"string_view": "cpp",
|
|
||||||
"atomic": "cpp",
|
|
||||||
"bitset": "cpp",
|
|
||||||
"cctype": "cpp",
|
|
||||||
"chrono": "cpp",
|
|
||||||
"clocale": "cpp",
|
|
||||||
"cmath": "cpp",
|
|
||||||
"condition_variable": "cpp",
|
|
||||||
"cstdarg": "cpp",
|
|
||||||
"cstddef": "cpp",
|
|
||||||
"cstdint": "cpp",
|
|
||||||
"cstdio": "cpp",
|
|
||||||
"cstdlib": "cpp",
|
|
||||||
"cstring": "cpp",
|
|
||||||
"ctime": "cpp",
|
|
||||||
"cwchar": "cpp",
|
|
||||||
"cwctype": "cpp",
|
|
||||||
"deque": "cpp",
|
|
||||||
"list": "cpp",
|
|
||||||
"unordered_map": "cpp",
|
|
||||||
"unordered_set": "cpp",
|
|
||||||
"vector": "cpp",
|
|
||||||
"exception": "cpp",
|
|
||||||
"algorithm": "cpp",
|
|
||||||
"iterator": "cpp",
|
|
||||||
"map": "cpp",
|
|
||||||
"memory": "cpp",
|
|
||||||
"memory_resource": "cpp",
|
|
||||||
"numeric": "cpp",
|
|
||||||
"random": "cpp",
|
|
||||||
"set": "cpp",
|
|
||||||
"fstream": "cpp",
|
|
||||||
"initializer_list": "cpp",
|
|
||||||
"iomanip": "cpp",
|
|
||||||
"iosfwd": "cpp",
|
|
||||||
"iostream": "cpp",
|
|
||||||
"limits": "cpp",
|
|
||||||
"mutex": "cpp",
|
|
||||||
"new": "cpp",
|
|
||||||
"sstream": "cpp",
|
|
||||||
"stdexcept": "cpp",
|
|
||||||
"streambuf": "cpp",
|
|
||||||
"thread": "cpp",
|
|
||||||
"cinttypes": "cpp",
|
|
||||||
"typeinfo": "cpp"
|
|
||||||
},
|
|
||||||
"todo-tree.filtering.excludeGlobs": [
|
|
||||||
"**/vendor/**",
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/dist/**",
|
|
||||||
"**/bower_components/**",
|
|
||||||
"**/build/**",
|
|
||||||
"**/.vscode/**",
|
|
||||||
"**/.github/**",
|
|
||||||
"**/_output/**",
|
|
||||||
"**/*.min.*",
|
|
||||||
"**/*.map",
|
|
||||||
"**/ArduinoJson/**"
|
|
||||||
],
|
|
||||||
"cSpell.enableFiletypes": [
|
|
||||||
"ini",
|
|
||||||
"makefile"
|
|
||||||
],
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
|
|
||||||
"sonarlint.connectedMode.project": {
|
|
||||||
"connectionId": "emsesp",
|
|
||||||
"projectKey": "emsesp_EMS-ESP32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
116
CHANGELOG.md
116
CHANGELOG.md
@@ -5,6 +5,120 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.8.1] 11 January 2026
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- update time saved in nvs
|
||||||
|
- heatpump entities [#2883](https://github.com/emsesp/EMS-ESP32/issues/2883)
|
||||||
|
- HA input number format (mode) selectable box/slider (slider for max range 100) [#2900](https://github.com/emsesp/EMS-ESP32/discussions/2900)
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- fix EMS bus disconnected errors on some systems [#2881](https://github.com/emsesp/EMS-ESP32/issues/2881)
|
||||||
|
- selflowtemp fix [#2876](https://github.com/emsesp/EMS-ESP32/issues/2876)
|
||||||
|
- updated valid GPIOs for ESP32S2, ESP32S3 and ESP32 that caused custom systems to block gpios [#2887](https://github.com/emsesp/EMS-ESP32/issues/2887)
|
||||||
|
- Junkers wwcharge offset [#2860](https://github.com/emsesp/EMS-ESP32/issues/2860)
|
||||||
|
- fixed minflowtemp [#2890](https://github.com/emsesp/EMS-ESP32/issues/2890)
|
||||||
|
- don't add HA uom/classes for bool values [#2885](https://github.com/emsesp/EMS-ESP32/issues/2885)
|
||||||
|
- fixed missing progress bar on web firmware uploads
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- snapshot gpios stored in temporary ram
|
||||||
|
- GPIOs stored along with the name and reported in log if conflicting
|
||||||
|
- free GPIOs depend on board profile [#2901](https://github.com/emsesp/EMS-ESP32/issues/2901)
|
||||||
|
- prefer PSram for mqtt queue [#2889](https://github.com/emsesp/EMS-ESP32/issues/2889)
|
||||||
|
- day schedule defult to all days, no day selected is not allowed
|
||||||
|
- board profile `CUSTOM` can only be selected in developer mode
|
||||||
|
- mqtt sends round values without decimals (`28` instead of `28.0`)
|
||||||
|
|
||||||
|
|
||||||
|
## [3.8.0] 31 December 2025
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- analogsensor types: NTC and RGB-Led
|
||||||
|
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
|
||||||
|
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
|
||||||
|
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
|
||||||
|
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
|
||||||
|
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
|
||||||
|
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
|
||||||
|
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
|
||||||
|
- added EMS Device details to Home Assistant MQTT Discovery
|
||||||
|
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
|
||||||
|
- added new board profile for upcoming BBQKees E32V2.2
|
||||||
|
- set differential pressure entity in Mixer device
|
||||||
|
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
|
||||||
|
- 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)
|
||||||
|
- 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
|
||||||
|
- heating assistance, rounding custum settings [#2763](https://github.com/emsesp/EMS-ESP32/discussions/2763)
|
||||||
|
- added counter 0..2 for short pulses, high frequency [#2758](https://github.com/emsesp/EMS-ESP32/issues/2758)
|
||||||
|
- 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)
|
||||||
|
- added RTL8201 to eth phy list [#2800](https://github.com/emsesp/EMS-ESP32/issues/2800)
|
||||||
|
- added partitions to Web UI Version page, so previous firmware versions can be installed [#2837](https://github.com/emsesp/EMS-ESP32/issues/2837)
|
||||||
|
- button pressures show LED. On a long press (10 seconds) the LED flashes for 5 seconds to indicate a factory reset is about to happen. [#2848](https://github.com/emsesp/EMS-ESP32/issues/2848)
|
||||||
|
- added `txpause` command to pause the TX, by setting Txmode to 0 (disabled) [#2850](https://github.com/emsesp/EMS-ESP32/issues/2850)
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
|
||||||
|
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
|
||||||
|
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
|
||||||
|
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
|
||||||
|
- also rebuild HA config on mqtt connect for scheduler, custom and shower
|
||||||
|
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
|
||||||
|
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
|
||||||
|
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
|
||||||
|
- shower active state retained, shows correctly in HA
|
||||||
|
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
|
||||||
|
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
|
||||||
|
- fix missing long 10-second press of Button to perform a factory reset
|
||||||
|
- 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.
|
||||||
|
- check for valid GPIOs when board profile is changed [#2841](https://github.com/emsesp/EMS-ESP32/issues/2841)
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
|
||||||
|
- removed ESP32 CPU temperature
|
||||||
|
- 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
|
||||||
|
- on tx read fail delay the 3rd. retry 2 sec
|
||||||
|
- move vectors and lists to PSRAM
|
||||||
|
- removed unused last topic/payload echo-check
|
||||||
|
- added Home Assistant device details to MQTT Discovery for all devices
|
||||||
|
- device_class and state_class changes for HA MQTT Discovery [#2825](https://github.com/emsesp/EMS-ESP32/issues/2825)
|
||||||
|
|
||||||
## [3.7.2] 22 March 2025
|
## [3.7.2] 22 March 2025
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -85,7 +199,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- The automatically generated temperature sensor ID has replaced dashes (`-`) with underscores (`_`) to be compatible with Home Assistant.
|
- The automatically generated temperature sensor ID has replaced dashes (`-`) with underscores (`_`) to be compatible with Home Assistant.
|
||||||
- `api/system/info` has it's JSON key names changed to camelCase syntax.
|
- `api/system/info` has it's JSON key names changed to camelCase syntax.
|
||||||
|
|
||||||
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
For more details go to [emsesp.org](https://emsesp.org/).
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
|
For more details go to [emsesp.org](https://emsesp.org/).
|
||||||
|
|
||||||
## [3.7.3]
|
## [3.9.0]
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- analogsensor types: NTC and RGB-Led
|
- comfortpoint for BC400 [#2935](https://github.com/emsesp/EMS-ESP32/issues/2935)
|
||||||
- Flag for HMC310 [#2465](https://github.com/emsesp/EMS-ESP32/issues/2465)
|
- customize device brand [#2784](https://github.com/emsesp/EMS-ESP32/issues/2784)
|
||||||
- boiler auxheatersource [#2489](https://github.com/emsesp/EMS-ESP32/discussions/2489)
|
- set model for ems-esp devices temperature, analog, etc. [#2958](https://github.com/emsesp/EMS-ESP32/discussions/2958)
|
||||||
- thermostat last error for RC100/300 [#2501](https://github.com/emsesp/EMS-ESP32/issues/2501)
|
- prometheus metrics for temperature/analog/scheduler/custom [#2962](https://github.com/emsesp/EMS-ESP32/issues/2962)
|
||||||
- boiler 0xC6 telegram [#1963](https://github.com/emsesp/EMS-ESP32/issues/1963)
|
- boiler pumpkick [#2965](https://github.com/emsesp/EMS-ESP32/discussions/2965)
|
||||||
- CS6800i changes [#2448](https://github.com/emsesp/EMS-ESP32/issues/2448), [#2449](https://github.com/emsesp/EMS-ESP32/issues/2449)
|
- heatpump reset [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
|
||||||
- charging pump [#2544](https://github.com/emsesp/EMS-ESP32/issues/2544)
|
- e-mail notification using ReadyMail Client
|
||||||
- hybrid CSH5800iG [#2569](https://github.com/emsesp/EMS-ESP32/issues/2569)
|
- 2.nd freshwater module (dhw4, dhw5) [#2991](https://github.com/emsesp/EMS-ESP32/issues/2991)
|
||||||
- add EMS Device details to Home Assistant MQTT Discovery
|
- full system backup and restore
|
||||||
- disinfection command [#2601](https://github.com/emsesp/EMS-ESP32/issues/2601)
|
- updated version check [#3047](https://github.com/emsesp/EMS-ESP32/issues/3047)
|
||||||
- added new board profile for upcoming BBQKees E32V2.2
|
|
||||||
- set differential pressure entity in Mixer device
|
|
||||||
- set set climate action cooling/heating in HA [#2583](https://github.com/emsesp/EMS-ESP32/issues/2583)
|
|
||||||
- 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
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- dhw/switchtime [#2490](https://github.com/emsesp/EMS-ESP32/issues/2490)
|
- SRC climate creation [#2936](https://github.com/emsesp/EMS-ESP32/issues/2936) and [#2960](https://github.com/emsesp/EMS-ESP32/issues/2960)
|
||||||
- switch to secure mqtt [#2492](https://github.com/emsesp/EMS-ESP32/issues/2492)
|
- missing translations [#3015](https://github.com/emsesp/EMS-ESP32/issues/3015)
|
||||||
- update link buttons [#2497](https://github.com/emsesp/EMS-ESP32/issues/2497)
|
- custom entities check fetch length
|
||||||
- refresh scheduler states [#2502](https://github.com/emsesp/EMS-ESP32/discussions/2502)
|
|
||||||
- also rebuild HA config on mqtt connect for scheduler, custom and shower
|
|
||||||
- FB100 controls the hc, not the master [#2510](https://github.com/emsesp/EMS-ESP32/issues/2510)
|
|
||||||
- IPM DHW module, [#2524](https://github.com/emsesp/EMS-ESP32/issues/2524)
|
|
||||||
- charge optimization [#2543](https://github.com/emsesp/EMS-ESP32/issues/2543)
|
|
||||||
- shower active state retained, shows correctly in HA
|
|
||||||
- MQTT Command Topic with slashes [#2571](https://github.com/emsesp/EMS-ESP32/issues/2571)
|
|
||||||
- Add pulsed water meter input to V1.3 gateway with Lilygo S3 [#2550](https://github.com/emsesp/EMS-ESP32/issues/2550)
|
|
||||||
- fix missing long 10-second press of Button to perform a factory reset
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- show console log with ISO date/time [#2533](https://github.com/emsesp/EMS-ESP32/discussions/2533)
|
- weblogbuffer up to 1000 messages with PSRAM, mentioned in [#2933](https://github.com/emsesp/EMS-ESP32/issues/2933)
|
||||||
- remove ESP32 CPU temperature
|
- validate custom entity writes, [#2931](https://github.com/emsesp/EMS-ESP32/issues/2931)
|
||||||
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus
|
- remove wrong burnMinPower [#2918](https://github.com/emsesp/EMS-ESP32/issues/2918)
|
||||||
- remove command `scan deep`
|
- store scheduler active state to nvs [#2946](https://github.com/emsesp/EMS-ESP32/discussions/2946)
|
||||||
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
|
- translated modes `heat` and `eco` for HA-climate mode-str-tpl
|
||||||
|
- support `minflowtemp` and `baseflowtemp` [#2969](https://github.com/emsesp/EMS-ESP32/discussions/2969)
|
||||||
|
- update version if it is 00.00 in first read [#2981](https://github.com/emsesp/EMS-ESP32/issues/2981)
|
||||||
|
- device class for % values [#2980](https://github.com/emsesp/EMS-ESP32/issues/2980)
|
||||||
|
- use tasmota core 2026.03.30
|
||||||
|
- secure mqtt uses ESP_SSLClient
|
||||||
|
- fetch telegrams: set length to fetch [#3017](https://github.com/emsesp/EMS-ESP32/issues/3017)
|
||||||
|
- move http client from stack to heap
|
||||||
|
- heap optimizations [#3021](https://github.com/emsesp/EMS-ESP32/discussions/3021)
|
||||||
|
- refactored network code into a single class [#3052](https://github.com/emsesp/EMS-ESP32/pull/3052)
|
||||||
@@ -6,7 +6,7 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
|
|||||||
|
|
||||||
- providing Pull Requests (Features, Fixes, suggestions)
|
- providing Pull Requests (Features, Fixes, suggestions)
|
||||||
- testing new released features and report issues on your EMS equipment
|
- testing new released features and report issues on your EMS equipment
|
||||||
- contributing to missing [documentation](https://docs.emsesp.org)
|
- contributing to missing [documentation](https://emsesp.org)
|
||||||
|
|
||||||
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
|
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.
|
||||||
|
|
||||||
|
|||||||
28
Makefile
28
Makefile
@@ -21,13 +21,14 @@ endif
|
|||||||
|
|
||||||
# Optimize parallel build configuration
|
# Optimize parallel build configuration
|
||||||
UNAME_S := $(shell uname -s)
|
UNAME_S := $(shell uname -s)
|
||||||
|
JOBS ?= 1
|
||||||
ifeq ($(UNAME_S),Linux)
|
ifeq ($(UNAME_S),Linux)
|
||||||
EXTRA_CPPFLAGS = -D LINUX
|
EXTRA_CPPFLAGS = -D LINUX
|
||||||
JOBS ?= $(shell nproc)
|
JOBS := $(shell nproc)
|
||||||
endif
|
endif
|
||||||
ifeq ($(UNAME_S),Darwin)
|
ifeq ($(UNAME_S),Darwin)
|
||||||
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
|
EXTRA_CPPFLAGS = -D OSX -Wno-tautological-constant-out-of-range-compare
|
||||||
JOBS ?= $(shell sysctl -n hw.ncpu)
|
JOBS := $(shell sysctl -n hw.ncpu)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Set optimal parallel build settings
|
# Set optimal parallel build settings
|
||||||
@@ -46,27 +47,28 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
|||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
TARGET := emsesp
|
TARGET := emsesp
|
||||||
BUILD := build
|
BUILD := build
|
||||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
||||||
INCLUDES := src/core src/devices src/web src/test lib/* lib_standalone lib/semver lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
||||||
LIBRARIES :=
|
LIBRARIES :=
|
||||||
|
|
||||||
CPPCHECK = cppcheck
|
CPPCHECK = cppcheck
|
||||||
CHECKFLAGS = -q --force --std=gnu++17
|
CHECKFLAGS = -q --force --std=gnu++20
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Languages Standard
|
# Languages Standard
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
C_STANDARD := -std=c17
|
C_STANDARD := -std=c20
|
||||||
CXX_STANDARD := -std=gnu++17
|
CXX_STANDARD := -std=gnu++20
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Defined Symbols
|
# Defined Symbols
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
||||||
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
||||||
|
DEFINES += -DNO_TLS_SUPPORT
|
||||||
DEFINES += $(ARGS)
|
DEFINES += $(ARGS)
|
||||||
|
|
||||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Sources & Files
|
# Sources & Files
|
||||||
@@ -78,6 +80,10 @@ SYMBOLS := $(CURDIR)/$(BUILD)/$(TARGET).out
|
|||||||
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
CSOURCES := $(shell find $(SOURCES) -name "*.c" 2>/dev/null)
|
||||||
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
CXXSOURCES := $(shell find $(SOURCES) -name "*.cpp" 2>/dev/null)
|
||||||
|
|
||||||
|
# Exclude files not needed for standalone build, if they exist
|
||||||
|
CSOURCES := $(filter-out src/core/ModuleLibrary.c,$(CSOURCES))
|
||||||
|
CXXSOURCES := $(filter-out src/core/ModuleLibrary.cpp,$(CXXSOURCES))
|
||||||
|
|
||||||
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
OBJS := $(patsubst %,$(BUILD)/%.o,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||||
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
DEPS := $(patsubst %,$(BUILD)/%.d,$(basename $(CSOURCES)) $(basename $(CXXSOURCES)))
|
||||||
|
|
||||||
@@ -107,7 +113,7 @@ CXX := /usr/bin/g++
|
|||||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||||
CPPFLAGS += -ggdb -g3 -MMD
|
CPPFLAGS += -ggdb -g3 -MMD
|
||||||
CPPFLAGS += -flto=auto
|
CPPFLAGS += -flto=auto
|
||||||
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
CPPFLAGS += -Wall -Wextra -Werror -Wno-switch-enum
|
||||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
||||||
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
||||||
CPPFLAGS += -Os -DNDEBUG
|
CPPFLAGS += -Os -DNDEBUG
|
||||||
@@ -137,6 +143,7 @@ DEPFLAGS += -MF $(BUILD)/$*.d -MT $@
|
|||||||
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
LINK.o = $(LD) $(LDFLAGS) $(LDLIBS) $^ -o $@
|
||||||
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
COMPILE.c = $(CC) $(C_STANDARD) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||||
COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||||
|
COMPILE.s = $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Special Built-in Target
|
# Special Built-in Target
|
||||||
@@ -180,6 +187,7 @@ $(BUILD)/%.o: %.cpp
|
|||||||
|
|
||||||
$(BUILD)/%.o: %.s
|
$(BUILD)/%.o: %.s
|
||||||
@mkdir -p $(@D)
|
@mkdir -p $(@D)
|
||||||
|
@$(ECHO) Compiling $@
|
||||||
@$(COMPILE.s)
|
@$(COMPILE.s)
|
||||||
|
|
||||||
cppcheck: $(SOURCES)
|
cppcheck: $(SOURCES)
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -15,10 +15,10 @@
|
|||||||
<a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md">
|
<a href="https://github.com/emsesp/EMS-ESP32/blob/dev/CONTRIBUTING.md">
|
||||||
<img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" />
|
<img src="https://img.shields.io/badge/Contribute-ff4785?style=for-the-badge&logo=git&logoColor=white" alt="Contribute" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://docs.emsesp.org">
|
<a href="https://emsesp.org">
|
||||||
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
|
<img src="https://img.shields.io/badge/Documentation-0077b5?style=for-the-badge&logo=googledocs&logoColor=white" alt="Guides" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/3J3GgnzpyT">
|
<a href="https://discord.gg/GP9DPSgeJq">
|
||||||
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
|
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
|
<a href="https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md">
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
||||||
[](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
[](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||||
[](https://github.com/emsesp/EMS-ESP32/releases)
|
[](https://github.com/emsesp/EMS-ESP32/releases)
|
||||||
[](https://discord.gg/3J3GgnzpyT)
|
[](https://discord.gg/GP9DPSgeJq)
|
||||||
|
[](https://deepwiki.com/emsesp/EMS-ESP32)
|
||||||
|
|
||||||
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
[](https://github.com/emsesp/EMS-ESP32/stargazers)
|
||||||
[](https://github.com/emsesp/EMS-ESP32/network)
|
[](https://github.com/emsesp/EMS-ESP32/network)
|
||||||
@@ -40,7 +41,8 @@
|
|||||||
|
|
||||||
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT.
|
||||||
|
|
||||||
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
|
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl>. These gateways are tested thoroughly and certified to work with EMS-ESP.
|
||||||
|
|
||||||
|
|
||||||
## 📦 **Key Features**
|
## 📦 **Key Features**
|
||||||
|
|
||||||
@@ -60,33 +62,31 @@ It requires a small circuit to interface with the EMS bus which can be purchased
|
|||||||
|
|
||||||
## 🚀 **Installing**
|
## 🚀 **Installing**
|
||||||
|
|
||||||
Head over to [download.emsesp.org](https://download.emsesp.org) for instructions on how to install EMS-ESP. There is also further details on which boards are supported in [this section](https://docs.emsesp.org/Installing/) of the documentation.
|
Head over to the [Installation Guide](https://emsesp.org/Installing) section of the documentation for instructions on how to install EMS-ESP.
|
||||||
|
|
||||||
## 📋 **Documentation**
|
## 📋 **Documentation**
|
||||||
|
|
||||||
Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community.
|
Visit [emsesp.org](https://emsesp.org) for more details on how to setup and configure EMS-ESP. You'll also find more a collection of example configuarations, Frequently Asked Questions and Troubleshooting tips.
|
||||||
|
|
||||||
## 💬 **Getting Support**
|
## 💬 **Getting Support**
|
||||||
|
|
||||||
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
|
To chat with the community reach out on our [Discord Server](https://discord.gg/GP9DPSgeJq).
|
||||||
|
|
||||||
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.
|
If you find an issue or have a request, see the [Getting Support](https://emsesp.org/Support/) section of the documentation. Note if you are using a non-BBQKees EMS gateway, you may need to contact the manufacturer for support.
|
||||||
|
|
||||||
## 🎥 **Live Demo**
|
## 🎥 **Live Demo**
|
||||||
|
|
||||||
For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data.
|
To see a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language and use any username and password to log in. Note whast you're seeing is static example data so not all features are operational.
|
||||||
|
|
||||||
## 💖 **Contributors**
|
## 💖 **Contributors**
|
||||||
|
|
||||||
EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned and maintained by both [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP) with support from [BBQKees Electronics](https://bbqkees-electronics.nl).
|
EMS-ESP is a project originally created by [proddy](https://github.com/proddy) and maintained by the ems-esp community.
|
||||||
|
|
||||||
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.
|
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**
|
## 📦 **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`.
|
See the [Building the firmware](https://emsesp.org/Building) guide in the documentation for instructions on how to build EMS-ESP from this source code.
|
||||||
|
|
||||||
To build the 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**
|
## 📢 **Libraries used**
|
||||||
|
|
||||||
|
|||||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report any security vulnerabilities using the [Contact Form](https://emsesp.org/About/#-contact).
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DTASMOTA_SDK",
|
"-DNO_TLS_SUPPORT",
|
||||||
"-DARDUINO_LOLIN_C3_MINI",
|
"-DARDUINO_LOLIN_C3_MINI",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=1",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
"-DTASMOTA_SDK",
|
"-DNO_TLS_SUPPORT",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=0"
|
"-DARDUINO_USB_MODE=0"
|
||||||
],
|
],
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
"flash_size": "4MB",
|
"flash_size": "4MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
"maximum_size": 4194304,
|
"maximum_size": 4194304,
|
||||||
"use_1200bps_touch": true,
|
"use_1200bps_touch": false,
|
||||||
"wait_for_upload_port": true,
|
"wait_for_upload_port": false,
|
||||||
"require_upload_port": true,
|
"require_upload_port": true,
|
||||||
"speed": 921600
|
"speed": 921600
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "32MB",
|
"flash_size": "32MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
"maximum_size": 16777216,
|
"maximum_size": 33554432,
|
||||||
"require_upload_port": true,
|
"require_upload_port": true,
|
||||||
"speed": 460800
|
"speed": 460800
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": "-DTASMOTA_SDK",
|
"extra_flags": "-DNO_TLS_SUPPORT",
|
||||||
"f_cpu": "240000000L",
|
"f_cpu": "240000000L",
|
||||||
"f_flash": "40000000L",
|
"f_flash": "40000000L",
|
||||||
"flash_mode": "dio",
|
"flash_mode": "dio",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "16MB",
|
"flash_size": "16MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "16MB",
|
"flash_size": "16MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": "-DTASMOTA_SDK",
|
"extra_flags": "-DNO_TLS_SUPPORT",
|
||||||
"f_cpu": "240000000L",
|
"f_cpu": "240000000L",
|
||||||
"f_flash": "40000000L",
|
"f_flash": "40000000L",
|
||||||
"flash_mode": "dio",
|
"flash_mode": "dio",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
|
"-DNO_TLS_SUPPORT",
|
||||||
"-DARDUINO_XIAO_ESP32C6",
|
"-DARDUINO_XIAO_ESP32C6",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=1",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dictionaries": ["project-words"],
|
"dictionaries": ["project-words"],
|
||||||
|
"caseSensitive": false,
|
||||||
"ignorePaths": [
|
"ignorePaths": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"compile_commands.json",
|
"compile_commands.json",
|
||||||
@@ -34,6 +35,10 @@
|
|||||||
"sdkconfig.*",
|
"sdkconfig.*",
|
||||||
"managed_components/**",
|
"managed_components/**",
|
||||||
"pnpm-*.yaml",
|
"pnpm-*.yaml",
|
||||||
"vite.config.ts"
|
"vite.config.ts",
|
||||||
|
"lib/esp32-psram/**",
|
||||||
|
"test/test_api/test_api.h",
|
||||||
|
"lib_standalone/**",
|
||||||
|
"**/*.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,85 +1,228 @@
|
|||||||
{
|
{
|
||||||
"type": "settings",
|
"type": "systembackup",
|
||||||
"Network": {
|
"version": "3.8.2",
|
||||||
"ssid": "my_wifi_ssid",
|
"date": "2026-03-29T13:28:15",
|
||||||
"bssid": "",
|
"systembackup": [
|
||||||
"password": "my_wifi_password",
|
{
|
||||||
"hostname": "ems-esp"
|
"type": "settings",
|
||||||
|
"Network": {
|
||||||
|
"ssid": "",
|
||||||
|
"bssid": "",
|
||||||
|
"password": "",
|
||||||
|
"hostname": "ems-esp",
|
||||||
|
"static_ip_config": false,
|
||||||
|
"bandwidth20": false,
|
||||||
|
"nosleep": true,
|
||||||
|
"enableMDNS": true,
|
||||||
|
"enableCORS": false,
|
||||||
|
"CORSOrigin": "*",
|
||||||
|
"tx_power": 0
|
||||||
|
},
|
||||||
|
"AP": {
|
||||||
|
"provision_mode": 2,
|
||||||
|
"ssid": "ems-esp",
|
||||||
|
"password": "ems-esp-neo",
|
||||||
|
"channel": 1,
|
||||||
|
"ssid_hidden": false,
|
||||||
|
"max_clients": 4,
|
||||||
|
"local_ip": "192.168.4.1",
|
||||||
|
"gateway_ip": "192.168.4.1",
|
||||||
|
"subnet_mask": "255.255.255.0"
|
||||||
|
},
|
||||||
|
"MQTT": {
|
||||||
|
"enableTLS": false,
|
||||||
|
"rootCA": "",
|
||||||
|
"enabled": false,
|
||||||
|
"host": "",
|
||||||
|
"port": 1883,
|
||||||
|
"base": "ems-esp",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"client_id": "esp32-b8ffc9ec",
|
||||||
|
"keep_alive": 60,
|
||||||
|
"clean_session": false,
|
||||||
|
"entity_format": 1,
|
||||||
|
"publish_time_boiler": 10,
|
||||||
|
"publish_time_thermostat": 10,
|
||||||
|
"publish_time_solar": 10,
|
||||||
|
"publish_time_mixer": 10,
|
||||||
|
"publish_time_water": 10,
|
||||||
|
"publish_time_other": 60,
|
||||||
|
"publish_time_sensor": 10,
|
||||||
|
"publish_time_heartbeat": 60,
|
||||||
|
"mqtt_qos": 0,
|
||||||
|
"mqtt_retain": false,
|
||||||
|
"ha_enabled": false,
|
||||||
|
"nested_format": 1,
|
||||||
|
"discovery_prefix": "homeassistant",
|
||||||
|
"discovery_type": 0,
|
||||||
|
"ha_number_mode": 0,
|
||||||
|
"publish_single": false,
|
||||||
|
"publish_single2cmd": false,
|
||||||
|
"send_response": false
|
||||||
|
},
|
||||||
|
"NTP": {
|
||||||
|
"enabled": true,
|
||||||
|
"server": "time.google.com",
|
||||||
|
"tz_label": "Europe/Amsterdam",
|
||||||
|
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"jwt_secret": "ems-esp-neo",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin",
|
||||||
|
"admin": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "guest",
|
||||||
|
"password": "guest",
|
||||||
|
"admin": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"version": "3.8.2",
|
||||||
|
"board_profile": "E32V2_2",
|
||||||
|
"platform": "ESP32",
|
||||||
|
"locale": "en",
|
||||||
|
"tx_mode": 1,
|
||||||
|
"ems_bus_id": 11,
|
||||||
|
"syslog_enabled": false,
|
||||||
|
"syslog_level": 3,
|
||||||
|
"trace_raw": false,
|
||||||
|
"syslog_mark_interval": 0,
|
||||||
|
"syslog_host": "",
|
||||||
|
"syslog_port": 514,
|
||||||
|
"boiler_heatingoff": false,
|
||||||
|
"remote_timeout": 24,
|
||||||
|
"remote_timeout_en": false,
|
||||||
|
"shower_timer": false,
|
||||||
|
"shower_alert": false,
|
||||||
|
"shower_alert_coldshot": 10,
|
||||||
|
"shower_alert_trigger": 7,
|
||||||
|
"shower_min_duration": 180,
|
||||||
|
"rx_gpio": 4,
|
||||||
|
"tx_gpio": 5,
|
||||||
|
"dallas_gpio": 14,
|
||||||
|
"dallas_parasite": false,
|
||||||
|
"led_gpio": 32,
|
||||||
|
"hide_led": false,
|
||||||
|
"led_type": 1,
|
||||||
|
"low_clock": false,
|
||||||
|
"telnet_enabled": true,
|
||||||
|
"notoken_api": false,
|
||||||
|
"readonly_mode": false,
|
||||||
|
"analog_enabled": true,
|
||||||
|
"pbutton_gpio": 34,
|
||||||
|
"solar_maxflow": 30,
|
||||||
|
"fahrenheit": false,
|
||||||
|
"bool_format": 1,
|
||||||
|
"bool_dashboard": 1,
|
||||||
|
"enum_format": 1,
|
||||||
|
"weblog_level": 6,
|
||||||
|
"weblog_buffer": 50,
|
||||||
|
"weblog_compact": true,
|
||||||
|
"phy_type": 1,
|
||||||
|
"eth_power": 15,
|
||||||
|
"eth_phy_addr": 0,
|
||||||
|
"eth_clock_mode": 1,
|
||||||
|
"modbus_enabled": false,
|
||||||
|
"modbus_port": 502,
|
||||||
|
"modbus_max_clients": 10,
|
||||||
|
"modbus_timeout": 300,
|
||||||
|
"developer_mode": true,
|
||||||
|
"email_enabled": false,
|
||||||
|
"email_ssl": false,
|
||||||
|
"email_starttls": true,
|
||||||
|
"email_server": "smtp.example.net",
|
||||||
|
"email_port": 587,
|
||||||
|
"email_login": "",
|
||||||
|
"email_pass": "",
|
||||||
|
"email_sender": "ems-esp@example.net",
|
||||||
|
"email_recp": "",
|
||||||
|
"email_subject": "ems-esp notification"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"AP": {
|
{
|
||||||
"provision_mode": 2,
|
"type": "schedule",
|
||||||
"ssid": "ems-esp",
|
"Schedule": {
|
||||||
"password": "ems-esp-neo",
|
"schedule": []
|
||||||
"channel": 1,
|
}
|
||||||
"ssid_hidden": false,
|
|
||||||
"max_clients": 4,
|
|
||||||
"local_ip": "192.168.4.1",
|
|
||||||
"gateway_ip": "192.168.4.1",
|
|
||||||
"subnet_mask": "255.255.255.0"
|
|
||||||
},
|
},
|
||||||
"MQTT": {
|
{
|
||||||
"enableTLS": false,
|
"type": "customizations",
|
||||||
"rootCA": "",
|
"Customizations": {
|
||||||
"enabled": false,
|
"ts": [
|
||||||
"host": "127.0.0.1",
|
{
|
||||||
"port": 1883,
|
"id": "28_1767_7B13_2502",
|
||||||
"base": "ems-esp",
|
"name": "gateway_temperature",
|
||||||
"username": "username",
|
"offset": 0,
|
||||||
"password": "password",
|
"is_system": true
|
||||||
"client_id": "ems-esp",
|
}
|
||||||
"entity_format": 1,
|
],
|
||||||
"publish_time_boiler": 10,
|
"as": [
|
||||||
"publish_time_thermostat": 10,
|
{
|
||||||
"publish_time_solar": 10,
|
"gpio": 39,
|
||||||
"publish_time_mixer": 10,
|
"name": "core_voltage",
|
||||||
"publish_time_water": 10,
|
"offset": 0,
|
||||||
"publish_time_other": 60,
|
"factor": 0.003771,
|
||||||
"publish_time_sensor": 10,
|
"uom": 23,
|
||||||
"publish_time_heartbeat": 60,
|
"type": 3,
|
||||||
"mqtt_qos": 0,
|
"is_system": true
|
||||||
"mqtt_retain": false,
|
},
|
||||||
"ha_enabled": false,
|
{
|
||||||
"nested_format": 1,
|
"gpio": 36,
|
||||||
"discovery_prefix": "homeassistant",
|
"name": "supply_voltage",
|
||||||
"discovery_type": 0,
|
"offset": 0,
|
||||||
"publish_single": false,
|
"factor": 0.017,
|
||||||
"publish_single2cmd": false,
|
"uom": 23,
|
||||||
"send_response": false
|
"type": 3,
|
||||||
|
"is_system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gpio": 2,
|
||||||
|
"name": "led",
|
||||||
|
"offset": 0,
|
||||||
|
"factor": 1,
|
||||||
|
"uom": 0,
|
||||||
|
"type": 6,
|
||||||
|
"is_system": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"masked_entities": []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"NTP": {
|
{
|
||||||
"enabled": true,
|
"type": "entities",
|
||||||
"server": "time.google.com",
|
"Entities": {
|
||||||
"tz_label": "Europe/Amsterdam",
|
"entities": []
|
||||||
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
}
|
||||||
},
|
},
|
||||||
"Security": {
|
{
|
||||||
"jwt_secret": "ems-esp-neo",
|
"type": "modules",
|
||||||
"users": [
|
"Modules": {
|
||||||
{
|
"modules": []
|
||||||
"username": "admin",
|
}
|
||||||
"password": "admin",
|
|
||||||
"admin": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"username": "guest",
|
|
||||||
"password": "guest",
|
|
||||||
"admin": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"Settings": {
|
{
|
||||||
"board_profile": "S3",
|
"type": "customSupport",
|
||||||
"locale": "en",
|
"Support": {
|
||||||
"tx_mode": 1,
|
"html": [
|
||||||
"ems_bus_id": 11,
|
"This product is installed and managed by:",
|
||||||
"boiler_heatingoff": false,
|
"",
|
||||||
"hide_led": true,
|
"<b>Bosch Installer Example</b>",
|
||||||
"telnet_enabled": true,
|
"",
|
||||||
"notoken_api": false,
|
"Nefit Road 12",
|
||||||
"analog_enabled": true,
|
"1234 AB Amsterdam",
|
||||||
"fahrenheit": false,
|
"Phone: +31 123 456 789",
|
||||||
"bool_format": 1,
|
"email: support@boschinstaller.nl",
|
||||||
"bool_dashboard": 1,
|
"",
|
||||||
"enum_format": 1
|
"For help and questions please <a target='_blank' href='https://emsesp.org'>contact</a> your installer."
|
||||||
|
],
|
||||||
|
"img_url": "https://emsesp.org/media/images/designer.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
10166
docs/dump_entities.csv
10166
docs/dump_entities.csv
File diff suppressed because it is too large
Load Diff
@@ -72,11 +72,12 @@ telegram_type_id,name,is_fetched
|
|||||||
0xE6,UBAParametersPlus,fetched
|
0xE6,UBAParametersPlus,fetched
|
||||||
0xE9,UBAMonitorWWPlus,
|
0xE9,UBAMonitorWWPlus,
|
||||||
0xEA,UBAParameterWWPlus,fetched
|
0xEA,UBAParameterWWPlus,fetched
|
||||||
|
0xEB,PumpKick,fetched
|
||||||
0x0101,ISM1Set,fetched
|
0x0101,ISM1Set,fetched
|
||||||
0x0103,ISM1StatusMessage,fetched
|
0x0103,ISM1StatusMessage,fetched
|
||||||
0x0104,ISM2StatusMessage,
|
0x0104,ISM2StatusMessage,
|
||||||
0x010C,IPMStatusMessage,
|
0x010C,IPMStatusMessage,
|
||||||
0x011E,JunkersDisp,fetched
|
0x011E,IPMTempMessage,
|
||||||
0x012E,HPEnergy1,
|
0x012E,HPEnergy1,
|
||||||
0x013B,HPEnergy2,
|
0x013B,HPEnergy2,
|
||||||
0x0165,JunkersSet,
|
0x0165,JunkersSet,
|
||||||
@@ -111,10 +112,10 @@ telegram_type_id,name,is_fetched
|
|||||||
0x02A0,RC300Curves,
|
0x02A0,RC300Curves,
|
||||||
0x02A1,RC300Curves,
|
0x02A1,RC300Curves,
|
||||||
0x02A2,RC300Curves,
|
0x02A2,RC300Curves,
|
||||||
0x02A5,RC300Monitor,
|
0x02A5,RC300Monitor,fetched
|
||||||
0x02A6,RC300Monitor,
|
0x02A6,CRFMonitor,
|
||||||
0x02A7,RC300Monitor,
|
0x02A7,RC300Monitor,
|
||||||
0x02A8,RC300Monitor,
|
0x02A8,CRFMonitor,
|
||||||
0x02A9,RC300Monitor,
|
0x02A9,RC300Monitor,
|
||||||
0x02AA,RC300Monitor,
|
0x02AA,RC300Monitor,
|
||||||
0x02AB,RC300Monitor,
|
0x02AB,RC300Monitor,
|
||||||
@@ -136,10 +137,7 @@ telegram_type_id,name,is_fetched
|
|||||||
0x02BF,RC300Set,
|
0x02BF,RC300Set,
|
||||||
0x02C0,RC300Set,
|
0x02C0,RC300Set,
|
||||||
0x02CC,HPPressure,fetched
|
0x02CC,HPPressure,fetched
|
||||||
0x02CD,MMPLUSConfigMessage,fetched
|
0x02CD,MMPLUSConfigMessage,
|
||||||
0x02CE,RC300Set2,
|
|
||||||
0x02D0,RC300Set2,
|
|
||||||
0x02D2,RC300Set2,
|
|
||||||
0x02D6,HPPump2,fetched
|
0x02D6,HPPump2,fetched
|
||||||
0x02D7,MMPLUSStatusMessage,
|
0x02D7,MMPLUSStatusMessage,
|
||||||
0x02E0,UBASetPoints,
|
0x02E0,UBASetPoints,
|
||||||
@@ -164,11 +162,16 @@ telegram_type_id,name,is_fetched
|
|||||||
0x0380,SM100CollectorConfig,fetched
|
0x0380,SM100CollectorConfig,fetched
|
||||||
0x038E,SM100Energy,fetched
|
0x038E,SM100Energy,fetched
|
||||||
0x0391,SM100Time,fetched
|
0x0391,SM100Time,fetched
|
||||||
|
0x0421,RC300Set2,
|
||||||
|
0x0422,RC300Set2,
|
||||||
|
0x0423,RC300Set2,
|
||||||
|
0x0424,RC300Set2,
|
||||||
0x043F,CRHolidays,fetched
|
0x043F,CRHolidays,fetched
|
||||||
0x0467,HPSet,
|
0x0467,HPSet,
|
||||||
0x0468,HPSet,
|
0x0468,HPSet,
|
||||||
0x0469,HPSet,
|
0x0469,HPSet,
|
||||||
0x046A,HPSet,
|
0x046A,HPSet,
|
||||||
|
0x0470,RC300Summer2,
|
||||||
0x0471,RC300Summer2,
|
0x0471,RC300Summer2,
|
||||||
0x0472,RC300Summer2,
|
0x0472,RC300Summer2,
|
||||||
0x0473,RC300Summer2,
|
0x0473,RC300Summer2,
|
||||||
@@ -176,7 +179,6 @@ telegram_type_id,name,is_fetched
|
|||||||
0x0475,RC300Summer2,
|
0x0475,RC300Summer2,
|
||||||
0x0476,RC300Summer2,
|
0x0476,RC300Summer2,
|
||||||
0x0477,RC300Summer2,
|
0x0477,RC300Summer2,
|
||||||
0x0478,RC300Summer2,
|
|
||||||
0x047B,HP2,
|
0x047B,HP2,
|
||||||
0x0484,HPSilentMode,fetched
|
0x0484,HPSilentMode,fetched
|
||||||
0x0485,HpCooling,fetched
|
0x0485,HpCooling,fetched
|
||||||
@@ -196,7 +198,7 @@ telegram_type_id,name,is_fetched
|
|||||||
0x04A2,HpInput,fetched
|
0x04A2,HpInput,fetched
|
||||||
0x04A5,HPFan,fetched
|
0x04A5,HPFan,fetched
|
||||||
0x04A7,HPPowerLimit,fetched
|
0x04A7,HPPowerLimit,fetched
|
||||||
0x04AA,HPPower2,fetched
|
0x04AA,HPPower,
|
||||||
0x04AE,HPEnergy,fetched
|
0x04AE,HPEnergy,fetched
|
||||||
0x04AF,HPMeters,fetched
|
0x04AF,HPMeters,fetched
|
||||||
0x055C,VentilationSet,fetched
|
0x055C,VentilationSet,fetched
|
||||||
|
|||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"adapter": "react",
|
"adapter": "react",
|
||||||
"baseLocale": "pl",
|
"baseLocale": "pl",
|
||||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
|
"$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json"
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import eslint from '@eslint/js';
|
import eslint from '@eslint/js';
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
import prettierConfig from 'eslint-config-prettier';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default defineConfig(
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
prettierConfig,
|
prettierConfig,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "EMS-ESP",
|
"name": "EMS-ESP",
|
||||||
"version": "3.7.3",
|
"version": "3.9.0",
|
||||||
"description": "EMS-ESP WebUI",
|
"description": "EMS-ESP WebUI",
|
||||||
"homepage": "https://emsesp.org",
|
"homepage": "https://emsesp.org",
|
||||||
"author": "proddy, emsesp.org",
|
"author": "emsesp.org",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,57 +12,53 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-hosted": "typesafe-i18n && vite build --mode hosted",
|
"build-hosted": "typesafe-i18n --no-watch && 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",
|
"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",
|
"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}'",
|
"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\""
|
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alova/adapter-xhr": "2.2.1",
|
"@alova/adapter-xhr": "2.3.1",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.4",
|
"@mui/icons-material": "^9.0.0",
|
||||||
"@mui/material": "^7.3.4",
|
"@mui/material": "^9.0.0",
|
||||||
"@table-library/react-table-library": "4.1.15",
|
"@table-library/react-table-library": "4.1.15",
|
||||||
"alova": "3.3.4",
|
"alova": "^3.5.1",
|
||||||
"async-validator": "^4.2.5",
|
"async-validator": "^4.2.5",
|
||||||
"formidable": "^3.5.4",
|
"etag": "^1.8.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"magic-string": "^0.30.21",
|
"mime-types": "^3.0.2",
|
||||||
"mime-types": "^3.0.1",
|
"preact": "^10.29.1",
|
||||||
"preact": "^10.27.2",
|
"react": "^19.2.5",
|
||||||
"react": "^19.2.0",
|
"react-dom": "^19.2.5",
|
||||||
"react-dom": "^19.2.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-router": "^7.14.2",
|
||||||
"react-router": "^7.9.5",
|
"react-toastify": "^11.1.0",
|
||||||
"react-toastify": "^11.0.5",
|
"typesafe-i18n": "^5.27.1",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typescript": "^6.0.3"
|
||||||
"typescript": "^5.9.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@eslint/js": "^10.0.1",
|
||||||
"@eslint/js": "^9.39.0",
|
"@preact/preset-vite": "^2.10.5",
|
||||||
"@preact/compat": "^18.3.1",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@preact/preset-vite": "^2.10.2",
|
"@types/node": "^25.6.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
"@types/react": "^19.2.14",
|
||||||
"@types/node": "^24.10.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react": "^19.2.2",
|
|
||||||
"@types/react-dom": "^19.2.2",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9.39.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.8.3",
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"terser": "^5.44.0",
|
"terser": "^5.46.2",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.59.1",
|
||||||
"vite": "^7.1.12",
|
"vite": "^8.0.10",
|
||||||
"vite-plugin-imagemin": "^0.6.1",
|
"vite-plugin-imagemin": "^0.6.1"
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.20.0"
|
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
|
||||||
}
|
}
|
||||||
|
|||||||
2678
interface/pnpm-lock.yaml
generated
2678
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 {
|
import {
|
||||||
createWriteStream,
|
createWriteStream,
|
||||||
existsSync,
|
existsSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
statSync,
|
|
||||||
unlinkSync
|
unlinkSync
|
||||||
} from 'fs';
|
} from 'fs';
|
||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
@@ -25,20 +24,26 @@ let bundleStats = {
|
|||||||
other: { count: 0, uncompressed: 0, compressed: 0 }
|
other: { count: 0, uncompressed: 0, compressed: 0 }
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateWWWClass =
|
// AsyncWebHandler that performs the lookup.
|
||||||
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
|
const generateWWWClass = () => `// Bundle Statistics:
|
||||||
// Bundle Statistics:
|
|
||||||
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
|
||||||
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
|
||||||
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
|
||||||
// - Generated on: ${new Date().toISOString()}
|
// - Generated on: ${new Date().toISOString()}
|
||||||
|
|
||||||
class WWWData {
|
struct WWWAsset {
|
||||||
${INDENT}public:
|
${INDENT}const char * uri;
|
||||||
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
${INDENT}const char * contentType;
|
||||||
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "${f.hash}");`).join('\n')}
|
${INDENT}const uint8_t * content;
|
||||||
${INDENT.repeat(2)}}
|
${INDENT}size_t len;
|
||||||
|
${INDENT}const char * etag; // already includes enclosing double quotes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static const WWWAsset WWW_ASSETS[] = {
|
||||||
|
${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "\\"${f.rawHash}\\""},`).join('\n')}
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const getFilesSync = (dir, files = []) => {
|
const getFilesSync = (dir, files = []) => {
|
||||||
@@ -71,7 +76,9 @@ const writeFile = (relativeFilePath, buffer) => {
|
|||||||
writeStream.write(`const uint8_t ${variable}[] = {`);
|
writeStream.write(`const uint8_t ${variable}[] = {`);
|
||||||
|
|
||||||
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
|
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
|
||||||
|
const rawHash = hash.replace(/^"|"$/g, '');
|
||||||
|
|
||||||
zipBuffer.forEach((b) => {
|
zipBuffer.forEach((b) => {
|
||||||
if (!(size % bytesPerLine)) {
|
if (!(size % bytesPerLine)) {
|
||||||
@@ -94,7 +101,8 @@ const writeFile = (relativeFilePath, buffer) => {
|
|||||||
mimeType,
|
mimeType,
|
||||||
variable,
|
variable,
|
||||||
size,
|
size,
|
||||||
hash
|
hash,
|
||||||
|
rawHash
|
||||||
});
|
});
|
||||||
|
|
||||||
totalSize += size;
|
totalSize += size;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import { ToastContainer, Zoom } from 'react-toastify';
|
import { ToastContainer, Zoom } from 'react-toastify';
|
||||||
|
|
||||||
import AppRouting from 'AppRouting';
|
import AppRouting from 'AppRouting';
|
||||||
@@ -8,7 +8,6 @@ import type { Locales } from 'i18n/i18n-types';
|
|||||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||||
|
|
||||||
// Memoize available locales to prevent recreation on every render
|
|
||||||
const AVAILABLE_LOCALES = [
|
const AVAILABLE_LOCALES = [
|
||||||
'de',
|
'de',
|
||||||
'en',
|
'en',
|
||||||
@@ -23,46 +22,41 @@ const AVAILABLE_LOCALES = [
|
|||||||
'cz'
|
'cz'
|
||||||
] as Locales[];
|
] as Locales[];
|
||||||
|
|
||||||
|
// Static toast configuration - no need to recreate on every render
|
||||||
|
const TOAST_CONTAINER_PROPS = {
|
||||||
|
position: 'bottom-left' as const,
|
||||||
|
autoClose: 3000,
|
||||||
|
hideProgressBar: false,
|
||||||
|
newestOnTop: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
rtl: false,
|
||||||
|
pauseOnFocusLoss: true,
|
||||||
|
draggable: false,
|
||||||
|
pauseOnHover: false,
|
||||||
|
transition: Zoom,
|
||||||
|
closeButton: false,
|
||||||
|
theme: 'dark' as const,
|
||||||
|
toastStyle: {
|
||||||
|
border: '1px solid #177ac9',
|
||||||
|
width: 'fit-content'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const App = memo(() => {
|
const App = memo(() => {
|
||||||
const [wasLoaded, setWasLoaded] = useState(false);
|
const [wasLoaded, setWasLoaded] = useState(false);
|
||||||
const [locale, setLocale] = useState<Locales>('en');
|
const [locale, setLocale] = useState<Locales>('en');
|
||||||
|
|
||||||
// Memoize locale initialization to prevent unnecessary re-runs
|
|
||||||
const initializeLocale = useCallback(async () => {
|
|
||||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
|
||||||
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
|
||||||
localStorage.setItem('lang', newLocale);
|
|
||||||
setLocale(newLocale);
|
|
||||||
await loadLocaleAsync(newLocale);
|
|
||||||
setWasLoaded(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const initializeLocale = async () => {
|
||||||
|
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||||
|
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
||||||
|
localStorage.setItem('lang', newLocale);
|
||||||
|
setLocale(newLocale);
|
||||||
|
await loadLocaleAsync(newLocale);
|
||||||
|
setWasLoaded(true);
|
||||||
|
};
|
||||||
void initializeLocale();
|
void initializeLocale();
|
||||||
}, [initializeLocale]);
|
}, []);
|
||||||
|
|
||||||
// Memoize toast container props to prevent recreation
|
|
||||||
const toastContainerProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!wasLoaded) return null;
|
if (!wasLoaded) return null;
|
||||||
|
|
||||||
@@ -70,7 +64,7 @@ const App = memo(() => {
|
|||||||
<TypesafeI18n locale={locale}>
|
<TypesafeI18n locale={locale}>
|
||||||
<CustomTheme>
|
<CustomTheme>
|
||||||
<AppRouting />
|
<AppRouting />
|
||||||
<ToastContainer {...toastContainerProps} />
|
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||||
</CustomTheme>
|
</CustomTheme>
|
||||||
</TypesafeI18n>
|
</TypesafeI18n>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext, useEffect } from 'react';
|
import { type FC, memo, useContext, useEffect, useRef } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router';
|
import { Navigate, Route, Routes } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -9,20 +9,32 @@ import { Authentication, AuthenticationContext } from 'contexts/authentication';
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
interface SecurityRedirectProps {
|
interface SecurityRedirectProps {
|
||||||
message: string;
|
readonly message: string;
|
||||||
signOut?: boolean;
|
readonly signOut?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
|
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
||||||
const authenticationContext = useContext(AuthenticationContext);
|
({ message, signOut = false }) => {
|
||||||
useEffect(() => {
|
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
||||||
signOut && authenticationContext.signOut(false);
|
const hasShownToast = useRef(false);
|
||||||
toast.success(message);
|
|
||||||
}, [message, signOut, authenticationContext]);
|
|
||||||
return <Navigate to="/" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,6 +67,6 @@ const AppRouting = () => {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Authentication>
|
</Authentication>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default AppRouting;
|
export default AppRouting;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from 'react';
|
import { memo, useContext } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router';
|
import { Navigate, Route, Routes } from 'react-router';
|
||||||
|
|
||||||
import CustomEntities from 'app/main/CustomEntities';
|
import CustomEntities from 'app/main/CustomEntities';
|
||||||
@@ -9,12 +9,14 @@ import Help from 'app/main/Help';
|
|||||||
import Modules from 'app/main/Modules';
|
import Modules from 'app/main/Modules';
|
||||||
import Scheduler from 'app/main/Scheduler';
|
import Scheduler from 'app/main/Scheduler';
|
||||||
import Sensors from 'app/main/Sensors';
|
import Sensors from 'app/main/Sensors';
|
||||||
|
import UserProfile from 'app/main/UserProfile';
|
||||||
import APSettings from 'app/settings/APSettings';
|
import APSettings from 'app/settings/APSettings';
|
||||||
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
||||||
import DownloadUpload from 'app/settings/DownloadUpload';
|
import DownloadUpload from 'app/settings/DownloadUpload';
|
||||||
import MqttSettings from 'app/settings/MqttSettings';
|
import MqttSettings from 'app/settings/MqttSettings';
|
||||||
import NTPSettings from 'app/settings/NTPSettings';
|
import NTPSettings from 'app/settings/NTPSettings';
|
||||||
import Settings from 'app/settings/Settings';
|
import Settings from 'app/settings/Settings';
|
||||||
|
import Version from 'app/settings/Version';
|
||||||
import Network from 'app/settings/network/Network';
|
import Network from 'app/settings/network/Network';
|
||||||
import Security from 'app/settings/security/Security';
|
import Security from 'app/settings/security/Security';
|
||||||
import APStatus from 'app/status/APStatus';
|
import APStatus from 'app/status/APStatus';
|
||||||
@@ -25,11 +27,10 @@ import NTPStatus from 'app/status/NTPStatus';
|
|||||||
import NetworkStatus from 'app/status/NetworkStatus';
|
import NetworkStatus from 'app/status/NetworkStatus';
|
||||||
import Status from 'app/status/Status';
|
import Status from 'app/status/Status';
|
||||||
import SystemLog from 'app/status/SystemLog';
|
import SystemLog from 'app/status/SystemLog';
|
||||||
import Version from 'app/status/Version';
|
|
||||||
import { Layout } from 'components';
|
import { Layout } from 'components';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
|
||||||
const AuthenticatedRouting = () => {
|
const AuthenticatedRouting = memo(() => {
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -38,6 +39,7 @@ const AuthenticatedRouting = () => {
|
|||||||
<Route path="/devices/*" element={<Devices />} />
|
<Route path="/devices/*" element={<Devices />} />
|
||||||
<Route path="/sensors/*" element={<Sensors />} />
|
<Route path="/sensors/*" element={<Sensors />} />
|
||||||
<Route path="/help/*" element={<Help />} />
|
<Route path="/help/*" element={<Help />} />
|
||||||
|
<Route path="/user/*" element={<UserProfile />} />
|
||||||
|
|
||||||
<Route path="/status/*" element={<Status />} />
|
<Route path="/status/*" element={<Status />} />
|
||||||
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
||||||
@@ -47,11 +49,11 @@ const AuthenticatedRouting = () => {
|
|||||||
<Route path="/status/ntp" element={<NTPStatus />} />
|
<Route path="/status/ntp" element={<NTPStatus />} />
|
||||||
<Route path="/status/ap" element={<APStatus />} />
|
<Route path="/status/ap" element={<APStatus />} />
|
||||||
<Route path="/status/network" element={<NetworkStatus />} />
|
<Route path="/status/network" element={<NetworkStatus />} />
|
||||||
<Route path="/status/version" element={<Version />} />
|
|
||||||
|
|
||||||
{me.admin && (
|
{me.admin && (
|
||||||
<>
|
<>
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/settings/version" element={<Version />} />
|
||||||
<Route path="/settings/application" element={<ApplicationSettings />} />
|
<Route path="/settings/application" element={<ApplicationSettings />} />
|
||||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
||||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
<Route path="/settings/ntp" element={<NTPSettings />} />
|
||||||
@@ -72,6 +74,6 @@ const AuthenticatedRouting = () => {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default AuthenticatedRouting;
|
export default AuthenticatedRouting;
|
||||||
|
|||||||
@@ -11,17 +11,15 @@ import { createTheme } from '@mui/material/styles';
|
|||||||
|
|
||||||
import type { RequiredChildrenProps } from 'utils';
|
import type { RequiredChildrenProps } from 'utils';
|
||||||
|
|
||||||
// Memoize dialog style to prevent recreation
|
|
||||||
export const dialogStyle = {
|
export const dialogStyle = {
|
||||||
'& .MuiDialog-paper': {
|
'& .MuiDialog-paper': {
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
borderColor: '#565656',
|
borderColor: '#565656',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderWidth: '1px'
|
borderWidth: '2px'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Memoize theme creation to prevent recreation
|
|
||||||
const theme = responsiveFontSizes(
|
const theme = responsiveFontSizes(
|
||||||
createTheme({
|
createTheme({
|
||||||
typography: {
|
typography: {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useContext, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import ForwardIcon from '@mui/icons-material/Forward';
|
import ForwardIcon from '@mui/icons-material/Forward';
|
||||||
import { Box, Button, Paper, Typography } from '@mui/material';
|
import { Box, Button, Paper, Typography } from '@mui/material';
|
||||||
|
import type { Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
import * as AuthenticationApi from 'components/routing/authentication';
|
import * as AuthenticationApi from 'components/routing/authentication';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
@@ -17,9 +18,9 @@ import { PROJECT_NAME } from 'env';
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { SignInRequest } from 'types';
|
import type { SignInRequest } from 'types';
|
||||||
import { onEnterCallback, updateValue } from 'utils';
|
import { onEnterCallback, updateValue } from 'utils';
|
||||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
|
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = memo(() => {
|
||||||
const authenticationContext = useContext(AuthenticationContext);
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -36,15 +37,23 @@ const SignIn = () => {
|
|||||||
{
|
{
|
||||||
immediate: false
|
immediate: false
|
||||||
}
|
}
|
||||||
).onSuccess((response) => {
|
).onSuccess((response: { data: { access_token: string } }) => {
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
authenticationContext.signIn(response.data.access_token);
|
authenticationContext.signIn(response.data.access_token);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateLoginRequestValue = updateValue(setSignInRequest);
|
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) => {
|
await callSignIn(signInRequest).catch((event: Error) => {
|
||||||
if (event.message === 'Unauthorized') {
|
if (event.message === 'Unauthorized') {
|
||||||
toast.warning(LL.INVALID_LOGIN());
|
toast.warning(LL.INVALID_LOGIN());
|
||||||
@@ -53,7 +62,7 @@ const SignIn = () => {
|
|||||||
}
|
}
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
});
|
});
|
||||||
};
|
}, [callSignIn, signInRequest, LL]);
|
||||||
|
|
||||||
const validateAndSignIn = async () => {
|
const validateAndSignIn = async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
@@ -64,22 +73,33 @@ const SignIn = () => {
|
|||||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||||
await signIn();
|
await signIn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitOnEnter = onEnterCallback(signIn);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
sx={(theme: Theme) => ({
|
||||||
height="100vh"
|
display: 'flex',
|
||||||
margin="auto"
|
height: '100vh',
|
||||||
padding={2}
|
margin: 'auto',
|
||||||
justifyContent="center"
|
padding: 2,
|
||||||
flexDirection="column"
|
justifyContent: 'center',
|
||||||
maxWidth={(theme) => theme.breakpoints.values.sm}
|
flexDirection: 'column',
|
||||||
|
maxWidth: theme.breakpoints.values.sm
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
@@ -92,23 +112,29 @@ const SignIn = () => {
|
|||||||
width: '100%'
|
width: '100%'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
<Typography sx={{ mb: 1 }} variant="h4">
|
||||||
|
{PROJECT_NAME}
|
||||||
|
</Typography>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
<Box
|
||||||
<Box display="flex" flexDirection="column" alignItems="center">
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
sx={{
|
sx={{
|
||||||
width: 240
|
width: '32ch'
|
||||||
}}
|
}}
|
||||||
name="username"
|
name="username"
|
||||||
label={LL.USERNAME(0)}
|
label={LL.USERNAME(0)}
|
||||||
value={signInRequest.username}
|
value={signInRequest.username}
|
||||||
onChange={updateLoginRequestValue}
|
onChange={updateLoginRequestValue}
|
||||||
margin="normal"
|
|
||||||
variant="outlined"
|
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
autoCapitalize: 'none',
|
autoCapitalize: 'none',
|
||||||
@@ -120,14 +146,13 @@ const SignIn = () => {
|
|||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
sx={{
|
sx={{
|
||||||
width: 240
|
width: '32ch'
|
||||||
}}
|
}}
|
||||||
name="password"
|
name="password"
|
||||||
label={LL.PASSWORD()}
|
label={LL.PASSWORD()}
|
||||||
value={signInRequest.password}
|
value={signInRequest.password}
|
||||||
onChange={updateLoginRequestValue}
|
onChange={updateLoginRequestValue}
|
||||||
onKeyDown={submitOnEnter}
|
onKeyDown={submitOnEnter}
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -144,6 +169,6 @@ const SignIn = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default SignIn;
|
export default SignIn;
|
||||||
|
|||||||
@@ -20,19 +20,18 @@ import type {
|
|||||||
WriteTemperatureSensor
|
WriteTemperatureSensor
|
||||||
} from '../app/main/types';
|
} from '../app/main/types';
|
||||||
|
|
||||||
|
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
export const readDashboard = () =>
|
export const readDashboard = () =>
|
||||||
alovaInstance.Get<DashboardData>('/rest/dashboardData', {
|
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
|
||||||
responseType: 'arraybuffer' // uses msgpack
|
|
||||||
});
|
|
||||||
|
|
||||||
// Devices
|
// Devices
|
||||||
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
|
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
|
||||||
export const readDeviceData = (id: number) =>
|
export const readDeviceData = (id: number) =>
|
||||||
alovaInstance.Get<DeviceData>('/rest/deviceData', {
|
alovaInstance.Get<DeviceData>('/rest/deviceData', {
|
||||||
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
|
|
||||||
params: { id },
|
params: { id },
|
||||||
responseType: 'arraybuffer' // uses msgpack
|
...MSGPACK_CONFIG
|
||||||
});
|
});
|
||||||
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
|
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
|
||||||
alovaInstance.Post('/rest/writeDeviceValue', data);
|
alovaInstance.Post('/rest/writeDeviceValue', data);
|
||||||
@@ -66,13 +65,13 @@ export const callAction = (action: Action) =>
|
|||||||
|
|
||||||
// SettingsCustomization
|
// SettingsCustomization
|
||||||
export const readDeviceEntities = (id: number) =>
|
export const readDeviceEntities = (id: number) =>
|
||||||
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
|
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
|
||||||
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
|
|
||||||
params: { id },
|
params: { id },
|
||||||
responseType: 'arraybuffer',
|
...MSGPACK_CONFIG,
|
||||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
|
const entities = data as DeviceEntity[];
|
||||||
|
return entities.map((de) => ({
|
||||||
...de,
|
...de,
|
||||||
o_m: de.m,
|
o_m: de.m,
|
||||||
o_cn: de.cn,
|
o_cn: de.cn,
|
||||||
@@ -95,7 +94,8 @@ export const readSchedule = () =>
|
|||||||
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
|
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
|
||||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
|
const schedule = (data as Schedule).schedule;
|
||||||
|
return schedule.map((si) => ({
|
||||||
...si,
|
...si,
|
||||||
o_id: si.id,
|
o_id: si.id,
|
||||||
o_active: si.active,
|
o_active: si.active,
|
||||||
@@ -115,7 +115,8 @@ export const writeSchedule = (data: Schedule) =>
|
|||||||
export const readModules = () =>
|
export const readModules = () =>
|
||||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return (data as Modules).modules.map((mi: ModuleItem) => ({
|
const modules = (data as Modules).modules;
|
||||||
|
return modules.map((mi) => ({
|
||||||
...mi,
|
...mi,
|
||||||
o_enabled: mi.enabled,
|
o_enabled: mi.enabled,
|
||||||
o_license: mi.license
|
o_license: mi.license
|
||||||
@@ -133,7 +134,8 @@ export const readCustomEntities = () =>
|
|||||||
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
|
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
|
||||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return (data as Entities).entities.map((ei: EntityItem) => ({
|
const entities = (data as Entities).entities;
|
||||||
|
return entities.map((ei) => ({
|
||||||
...ei,
|
...ei,
|
||||||
o_id: ei.id,
|
o_id: ei.id,
|
||||||
o_ram: ei.ram,
|
o_ram: ei.ram,
|
||||||
|
|||||||
@@ -4,63 +4,56 @@ import ReactHook from 'alova/react';
|
|||||||
|
|
||||||
import { unpack } from './unpack';
|
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({
|
export const alovaInstance = createAlova({
|
||||||
statesHook: ReactHook,
|
statesHook: ReactHook,
|
||||||
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
|
|
||||||
cacheFor: null, // disable cache
|
cacheFor: null, // disable cache
|
||||||
// cacheFor: {
|
|
||||||
// GET: {
|
|
||||||
// mode: 'memory',
|
|
||||||
// expire: 60 * 10 * 1000 // 60 seconds in cache
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
requestAdapter: xhrRequestAdapter(),
|
requestAdapter: xhrRequestAdapter(),
|
||||||
beforeRequest(method) {
|
beforeRequest(method) {
|
||||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
const token = getAccessToken();
|
||||||
method.config.headers.Authorization =
|
if (token) {
|
||||||
'Bearer ' + localStorage.getItem(ACCESS_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: {
|
responded: {
|
||||||
onSuccess: async (response: AlovaXHRResponse) => {
|
onSuccess: handleResponse
|
||||||
// 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);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const alovaInstanceGH = createAlova({
|
|
||||||
baseURL:
|
|
||||||
process.env.NODE_ENV === 'development'
|
|
||||||
? '/gh'
|
|
||||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
|
||||||
statesHook: ReactHook,
|
|
||||||
requestAdapter: xhrRequestAdapter()
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
|
|||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
|
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
|
||||||
|
|
||||||
export const readNetworkStatus = () =>
|
export const readNetworkStatus = () =>
|
||||||
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
|
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
|
||||||
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
|
||||||
export const listNetworks = () =>
|
export const listNetworks = () =>
|
||||||
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
|
||||||
timeout: 20000 // 20 seconds
|
timeout: LIST_NETWORKS_TIMEOUT
|
||||||
});
|
});
|
||||||
export const readNetworkSettings = () =>
|
export const readNetworkSettings = () =>
|
||||||
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
|
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
|
|||||||
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
|
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
|
||||||
|
|
||||||
export const readNTPSettings = () =>
|
export const readNTPSettings = () =>
|
||||||
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
|
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
|
||||||
export const updateNTPSettings = (data: NTPSettingsType) =>
|
export const updateNTPSettings = (data: NTPSettingsType) =>
|
||||||
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
|
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { LogSettings, SystemStatus } from 'types';
|
import type { LogSettings, SystemStatus } from 'types';
|
||||||
|
|
||||||
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
// systemStatus - also used to ping in System Monitor for pinging
|
// systemStatus - also used to ping in System Monitor for pinging
|
||||||
export const readSystemStatus = () =>
|
export const readSystemStatus = () =>
|
||||||
@@ -8,38 +8,17 @@ export const readSystemStatus = () =>
|
|||||||
|
|
||||||
// SystemLog
|
// SystemLog
|
||||||
export const readLogSettings = () =>
|
export const readLogSettings = () =>
|
||||||
alovaInstance.Get<LogSettings>(`/rest/logSettings`);
|
alovaInstance.Get<LogSettings>('/rest/logSettings');
|
||||||
export const updateLogSettings = (data: LogSettings) =>
|
export const updateLogSettings = (data: LogSettings) =>
|
||||||
alovaInstance.Post('/rest/logSettings', data);
|
alovaInstance.Post('/rest/logSettings', data);
|
||||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||||
|
|
||||||
// Get versions from GitHub
|
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||||
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
|
||||||
export const getStableVersion = () =>
|
|
||||||
alovaInstanceGH.Get('latest', {
|
|
||||||
cacheFor: 60 * 10 * 1000,
|
|
||||||
transform(response: { data: { name: string; published_at: string } }) {
|
|
||||||
return {
|
|
||||||
name: response.data.name.substring(1),
|
|
||||||
published_at: response.data.published_at
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const getDevVersion = () =>
|
|
||||||
alovaInstanceGH.Get('tags/latest', {
|
|
||||||
cacheFor: 60 * 10 * 1000,
|
|
||||||
transform(response: { data: { name: string; published_at: string } }) {
|
|
||||||
return {
|
|
||||||
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
|
||||||
published_at: response.data.published_at
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uploadFile = (file: File) => {
|
export const uploadFile = (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return alovaInstance.Post('/rest/uploadFile', formData, {
|
return alovaInstance.Post('/rest/uploadFile', formData, {
|
||||||
timeout: 60000 // override timeout for uploading firmware - 1 minute
|
timeout: UPLOAD_TIMEOUT
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class Unpackr {
|
|||||||
}
|
}
|
||||||
Object.assign(this, options);
|
Object.assign(this, options);
|
||||||
}
|
}
|
||||||
unpack(source, options?: any) {
|
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
|
||||||
if (src) {
|
if (src) {
|
||||||
return saveState(() => {
|
return saveState(() => {
|
||||||
clearSource();
|
clearSource();
|
||||||
@@ -184,7 +184,7 @@ export class Unpackr {
|
|||||||
function getPosition() {
|
function getPosition() {
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
function checkedRead(options: any) {
|
function checkedRead(options?: { lazy?: boolean }) {
|
||||||
try {
|
try {
|
||||||
if (!currentUnpackr.trusted && !sequentialMode) {
|
if (!currentUnpackr.trusted && !sequentialMode) {
|
||||||
const sharedLength = currentStructures.sharedLength || 0;
|
const sharedLength = currentStructures.sharedLength || 0;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
|||||||
import type { Entities, EntityItem } from './types';
|
import type { Entities, EntityItem } from './types';
|
||||||
import { entityItemValidation } from './validators';
|
import { entityItemValidation } from './validators';
|
||||||
|
|
||||||
|
const MIN_ID = -100;
|
||||||
|
const MAX_ID = 100;
|
||||||
|
const ICON_SIZE = 12;
|
||||||
|
|
||||||
const CustomEntities = () => {
|
const CustomEntities = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
const [numChanges, setNumChanges] = useState<number>(0);
|
||||||
@@ -64,7 +68,7 @@ const CustomEntities = () => {
|
|||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
function hasEntityChanged(ei: EntityItem) {
|
const hasEntityChanged = (ei: EntityItem) => {
|
||||||
return (
|
return (
|
||||||
ei.id !== ei.o_id ||
|
ei.id !== ei.o_id ||
|
||||||
ei.ram !== ei.o_ram ||
|
ei.ram !== ei.o_ram ||
|
||||||
@@ -80,7 +84,7 @@ const CustomEntities = () => {
|
|||||||
ei.deleted !== ei.o_deleted ||
|
ei.deleted !== ei.o_deleted ||
|
||||||
(ei.value || '') !== (ei.o_value || '')
|
(ei.value || '') !== (ei.o_value || '')
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const entity_theme = useTheme({
|
const entity_theme = useTheme({
|
||||||
Table: `
|
Table: `
|
||||||
@@ -165,11 +169,11 @@ const CustomEntities = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
const editEntityItem = (ei: EntityItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setSelectedEntityItem(ei);
|
setSelectedEntityItem(ei);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -200,7 +204,7 @@ const CustomEntities = () => {
|
|||||||
const onDialogDup = (item: EntityItem) => {
|
const onDialogDup = (item: EntityItem) => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedEntityItem({
|
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 + '_',
|
name: item.name + '_',
|
||||||
ram: item.ram,
|
ram: item.ram,
|
||||||
device_id: item.device_id,
|
device_id: item.device_id,
|
||||||
@@ -220,7 +224,7 @@ const CustomEntities = () => {
|
|||||||
const addEntityItem = () => {
|
const addEntityItem = () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedEntityItem({
|
setSelectedEntityItem({
|
||||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||||
name: '',
|
name: '',
|
||||||
ram: 0,
|
ram: 0,
|
||||||
device_id: '0',
|
device_id: '0',
|
||||||
@@ -237,18 +241,23 @@ const CustomEntities = () => {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatValue(value: unknown, uom: number) {
|
const formatValue = (value: unknown, uom: number) => {
|
||||||
return value === undefined
|
return value === undefined
|
||||||
? ''
|
? ''
|
||||||
: typeof value === 'number'
|
: typeof value === 'number'
|
||||||
? new Intl.NumberFormat().format(value) +
|
? new Intl.NumberFormat().format(value) +
|
||||||
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
|
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
||||||
: (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]);
|
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
function showHex(value: number, digit: number) {
|
const showHex = (value: number, digit: number) => {
|
||||||
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
|
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const filteredAndSortedEntities =
|
||||||
|
entities
|
||||||
|
?.filter((ei: EntityItem) => !ei.deleted)
|
||||||
|
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
|
||||||
|
|
||||||
const renderEntity = () => {
|
const renderEntity = () => {
|
||||||
if (!entities) {
|
if (!entities) {
|
||||||
@@ -260,9 +269,7 @@ const CustomEntities = () => {
|
|||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
data={{
|
data={{
|
||||||
nodes: entities
|
nodes: filteredAndSortedEntities
|
||||||
.filter((ei: EntityItem) => !ei.deleted)
|
|
||||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name))
|
|
||||||
}}
|
}}
|
||||||
theme={entity_theme}
|
theme={entity_theme}
|
||||||
layout={{ custom: true }}
|
layout={{ custom: true }}
|
||||||
@@ -285,16 +292,21 @@ const CustomEntities = () => {
|
|||||||
<Cell>
|
<Cell>
|
||||||
{ei.name}
|
{ei.name}
|
||||||
{ei.writeable && (
|
{ei.writeable && (
|
||||||
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
<EditOutlinedIcon
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontSize: ICON_SIZE }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
|
<Cell>{ei.ram > 0 ? '' : showHex(ei.device_id as number, 2)}</Cell>
|
||||||
|
<Cell>{ei.ram > 0 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
||||||
|
<Cell>{ei.ram > 0 ? '' : ei.offset}</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
|
{ei.ram === 1
|
||||||
</Cell>
|
? 'RAM'
|
||||||
<Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
|
: ei.ram === 2
|
||||||
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
|
? 'NVS'
|
||||||
<Cell>
|
: DeviceValueTypeNames[ei.value_type]}
|
||||||
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
|
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
|
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -309,9 +321,9 @@ const CustomEntities = () => {
|
|||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
<Box mb={2} color="warning.main">
|
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||||
<Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
|
{LL.ENTITIES_HELP_1()}.
|
||||||
</Box>
|
</Typography>
|
||||||
|
|
||||||
{renderEntity()}
|
{renderEntity()}
|
||||||
|
|
||||||
@@ -327,8 +339,8 @@ const CustomEntities = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box mt={2} display="flex" flexWrap="wrap">
|
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap' }}>
|
||||||
<Box flexGrow={1}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
{numChanges > 0 && (
|
{numChanges > 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
@@ -350,7 +362,7 @@ const CustomEntities = () => {
|
|||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import DoneIcon from '@mui/icons-material/Done';
|
|||||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -28,11 +28,24 @@ import type { ValidateFieldsError } from 'async-validator';
|
|||||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||||
import type { EntityItem } 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 {
|
interface CustomEntitiesDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -55,21 +68,35 @@ const CustomEntitiesDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(
|
||||||
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
setEditItem(selectedItem);
|
// Convert to hex strings - combined into single setEditItem call
|
||||||
// convert to hex strings straight away
|
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({
|
setEditItem({
|
||||||
...selectedItem,
|
...selectedItem,
|
||||||
device_id: selectedItem.device_id.toString(16).toUpperCase(),
|
device_id: deviceIdHex,
|
||||||
type_id: selectedItem.type_id.toString(16).toUpperCase(),
|
type_id: typeIdHex,
|
||||||
factor:
|
factor: factorValue
|
||||||
selectedItem.value_type === DeviceValueType.BOOL
|
|
||||||
? selectedItem.factor.toString(16).toUpperCase()
|
|
||||||
: selectedItem.factor
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
@@ -87,42 +114,48 @@ const CustomEntitiesDialog = ({
|
|||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
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') {
|
if (typeof processedItem.type_id === 'string') {
|
||||||
editItem.type_id = parseInt(editItem.type_id, 16);
|
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
editItem.value_type === DeviceValueType.BOOL &&
|
processedItem.value_type === DeviceValueType.BOOL &&
|
||||||
typeof editItem.factor === 'string'
|
typeof processedItem.factor === 'string'
|
||||||
) {
|
) {
|
||||||
editItem.factor = parseInt(editItem.factor, 16);
|
processedItem.factor = Number.parseInt(processedItem.factor, 16);
|
||||||
}
|
}
|
||||||
onSave(editItem);
|
onSave(processedItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
editItem.deleted = true;
|
onSave({ ...editItem, deleted: true });
|
||||||
onSave(editItem);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dup = () => {
|
const dup = () => {
|
||||||
onDup(editItem);
|
onDup(editItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
||||||
|
<MenuItem key={val} value={i}>
|
||||||
|
{val}
|
||||||
|
</MenuItem>
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()}
|
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Box display="flex" flexWrap="wrap" mb={1}>
|
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap" />
|
|
||||||
</Box>
|
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
@@ -135,7 +168,7 @@ const CustomEntitiesDialog = ({
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid mt={3}>
|
<Grid sx={{ mt: 3 }}>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -162,16 +195,17 @@ const CustomEntitiesDialog = ({
|
|||||||
>
|
>
|
||||||
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
|
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
|
||||||
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
|
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
|
||||||
|
<MenuItem value={2}>NVS-{LL.VALUE(1)}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
{editItem.ram === 1 && (
|
{editItem.ram > 0 && (
|
||||||
<>
|
<>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
name="value"
|
name="value"
|
||||||
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
|
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
|
||||||
type="string"
|
type="string"
|
||||||
value={editItem.value as string}
|
value={editItem.value}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -187,18 +221,14 @@ const CustomEntitiesDialog = ({
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{DeviceValueUOM_s.map((val, i) => (
|
{uomMenuItems}
|
||||||
<MenuItem key={val} value={i}>
|
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{editItem.ram === 0 && (
|
{editItem.ram === 0 && (
|
||||||
<>
|
<>
|
||||||
<Grid mt={3}>
|
<Grid sx={{ mt: 3 }}>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -220,7 +250,7 @@ const CustomEntitiesDialog = ({
|
|||||||
margin="normal"
|
margin="normal"
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
type="string"
|
type="string"
|
||||||
value={editItem.device_id as string}
|
value={editItem.device_id}
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
@@ -240,7 +270,7 @@ const CustomEntitiesDialog = ({
|
|||||||
margin="normal"
|
margin="normal"
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
type="string"
|
type="string"
|
||||||
value={editItem.type_id as string}
|
value={editItem.type_id}
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
@@ -275,33 +305,11 @@ const CustomEntitiesDialog = ({
|
|||||||
margin="normal"
|
margin="normal"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
<MenuItem value={DeviceValueType.BOOL}>
|
{VALUE_TYPE_OPTIONS.map((valueType) => (
|
||||||
{DeviceValueTypeNames[DeviceValueType.BOOL]}
|
<MenuItem key={valueType} value={valueType}>
|
||||||
</MenuItem>
|
{DeviceValueTypeNames[valueType]}
|
||||||
<MenuItem value={DeviceValueType.INT8}>
|
</MenuItem>
|
||||||
{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>
|
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -333,11 +341,7 @@ const CustomEntitiesDialog = ({
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{DeviceValueUOM_s.map((val, i) => (
|
{uomMenuItems}
|
||||||
<MenuItem key={val} value={i}>
|
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
@@ -367,7 +371,7 @@ const CustomEntitiesDialog = ({
|
|||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="factor"
|
name="factor"
|
||||||
label={LL.BITMASK()}
|
label={LL.BITMASK()}
|
||||||
value={editItem.factor as string}
|
value={editItem.factor}
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
@@ -390,7 +394,7 @@ const CustomEntitiesDialog = ({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
<Box flexGrow={1}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<RemoveIcon />}
|
startIcon={<RemoveIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useBlocker, useLocation } from 'react-router';
|
import { useBlocker, useLocation } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon';
|
|||||||
import { DeviceEntityMask } from './types';
|
import { DeviceEntityMask } from './types';
|
||||||
import type { APIcall, Device, DeviceEntity } 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 Customizations = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -94,13 +111,14 @@ const Customizations = () => {
|
|||||||
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
|
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
|
||||||
useState<string>(''); // needed for API URL
|
useState<string>(''); // needed for API URL
|
||||||
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
|
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
|
||||||
|
const [selectedDeviceBrand, setSelectedDeviceBrand] = useState<string>('');
|
||||||
|
|
||||||
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
|
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
|
||||||
immediate: false
|
immediate: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const { send: sendDeviceName } = useRequest(
|
const { send: sendDeviceName } = useRequest(
|
||||||
(data: { id: number; name: string }) => writeDeviceName(data),
|
(data: { id: number; name: string; brand: string }) => writeDeviceName(data),
|
||||||
{
|
{
|
||||||
immediate: false
|
immediate: false
|
||||||
}
|
}
|
||||||
@@ -229,19 +247,8 @@ const Customizations = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deviceEntities.length) {
|
if (deviceEntities.length) {
|
||||||
setNumChanges(
|
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
|
||||||
deviceEntities
|
setNumChanges(changedEntities.length);
|
||||||
.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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [deviceEntities]);
|
}, [deviceEntities]);
|
||||||
|
|
||||||
@@ -257,6 +264,7 @@ const Customizations = () => {
|
|||||||
if (device) {
|
if (device) {
|
||||||
setSelectedDeviceTypeNameURL(device.url || '');
|
setSelectedDeviceTypeNameURL(device.url || '');
|
||||||
setSelectedDeviceName(device.n);
|
setSelectedDeviceName(device.n);
|
||||||
|
setSelectedDeviceBrand(device.b);
|
||||||
}
|
}
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
setRestartNeeded(false);
|
setRestartNeeded(false);
|
||||||
@@ -275,18 +283,23 @@ const Customizations = () => {
|
|||||||
return value as string;
|
return value as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatName = (de: DeviceEntity, withShortname: boolean) =>
|
const isCommand = (de: DeviceEntity) => {
|
||||||
(de.n && de.n[0] === '!'
|
return de.n && de.n[0] === '!';
|
||||||
? de.t
|
};
|
||||||
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
|
|
||||||
: LL.COMMAND(1) + ': ' + de.n.slice(1)
|
const formatName = (de: DeviceEntity, withShortname: boolean) => {
|
||||||
: de.cn && de.cn !== ''
|
let name: string;
|
||||||
? de.t
|
if (isCommand(de)) {
|
||||||
? de.t + ' ' + de.cn
|
name = de.t
|
||||||
: de.cn
|
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||||
: de.t
|
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||||
? de.t + ' ' + de.n
|
} else if (de.cn && de.cn !== '') {
|
||||||
: de.n) + (withShortname ? ' ' + de.id : '');
|
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||||
|
} else {
|
||||||
|
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||||
|
}
|
||||||
|
return withShortname ? `${name} ${de.id}` : name;
|
||||||
|
};
|
||||||
|
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
const getMaskNumber = (newMask: string[]) => {
|
||||||
let new_mask = 0;
|
let new_mask = 0;
|
||||||
@@ -321,24 +334,17 @@ const Customizations = () => {
|
|||||||
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
const maskDisabled = (set: boolean) => {
|
const maskDisabled = (set: boolean) => {
|
||||||
setDeviceEntities(
|
setDeviceEntities((prev) =>
|
||||||
deviceEntities.map(function (de) {
|
prev.map((de) => {
|
||||||
if (filter_entity(de)) {
|
if (filter_entity(de)) {
|
||||||
|
const excludeMask =
|
||||||
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
return {
|
return {
|
||||||
...de,
|
...de,
|
||||||
m: set
|
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||||
? de.m |
|
|
||||||
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE)
|
|
||||||
: de.m &
|
|
||||||
~(
|
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
return de;
|
|
||||||
}
|
}
|
||||||
|
return de;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -351,6 +357,7 @@ const Customizations = () => {
|
|||||||
toast.error((error as Error).message);
|
toast.error((error as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
|
setRestarting(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,9 +367,10 @@ const Customizations = () => {
|
|||||||
|
|
||||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||||
setDeviceEntities(
|
setDeviceEntities(
|
||||||
deviceEntities?.map((de) =>
|
(prev) =>
|
||||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
prev?.map((de) =>
|
||||||
)
|
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||||
|
) ?? []
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -371,7 +379,7 @@ const Customizations = () => {
|
|||||||
updateDeviceEntity(updatedItem);
|
updateDeviceEntity(updatedItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
const editDeviceEntity = (de: DeviceEntity) => {
|
||||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -382,56 +390,54 @@ const Customizations = () => {
|
|||||||
|
|
||||||
setSelectedDeviceEntity(de);
|
setSelectedDeviceEntity(de);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const saveCustomization = async () => {
|
const saveCustomization = async () => {
|
||||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||||
const masked_entities = deviceEntities
|
return;
|
||||||
.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 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);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renameDevice = async () => {
|
const renameDevice = async () => {
|
||||||
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
|
await sendDeviceName({
|
||||||
|
id: selectedDevice,
|
||||||
|
name: selectedDeviceName,
|
||||||
|
brand: selectedDeviceBrand
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.NAME(1)));
|
toast.success(LL.UPDATED_OF(LL.NAME(1)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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 () => {
|
.finally(async () => {
|
||||||
setRename(false);
|
setRename(false);
|
||||||
@@ -441,20 +447,31 @@ const Customizations = () => {
|
|||||||
|
|
||||||
const renderDeviceList = () => (
|
const renderDeviceList = () => (
|
||||||
<>
|
<>
|
||||||
<Box mb={1} color="warning.main">
|
<Typography sx={{ mb: 1 }} color="warning" variant="body1">
|
||||||
<Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
|
{LL.CUSTOMIZATIONS_HELP_1()}.
|
||||||
</Box>
|
</Typography>
|
||||||
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 2 }}>
|
||||||
{rename ? (
|
{rename ? (
|
||||||
<TextField
|
<>
|
||||||
name="device"
|
<TextField
|
||||||
label={LL.EMS_DEVICE()}
|
name="device"
|
||||||
fullWidth
|
label={LL.EMS_DEVICE()}
|
||||||
variant="outlined"
|
style={{ minWidth: '48%' }}
|
||||||
value={selectedDeviceName}
|
variant="outlined"
|
||||||
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
value={selectedDeviceName}
|
||||||
margin="normal"
|
onChange={(e) => setSelectedDeviceName(e.target.value)}
|
||||||
/>
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="brand"
|
||||||
|
label={LL.BRAND()}
|
||||||
|
style={{ minWidth: '48%' }}
|
||||||
|
variant="outlined"
|
||||||
|
value={selectedDeviceBrand}
|
||||||
|
onChange={(e) => setSelectedDeviceBrand(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
name="device"
|
name="device"
|
||||||
@@ -500,50 +517,56 @@ const Customizations = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
startIcon={<EditIcon />}
|
<Button
|
||||||
variant="outlined"
|
startIcon={<EditIcon />}
|
||||||
onClick={() => setRename(true)}
|
variant="outlined"
|
||||||
>
|
onClick={() => setRename(true)}
|
||||||
{LL.RENAME()}
|
>
|
||||||
</Button>
|
{LL.RENAME()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setConfirmReset(true)}
|
||||||
|
>
|
||||||
|
{LL.REMOVE_ALL()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDeviceData = () => {
|
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
|
||||||
const shown_data = deviceEntities.filter((de) => filter_entity(de));
|
|
||||||
|
|
||||||
|
const renderDeviceData = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box color="warning.main">
|
<Typography sx={{ mt: 1, mb: 1 }} color="warning" variant="body2">
|
||||||
<Typography variant="body2" mt={1}>
|
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
||||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
|
||||||
|
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
||||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
|
||||||
|
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
|
{LL.CUSTOMIZATIONS_HELP_4()}
|
||||||
{LL.CUSTOMIZATIONS_HELP_4()}
|
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()}
|
||||||
<OptionIcon type="web_exclude" isSet={true} />=
|
|
||||||
{LL.CUSTOMIZATIONS_HELP_5()}
|
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
||||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
</Typography>
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
mb={1}
|
|
||||||
mt={0}
|
|
||||||
spacing={2}
|
spacing={2}
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent="flex-start"
|
sx={{ mb: 1, mt: 0, justifyContent: 'flex-start', alignItems: 'center' }}
|
||||||
alignItems="center"
|
|
||||||
>
|
>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
placeholder={LL.SEARCH()}
|
placeholder={LL.SEARCH()}
|
||||||
|
aria-label={LL.SEARCH()}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSearch(event.target.value);
|
setSearch(event.target.value);
|
||||||
}}
|
}}
|
||||||
@@ -612,13 +635,13 @@ const Customizations = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Typography variant="subtitle2" color="grey">
|
<Typography variant="subtitle2" color="grey">
|
||||||
{LL.SHOWING()} {shown_data.length}/{deviceEntities.length}
|
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
||||||
{LL.ENTITIES(deviceEntities.length)}
|
{LL.ENTITIES(deviceEntities.length)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: shown_data }}
|
data={{ nodes: filteredEntities }}
|
||||||
theme={entities_theme}
|
theme={entities_theme}
|
||||||
layout={{ custom: true }}
|
layout={{ custom: true }}
|
||||||
>
|
>
|
||||||
@@ -640,14 +663,27 @@ const Customizations = () => {
|
|||||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
{formatName(de, false)} (
|
<span
|
||||||
<Link
|
style={{
|
||||||
target="_blank"
|
color:
|
||||||
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
|
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{de.id}
|
{formatName(de, false)} (
|
||||||
</Link>
|
<Link
|
||||||
)
|
style={{
|
||||||
|
color:
|
||||||
|
de.v === undefined && !isCommand(de)
|
||||||
|
? 'grey'
|
||||||
|
: 'primary'
|
||||||
|
}}
|
||||||
|
target="_blank"
|
||||||
|
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
|
||||||
|
>
|
||||||
|
{de.id}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
</span>
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
|
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
|
||||||
@@ -672,7 +708,7 @@ const Customizations = () => {
|
|||||||
open={confirmReset}
|
open={confirmReset}
|
||||||
onClose={() => setConfirmReset(false)}
|
onClose={() => setConfirmReset(false)}
|
||||||
>
|
>
|
||||||
<DialogTitle>{LL.RESET(1)}</DialogTitle>
|
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
|
||||||
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
@@ -689,7 +725,7 @@ const Customizations = () => {
|
|||||||
onClick={resetCustomization}
|
onClick={resetCustomization}
|
||||||
color="error"
|
color="error"
|
||||||
>
|
>
|
||||||
{LL.RESET(0)}
|
{LL.REMOVE_ALL()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -700,8 +736,9 @@ const Customizations = () => {
|
|||||||
{devices && renderDeviceList()}
|
{devices && renderDeviceList()}
|
||||||
{selectedDevice !== -1 && !rename && renderDeviceData()}
|
{selectedDevice !== -1 && !rename && renderDeviceData()}
|
||||||
{restartNeeded ? (
|
{restartNeeded ? (
|
||||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
||||||
<Button
|
<Button
|
||||||
|
sx={{ ml: 2 }}
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -711,15 +748,19 @@ const Customizations = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</MessageBox>
|
</MessageBox>
|
||||||
) : (
|
) : (
|
||||||
<Box display="flex" flexWrap="wrap">
|
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
<Box flexGrow={1}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
{numChanges !== 0 && (
|
{numChanges !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={() => devices && sendDeviceEntities(selectedDevice)}
|
onClick={() => {
|
||||||
|
if (devices) {
|
||||||
|
void sendDeviceEntities(selectedDevice);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -734,28 +775,18 @@ const Customizations = () => {
|
|||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!rename && (
|
|
||||||
<ButtonRow mt={1}>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={() => setConfirmReset(true)}
|
|
||||||
>
|
|
||||||
{LL.RESET(0)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{renderResetDialog()}
|
{renderResetDialog()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return restarting ? (
|
||||||
|
<SystemMonitor />
|
||||||
|
) : (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{restarting ? <SystemMonitor /> : renderContent()}
|
{renderContent()}
|
||||||
{selectedDeviceEntity && (
|
{selectedDeviceEntity && (
|
||||||
<SettingsCustomizationsDialog
|
<SettingsCustomizationsDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
@@ -30,6 +30,23 @@ interface SettingsCustomizationsDialogProps {
|
|||||||
selectedItem: DeviceEntity;
|
selectedItem: DeviceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LabelValueProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabelValue = memo(({ label, value }: LabelValueProps) => (
|
||||||
|
<Grid container direction="row">
|
||||||
|
<Typography variant="body2" color="warning">
|
||||||
|
{label}:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{value}</Typography>
|
||||||
|
</Grid>
|
||||||
|
));
|
||||||
|
LabelValue.displayName = 'LabelValue';
|
||||||
|
|
||||||
|
const ICON_SIZE = 16;
|
||||||
|
|
||||||
const CustomizationsDialog = ({
|
const CustomizationsDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -40,7 +57,11 @@ const CustomizationsDialog = ({
|
|||||||
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
||||||
const [error, setError] = useState<boolean>(false);
|
const [error, setError] = useState<boolean>(false);
|
||||||
|
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(
|
||||||
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
const isWriteableNumber =
|
const isWriteableNumber =
|
||||||
typeof editItem.v === 'number' &&
|
typeof editItem.v === 'number' &&
|
||||||
@@ -68,7 +89,7 @@ const CustomizationsDialog = ({
|
|||||||
isWriteableNumber &&
|
isWriteableNumber &&
|
||||||
editItem.mi &&
|
editItem.mi &&
|
||||||
editItem.ma &&
|
editItem.ma &&
|
||||||
editItem.mi > editItem?.ma
|
editItem.mi > editItem.ma
|
||||||
) {
|
) {
|
||||||
setError(true);
|
setError(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -77,43 +98,33 @@ const CustomizationsDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||||
setEditItem({ ...editItem, m: updatedItem.m });
|
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
|
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container>
|
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
||||||
<Typography variant="body2" color="warning.main">
|
<LabelValue
|
||||||
{LL.ID_OF(LL.ENTITY())}:
|
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
||||||
</Typography>
|
value={editItem.n}
|
||||||
<Typography variant="body2">{editItem.id}</Typography>
|
/>
|
||||||
</Grid>
|
<LabelValue
|
||||||
|
label={LL.WRITEABLE()}
|
||||||
<Grid container direction="row">
|
value={
|
||||||
<Typography variant="body2" color="warning.main">
|
editItem.w ? (
|
||||||
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:
|
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
||||||
</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 }} />
|
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
||||||
)}
|
)
|
||||||
</Typography>
|
}
|
||||||
</Grid>
|
/>
|
||||||
|
|
||||||
<Box mt={1} mb={2}>
|
<Box sx={{ mt: 1, mb: 2 }}>
|
||||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -149,12 +160,14 @@ const CustomizationsDialog = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Typography variant="body2" color="error" mt={2}>
|
<Typography sx={{ mt: 2 }} variant="body2" color="error">
|
||||||
Error: Check min and max values
|
Error: Check min and max values
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { memo, useContext, useEffect, useState } from 'react';
|
||||||
import { IconContext } from 'react-icons/lib';
|
import { IconContext } from 'react-icons/lib';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -6,7 +6,7 @@ import { toast } from 'react-toastify';
|
|||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
|
||||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||||
import {
|
import {
|
||||||
@@ -77,40 +77,35 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const deviceValueDialogSave = useCallback(
|
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
||||||
async (devicevalue: DeviceValue) => {
|
if (!selectedDashboardItem) {
|
||||||
if (!selectedDashboardItem) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
.then(() => {
|
||||||
.then(() => {
|
toast.success(LL.WRITE_CMD_SENT());
|
||||||
toast.success(LL.WRITE_CMD_SENT());
|
})
|
||||||
})
|
.catch((error: Error) => {
|
||||||
.catch((error: Error) => {
|
toast.error(error.message);
|
||||||
toast.error(error.message);
|
})
|
||||||
})
|
.finally(() => {
|
||||||
.finally(() => {
|
setDeviceValueDialogOpen(false);
|
||||||
setDeviceValueDialogOpen(false);
|
setSelectedDashboardItem(undefined);
|
||||||
setSelectedDashboardItem(undefined);
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[selectedDashboardItem, sendDeviceValue, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dashboard_theme = useMemo(
|
const dashboard_theme = useTheme({
|
||||||
() =>
|
Table: `
|
||||||
useTheme({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
.td {
|
.td {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
&:nth-of-type(odd) .td {
|
&:nth-of-type(odd) .td {
|
||||||
@@ -120,7 +115,7 @@ const Dashboard = memo(() => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
},
|
},
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -128,12 +123,10 @@ const Dashboard = memo(() => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tree = useTree(
|
const tree = useTree(
|
||||||
{ nodes: data.nodes },
|
{ nodes: [...data.nodes] },
|
||||||
{
|
{
|
||||||
onChange: () => {} // not used but needed
|
onChange: () => {} // not used but needed
|
||||||
},
|
},
|
||||||
@@ -164,79 +157,64 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodeIds = useMemo(
|
|
||||||
() => data.nodes.map((item: DashboardItem) => item.id),
|
|
||||||
[data.nodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
|
||||||
showAll
|
showAll
|
||||||
? tree.fns.onAddAll(nodeIds) // expand tree
|
? tree.fns.onAddAll(nodeIds) // expand tree
|
||||||
: tree.fns.onRemoveAll(); // collapse tree
|
: tree.fns.onRemoveAll(); // collapse tree
|
||||||
}, [parentNodes]);
|
}, [parentNodes]);
|
||||||
|
|
||||||
const showType = useCallback(
|
const showType = (n?: string, t?: number) => {
|
||||||
(n?: string, t?: number) => {
|
// if we have a name show it
|
||||||
// if we have a name show it
|
if (n) {
|
||||||
if (n) {
|
return n;
|
||||||
return n;
|
}
|
||||||
|
if (t) {
|
||||||
|
// otherwise pick translation based on type
|
||||||
|
switch (t) {
|
||||||
|
case DeviceType.CUSTOM:
|
||||||
|
return LL.CUSTOM_ENTITIES(0);
|
||||||
|
case DeviceType.ANALOGSENSOR:
|
||||||
|
return LL.ANALOG_SENSORS();
|
||||||
|
case DeviceType.TEMPERATURESENSOR:
|
||||||
|
return LL.TEMP_SENSORS();
|
||||||
|
case DeviceType.SCHEDULER:
|
||||||
|
return LL.SCHEDULER();
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (t) {
|
}
|
||||||
// otherwise pick translation based on type
|
return '';
|
||||||
switch (t) {
|
};
|
||||||
case DeviceType.CUSTOM:
|
|
||||||
return LL.CUSTOM_ENTITIES(0);
|
|
||||||
case DeviceType.ANALOGSENSOR:
|
|
||||||
return LL.ANALOG_SENSORS();
|
|
||||||
case DeviceType.TEMPERATURESENSOR:
|
|
||||||
return LL.TEMP_SENSORS();
|
|
||||||
case DeviceType.SCHEDULER:
|
|
||||||
return LL.SCHEDULER();
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showName = useCallback(
|
const showName = (di: DashboardItem) => {
|
||||||
(di: DashboardItem) => {
|
if (di.id < 100) {
|
||||||
if (di.id < 100) {
|
// if its a device (parent node) and has entities
|
||||||
// if its a device (parent node) and has entities
|
if (di.nodes?.length) {
|
||||||
if (di.nodes?.length) {
|
return (
|
||||||
return (
|
<span style={{ fontSize: '15px' }}>
|
||||||
<span style={{ fontSize: '15px' }}>
|
<DeviceIcon type_id={di.t ?? 0} />
|
||||||
<DeviceIcon type_id={di.t ?? 0} />
|
{showType(di.n, di.t)}
|
||||||
{showType(di.n, di.t)}
|
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (di.dv) {
|
}
|
||||||
return <span>{di.dv.id.slice(2)}</span>;
|
if (di.dv) {
|
||||||
}
|
return <span>{di.dv.id.slice(2)}</span>;
|
||||||
return null;
|
}
|
||||||
},
|
return null;
|
||||||
[showType]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const hasMask = useCallback(
|
const hasMask = (id: string, mask: number) =>
|
||||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editDashboardValue = useCallback(
|
const editDashboardValue = (di: DashboardItem) => {
|
||||||
(di: DashboardItem) => {
|
if (me.admin && di.dv?.c) {
|
||||||
if (me.admin && di.dv?.c) {
|
setSelectedDashboardItem(di);
|
||||||
setSelectedDashboardItem(di);
|
setDeviceValueDialogOpen(true);
|
||||||
setDeviceValueDialogOpen(true);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowAll = (
|
const handleShowAll = (
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
@@ -248,10 +226,9 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFavEntities = useMemo(
|
const hasFavEntities = data.nodes.filter(
|
||||||
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
(item: DashboardItem) => item.id <= 90
|
||||||
[data.nodes]
|
).length;
|
||||||
);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -262,12 +239,8 @@ const Dashboard = memo(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!data.connected && (
|
|
||||||
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
|
||||||
<MessageBox mb={2} level="warning">
|
<MessageBox sx={{ mb: 2 }} level="warning">
|
||||||
<Typography>
|
<Typography>
|
||||||
{LL.NO_DATA_1()}
|
{LL.NO_DATA_1()}
|
||||||
<Link to="/customizations" style={{ color: 'white' }}>
|
<Link to="/customizations" style={{ color: 'white' }}>
|
||||||
@@ -283,112 +256,118 @@ const Dashboard = memo(() => {
|
|||||||
</MessageBox>
|
</MessageBox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.nodes.length > 0 && (
|
<Box
|
||||||
<>
|
sx={{
|
||||||
<Box
|
display: 'flex',
|
||||||
display="flex"
|
justifyContent: 'flex-end',
|
||||||
justifyContent="flex-end"
|
flexWrap: 'nowrap',
|
||||||
flexWrap="nowrap"
|
whiteSpace: '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>
|
||||||
|
|
||||||
|
{data.nodes.length > 0 ? (
|
||||||
|
<Box sx={{ mt: 1, justifyContent: 'center', flexDirection: 'column' }}>
|
||||||
|
<IconContext.Provider
|
||||||
|
value={{
|
||||||
|
color: 'lightblue',
|
||||||
|
size: '18',
|
||||||
|
style: { verticalAlign: 'middle' }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ToggleButtonGroup
|
<Table
|
||||||
size="small"
|
data={{ nodes: data.nodes }}
|
||||||
color="primary"
|
theme={dashboard_theme}
|
||||||
value={showAll}
|
layout={{ custom: true }}
|
||||||
exclusive
|
tree={tree}
|
||||||
onChange={handleShowAll}
|
|
||||||
>
|
>
|
||||||
<ButtonTooltip title={LL.ALLVALUES()}>
|
{(tableList: DashboardItem[]) => (
|
||||||
<ToggleButton value={true}>
|
<Body>
|
||||||
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
|
{tableList.map((di: DashboardItem) => (
|
||||||
</ToggleButton>
|
<Row
|
||||||
</ButtonTooltip>
|
key={di.id}
|
||||||
<ButtonTooltip title={LL.COMPACT()}>
|
item={di}
|
||||||
<ToggleButton value={false}>
|
onClick={() => editDashboardValue(di)}
|
||||||
<UnfoldLessIcon sx={{ fontSize: 18 }} />
|
>
|
||||||
</ToggleButton>
|
{di.id > 99 ? (
|
||||||
</ButtonTooltip>
|
<>
|
||||||
</ToggleButtonGroup>
|
<Cell>{showName(di)}</Cell>
|
||||||
<Tooltip title={LL.DASHBOARD_1()}>
|
<Cell>
|
||||||
<HelpOutlineIcon
|
<ButtonTooltip
|
||||||
sx={{
|
title={formatValue(LL, di.dv?.v, di.dv?.u)}
|
||||||
ml: 1,
|
>
|
||||||
mt: 1,
|
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
|
||||||
fontSize: 20,
|
</ButtonTooltip>
|
||||||
verticalAlign: 'middle'
|
</Cell>
|
||||||
}}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box mt={1} justifyContent="center" flexDirection="column">
|
<Cell>
|
||||||
<IconContext.Provider
|
{me.admin &&
|
||||||
value={{
|
di.dv?.c &&
|
||||||
color: 'lightblue',
|
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||||
size: '18',
|
<IconButton
|
||||||
style: { verticalAlign: 'middle' }
|
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 sx={{ display: 'flex' }}>
|
||||||
|
<Typography sx={{ mt: 1 }} color="warning" variant="body1">
|
||||||
|
no data
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={LL.DASHBOARD_1()}>
|
||||||
|
<HelpOutlineIcon
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
mt: 1,
|
||||||
|
fontSize: 20,
|
||||||
|
verticalAlign: 'middle'
|
||||||
}}
|
}}
|
||||||
>
|
color="primary"
|
||||||
<Table
|
/>
|
||||||
data={{ nodes: data.nodes }}
|
</Tooltip>
|
||||||
theme={dashboard_theme}
|
</Box>
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import type { IconType } from 'react-icons';
|
||||||
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
|
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
|
||||||
import { CgSmartHomeBoiler } from 'react-icons/cg';
|
import { CgSmartHomeBoiler } from 'react-icons/cg';
|
||||||
import { FaSolarPanel } from 'react-icons/fa';
|
import { FaSolarPanel } from 'react-icons/fa';
|
||||||
import { GiHeatHaze, GiTap } from 'react-icons/gi';
|
import { GiHeatHaze, GiTap } from 'react-icons/gi';
|
||||||
import { MdPlaylistAdd } from 'react-icons/md';
|
|
||||||
import { MdMoreTime } from 'react-icons/md';
|
|
||||||
import {
|
import {
|
||||||
|
MdMoreTime,
|
||||||
MdOutlineDevices,
|
MdOutlineDevices,
|
||||||
MdOutlinePool,
|
MdOutlinePool,
|
||||||
MdOutlineSensors,
|
MdOutlineSensors,
|
||||||
|
MdPlaylistAdd,
|
||||||
MdThermostatAuto
|
MdThermostatAuto
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { PiFan, PiGauge } from 'react-icons/pi';
|
import { PiFan, PiGauge } from 'react-icons/pi';
|
||||||
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
|
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
|
||||||
import { VscVmConnect } from 'react-icons/vsc';
|
import { VscVmConnect } from 'react-icons/vsc';
|
||||||
|
|
||||||
import type { SvgIconProps } from '@mui/material';
|
|
||||||
|
|
||||||
import { DeviceType } from './types';
|
import { DeviceType } from './types';
|
||||||
|
|
||||||
const deviceIconLookup: {
|
const deviceIconLookup: Record<DeviceType, IconType | null> = {
|
||||||
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
|
|
||||||
} = {
|
|
||||||
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
|
||||||
[DeviceType.ANALOGSENSOR]: PiGauge,
|
[DeviceType.ANALOGSENSOR]: PiGauge,
|
||||||
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
[DeviceType.BOILER]: CgSmartHomeBoiler,
|
||||||
@@ -39,15 +37,19 @@ const deviceIconLookup: {
|
|||||||
[DeviceType.POOL]: MdOutlinePool,
|
[DeviceType.POOL]: MdOutlinePool,
|
||||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||||
[DeviceType.SYSTEM]: undefined,
|
[DeviceType.SYSTEM]: null,
|
||||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||||
[DeviceType.VENTILATION]: PiFan
|
[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];
|
const Icon = deviceIconLookup[type_id];
|
||||||
return Icon ? <Icon /> : null;
|
return Icon ? <Icon /> : null;
|
||||||
};
|
});
|
||||||
|
|
||||||
export default DeviceIcon;
|
export default DeviceIcon;
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
|
||||||
useState
|
useState
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { IconContext } from 'react-icons';
|
import { IconContext } from 'react-icons';
|
||||||
import { useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||||
@@ -93,7 +92,7 @@ const Devices = memo(() => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.DEVICES());
|
useLayoutTitle(LL.DEVICES());
|
||||||
|
|
||||||
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
|
const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
|
||||||
initialData: {
|
initialData: {
|
||||||
connected: true,
|
connected: true,
|
||||||
devices: []
|
devices: []
|
||||||
@@ -118,38 +117,34 @@ const Devices = memo(() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
function updateSize() {
|
let raf = 0;
|
||||||
setSize([window.innerWidth, window.innerHeight]);
|
const updateSize = () => {
|
||||||
}
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
setSize([window.innerWidth, window.innerHeight]);
|
||||||
|
});
|
||||||
|
};
|
||||||
window.addEventListener('resize', updateSize);
|
window.addEventListener('resize', updateSize);
|
||||||
updateSize();
|
updateSize();
|
||||||
return () => window.removeEventListener('resize', updateSize);
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateSize);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const leftOffset = () => {
|
const leftOffset = () => {
|
||||||
const devicesWindow = document.getElementById('devices-window');
|
const devicesWindow = document.getElementById('devices-window');
|
||||||
if (!devicesWindow) {
|
if (!devicesWindow) return 0;
|
||||||
return 0;
|
const { left, right } = devicesWindow.getBoundingClientRect();
|
||||||
}
|
if (!left || !right) return 0;
|
||||||
|
|
||||||
const clientRect = devicesWindow.getBoundingClientRect();
|
|
||||||
const left = clientRect.left;
|
|
||||||
const right = clientRect.right;
|
|
||||||
|
|
||||||
if (!left || !right) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left + (right - left < 400 ? 0 : 200);
|
return left + (right - left < 400 ? 0 : 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const common_theme = useMemo(
|
const common_theme = useTheme({
|
||||||
() =>
|
BaseRow: `
|
||||||
useTheme({
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -157,7 +152,7 @@ const Devices = memo(() => {
|
|||||||
border-bottom: 1px solid #565656;
|
border-bottom: 1px solid #565656;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: #1E1E1E;
|
background-color: #1E1E1E;
|
||||||
.td {
|
.td {
|
||||||
@@ -167,88 +162,78 @@ const Devices = memo(() => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const device_theme = useMemo(
|
const device_theme = useTheme([
|
||||||
() =>
|
common_theme,
|
||||||
useTheme([
|
{
|
||||||
common_theme,
|
BaseRow: `
|
||||||
{
|
font-size: 15px;
|
||||||
BaseRow: `
|
.td {
|
||||||
font-size: 15px;
|
height: 28px;
|
||||||
.td {
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
.th {
|
|
||||||
padding: 8px;
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
|
||||||
},
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
},
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]),
|
|
||||||
[common_theme]
|
|
||||||
);
|
|
||||||
|
|
||||||
const data_theme = useMemo(
|
|
||||||
() =>
|
|
||||||
useTheme([
|
|
||||||
common_theme,
|
|
||||||
{
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
|
||||||
height: auto;
|
|
||||||
max-height: 100%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
display:none;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
Table: `
|
||||||
.td {
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||||
height: 32px;
|
`,
|
||||||
}
|
HeaderRow: `
|
||||||
`,
|
.th {
|
||||||
BaseCell: `
|
padding: 8px;
|
||||||
&:nth-of-type(1) {
|
`,
|
||||||
border-left: 1px solid #177ac9;
|
Row: `
|
||||||
},
|
&:nth-of-type(odd) .td {
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: right;
|
|
||||||
},
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
border-right: 1px solid #177ac9;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
.th {
|
|
||||||
border-top: 1px solid #565656;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
background-color: #303030;
|
||||||
},
|
},
|
||||||
&:hover .td {
|
&:hover .td {
|
||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
|
},
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data_theme = useTheme([
|
||||||
|
common_theme,
|
||||||
|
{
|
||||||
|
Table: `
|
||||||
|
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display:none;
|
||||||
}
|
}
|
||||||
`
|
`,
|
||||||
}
|
BaseRow: `
|
||||||
]),
|
.td {
|
||||||
[common_theme]
|
height: 32px;
|
||||||
);
|
}
|
||||||
|
`,
|
||||||
|
BaseCell: `
|
||||||
|
&:nth-of-type(1) {
|
||||||
|
border-left: 1px solid #177ac9;
|
||||||
|
},
|
||||||
|
&:nth-of-type(2) {
|
||||||
|
text-align: right;
|
||||||
|
},
|
||||||
|
&:nth-of-type(3) {
|
||||||
|
border-right: 1px solid #177ac9;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
HeaderRow: `
|
||||||
|
.th {
|
||||||
|
border-top: 1px solid #565656;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Row: `
|
||||||
|
&:nth-of-type(odd) .td {
|
||||||
|
background-color: #303030;
|
||||||
|
},
|
||||||
|
&:hover .td {
|
||||||
|
background-color: #177ac9;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
if (state.sortKey === sortKey && state.reverse) {
|
||||||
@@ -261,7 +246,7 @@ const Devices = memo(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dv_sort = useSort(
|
const dv_sort = useSort(
|
||||||
{ nodes: deviceData.nodes },
|
{ nodes: [...deviceData.nodes] },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
sortIcon: {
|
sortIcon: {
|
||||||
@@ -291,7 +276,7 @@ const Devices = memo(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const device_select = useRowSelect(
|
const device_select = useRowSelect(
|
||||||
{ nodes: coreData.devices },
|
{ nodes: [...coreData.devices] },
|
||||||
{
|
{
|
||||||
onChange: onSelectChange
|
onChange: onSelectChange
|
||||||
}
|
}
|
||||||
@@ -347,10 +332,8 @@ const Devices = memo(() => {
|
|||||||
return sc;
|
return sc;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMask = useCallback(
|
const hasMask = (id: string, mask: number) =>
|
||||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDownloadCsv = () => {
|
const handleDownloadCsv = () => {
|
||||||
const deviceIndex = coreData.devices.findIndex(
|
const deviceIndex = coreData.devices.findIndex(
|
||||||
@@ -535,21 +518,29 @@ const Devices = memo(() => {
|
|||||||
|
|
||||||
const renderCoreData = () => (
|
const renderCoreData = () => (
|
||||||
<>
|
<>
|
||||||
<Box justifyContent="center" flexDirection="column">
|
{!coreData.connected ? (
|
||||||
<IconContext.Provider
|
<MessageBox level="error" message={LL.EMS_BUS_WARNING() + '.'}>
|
||||||
value={{
|
(
|
||||||
color: 'lightblue',
|
<Link
|
||||||
size: '18',
|
target="_blank"
|
||||||
style: { verticalAlign: 'middle' }
|
to="https://docs.emsesp.org/Troubleshooting#ems-bus-is-not-connecting"
|
||||||
}}
|
style={{ color: 'white' }}
|
||||||
>
|
>
|
||||||
{!coreData.connected && (
|
{LL.ONLINE_HELP()}
|
||||||
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
|
</Link>
|
||||||
)}
|
)
|
||||||
|
</MessageBox>
|
||||||
{coreData.connected && (
|
) : (
|
||||||
|
<Box sx={{ justifyContent: 'center', flexDirection: 'column' }}>
|
||||||
|
<IconContext.Provider
|
||||||
|
value={{
|
||||||
|
color: 'lightblue',
|
||||||
|
size: '18',
|
||||||
|
style: { verticalAlign: 'middle' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: coreData.devices }}
|
data={{ nodes: [...coreData.devices] }}
|
||||||
select={device_select}
|
select={device_select}
|
||||||
theme={device_theme}
|
theme={device_theme}
|
||||||
layout={{ custom: true }}
|
layout={{ custom: true }}
|
||||||
@@ -583,9 +574,9 @@ const Devices = memo(() => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
</IconContext.Provider>
|
||||||
</IconContext.Provider>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -601,41 +592,35 @@ const Devices = memo(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDeviceValue = useCallback((dv: DeviceValue) => {
|
const showDeviceValue = (dv: DeviceValue) => {
|
||||||
setSelectedDeviceValue(dv);
|
setSelectedDeviceValue(dv);
|
||||||
setDeviceValueDialogOpen(true);
|
setDeviceValueDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const renderNameCell = useCallback(
|
const renderNameCell = (dv: DeviceValue) => (
|
||||||
(dv: DeviceValue) => (
|
<>
|
||||||
<>
|
{dv.id.slice(2)}
|
||||||
{dv.id.slice(2)}
|
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
)}
|
||||||
)}
|
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
)}
|
||||||
)}
|
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
|
||||||
),
|
|
||||||
[hasMask]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const shown_data = useMemo(() => {
|
const shown_data = onlyFav
|
||||||
if (onlyFav) {
|
? deviceData.nodes.filter(
|
||||||
return deviceData.nodes.filter(
|
|
||||||
(dv: DeviceValue) =>
|
(dv: DeviceValue) =>
|
||||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
||||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: deviceData.nodes.filter((dv: DeviceValue) =>
|
||||||
|
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return deviceData.nodes.filter((dv: DeviceValue) =>
|
|
||||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
}, [deviceData.nodes, onlyFav, search]);
|
|
||||||
|
|
||||||
const deviceIndex = coreData.devices.findIndex(
|
const deviceIndex = coreData.devices.findIndex(
|
||||||
(d: Device) => d.id === device_select.state.id
|
(d: Device) => d.id === device_select.state.id
|
||||||
@@ -654,7 +639,7 @@ const Devices = memo(() => {
|
|||||||
sx={{
|
sx={{
|
||||||
backgroundColor: 'black',
|
backgroundColor: 'black',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: () => leftOffset(),
|
left: leftOffset,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
top: 64,
|
top: 64,
|
||||||
@@ -664,14 +649,14 @@ const Devices = memo(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Grid container justifyContent="space-between">
|
<Grid container sx={{ justifyContent: 'space-between' }}>
|
||||||
<Typography noWrap variant="subtitle1" color="warning.main">
|
<Typography noWrap variant="subtitle1" color="warning">
|
||||||
{deviceInfo.n} (
|
{deviceInfo.n} (
|
||||||
{deviceInfo.tn})
|
{deviceInfo.tn})
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid justifyContent="flex-end">
|
<Grid sx={{ justifyContent: 'flex-end' }}>
|
||||||
<ButtonTooltip title={LL.CLOSE()}>
|
<ButtonTooltip title={LL.CLOSE()}>
|
||||||
<IconButton onClick={resetDeviceSelect}>
|
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
|
||||||
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonTooltip>
|
</ButtonTooltip>
|
||||||
@@ -683,6 +668,7 @@ const Devices = memo(() => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{ width: '22ch' }}
|
sx={{ width: '22ch' }}
|
||||||
placeholder={LL.SEARCH()}
|
placeholder={LL.SEARCH()}
|
||||||
|
aria-label={LL.SEARCH()}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSearch(event.target.value);
|
setSearch(event.target.value);
|
||||||
}}
|
}}
|
||||||
@@ -697,19 +683,22 @@ const Devices = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
|
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
|
||||||
<IconButton onClick={() => setShowDeviceInfo(true)}>
|
<IconButton
|
||||||
|
onClick={() => setShowDeviceInfo(true)}
|
||||||
|
aria-label={LL.DEVICE_DETAILS()}
|
||||||
|
>
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonTooltip>
|
</ButtonTooltip>
|
||||||
{me.admin && (
|
{me.admin && (
|
||||||
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
|
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
|
||||||
<IconButton onClick={customize}>
|
<IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
|
||||||
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
|
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonTooltip>
|
</ButtonTooltip>
|
||||||
)}
|
)}
|
||||||
<ButtonTooltip title={LL.EXPORT()}>
|
<ButtonTooltip title={LL.EXPORT()}>
|
||||||
<IconButton onClick={handleDownloadCsv}>
|
<IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
|
||||||
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
|
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ButtonTooltip>
|
</ButtonTooltip>
|
||||||
@@ -744,7 +733,7 @@ const Devices = memo(() => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: shown_data }}
|
data={{ nodes: Array.from(shown_data) }}
|
||||||
theme={data_theme}
|
theme={data_theme}
|
||||||
sort={dv_sort}
|
sort={dv_sort}
|
||||||
layout={{ custom: true, fixedHeader: true }}
|
layout={{ custom: true, fixedHeader: true }}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type { ValidateFieldsError } from 'async-validator';
|
|||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||||
import type { DeviceValue } from './types';
|
import type { DeviceValue } from './types';
|
||||||
@@ -61,17 +61,13 @@ const DevicesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,37 +87,44 @@ const DevicesDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showHelperText = (dv: DeviceValue) =>
|
const showHelperText = (dv: DeviceValue) => {
|
||||||
dv.h ? (
|
if (dv.h) return dv.h;
|
||||||
dv.h
|
if (dv.l) return dv.l.join(' | ');
|
||||||
) : dv.l ? (
|
if (dv.m !== undefined && dv.x !== undefined) {
|
||||||
dv.l.join(' | ')
|
return (
|
||||||
) : dv.m !== undefined && dv.x !== undefined ? (
|
<>
|
||||||
<>
|
{dv.m} → {dv.x}
|
||||||
{dv.m} → {dv.x}
|
</>
|
||||||
</>
|
);
|
||||||
) : undefined;
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCommand = selectedItem.v === '' && selectedItem.c;
|
||||||
|
const dialogTitle = isCommand
|
||||||
|
? LL.RUN_COMMAND()
|
||||||
|
: writeable
|
||||||
|
? LL.CHANGE_VALUE()
|
||||||
|
: LL.VALUE(0);
|
||||||
|
const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||||
|
const helperText = showHelperText(editItem);
|
||||||
|
|
||||||
|
const valueLabel = LL.VALUE(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={close}>
|
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
{selectedItem.v === '' && selectedItem.c
|
|
||||||
? LL.RUN_COMMAND()
|
|
||||||
: writeable
|
|
||||||
? LL.CHANGE_VALUE()
|
|
||||||
: LL.VALUE(0)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||||
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
|
{editItem.id.slice(2)}
|
||||||
</Box>
|
</Typography>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
{editItem.l ? (
|
{editItem.l ? (
|
||||||
<TextField
|
<TextField
|
||||||
name="v"
|
name="v"
|
||||||
// label={LL.VALUE(0)}
|
|
||||||
value={editItem.v}
|
value={editItem.v}
|
||||||
|
aria-label={valueLabel}
|
||||||
disabled={!writeable}
|
disabled={!writeable}
|
||||||
sx={{ width: '30ch' }}
|
sx={{ width: '30ch' }}
|
||||||
select
|
select
|
||||||
@@ -137,7 +140,7 @@ const DevicesDialog = ({
|
|||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="v"
|
name="v"
|
||||||
label={LL.VALUE(0)}
|
label={valueLabel}
|
||||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={!writeable}
|
disabled={!writeable}
|
||||||
@@ -161,7 +164,7 @@ const DevicesDialog = ({
|
|||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="v"
|
name="v"
|
||||||
label={LL.VALUE(0)}
|
label={valueLabel}
|
||||||
value={editItem.v}
|
value={editItem.v}
|
||||||
disabled={!writeable}
|
disabled={!writeable}
|
||||||
sx={{ width: '30ch' }}
|
sx={{ width: '30ch' }}
|
||||||
@@ -170,9 +173,9 @@ const DevicesDialog = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{writeable && (
|
{writeable && helperText && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
|
<FormHelperText>{helperText}</FormHelperText>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -191,7 +194,7 @@ const DevicesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={close}
|
onClick={onClose}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
>
|
>
|
||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
@@ -202,7 +205,7 @@ const DevicesDialog = ({
|
|||||||
onClick={save}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
|
{buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{progress && (
|
{progress && (
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
@@ -217,7 +220,7 @@ const DevicesDialog = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outlined" onClick={close} color="secondary">
|
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||||
{LL.CLOSE()}
|
{LL.CLOSE()}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,33 +9,42 @@ interface EntityMaskToggleProps {
|
|||||||
de: DeviceEntity;
|
de: DeviceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
const MASK_VALUES = [
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||||
let new_mask = 0;
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||||
for (const entry of newMask) {
|
DeviceEntityMask.DV_READONLY, // 4
|
||||||
new_mask |= Number(entry);
|
DeviceEntityMask.DV_FAVORITE, // 8
|
||||||
}
|
DeviceEntityMask.DV_DELETED // 128
|
||||||
return new_mask;
|
];
|
||||||
};
|
|
||||||
|
|
||||||
const getMaskString = (m: number) => {
|
const getMaskNumber = (newMask: string[]): number =>
|
||||||
const new_masks: string[] = [];
|
newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
||||||
if ((m & 1) === 1) {
|
|
||||||
new_masks.push('1');
|
const getMaskString = (mask: number): string[] =>
|
||||||
|
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
||||||
|
String(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
||||||
|
|
||||||
|
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||||
|
const handleChange = (_event: unknown, mask: string[]) => {
|
||||||
|
const newMask = getMaskNumber(mask);
|
||||||
|
const updatedDe = { ...de };
|
||||||
|
|
||||||
|
// 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 ((m & 2) === 2) {
|
|
||||||
new_masks.push('2');
|
// If excluded from web, cannot be favorite
|
||||||
|
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||||
|
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||||
}
|
}
|
||||||
if ((m & 4) === 4) {
|
|
||||||
new_masks.push('4');
|
onUpdate(updatedDe);
|
||||||
}
|
|
||||||
if ((m & 8) === 8) {
|
|
||||||
new_masks.push('8');
|
|
||||||
}
|
|
||||||
if ((m & 128) === 128) {
|
|
||||||
new_masks.push('128');
|
|
||||||
}
|
|
||||||
return new_masks;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,57 +52,59 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
|||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
value={getMaskString(de.m)}
|
value={getMaskString(de.m)}
|
||||||
onChange={(_event, mask: string[]) => {
|
onChange={handleChange}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
|
<ToggleButton
|
||||||
|
value="8"
|
||||||
|
disabled={
|
||||||
|
hasMask(
|
||||||
|
de.m,
|
||||||
|
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
|
||||||
|
) || de.n === undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<OptionIcon
|
<OptionIcon
|
||||||
type="favorite"
|
type="favorite"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
|
||||||
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
|
<ToggleButton
|
||||||
|
value="4"
|
||||||
|
disabled={
|
||||||
|
!de.w ||
|
||||||
|
hasMask(
|
||||||
|
de.m,
|
||||||
|
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<OptionIcon
|
<OptionIcon
|
||||||
type="readonly"
|
type="readonly"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
|
||||||
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
|
<ToggleButton
|
||||||
|
value="2"
|
||||||
|
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||||
|
>
|
||||||
<OptionIcon
|
<OptionIcon
|
||||||
type="api_mqtt_exclude"
|
type="api_mqtt_exclude"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
|
||||||
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
|
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
|
<ToggleButton
|
||||||
|
value="1"
|
||||||
|
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||||
|
>
|
||||||
<OptionIcon
|
<OptionIcon
|
||||||
type="web_exclude"
|
type="web_exclude"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
|
||||||
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="128">
|
<ToggleButton value="128">
|
||||||
<OptionIcon
|
<OptionIcon
|
||||||
type="deleted"
|
type="deleted"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||||
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useContext, useState } from 'react';
|
import { memo, useContext, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
|
Grid,
|
||||||
Link,
|
Link,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
import { SectionContent, useLayoutTitle } from 'components';
|
||||||
@@ -29,26 +32,57 @@ import { saveFile } from 'utils';
|
|||||||
import { API, callAction } from '../../api/app';
|
import { API, callAction } from '../../api/app';
|
||||||
import type { APIcall } from './types';
|
import type { APIcall } from './types';
|
||||||
|
|
||||||
const Help = () => {
|
interface HelpLink {
|
||||||
|
href: string;
|
||||||
|
icon: ReactElement;
|
||||||
|
label: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomSupport {
|
||||||
|
img_url: string | null;
|
||||||
|
html: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE_URL = 'https://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 SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
|
||||||
|
|
||||||
|
const HelpComponent = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.HELP());
|
useLayoutTitle(LL.HELP());
|
||||||
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
|
const [customSupport, setCustomSupport] = useState<CustomSupport>({
|
||||||
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
|
img_url: null,
|
||||||
const [notFound, setNotFound] = useState<boolean>(false);
|
html: null
|
||||||
|
});
|
||||||
|
const [imgError, setImgError] = useState<boolean>(false);
|
||||||
|
|
||||||
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
||||||
if (event && event.data && Object.keys(event.data).length !== 0) {
|
if (event?.data && Object.keys(event.data).length !== 0) {
|
||||||
const data = (event.data as { Support: { img_url?: string; html?: string[] } })
|
const { Support } = event.data as {
|
||||||
.Support;
|
Support: { img_url?: string; html?: string[] };
|
||||||
if (data.img_url) {
|
};
|
||||||
setCustomSupportIMG(data.img_url);
|
setCustomSupport({
|
||||||
}
|
img_url: Support.img_url || null,
|
||||||
if (data.html) {
|
html: Support.html?.join('<br/>') || null
|
||||||
setCustomSupportHTML(data.html.join('<br/>'));
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,110 +97,85 @@ const Help = () => {
|
|||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const helpLinks: HelpLink[] = [
|
||||||
|
{
|
||||||
|
href: 'https://emsesp.org',
|
||||||
|
icon: <MenuBookIcon />,
|
||||||
|
label: () => LL.HELP_INFORMATION_1()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://discord.gg/GP9DPSgeJq',
|
||||||
|
icon: <CommentIcon />,
|
||||||
|
label: () => LL.HELP_INFORMATION_2()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
|
||||||
|
icon: <GitHubIcon />,
|
||||||
|
label: () => LL.HELP_INFORMATION_3()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const imageSrc =
|
||||||
|
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{customSupportHTML && (
|
{customSupport.html && (
|
||||||
<Stack
|
<Stack
|
||||||
padding={1}
|
|
||||||
mb={2}
|
|
||||||
direction="row"
|
direction="row"
|
||||||
divider={<Divider orientation="vertical" flexItem />}
|
divider={<Divider orientation="vertical" flexItem />}
|
||||||
sx={{
|
sx={{ padding: 1, mb: 2, ...SUPPORT_BOX_STYLES }}
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid lightblue',
|
|
||||||
justifyContent: 'space-evenly',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle1">
|
<Typography variant="subtitle1">
|
||||||
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
|
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
sx={{
|
sx={IMAGE_STYLES}
|
||||||
maxHeight: { xs: 100, md: 250 }
|
onError={() => setImgError(true)}
|
||||||
}}
|
src={imageSrc}
|
||||||
onError={() => setNotFound(true)}
|
|
||||||
src={
|
|
||||||
notFound
|
|
||||||
? ''
|
|
||||||
: customSupportIMG ||
|
|
||||||
'https://docs.emsesp.org/_media/images/installer.jpeg'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{me.admin && (
|
{me?.admin && (
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
{helpLinks.map(({ href, icon, label }) => (
|
||||||
<ListItemButton
|
<ListItem key={href}>
|
||||||
component="a"
|
<ListItemButton
|
||||||
target="_blank"
|
component="a"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
href="https://docs.emsesp.org"
|
rel="noreferrer"
|
||||||
>
|
href={href}
|
||||||
<ListItemAvatar>
|
>
|
||||||
<Avatar sx={{ bgcolor: '#72caf9' }}>
|
<ListItemAvatar>
|
||||||
<MenuBookIcon />
|
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
|
||||||
</Avatar>
|
</ListItemAvatar>
|
||||||
</ListItemAvatar>
|
<ListItemText primary={label()} />
|
||||||
<ListItemText primary={LL.HELP_INFORMATION_1()} />
|
</ListItemButton>
|
||||||
</ListItemButton>
|
</ListItem>
|
||||||
</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>
|
|
||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box p={2} color="warning.main">
|
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
|
||||||
<Typography mb={1} variant="body1">
|
<Typography sx={{ mb: 1 }} color="warning" variant="body1">
|
||||||
{LL.HELP_INFORMATION_4()}.
|
{LL.HELP_INFORMATION_4()}:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
|
onClick={() => void sendAPI(SYSTEM_INFO_API)}
|
||||||
>
|
>
|
||||||
{LL.SUPPORT_INFORMATION(0)}
|
{LL.SUPPORT_INFORMATION(0)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Grid>
|
||||||
|
|
||||||
<Divider sx={{ mt: 4 }} />
|
<Divider sx={{ mt: 4 }} />
|
||||||
|
|
||||||
<Typography color="white" variant="subtitle1" align="center" mt={1}>
|
<Typography color="white" variant="subtitle1" align="center" sx={{ mt: 1 }}>
|
||||||
©
|
©
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -174,11 +183,13 @@ const Help = () => {
|
|||||||
href="https://emsesp.org"
|
href="https://emsesp.org"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{'emsesp.org'}
|
emsesp.org
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Help = memo(HelpComponent);
|
||||||
|
|
||||||
export default Help;
|
export default Help;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@ import { readModules, writeModules } from '../../api/app';
|
|||||||
import ModulesDialog from './ModulesDialog';
|
import ModulesDialog from './ModulesDialog';
|
||||||
import type { ModuleItem } from './types';
|
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 Modules = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
const [numChanges, setNumChanges] = useState<number>(0);
|
||||||
@@ -102,15 +115,25 @@ const Modules = () => {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateModuleItem = (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 = (updatedItem: ModuleItem) => {
|
const onDialogSave = (updatedItem: ModuleItem) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
updateModuleItem(updatedItem);
|
updateModuleItem(updatedItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
const editModuleItem = (mi: ModuleItem) => {
|
||||||
setSelectedModuleItem(mi);
|
setSelectedModuleItem(mi);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onCancel = async () => {
|
const onCancel = async () => {
|
||||||
await fetchModules().then(() => {
|
await fetchModules().then(() => {
|
||||||
@@ -118,40 +141,24 @@ const Modules = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
|
|
||||||
return new_data;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveModules = async () => {
|
const saveModules = async () => {
|
||||||
await Promise.all(
|
try {
|
||||||
modules.map((condensed_mi: ModuleItem) =>
|
await Promise.all(
|
||||||
updateModules({
|
modules.map((condensed_mi: ModuleItem) =>
|
||||||
key: condensed_mi.key,
|
updateModules({
|
||||||
enabled: condensed_mi.enabled,
|
key: condensed_mi.key,
|
||||||
license: condensed_mi.license
|
enabled: condensed_mi.enabled,
|
||||||
})
|
license: condensed_mi.license
|
||||||
)
|
})
|
||||||
)
|
)
|
||||||
.then(() => {
|
);
|
||||||
toast.success(LL.MODULES_UPDATED());
|
toast.success(LL.MODULES_UPDATED());
|
||||||
})
|
} catch (error) {
|
||||||
.catch((error: Error) => {
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
toast.error(error.message);
|
} finally {
|
||||||
})
|
await fetchModules();
|
||||||
.finally(async () => {
|
setNumChanges(0);
|
||||||
await fetchModules();
|
}
|
||||||
setNumChanges(0);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
@@ -169,18 +176,11 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box mb={2} color="warning.main">
|
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||||
<Typography variant="body1">{LL.MODULES_DESCRIPTION()}.</Typography>
|
{LL.MODULES_DESCRIPTION()}.
|
||||||
</Box>
|
</Typography>
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: modules }}
|
data={{ nodes: modules }}
|
||||||
theme={modules_theme}
|
theme={modules_theme}
|
||||||
@@ -218,7 +218,9 @@ const Modules = () => {
|
|||||||
<Cell>{mi.author}</Cell>
|
<Cell>{mi.author}</Cell>
|
||||||
<Cell>{mi.version}</Cell>
|
<Cell>{mi.version}</Cell>
|
||||||
<Cell>{mi.message}</Cell>
|
<Cell>{mi.message}</Cell>
|
||||||
<Cell>{colorStatus(mi.status)}</Cell>
|
<Cell>
|
||||||
|
<ColorStatus status={mi.status} />
|
||||||
|
</Cell>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
</Body>
|
</Body>
|
||||||
@@ -226,8 +228,8 @@ const Modules = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Box mt={1} display="flex" flexWrap="wrap">
|
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap' }}>
|
||||||
<Box flexGrow={1}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
{numChanges !== 0 && (
|
{numChanges !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -37,25 +37,26 @@ const ModulesDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||||
|
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(
|
||||||
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync form state when dialog opens or selected item changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setEditItem(selectedItem);
|
setEditItem(selectedItem);
|
||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const close = () => {
|
const handleSave = () => {
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = () => {
|
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
||||||
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
|
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
@@ -69,7 +70,7 @@ const ModulesDialog = ({
|
|||||||
label="Enabled"
|
label="Enabled"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Box mt={2} mb={1}>
|
<Box sx={{ mt: 2, mb: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
name="license"
|
name="license"
|
||||||
label="License Key"
|
label="License Key"
|
||||||
@@ -85,7 +86,7 @@ const ModulesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={close}
|
onClick={onClose}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
>
|
>
|
||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
@@ -93,7 +94,7 @@ const ModulesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<DoneIcon />}
|
startIcon={<DoneIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={handleSave}
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{LL.UPDATE()}
|
{LL.UPDATE()}
|
||||||
|
|||||||
@@ -1,42 +1,50 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
|
||||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
|
||||||
import StarIcon from '@mui/icons-material/Star';
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
import StarOutlineIcon from '@mui/icons-material/StarOutline';
|
import StarOutlineIcon from '@mui/icons-material/StarOutlined';
|
||||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||||
import type { SvgIconProps } from '@mui/material';
|
import type { SvgIconProps } from '@mui/material';
|
||||||
|
|
||||||
type OptionType =
|
export type OptionType =
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'readonly'
|
| 'readonly'
|
||||||
| 'web_exclude'
|
| 'web_exclude'
|
||||||
| 'api_mqtt_exclude'
|
| 'api_mqtt_exclude'
|
||||||
| 'favorite';
|
| 'favorite';
|
||||||
|
|
||||||
const OPTION_ICONS: {
|
type IconPair = [
|
||||||
[type in OptionType]: [
|
React.ComponentType<SvgIconProps>,
|
||||||
React.ComponentType<SvgIconProps>,
|
React.ComponentType<SvgIconProps>
|
||||||
React.ComponentType<SvgIconProps>
|
];
|
||||||
];
|
|
||||||
} = {
|
const OPTION_ICONS: Record<OptionType, IconPair> = {
|
||||||
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
|
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
|
||||||
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
|
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
|
||||||
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
|
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
|
||||||
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
|
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
|
||||||
favorite: [StarIcon, StarOutlineIcon]
|
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 }) => {
|
export default memo(OptionIcon);
|
||||||
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;
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -35,6 +35,77 @@ import { ScheduleFlag } from './types';
|
|||||||
import type { Schedule, ScheduleItem } from './types';
|
import type { Schedule, ScheduleItem } from './types';
|
||||||
import { schedulerItemValidation } from './validators';
|
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 FLAG_ALL_DAYS = 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: FLAG_ALL_DAYS,
|
||||||
|
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 Scheduler = () => {
|
||||||
const { LL, locale } = useI18nContext();
|
const { LL, locale } = useI18nContext();
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
const [numChanges, setNumChanges] = useState<number>(0);
|
||||||
@@ -61,7 +132,7 @@ const Scheduler = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function hasScheduleChanged(si: ScheduleItem) {
|
const hasScheduleChanged = (si: ScheduleItem) => {
|
||||||
return (
|
return (
|
||||||
si.id !== si.o_id ||
|
si.id !== si.o_id ||
|
||||||
(si.name || '') !== (si.o_name || '') ||
|
(si.name || '') !== (si.o_name || '') ||
|
||||||
@@ -72,100 +143,63 @@ const Scheduler = () => {
|
|||||||
si.cmd !== si.o_cmd ||
|
si.cmd !== si.o_cmd ||
|
||||||
si.value !== si.o_value
|
si.value !== si.o_value
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
if (numChanges === 0) {
|
if (numChanges === 0) {
|
||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
}
|
}
|
||||||
});
|
}, INTERVAL_DELAY);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const formatter = new Intl.DateTimeFormat(locale, {
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
timeZone: 'UTC'
|
timeZone: 'UTC'
|
||||||
});
|
});
|
||||||
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
|
const days = WEEK_DAYS.map((day) => {
|
||||||
const dd = day < 10 ? `0${day}` : day;
|
const dayStr = String(day).padStart(2, '0');
|
||||||
return new Date(`2017-01-${dd}T00:00:00+00:00`);
|
return new Date(
|
||||||
|
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
setDow(days.map((date) => formatter.format(date)));
|
setDow(days.map((date) => formatter.format(date)));
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const schedule_theme = useTheme({
|
const schedule_theme = useTheme(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 saveSchedule = async () => {
|
const saveSchedule = async () => {
|
||||||
await updateSchedule({
|
try {
|
||||||
schedule: schedule
|
await updateSchedule({
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
schedule: schedule
|
||||||
.map((condensed_si: ScheduleItem) => ({
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
id: condensed_si.id,
|
.map((condensed_si: ScheduleItem) => ({
|
||||||
active: condensed_si.active,
|
id: condensed_si.id,
|
||||||
flags: condensed_si.flags,
|
active: condensed_si.active,
|
||||||
time: condensed_si.time,
|
flags: condensed_si.flags,
|
||||||
cmd: condensed_si.cmd,
|
time: condensed_si.time,
|
||||||
value: condensed_si.value,
|
cmd: condensed_si.cmd,
|
||||||
name: condensed_si.name
|
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);
|
|
||||||
});
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
const editScheduleItem = (si: ScheduleItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setSelectedScheduleItem(si);
|
setSelectedScheduleItem(si);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
if (si.o_name === undefined) {
|
if (si.o_name === undefined) {
|
||||||
si.o_name = si.name;
|
si.o_name = si.name;
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -181,10 +215,7 @@ const Scheduler = () => {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||||
const new_data = creating
|
const new_data = creating
|
||||||
? [
|
? [...data, updatedItem]
|
||||||
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
|
|
||||||
updatedItem
|
|
||||||
]
|
|
||||||
: data.map((si) =>
|
: data.map((si) =>
|
||||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||||
);
|
);
|
||||||
@@ -197,19 +228,46 @@ const Scheduler = () => {
|
|||||||
|
|
||||||
const addScheduleItem = () => {
|
const addScheduleItem = () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedScheduleItem({
|
const newItem: ScheduleItem = {
|
||||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||||
active: false,
|
...DEFAULT_SCHEDULE_ITEM
|
||||||
deleted: false,
|
};
|
||||||
flags: ScheduleFlag.SCHEDULE_DAY,
|
setSelectedScheduleItem(newItem);
|
||||||
time: '',
|
|
||||||
cmd: '',
|
|
||||||
value: '',
|
|
||||||
name: ''
|
|
||||||
});
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredAndSortedSchedule = schedule
|
||||||
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
|
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
|
||||||
|
|
||||||
|
const dayBox = (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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleType = (si: ScheduleItem) => {
|
||||||
|
const label = scheduleTypeLabels[si.flags];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: 11 }} color="primary">
|
||||||
|
{label || ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderSchedule = () => {
|
const renderSchedule = () => {
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return (
|
return (
|
||||||
@@ -217,45 +275,9 @@ const Scheduler = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Table
|
<Table
|
||||||
data={{
|
data={{ nodes: filteredAndSortedSchedule }}
|
||||||
nodes: schedule
|
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
|
||||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags)
|
|
||||||
}}
|
|
||||||
theme={schedule_theme}
|
theme={schedule_theme}
|
||||||
layout={{ custom: true }}
|
layout={{ custom: true }}
|
||||||
>
|
>
|
||||||
@@ -275,22 +297,15 @@ const Scheduler = () => {
|
|||||||
{tableList.map((si: ScheduleItem) => (
|
{tableList.map((si: ScheduleItem) => (
|
||||||
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
|
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
|
||||||
<Cell stiff>
|
<Cell stiff>
|
||||||
{si.active ? (
|
<CircleIcon
|
||||||
<CircleIcon
|
color={si.active ? 'success' : 'error'}
|
||||||
color="success"
|
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CircleIcon
|
|
||||||
color="error"
|
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell stiff>
|
<Cell stiff>
|
||||||
<Stack spacing={0.5} direction="row">
|
<Stack spacing={0.5} direction="row">
|
||||||
<Divider orientation="vertical" flexItem />
|
<Divider orientation="vertical" flexItem />
|
||||||
{si.flags > 127 ? (
|
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
||||||
scheduleType(si)
|
scheduleType(si)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -321,9 +336,9 @@ const Scheduler = () => {
|
|||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
<Box mb={2} color="warning.main">
|
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||||
<Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
|
{LL.SCHEDULER_HELP_1()}.
|
||||||
</Box>
|
</Typography>
|
||||||
{renderSchedule()}
|
{renderSchedule()}
|
||||||
|
|
||||||
{selectedScheduleItem && (
|
{selectedScheduleItem && (
|
||||||
@@ -338,8 +353,8 @@ const Scheduler = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display="flex" flexWrap="wrap">
|
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
<Box flexGrow={1}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
{numChanges !== 0 && (
|
{numChanges !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
@@ -361,7 +376,7 @@ const Scheduler = () => {
|
|||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import AddIcon from '@mui/icons-material/Add';
|
|||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -26,11 +26,46 @@ import type { ValidateFieldsError } from 'async-validator';
|
|||||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { updateValue } from 'utils';
|
import { updateValue } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
import { ScheduleFlag } from './types';
|
import { ScheduleFlag } from './types';
|
||||||
import type { ScheduleItem } from './types';
|
import type { ScheduleItem } from './types';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const FLAG_MASK_127 = 127;
|
||||||
|
const SCHEDULE_TYPE_THRESHOLD = 127;
|
||||||
|
const FLAG_ALL_DAYS = 127;
|
||||||
|
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;
|
||||||
|
|
||||||
|
const getFlagDOWnumber = (flags: string[]) =>
|
||||||
|
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||||
|
|
||||||
|
const getFlagDOWstring = (f: number) =>
|
||||||
|
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
|
||||||
|
|
||||||
interface SchedulerDialogProps {
|
interface SchedulerDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -53,97 +88,67 @@ const SchedulerDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
||||||
|
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(
|
||||||
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
setEditItem(selectedItem);
|
setEditItem(selectedItem);
|
||||||
// set the flags based on type when page is loaded...
|
// Set the flags based on type when page is loaded:
|
||||||
// 0-127 is day schedule
|
// 0-127 is day schedule
|
||||||
// 128 is timer
|
// 128 is timer
|
||||||
// 129 is on change
|
// 129 is on change
|
||||||
// 130 is on condition
|
// 130 is on condition
|
||||||
// 132 is immediate
|
// 132 is immediate
|
||||||
setScheduleType(
|
setScheduleType(
|
||||||
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
|
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
|
||||||
|
? ScheduleFlag.SCHEDULE_DAY
|
||||||
|
: selectedItem.flags
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const save = async () => {
|
const handleSave = async (itemToSave: ScheduleItem) => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, itemToSave);
|
||||||
onSave(editItem);
|
onSave(itemToSave);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
await handleSave(editItem);
|
||||||
|
};
|
||||||
|
|
||||||
const saveandactivate = async () => {
|
const saveandactivate = async () => {
|
||||||
editItem.active = true;
|
await handleSave({ ...editItem, active: true });
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(validator, editItem);
|
|
||||||
onSave(editItem);
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
editItem.deleted = true;
|
onSave({ ...editItem, deleted: true });
|
||||||
onSave(editItem);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFlagDOWnumber = (newFlag: string[]) => {
|
const DayOfWeekButton = (flag: number) => {
|
||||||
let new_flag = 0;
|
const dayIndex = Math.log2(flag);
|
||||||
for (const entry of newFlag) {
|
const isSelected = (editItem.flags & flag) === flag;
|
||||||
new_flag |= Number(entry);
|
return (
|
||||||
}
|
<Typography
|
||||||
return new_flag & 127;
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
|
color={isSelected ? 'primary' : 'grey'}
|
||||||
|
>
|
||||||
|
{dow[dayIndex]}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = (
|
const handleClose = (
|
||||||
_event: React.SyntheticEvent,
|
_event: React.SyntheticEvent,
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
@@ -153,10 +158,56 @@ const SchedulerDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScheduleTypeChange = (
|
||||||
|
_event: React.SyntheticEvent<HTMLElement>,
|
||||||
|
flag: ScheduleFlag | null
|
||||||
|
) => {
|
||||||
|
if (flag !== null) {
|
||||||
|
setFieldErrors(undefined); // clear any validation errors
|
||||||
|
setScheduleType(flag);
|
||||||
|
// wipe the time field when changing the schedule type
|
||||||
|
// set the flags based on type
|
||||||
|
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
|
||||||
|
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDOWChange = (
|
||||||
|
_event: React.SyntheticEvent<HTMLElement>,
|
||||||
|
flags: string[]
|
||||||
|
) => {
|
||||||
|
const newFlags =
|
||||||
|
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
||||||
|
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
|
||||||
|
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
|
||||||
|
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
|
||||||
|
const needsTimeField = isDaySchedule || isTimerSchedule;
|
||||||
|
|
||||||
|
const dowFlags = getFlagDOWstring(editItem.flags);
|
||||||
|
|
||||||
|
const timeFieldValue = needsTimeField
|
||||||
|
? editItem.time === ''
|
||||||
|
? DEFAULT_TIME
|
||||||
|
: editItem.time
|
||||||
|
: editItem.time === DEFAULT_TIME
|
||||||
|
? ''
|
||||||
|
: editItem.time;
|
||||||
|
|
||||||
|
const timeFieldLabel = (() => {
|
||||||
|
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
||||||
|
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
||||||
|
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
||||||
|
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
||||||
|
return LL.TIME(1);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
||||||
{LL.SCHEDULE(1)}
|
{LL.SCHEDULE(1)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
@@ -166,47 +217,27 @@ const SchedulerDialog = ({
|
|||||||
value={scheduleType}
|
value={scheduleType}
|
||||||
exclusive
|
exclusive
|
||||||
disabled={!creating}
|
disabled={!creating}
|
||||||
onChange={(_event, flag: ScheduleFlag) => {
|
onChange={handleScheduleTypeChange}
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
|
color={isDaySchedule ? 'primary' : 'grey'}
|
||||||
>
|
>
|
||||||
{LL.SCHEDULE(0)}
|
{LL.SCHEDULE(0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
color={
|
color={isTimerSchedule ? 'primary' : 'grey'}
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{LL.TIMER(0)}
|
{LL.TIMER(0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
color={
|
color={
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
|
||||||
}
|
}
|
||||||
@@ -216,7 +247,7 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
color={
|
color={
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
|
||||||
}
|
}
|
||||||
@@ -226,50 +257,30 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
color={
|
color={isImmediateSchedule ? 'primary' : 'grey'}
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{LL.IMMEDIATE()}
|
{LL.IMMEDIATE()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
|
{isDaySchedule && (
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
value={getFlagDOWstring(editItem.flags)}
|
value={dowFlags}
|
||||||
onChange={(_event, flag: string[]) => {
|
onChange={handleDOWChange}
|
||||||
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ToggleButton value="2">
|
{DAY_FLAGS.map(({ value, flag }) => (
|
||||||
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
|
<ToggleButton key={value} value={value}>
|
||||||
</ToggleButton>
|
{DayOfWeekButton(flag)}
|
||||||
<ToggleButton value="4">
|
</ToggleButton>
|
||||||
{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>
|
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
|
{!isImmediateSchedule && (
|
||||||
<>
|
<>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
@@ -284,42 +295,33 @@ const SchedulerDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
|
{needsTimeField ? (
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
|
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
name="time"
|
name="time"
|
||||||
type="time"
|
type="time"
|
||||||
label={
|
label={timeFieldLabel}
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER
|
value={timeFieldValue}
|
||||||
? LL.TIMER(1)
|
|
||||||
: LL.TIME(1)
|
|
||||||
}
|
|
||||||
value={editItem.time === '' ? '00:00' : editItem.time}
|
|
||||||
margin="normal"
|
margin="normal"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
|
{isTimerSchedule && (
|
||||||
<Box color="warning.main" ml={2} mt={4}>
|
<Typography
|
||||||
<Typography variant="body2">
|
sx={{ ml: 2, mt: 4 }}
|
||||||
{LL.SCHEDULER_HELP_2()}
|
color="warning"
|
||||||
</Typography>
|
variant="body2"
|
||||||
</Box>
|
>
|
||||||
|
{LL.SCHEDULER_HELP_2()}
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
name="time"
|
name="time"
|
||||||
label={
|
label={timeFieldLabel}
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
|
|
||||||
? LL.CONDITION()
|
|
||||||
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
|
|
||||||
? LL.ONCHANGE()
|
|
||||||
: LL.IMMEDIATE()
|
|
||||||
}
|
|
||||||
multiline
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
value={editItem.time === '00:00' ? '' : editItem.time}
|
value={timeFieldValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
@@ -359,7 +361,7 @@ const SchedulerDialog = ({
|
|||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
<Box flexGrow={1}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<RemoveIcon />}
|
startIcon={<RemoveIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -386,7 +388,7 @@ const SchedulerDialog = ({
|
|||||||
>
|
>
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||||
</Button>
|
</Button>
|
||||||
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
|
{isImmediateSchedule && editItem.cmd !== '' && (
|
||||||
<Button
|
<Button
|
||||||
startIcon={<PlayArrowIcon />}
|
startIcon={<PlayArrowIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext, useState } from 'react';
|
import { useContext, useRef, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||||
@@ -49,6 +49,74 @@ import {
|
|||||||
temperatureSensorItemValidation
|
temperatureSensorItemValidation
|
||||||
} from './validators';
|
} 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 Sensors = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
@@ -59,18 +127,22 @@ const Sensors = () => {
|
|||||||
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
|
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
|
||||||
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
||||||
const [creating, setCreating] = useState<boolean>(false);
|
const [creating, setCreating] = useState<boolean>(false);
|
||||||
|
const firstAvailableGPIO = useRef<number>(undefined);
|
||||||
|
|
||||||
const { data: sensorData, send: fetchSensorData } = useRequest(
|
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
|
||||||
() => readSensorData(),
|
initialData: {
|
||||||
{
|
ts: [],
|
||||||
initialData: {
|
as: [],
|
||||||
ts: [],
|
analog_enabled: false,
|
||||||
as: [],
|
available_gpios: [] as number[],
|
||||||
analog_enabled: false,
|
platform: 'ESP32'
|
||||||
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(
|
const { send: sendTemperatureSensor } = useRequest(
|
||||||
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
|
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
|
||||||
@@ -92,108 +164,8 @@ const Sensors = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const common_theme = useTheme({
|
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
||||||
BaseRow: `
|
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
||||||
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;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Cell: `
|
|
||||||
&:last-of-type {
|
|
||||||
text-align: right;
|
|
||||||
},
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
const temperature_theme = useTheme([
|
|
||||||
common_theme,
|
|
||||||
{
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
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 = (state: State, sortKey: unknown) => {
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
if (state.sortKey === sortKey && state.reverse) {
|
||||||
@@ -216,11 +188,20 @@ const Sensors = () => {
|
|||||||
},
|
},
|
||||||
sortToggleType: SortToggleType.AlternateWithReset,
|
sortToggleType: SortToggleType.AlternateWithReset,
|
||||||
sortFns: {
|
sortFns: {
|
||||||
GPIO: (array) => array.sort((a, b) => a.g - b.g),
|
GPIO: (array) =>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
[...array].sort(
|
||||||
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
|
||||||
TYPE: (array) => array.sort((a, b) => a.t - b.t),
|
),
|
||||||
VALUE: (array) => array.sort((a, b) => a.v - b.v)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -236,9 +217,15 @@ const Sensors = () => {
|
|||||||
},
|
},
|
||||||
sortToggleType: SortToggleType.AlternateWithReset,
|
sortToggleType: SortToggleType.AlternateWithReset,
|
||||||
sortFns: {
|
sortFns: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
NAME: (array) =>
|
||||||
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
|
[...array].sort((a, b) =>
|
||||||
VALUE: (array) => array.sort((a, b) => a.t - b.t)
|
(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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -246,24 +233,25 @@ const Sensors = () => {
|
|||||||
useLayoutTitle(LL.SENSORS());
|
useLayoutTitle(LL.SENSORS());
|
||||||
|
|
||||||
const formatDurationMin = (duration_min: number) => {
|
const formatDurationMin = (duration_min: number) => {
|
||||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
const totalMs = duration_min * MS_PER_MINUTE;
|
||||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||||
|
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||||
|
|
||||||
let formatted = '';
|
const parts: string[] = [];
|
||||||
if (days) {
|
if (days > 0) {
|
||||||
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
parts.push(LL.NUM_DAYS({ num: days }));
|
||||||
}
|
}
|
||||||
if (hours) {
|
if (hours > 0) {
|
||||||
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
|
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||||
}
|
}
|
||||||
if (minutes) {
|
if (minutes > 0) {
|
||||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||||
}
|
}
|
||||||
return formatted;
|
return parts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatValue(value: unknown, uom: DeviceValueUOM) {
|
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -292,7 +280,7 @@ const Sensors = () => {
|
|||||||
default:
|
default:
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
||||||
if (me.admin) {
|
if (me.admin) {
|
||||||
@@ -308,7 +296,12 @@ const Sensors = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
||||||
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
|
await sendTemperatureSensor({
|
||||||
|
id: ts.id,
|
||||||
|
name: ts.n,
|
||||||
|
offset: ts.o,
|
||||||
|
is_system: ts.s
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||||
})
|
})
|
||||||
@@ -337,17 +330,22 @@ const Sensors = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addAnalogSensor = () => {
|
const addAnalogSensor = () => {
|
||||||
|
if (firstAvailableGPIO.current === undefined) {
|
||||||
|
toast.error(LL.NO_GPIO());
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedAnalogSensor({
|
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: '',
|
n: '',
|
||||||
g: 21, // default GPIO 21 which is safe for all platforms
|
g: firstAvailableGPIO.current,
|
||||||
u: 0,
|
u: DeviceValueUOM.NONE,
|
||||||
v: 0,
|
v: 0,
|
||||||
o: 0,
|
o: 0,
|
||||||
t: 0,
|
|
||||||
f: 1,
|
f: 1,
|
||||||
|
t: AnalogType.DIGITAL_IN, // default to digital in 1
|
||||||
d: false,
|
d: false,
|
||||||
|
s: false,
|
||||||
o_n: ''
|
o_n: ''
|
||||||
});
|
});
|
||||||
setAnalogDialogOpen(true);
|
setAnalogDialogOpen(true);
|
||||||
@@ -362,7 +360,8 @@ const Sensors = () => {
|
|||||||
factor: as.f,
|
factor: as.f,
|
||||||
uom: as.u,
|
uom: as.u,
|
||||||
type: as.t,
|
type: as.t,
|
||||||
deleted: as.d
|
deleted: as.d,
|
||||||
|
is_system: as.s
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||||
@@ -377,7 +376,7 @@ const Sensors = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderAnalogSensors = () => (
|
const RenderAnalogSensors = (
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: sensorData.as }}
|
data={{ nodes: sensorData.as }}
|
||||||
theme={analog_theme}
|
theme={analog_theme}
|
||||||
@@ -391,7 +390,7 @@ const Sensors = () => {
|
|||||||
<HeaderCell stiff>
|
<HeaderCell stiff>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
style={HEADER_BUTTON_STYLE}
|
||||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||||
>
|
>
|
||||||
@@ -401,7 +400,7 @@ const Sensors = () => {
|
|||||||
<HeaderCell resize>
|
<HeaderCell resize>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
style={HEADER_BUTTON_STYLE}
|
||||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||||
>
|
>
|
||||||
@@ -411,7 +410,7 @@ const Sensors = () => {
|
|||||||
<HeaderCell stiff>
|
<HeaderCell stiff>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
style={HEADER_BUTTON_STYLE}
|
||||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||||
>
|
>
|
||||||
@@ -421,7 +420,7 @@ const Sensors = () => {
|
|||||||
<HeaderCell stiff>
|
<HeaderCell stiff>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
style={HEADER_BUTTON_STYLE_END}
|
||||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||||
>
|
>
|
||||||
@@ -431,17 +430,24 @@ const Sensors = () => {
|
|||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
</Header>
|
</Header>
|
||||||
<Body>
|
<Body>
|
||||||
{tableList.map((a: AnalogSensor) => (
|
{tableList.map((as: AnalogSensor) => (
|
||||||
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
|
<Row
|
||||||
<Cell stiff>{a.g}</Cell>
|
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||||
<Cell>{a.n}</Cell>
|
key={as.id}
|
||||||
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
|
item={as}
|
||||||
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
|
onClick={() => updateAnalogSensor(as)}
|
||||||
a.t === AnalogType.DIGITAL_IN ||
|
>
|
||||||
a.t === AnalogType.PULSE ? (
|
<Cell stiff>{as.g}</Cell>
|
||||||
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</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>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
|
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
@@ -451,12 +457,67 @@ const Sensors = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const RenderTemperatureSensors = (
|
||||||
|
<Table
|
||||||
|
data={{ nodes: sensorData.ts }}
|
||||||
|
theme={temperature_theme}
|
||||||
|
sort={temperature_sort}
|
||||||
|
layout={{ custom: true }}
|
||||||
|
>
|
||||||
|
{(tableList: TemperatureSensor[]) => (
|
||||||
|
<>
|
||||||
|
<Header>
|
||||||
|
<HeaderRow>
|
||||||
|
<HeaderCell resize>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE}
|
||||||
|
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||||
|
onClick={() =>
|
||||||
|
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.NAME(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell stiff>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE_END}
|
||||||
|
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||||
|
onClick={() =>
|
||||||
|
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.VALUE(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</Header>
|
||||||
|
<Body>
|
||||||
|
{tableList.map((ts: TemperatureSensor) => (
|
||||||
|
<Row
|
||||||
|
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||||
|
key={ts.id}
|
||||||
|
item={ts}
|
||||||
|
onClick={() => updateTemperatureSensor(ts)}
|
||||||
|
>
|
||||||
|
<Cell>{ts.n}</Cell>
|
||||||
|
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
|
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||||
{LL.TEMP_SENSORS()}
|
{LL.TEMP_SENSORS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<RenderTemperatureSensors />
|
{RenderTemperatureSensors}
|
||||||
{selectedTemperatureSensor && (
|
{selectedTemperatureSensor && (
|
||||||
<DashboardSensorsTemperatureDialog
|
<DashboardSensorsTemperatureDialog
|
||||||
open={temperatureDialogOpen}
|
open={temperatureDialogOpen}
|
||||||
@@ -469,10 +530,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()}
|
{LL.ANALOG_SENSORS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<RenderAnalogSensors />
|
{RenderAnalogSensors}
|
||||||
{selectedAnalogSensor && (
|
{selectedAnalogSensor && (
|
||||||
<DashboardSensorsAnalogDialog
|
<DashboardSensorsAnalogDialog
|
||||||
open={analogDialogOpen}
|
open={analogDialogOpen}
|
||||||
@@ -480,16 +541,20 @@ const Sensors = () => {
|
|||||||
onSave={onAnalogDialogSave}
|
onSave={onAnalogDialogSave}
|
||||||
creating={creating}
|
creating={creating}
|
||||||
selectedItem={selectedAnalogSensor}
|
selectedItem={selectedAnalogSensor}
|
||||||
validator={analogSensorItemValidation(
|
analogGPIOList={sensorData.available_gpios}
|
||||||
sensorData.as,
|
disabledTypeList={sensorData.exclude_types}
|
||||||
selectedAnalogSensor,
|
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
|
||||||
creating,
|
|
||||||
sensorData.platform
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sensorData?.analog_enabled === true && me.admin && (
|
{sensorData?.analog_enabled === true && me.admin && (
|
||||||
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
|
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
TextField,
|
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ import type { ValidateFieldsError } from 'async-validator';
|
|||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
||||||
import type { AnalogSensor } from './types';
|
import type { AnalogSensor } from './types';
|
||||||
@@ -34,6 +34,8 @@ interface DashboardSensorsAnalogDialogProps {
|
|||||||
onSave: (as: AnalogSensor) => void;
|
onSave: (as: AnalogSensor) => void;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
selectedItem: AnalogSensor;
|
selectedItem: AnalogSensor;
|
||||||
|
analogGPIOList: number[];
|
||||||
|
disabledTypeList: number[];
|
||||||
validator: Schema;
|
validator: Schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +45,81 @@ const SensorsAnalogDialog = ({
|
|||||||
onSave,
|
onSave,
|
||||||
creating,
|
creating,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
|
analogGPIOList,
|
||||||
|
disabledTypeList,
|
||||||
validator
|
validator
|
||||||
}: DashboardSensorsAnalogDialogProps) => {
|
}: DashboardSensorsAnalogDialogProps) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||||
const updateFormValue = updateValue(setEditItem);
|
|
||||||
|
|
||||||
|
const updateFormValue = updateValue((updater) =>
|
||||||
|
setEditItem(
|
||||||
|
(prev) =>
|
||||||
|
updater(
|
||||||
|
prev as unknown as Record<string, unknown>
|
||||||
|
) as unknown as AnalogSensor
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCounterOrRate =
|
||||||
|
editItem.t === AnalogType.COUNTER ||
|
||||||
|
editItem.t === AnalogType.RATE ||
|
||||||
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||||
|
const isCounter =
|
||||||
|
editItem.t === AnalogType.COUNTER ||
|
||||||
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||||
|
const isFreqType =
|
||||||
|
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
||||||
|
const isPWM =
|
||||||
|
editItem.t === AnalogType.PWM_0 ||
|
||||||
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
|
editItem.t === AnalogType.PWM_2;
|
||||||
|
const isDACOutGPIO =
|
||||||
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
|
(editItem.g === 25 || editItem.g === 26);
|
||||||
|
const isDigitalOutGPIO =
|
||||||
|
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
||||||
|
|
||||||
|
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
|
||||||
|
name: val,
|
||||||
|
value: i + 1
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map(({ name, value }) => (
|
||||||
|
<MenuItem
|
||||||
|
key={name}
|
||||||
|
value={value}
|
||||||
|
disabled={disabledTypeList?.includes(value)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
));
|
||||||
|
|
||||||
|
const uomMenuItems = 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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -72,99 +142,82 @@ const SensorsAnalogDialog = ({
|
|||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
editItem.d = true;
|
onSave({ ...editItem, d: true });
|
||||||
onSave(editItem);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
|
||||||
{LL.ANALOG_SENSOR(0)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container spacing={2}>
|
<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>
|
<Grid>
|
||||||
<ValidatedTextField
|
<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"
|
name="n"
|
||||||
label={LL.NAME(0)}
|
label={LL.NAME(0)}
|
||||||
value={editItem.n}
|
value={editItem.n}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="t"
|
name="t"
|
||||||
label={LL.TYPE(0)}
|
label={LL.TYPE(0)}
|
||||||
value={editItem.t}
|
value={editItem.t}
|
||||||
fullWidth
|
fullWidth
|
||||||
select
|
select
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
>
|
>
|
||||||
{AnalogTypeNames.map((val, i) => (
|
{analogTypeMenuItems}
|
||||||
<MenuItem key={val} value={i}>
|
</ValidatedTextField>
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
{((editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE) ||
|
{(isCounterOrRate ||
|
||||||
(editItem.t >= AnalogType.FREQ_0 &&
|
isFreqType ||
|
||||||
editItem.t <= AnalogType.FREQ_2)) && (
|
editItem.t === AnalogType.ADC ||
|
||||||
|
editItem.t === AnalogType.TIMER) && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="u"
|
name="u"
|
||||||
label={LL.UNIT()}
|
label={LL.UNIT()}
|
||||||
value={editItem.u}
|
value={editItem.u}
|
||||||
sx={{ width: '15ch' }}
|
sx={{ width: '15ch' }}
|
||||||
select
|
select
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
>
|
>
|
||||||
{DeviceValueUOM_s.map((val, i) => (
|
{uomMenuItems}
|
||||||
<MenuItem key={val} value={i}>
|
</ValidatedTextField>
|
||||||
{val}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.ADC && (
|
{editItem.t === AnalogType.ADC && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.OFFSET()}
|
label={LL.OFFSET()}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
type="number"
|
type="number"
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -178,14 +231,14 @@ const SensorsAnalogDialog = ({
|
|||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.NTC && (
|
{editItem.t === AnalogType.NTC && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.OFFSET()}
|
label={LL.OFFSET()}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
type="number"
|
type="number"
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -197,16 +250,16 @@ const SensorsAnalogDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.COUNTER && (
|
{isCounter && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.STARTVALUE()}
|
label={LL.STARTVALUE()}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
type="number"
|
type="number"
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
htmlInput: { step: '0.001' }
|
htmlInput: { step: '0.001' }
|
||||||
}}
|
}}
|
||||||
@@ -215,113 +268,113 @@ const SensorsAnalogDialog = ({
|
|||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.RGB && (
|
{editItem.t === AnalogType.RGB && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={'RGB ' + LL.VALUE(0)}
|
label={'RGB ' + LL.VALUE(0)}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
type="number"
|
type="number"
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
|
{(isCounterOrRate ||
|
||||||
|
isFreqType ||
|
||||||
|
editItem.t === AnalogType.ADC ||
|
||||||
|
editItem.t === AnalogType.TIMER) && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="f"
|
name="f"
|
||||||
label={LL.FACTOR()}
|
label={LL.FACTOR()}
|
||||||
value={numberValue(editItem.f)}
|
value={numberValue(editItem.f)}
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '14ch' }}
|
||||||
type="number"
|
type="number"
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
htmlInput: { step: '0.001' }
|
htmlInput: { step: '0.001' }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
{isDACOutGPIO && (
|
||||||
(editItem.g === 25 || editItem.g === 26) && (
|
<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>
|
||||||
|
)}
|
||||||
|
{isDigitalOutGPIO && (
|
||||||
|
<>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.VALUE(0)}
|
label={LL.VALUE(0)}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
sx={{ width: '11ch' }}
|
select
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
slotProps={{
|
disabled={editItem.s}
|
||||||
htmlInput: { min: '0', max: '255', step: '1' }
|
>
|
||||||
}}
|
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
||||||
/>
|
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
||||||
|
</ValidatedTextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
<Grid>
|
||||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
<ValidatedTextField
|
||||||
editItem.g !== 25 &&
|
name="f"
|
||||||
editItem.g !== 26 && (
|
label={LL.POLARITY()}
|
||||||
<>
|
value={editItem.f}
|
||||||
<Grid>
|
sx={{ width: '15ch' }}
|
||||||
<TextField
|
select
|
||||||
name="o"
|
onChange={updateFormValue}
|
||||||
label={LL.VALUE(0)}
|
disabled={editItem.s}
|
||||||
value={numberValue(editItem.o)}
|
>
|
||||||
select
|
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||||
variant="outlined"
|
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
||||||
onChange={updateFormValue}
|
</ValidatedTextField>
|
||||||
>
|
</Grid>
|
||||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
<Grid>
|
||||||
<MenuItem value={1}>{LL.ON()}</MenuItem>
|
<ValidatedTextField
|
||||||
</TextField>
|
name="u"
|
||||||
</Grid>
|
label={LL.STARTVALUE()}
|
||||||
<Grid>
|
sx={{ width: '15ch' }}
|
||||||
<TextField
|
value={editItem.u}
|
||||||
name="f"
|
select
|
||||||
label={LL.POLARITY()}
|
onChange={updateFormValue}
|
||||||
value={editItem.f}
|
disabled={editItem.s}
|
||||||
sx={{ width: '15ch' }}
|
>
|
||||||
select
|
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
|
||||||
onChange={updateFormValue}
|
<MenuItem value={1}>
|
||||||
>
|
{LL.ALWAYS()} {LL.OFF()}
|
||||||
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
|
<MenuItem value={2}>
|
||||||
</TextField>
|
{LL.ALWAYS()} {LL.ON()}
|
||||||
</Grid>
|
</MenuItem>
|
||||||
<Grid>
|
</ValidatedTextField>
|
||||||
<TextField
|
</Grid>
|
||||||
name="u"
|
</>
|
||||||
label={LL.STARTVALUE()}
|
)}
|
||||||
sx={{ width: '15ch' }}
|
{isPWM && (
|
||||||
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>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="f"
|
name="f"
|
||||||
label={LL.FREQ()}
|
label={LL.FREQ()}
|
||||||
value={numberValue(editItem.f)}
|
value={numberValue(editItem.f)}
|
||||||
type="number"
|
type="number"
|
||||||
variant="outlined"
|
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -333,14 +386,14 @@ const SensorsAnalogDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.DUTY_CYCLE()}
|
label={LL.DUTY_CYCLE()}
|
||||||
value={numberValue(editItem.o)}
|
value={numberValue(editItem.o)}
|
||||||
type="number"
|
type="number"
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -356,27 +409,28 @@ const SensorsAnalogDialog = ({
|
|||||||
{editItem.t === AnalogType.PULSE && (
|
{editItem.t === AnalogType.PULSE && (
|
||||||
<>
|
<>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.POLARITY()}
|
label={LL.POLARITY()}
|
||||||
value={editItem.o}
|
value={editItem.o}
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
select
|
select
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
>
|
>
|
||||||
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
|
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
|
||||||
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
|
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
|
||||||
</TextField>
|
</ValidatedTextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<ValidatedTextField
|
||||||
name="f"
|
name="f"
|
||||||
label="Pulse"
|
label="Pulse"
|
||||||
value={numberValue(editItem.f)}
|
value={numberValue(editItem.f)}
|
||||||
type="number"
|
type="number"
|
||||||
sx={{ width: '15ch' }}
|
sx={{ width: '15ch' }}
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
|
disabled={editItem.s}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -390,12 +444,43 @@ const SensorsAnalogDialog = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
{Object.values(fieldErrors).map((errArr, idx) =>
|
||||||
|
Array.isArray(errArr)
|
||||||
|
? errArr.map((err, j) => (
|
||||||
|
<Typography
|
||||||
|
key={`${idx}-${j}`}
|
||||||
|
color="error"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
{err.message}
|
||||||
|
</Typography>
|
||||||
|
))
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{editItem.s && (
|
||||||
|
<Grid>
|
||||||
|
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
|
||||||
|
<WarningIcon
|
||||||
|
fontSize="small"
|
||||||
|
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
<Box sx={{ flexGrow: 1, '& button': { mt: 0 } }}>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<RemoveIcon />}
|
startIcon={<RemoveIcon />}
|
||||||
|
disabled={editItem.s}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="warning"
|
color="warning"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
@@ -413,7 +498,7 @@ const SensorsAnalogDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<WarningIcon color="warning" />}
|
startIcon={<DoneIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
@@ -21,7 +21,7 @@ import type { ValidateFieldsError } from 'async-validator';
|
|||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
import type { TemperatureSensor } from './types';
|
import type { TemperatureSensor } from './types';
|
||||||
|
|
||||||
@@ -33,6 +33,12 @@ interface SensorsTemperatureDialogProps {
|
|||||||
validator: Schema;
|
validator: Schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const OFFSET_MIN = -5;
|
||||||
|
const OFFSET_MAX = 5;
|
||||||
|
const OFFSET_STEP = 0.1;
|
||||||
|
const TEMP_UNIT = '°C';
|
||||||
|
|
||||||
const SensorsTemperatureDialog = ({
|
const SensorsTemperatureDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -43,7 +49,14 @@ const SensorsTemperatureDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
||||||
const updateFormValue = updateValue(setEditItem);
|
|
||||||
|
const updateFormValue = updateValue(
|
||||||
|
setEditItem as unknown as (
|
||||||
|
updater: (
|
||||||
|
prevState: Readonly<Record<string, unknown>>
|
||||||
|
) => Record<string, unknown>
|
||||||
|
) => void
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -52,10 +65,7 @@ const SensorsTemperatureDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = (
|
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
|
||||||
_event: React.SyntheticEvent,
|
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
|
||||||
) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -67,25 +77,21 @@ const SensorsTemperatureDialog = ({
|
|||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>
|
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
|
||||||
{LL.EDIT()} {LL.TEMP_SENSOR()}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
|
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||||
<Typography variant="body2">
|
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
</Typography>
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="n"
|
name="n"
|
||||||
label={LL.NAME(0)}
|
label={LL.NAME(0)}
|
||||||
value={editItem.n}
|
value={editItem.n}
|
||||||
@@ -105,14 +111,30 @@ const SensorsTemperatureDialog = ({
|
|||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">°C</InputAdornment>
|
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
htmlInput: { min: '-5', max: '5', step: '0.1' }
|
htmlInput: {
|
||||||
|
min: OFFSET_MIN,
|
||||||
|
max: OFFSET_MAX,
|
||||||
|
step: OFFSET_STEP
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{editItem.s && (
|
||||||
|
<Grid>
|
||||||
|
<Typography sx={{ mt: 1 }} color="warning" variant="body2">
|
||||||
|
<WarningIcon
|
||||||
|
fontSize="small"
|
||||||
|
sx={{ mr: 1, verticalAlign: 'middle' }}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
{LL.SYSTEM(0)} {LL.SENSOR(0)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
@@ -124,7 +146,7 @@ const SensorsTemperatureDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<WarningIcon color="warning" />}
|
startIcon={<DoneIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
69
interface/src/app/main/UserProfile.tsx
Normal file
69
interface/src/app/main/UserProfile.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { memo, 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 = () => {
|
||||||
|
signOut(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 sx={{ mt: 2, mb: 2, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Typography
|
||||||
|
sx={{ mr: 2, textAlign: 'center' }}
|
||||||
|
color="warning"
|
||||||
|
variant="body1"
|
||||||
|
>
|
||||||
|
{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';
|
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 formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
|
||||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
const totalMs = duration_min * 60000;
|
||||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
const days = Math.trunc(totalMs / 86400000);
|
||||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
const hours = Math.trunc(totalMs / 3600000) % 24;
|
||||||
|
const minutes = Math.trunc(duration_min) % 60;
|
||||||
|
|
||||||
let formatted = '';
|
const parts: string[] = [];
|
||||||
if (days) {
|
if (days) {
|
||||||
formatted += LL.NUM_DAYS({ num: days });
|
parts.push(LL.NUM_DAYS({ num: days }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hours) {
|
if (hours) {
|
||||||
if (formatted) formatted += ' ';
|
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||||
formatted += LL.NUM_HOURS({ num: hours });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minutes) {
|
if (minutes) {
|
||||||
if (formatted) formatted += ' ';
|
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted;
|
return parts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatValue(
|
export function formatValue(
|
||||||
@@ -30,18 +33,21 @@ export function formatValue(
|
|||||||
value?: unknown,
|
value?: unknown,
|
||||||
uom?: DeviceValueUOM
|
uom?: DeviceValueUOM
|
||||||
) {
|
) {
|
||||||
|
// Handle non-numeric values or missing data
|
||||||
if (typeof value !== 'number' || uom === undefined || value === undefined) {
|
if (typeof value !== 'number' || uom === undefined || value === undefined) {
|
||||||
if (value === undefined || typeof value === 'boolean') {
|
if (value === undefined || typeof value === 'boolean') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
// Type assertion is safe here since we know it's not a number, boolean, or undefined
|
||||||
return (
|
return (
|
||||||
(value as string) +
|
(value as string) +
|
||||||
(value === '' || uom === undefined || uom === 0
|
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
|
||||||
? ''
|
? ''
|
||||||
: ' ' + DeviceValueUOM_s[uom])
|
: ' ' + DeviceValueUOM_s[uom])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle numeric values
|
||||||
switch (uom) {
|
switch (uom) {
|
||||||
case DeviceValueUOM.HOURS:
|
case DeviceValueUOM.HOURS:
|
||||||
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
|
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||||
@@ -50,18 +56,12 @@ export function formatValue(
|
|||||||
case DeviceValueUOM.SECONDS:
|
case DeviceValueUOM.SECONDS:
|
||||||
return LL.NUM_SECONDS({ num: value });
|
return LL.NUM_SECONDS({ num: value });
|
||||||
case DeviceValueUOM.NONE:
|
case DeviceValueUOM.NONE:
|
||||||
return new Intl.NumberFormat().format(value);
|
return numberFormatter.format(value);
|
||||||
case DeviceValueUOM.DEGREES:
|
case DeviceValueUOM.DEGREES:
|
||||||
case DeviceValueUOM.DEGREES_R:
|
case DeviceValueUOM.DEGREES_R:
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
case DeviceValueUOM.FAHRENHEIT:
|
||||||
return (
|
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
new Intl.NumberFormat(undefined, {
|
|
||||||
minimumFractionDigits: 1
|
|
||||||
}).format(value) +
|
|
||||||
' ' +
|
|
||||||
DeviceValueUOM_s[uom]
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ export interface Settings {
|
|||||||
modbus_port: number;
|
modbus_port: number;
|
||||||
modbus_max_clients: number;
|
modbus_max_clients: number;
|
||||||
modbus_timeout: number;
|
modbus_timeout: number;
|
||||||
|
email_enabled: boolean;
|
||||||
|
email_ssl?: boolean;
|
||||||
|
email_starttls?: boolean;
|
||||||
|
email_server: string;
|
||||||
|
email_port: number;
|
||||||
|
email_login: string;
|
||||||
|
email_pass: string;
|
||||||
|
email_sender: string;
|
||||||
|
email_recp: string;
|
||||||
|
email_subject: string;
|
||||||
developer_mode: boolean;
|
developer_mode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +70,7 @@ export interface Stat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
stats: Stat[];
|
readonly stats: readonly Stat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
@@ -82,38 +92,43 @@ export interface TemperatureSensor {
|
|||||||
t?: number; // temp, optional
|
t?: number; // temp, optional
|
||||||
o: number; // offset
|
o: number; // offset
|
||||||
u: number; // uom
|
u: number; // uom
|
||||||
|
s: boolean; // system sensor flag
|
||||||
o_n?: string;
|
o_n?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalogSensor {
|
export interface AnalogSensor {
|
||||||
id: number;
|
id: number;
|
||||||
g: number; // GPIO
|
g: number; // GPIO
|
||||||
n: string;
|
n: string; // name
|
||||||
v: number;
|
v: number; // value
|
||||||
u: number;
|
u: number; // uom
|
||||||
o: number;
|
o: number; // offset
|
||||||
f: number;
|
f: number; // factor
|
||||||
t: number;
|
t: number; // type
|
||||||
d: boolean; // deleted flag
|
d: boolean; // deleted flag
|
||||||
o_n?: string;
|
s: boolean; // system sensor flag
|
||||||
|
o_n?: string; // original name
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WriteTemperatureSensor {
|
export interface WriteTemperatureSensor {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
is_system: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SensorData {
|
export interface SensorData {
|
||||||
ts: TemperatureSensor[];
|
ts: TemperatureSensor[];
|
||||||
as: AnalogSensor[];
|
as: AnalogSensor[];
|
||||||
analog_enabled: boolean;
|
analog_enabled: boolean;
|
||||||
|
available_gpios: number[];
|
||||||
|
exclude_types: number[];
|
||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoreData {
|
export interface CoreData {
|
||||||
connected: boolean;
|
readonly connected: boolean;
|
||||||
devices: Device[];
|
readonly devices: readonly Device[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardItem {
|
export interface DashboardItem {
|
||||||
@@ -122,11 +137,12 @@ export interface DashboardItem {
|
|||||||
n?: string; // name, optional
|
n?: string; // name, optional
|
||||||
dv?: DeviceValue; // device value, optional
|
dv?: DeviceValue; // device value, optional
|
||||||
nodes?: DashboardItem[]; // children nodes, optional
|
nodes?: DashboardItem[]; // children nodes, optional
|
||||||
|
parentNode: DashboardItem; // to stop lint errors
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
connected: boolean; // true if connected to EMS bus
|
readonly connected: boolean; // true if connected to EMS bus
|
||||||
nodes: DashboardItem[];
|
readonly nodes: readonly DashboardItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceValue {
|
export interface DeviceValue {
|
||||||
@@ -139,10 +155,11 @@ export interface DeviceValue {
|
|||||||
s?: string; // steps for up/down, optional
|
s?: string; // steps for up/down, optional
|
||||||
m?: number; // min, optional
|
m?: number; // min, optional
|
||||||
x?: number; // max, optional
|
x?: number; // max, optional
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceData {
|
export interface DeviceData {
|
||||||
nodes: DeviceValue[];
|
readonly nodes: readonly DeviceValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceEntity {
|
export interface DeviceEntity {
|
||||||
@@ -189,13 +206,13 @@ export enum DeviceValueUOM {
|
|||||||
MBAR,
|
MBAR,
|
||||||
LH,
|
LH,
|
||||||
CTKWH,
|
CTKWH,
|
||||||
HZ
|
HERTZ
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeviceValueUOM_s = [
|
export const DeviceValueUOM_s = [
|
||||||
'',
|
'',
|
||||||
'°C',
|
'°C',
|
||||||
'°C',
|
'°C Rel',
|
||||||
'%',
|
'%',
|
||||||
'l/min',
|
'l/min',
|
||||||
'kWh',
|
'kWh',
|
||||||
@@ -221,11 +238,10 @@ export const DeviceValueUOM_s = [
|
|||||||
'l/h',
|
'l/h',
|
||||||
'ct/kWh',
|
'ct/kWh',
|
||||||
'Hz'
|
'Hz'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export enum AnalogType {
|
export enum AnalogType {
|
||||||
REMOVED = -1,
|
REMOVED = -1,
|
||||||
NOTUSED = 0,
|
|
||||||
DIGITAL_IN = 1,
|
DIGITAL_IN = 1,
|
||||||
COUNTER = 2,
|
COUNTER = 2,
|
||||||
ADC = 3,
|
ADC = 3,
|
||||||
@@ -240,31 +256,34 @@ export enum AnalogType {
|
|||||||
PULSE = 12,
|
PULSE = 12,
|
||||||
FREQ_0 = 13,
|
FREQ_0 = 13,
|
||||||
FREQ_1 = 14,
|
FREQ_1 = 14,
|
||||||
FREQ_2 = 15
|
FREQ_2 = 15,
|
||||||
|
CNT_0 = 16,
|
||||||
|
CNT_1 = 17,
|
||||||
|
CNT_2 = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnalogTypeNames = [
|
export const AnalogTypeNames = [
|
||||||
'(disabled)',
|
'Digital In', // 1
|
||||||
'Digital In',
|
'Counter', // 2
|
||||||
'Counter',
|
'ADC In', // 3
|
||||||
'ADC In',
|
'Timer', // 4
|
||||||
'Timer',
|
'Rate', // 5
|
||||||
'Rate',
|
'Digital Out', // 6
|
||||||
'Digital Out',
|
'PWM 0', // 7
|
||||||
'PWM 0',
|
'PWM 1', // 8
|
||||||
'PWM 1',
|
'PWM 2', // 9
|
||||||
'PWM 2',
|
'NTC Temp', // 10
|
||||||
'NTC Temp.',
|
'RGB Led', // 11
|
||||||
'RGB Led',
|
'Pulse', // 12
|
||||||
'Pulse',
|
'Freq 0', // 13
|
||||||
'Freq 0',
|
'Freq 1', // 14
|
||||||
'Freq 1',
|
'Freq 2', // 15
|
||||||
'Freq 2'
|
'Counter 0', // 16
|
||||||
];
|
'Counter 1', // 17
|
||||||
|
'Counter 2' // 18
|
||||||
|
] as const;
|
||||||
|
|
||||||
type BoardProfiles = Record<string, string>;
|
export const BOARD_PROFILES = {
|
||||||
|
|
||||||
export const BOARD_PROFILES: BoardProfiles = {
|
|
||||||
S32: 'BBQKees Gateway S32',
|
S32: 'BBQKees Gateway S32',
|
||||||
S32S3: 'BBQKees Gateway S3',
|
S32S3: 'BBQKees Gateway S3',
|
||||||
E32: 'BBQKees Gateway E32',
|
E32: 'BBQKees Gateway E32',
|
||||||
@@ -278,7 +297,9 @@ export const BOARD_PROFILES: BoardProfiles = {
|
|||||||
C3MINI: 'Wemos C3 Mini',
|
C3MINI: 'Wemos C3 Mini',
|
||||||
S2MINI: 'Wemos S2 Mini',
|
S2MINI: 'Wemos S2 Mini',
|
||||||
S3MINI: 'Liligo S3'
|
S3MINI: 'Liligo S3'
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
|
||||||
|
|
||||||
export interface BoardProfile {
|
export interface BoardProfile {
|
||||||
board_profile: string;
|
board_profile: string;
|
||||||
@@ -315,6 +336,7 @@ export interface WriteAnalogSensor {
|
|||||||
uom: number;
|
uom: number;
|
||||||
type: number;
|
type: number;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
|
is_system: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DeviceEntityMask {
|
export enum DeviceEntityMask {
|
||||||
@@ -346,7 +368,7 @@ export interface ScheduleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Schedule {
|
export interface Schedule {
|
||||||
schedule: ScheduleItem[];
|
readonly schedule: readonly ScheduleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleItem {
|
export interface ModuleItem {
|
||||||
@@ -364,7 +386,7 @@ export interface ModuleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Modules {
|
export interface Modules {
|
||||||
modules: ModuleItem[];
|
readonly modules: readonly ModuleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ScheduleFlag {
|
export enum ScheduleFlag {
|
||||||
@@ -413,7 +435,7 @@ export interface EntityItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Entities {
|
export interface Entities {
|
||||||
entities: EntityItem[];
|
readonly entities: readonly EntityItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// matches emsdevice.h DeviceType
|
// matches emsdevice.h DeviceType
|
||||||
@@ -469,4 +491,4 @@ export const DeviceValueTypeNames = [
|
|||||||
'ENUM',
|
'ENUM',
|
||||||
'RAW',
|
'RAW',
|
||||||
'CMD'
|
'CMD'
|
||||||
];
|
] as const;
|
||||||
|
|||||||
@@ -11,273 +11,158 @@ import type {
|
|||||||
TemperatureSensor
|
TemperatureSensor
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const GPIO_VALIDATOR = {
|
// Constants
|
||||||
validator(
|
const ERROR_MESSAGES = {
|
||||||
_rule: InternalRuleItem,
|
GPIO_INVALID: 'Must be an valid GPIO port',
|
||||||
value: number,
|
NAME_DUPLICATE: 'Name already in use',
|
||||||
callback: (error?: string) => void
|
GPIO_DUPLICATE: 'GPIO already in use',
|
||||||
) {
|
VALUE_OUT_OF_RANGE: 'Value out of range',
|
||||||
if (
|
HEX_REQUIRED: 'Is required and must be in hex format'
|
||||||
value &&
|
} as const;
|
||||||
(value === 1 ||
|
|
||||||
(value >= 6 && value <= 11) ||
|
const VALIDATION_LIMITS = {
|
||||||
value === 20 ||
|
PORT_MIN: 0,
|
||||||
value === 24 ||
|
PORT_MAX: 65535,
|
||||||
(value >= 28 && value <= 31) ||
|
MODBUS_MAX_CLIENTS_MIN: 0,
|
||||||
value > 40 ||
|
MODBUS_MAX_CLIENTS_MAX: 50,
|
||||||
value < 0)
|
MODBUS_TIMEOUT_MIN: 100,
|
||||||
) {
|
MODBUS_TIMEOUT_MAX: 20000,
|
||||||
callback('Must be an valid GPIO port');
|
SYSLOG_MARK_INTERVAL_MIN: 0,
|
||||||
} else {
|
SYSLOG_MARK_INTERVAL_MAX: 3600,
|
||||||
callback();
|
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 = {
|
// Generic unique name validator factory
|
||||||
validator(
|
const createUniqueNameValidator = <T extends { name: string }>(
|
||||||
_rule: InternalRuleItem,
|
items: T[],
|
||||||
value: number,
|
originalName?: string
|
||||||
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) => ({
|
|
||||||
validator(
|
validator(
|
||||||
_rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -285,43 +170,22 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =
|
|||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
name !== '' &&
|
name !== '' &&
|
||||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
(originalName === undefined ||
|
||||||
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
|
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||||
|
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
|
||||||
) {
|
) {
|
||||||
callback('Name already in use');
|
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
||||||
} else {
|
return;
|
||||||
callback();
|
|
||||||
}
|
}
|
||||||
|
callback();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schedulerItemValidation = (
|
// Generic field name validator (for cases where the name field has different property names)
|
||||||
schedule: ScheduleItem[],
|
const createUniqueFieldNameValidator = <T>(
|
||||||
scheduleItem: ScheduleItem
|
items: T[],
|
||||||
) =>
|
getName: (item: T) => string,
|
||||||
new Schema({
|
originalName?: string
|
||||||
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
|
|
||||||
) => ({
|
) => ({
|
||||||
validator(
|
validator(
|
||||||
_rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
@@ -329,58 +193,91 @@ export const uniqueCustomNameValidator = (
|
|||||||
callback: (error?: string) => void
|
callback: (error?: string) => void
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
name !== '' &&
|
||||||
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
|
(originalName === undefined ||
|
||||||
|
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||||
|
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
|
||||||
) {
|
) {
|
||||||
callback('Name already in use');
|
callback(ERROR_MESSAGES.NAME_DUPLICATE);
|
||||||
} else {
|
return;
|
||||||
callback();
|
|
||||||
}
|
}
|
||||||
|
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) =>
|
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: 'Name is required' },
|
{ required: true, message: 'Name is required' },
|
||||||
{
|
NAME_PATTERN_REQUIRED,
|
||||||
type: 'string',
|
uniqueCustomNameValidator(entity, entityItem.o_name)
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
|
device_id: [hexValidator],
|
||||||
|
type_id: [hexValidator],
|
||||||
offset: [
|
offset: [
|
||||||
{ required: true, message: 'Offset is required' },
|
{ 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' }]
|
factor: [{ required: true, message: 'is required' }]
|
||||||
});
|
});
|
||||||
@@ -388,93 +285,34 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
|
|||||||
export const uniqueTemperatureNameValidator = (
|
export const uniqueTemperatureNameValidator = (
|
||||||
sensors: TemperatureSensor[],
|
sensors: TemperatureSensor[],
|
||||||
o_name?: string
|
o_name?: string
|
||||||
) => ({
|
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const temperatureSensorItemValidation = (
|
export const temperatureSensorItemValidation = (
|
||||||
sensors: TemperatureSensor[],
|
sensors: TemperatureSensor[],
|
||||||
sensor: TemperatureSensor
|
sensor: TemperatureSensor
|
||||||
) =>
|
) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
n: [
|
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
|
||||||
message: "Must be <20 characters: alphanumeric or '_'"
|
|
||||||
},
|
|
||||||
...[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 = (
|
export const uniqueAnalogNameValidator = (
|
||||||
sensors: AnalogSensor[],
|
sensors: AnalogSensor[],
|
||||||
o_name?: string
|
o_name?: string
|
||||||
) => ({
|
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const analogSensorItemValidation = (
|
export const analogSensorItemValidation = (
|
||||||
sensors: AnalogSensor[],
|
sensors: AnalogSensor[],
|
||||||
sensor: AnalogSensor,
|
sensor: AnalogSensor
|
||||||
creating: boolean,
|
) => {
|
||||||
platform: string
|
return new Schema({
|
||||||
) =>
|
// name is required and must be unique
|
||||||
new Schema({
|
|
||||||
n: [
|
n: [
|
||||||
{
|
{ required: true, message: 'Name is required' },
|
||||||
type: 'string',
|
NAME_PATTERN,
|
||||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
uniqueAnalogNameValidator(sensors, sensor.o_n)
|
||||||
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)] : [])
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
@@ -488,11 +326,12 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
|
|||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
typeof value === 'number' &&
|
typeof value === 'number' &&
|
||||||
dv.m &&
|
dv.m !== undefined &&
|
||||||
dv.x &&
|
dv.x !== undefined &&
|
||||||
(value < dv.m || value > dv.x)
|
(value < dv.m || value > dv.x)
|
||||||
) {
|
) {
|
||||||
callback('Value out of range');
|
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,24 @@ import { useI18nContext } from 'i18n/i18n-react';
|
|||||||
import type { APSettingsType } from 'types';
|
import type { APSettingsType } from 'types';
|
||||||
import { APProvisionMode } from 'types';
|
import { APProvisionMode } from 'types';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { createAPSettingsValidator, validate } from 'validators';
|
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
|
||||||
|
|
||||||
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
||||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
|
||||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||||
|
|
||||||
|
// Efficient range function without recursion
|
||||||
|
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 APSettings = () => {
|
||||||
const {
|
const {
|
||||||
loadData,
|
loadData,
|
||||||
@@ -51,32 +63,31 @@ const APSettings = () => {
|
|||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const apEnabled = data ? isAPEnabled(data) : false;
|
||||||
|
|
||||||
|
const validateAndSubmit = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
await validate(createAPSettingsValidator(data), data);
|
||||||
|
await saveData();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
@@ -90,9 +101,6 @@ const APSettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
>
|
>
|
||||||
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
|
|
||||||
{LL.AP_PROVIDE_TEXT_1()}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
||||||
{LL.AP_PROVIDE_TEXT_2()}
|
{LL.AP_PROVIDE_TEXT_2()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -100,7 +108,7 @@ const APSettings = () => {
|
|||||||
{LL.AP_PROVIDE_TEXT_3()}
|
{LL.AP_PROVIDE_TEXT_3()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</ValidatedTextField>
|
</ValidatedTextField>
|
||||||
{isAPEnabled(data) && (
|
{apEnabled && (
|
||||||
<>
|
<>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
@@ -134,7 +142,7 @@ const APSettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
>
|
>
|
||||||
{range(1, 14).map((i) => (
|
{CHANNEL_RANGE.map((i) => (
|
||||||
<MenuItem key={i} value={i}>
|
<MenuItem key={i} value={i}>
|
||||||
{i}
|
{i}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -162,7 +170,7 @@ const APSettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
>
|
>
|
||||||
{range(1, 9).map((i) => (
|
{MAX_CLIENTS_RANGE.map((i) => (
|
||||||
<MenuItem key={i} value={i}>
|
<MenuItem key={i} value={i}>
|
||||||
{i}
|
{i}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -28,22 +28,23 @@ import {
|
|||||||
FormLoader,
|
FormLoader,
|
||||||
MessageBox,
|
MessageBox,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
|
ValidatedPasswordField,
|
||||||
ValidatedTextField,
|
ValidatedTextField,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||||
import { BOARD_PROFILES } from '../main/types';
|
import { BOARD_PROFILES } from '../main/types';
|
||||||
import type { APIcall, Settings } from '../main/types';
|
import type { APIcall, BoardProfileKey, Settings } from '../main/types';
|
||||||
import { createSettingsValidator } from '../main/validators';
|
import { createSettingsValidator } from '../main/validators';
|
||||||
|
|
||||||
export function boardProfileSelectItems() {
|
export function boardProfileSelectItems() {
|
||||||
return Object.keys(BOARD_PROFILES).map((code) => (
|
return Object.keys(BOARD_PROFILES).map((code) => (
|
||||||
<MenuItem key={code} value={code}>
|
<MenuItem key={code} value={code}>
|
||||||
{BOARD_PROFILES[code]}
|
{BOARD_PROFILES[code as BoardProfileKey]}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ const ApplicationSettings = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
@@ -106,6 +107,18 @@ const ApplicationSettings = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 doRestart = async () => {
|
const doRestart = async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
@@ -123,50 +136,42 @@ const ApplicationSettings = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.APPLICATION());
|
useLayoutTitle(LL.APPLICATION());
|
||||||
|
|
||||||
const SecondsInputProps = {
|
const validateAndSubmit = async () => {
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
try {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
await validate(createSettingsValidator(data), data);
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
|
} finally {
|
||||||
|
await saveData();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const MinutesInputProps = {
|
|
||||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const boardProfile = event.target.value;
|
||||||
|
updateFormValue(event);
|
||||||
|
if (boardProfile === 'CUSTOM') {
|
||||||
|
updateDataValue({
|
||||||
|
...data,
|
||||||
|
board_profile: boardProfile
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void updateBoardProfile(boardProfile);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const HoursInputProps = {
|
|
||||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
const restart = async () => {
|
||||||
|
await validateAndSubmit();
|
||||||
|
await doRestart();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const boardProfileItems = boardProfileSelectItems();
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data || !hardwareData) {
|
if (!data || !hardwareData) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 boardProfile = event.target.value;
|
|
||||||
updateFormValue(event);
|
|
||||||
if (boardProfile === 'CUSTOM') {
|
|
||||||
updateDataValue({
|
|
||||||
...data,
|
|
||||||
board_profile: boardProfile
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
void updateBoardProfile(boardProfile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const restart = async () => {
|
|
||||||
await validateAndSubmit();
|
|
||||||
await doRestart();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||||
@@ -307,9 +312,9 @@ const ApplicationSettings = () => {
|
|||||||
>
|
>
|
||||||
<MenuItem value={-1}>OFF</MenuItem>
|
<MenuItem value={-1}>OFF</MenuItem>
|
||||||
<MenuItem value={3}>ERR</MenuItem>
|
<MenuItem value={3}>ERR</MenuItem>
|
||||||
|
<MenuItem value={4}>WARN</MenuItem>
|
||||||
<MenuItem value={5}>NOTICE</MenuItem>
|
<MenuItem value={5}>NOTICE</MenuItem>
|
||||||
<MenuItem value={6}>INFO</MenuItem>
|
<MenuItem value={6}>INFO</MenuItem>
|
||||||
<MenuItem value={7}>DEBUG</MenuItem>
|
|
||||||
<MenuItem value={9}>ALL</MenuItem>
|
<MenuItem value={9}>ALL</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -330,6 +335,156 @@ const ApplicationSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
<Typography color="secondary">eMail</Typography>
|
||||||
|
<BlockFormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={data.email_enabled}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
name="email_enabled"
|
||||||
|
disabled={!hardwareData.psram}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
|
||||||
|
Enable eMail notification
|
||||||
|
{!hardwareData.psram && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
({LL.IS_REQUIRED('PSRAM')})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{data.email_enabled && (
|
||||||
|
<>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
spacing={2}
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-start"
|
||||||
|
alignItems="flex-start"
|
||||||
|
>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="email_server"
|
||||||
|
label="SMTP Server"
|
||||||
|
variant="outlined"
|
||||||
|
value={data.email_server}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
sx={{ width: '12ch' }}
|
||||||
|
name="email_port"
|
||||||
|
variant="outlined"
|
||||||
|
label="Port"
|
||||||
|
value={numberValue(data.email_port)}
|
||||||
|
type="number"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={4} mt={!data.email_ssl && !data.email_starttls ? 0 : 3}>
|
||||||
|
{!data.email_starttls && (
|
||||||
|
<BlockFormControlLabel
|
||||||
|
sx={{ width: '12ch' }}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={data.email_ssl}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
name="email_ssl"
|
||||||
|
disabled={
|
||||||
|
data.email_starttls || data.email_ssl === undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="SSL/TLS"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!data.email_ssl && (
|
||||||
|
<BlockFormControlLabel
|
||||||
|
sx={{ width: '12ch' }}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={data.email_starttls}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
name="email_starttls"
|
||||||
|
disabled={
|
||||||
|
data.email_ssl || data.email_starttls === undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="STARTTLS"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="email_login"
|
||||||
|
label="Login"
|
||||||
|
variant="outlined"
|
||||||
|
value={data.email_login}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedPasswordField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="email_pass"
|
||||||
|
label="Password"
|
||||||
|
variant="outlined"
|
||||||
|
value={data.email_pass}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="email_sender"
|
||||||
|
label="From"
|
||||||
|
variant="outlined"
|
||||||
|
value={data.email_sender}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="email_recp"
|
||||||
|
label="To"
|
||||||
|
variant="outlined"
|
||||||
|
value={data.email_recp}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="email_subject"
|
||||||
|
label="Subject"
|
||||||
|
variant="outlined"
|
||||||
|
value={data.email_subject}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
||||||
{LL.SENSORS()}
|
{LL.SENSORS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -468,17 +623,25 @@ const ApplicationSettings = () => {
|
|||||||
name="board_profile"
|
name="board_profile"
|
||||||
label={LL.BOARD_PROFILE()}
|
label={LL.BOARD_PROFILE()}
|
||||||
value={data.board_profile}
|
value={data.board_profile}
|
||||||
disabled={processingBoard || hardwareData.model.startsWith('BBQKees')}
|
disabled={processingBoard}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={changeBoardProfile}
|
onChange={changeBoardProfile}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{boardProfileSelectItems()}
|
{hardwareData.model.startsWith('BBQKees') ? (
|
||||||
<Divider />
|
<MenuItem key={hardwareData.board} value={hardwareData.board}>
|
||||||
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
{BOARD_PROFILES[hardwareData.board as BoardProfileKey]}
|
||||||
{LL.CUSTOM()}…
|
</MenuItem>
|
||||||
</MenuItem>
|
) : (
|
||||||
|
boardProfileItems
|
||||||
|
)}
|
||||||
|
{(data.board_profile === 'CUSTOM' || data.developer_mode) && <Divider />}
|
||||||
|
{(data.board_profile === 'CUSTOM' || data.developer_mode) && (
|
||||||
|
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
|
||||||
|
{LL.CUSTOM()}…
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</TextField>
|
</TextField>
|
||||||
{data.board_profile === 'CUSTOM' && (
|
{data.board_profile === 'CUSTOM' && (
|
||||||
<>
|
<>
|
||||||
@@ -581,6 +744,7 @@ const ApplicationSettings = () => {
|
|||||||
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
|
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
|
||||||
<MenuItem value={1}>LAN8720</MenuItem>
|
<MenuItem value={1}>LAN8720</MenuItem>
|
||||||
<MenuItem value={2}>TLK110</MenuItem>
|
<MenuItem value={2}>TLK110</MenuItem>
|
||||||
|
<MenuItem value={3}>RTL8201</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -741,7 +905,7 @@ const ApplicationSettings = () => {
|
|||||||
label={LL.REMOTE_TIMEOUT_EN()}
|
label={LL.REMOTE_TIMEOUT_EN()}
|
||||||
/>
|
/>
|
||||||
{data.remote_timeout_en && (
|
{data.remote_timeout_en && (
|
||||||
<Box mt={2}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="remote_timeout"
|
name="remote_timeout"
|
||||||
@@ -836,8 +1000,9 @@ const ApplicationSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{restartNeeded && (
|
{restartNeeded && (
|
||||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
||||||
<Button
|
<Button
|
||||||
|
sx={{ ml: 2 }}
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -873,10 +1038,12 @@ const ApplicationSettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return restarting ? (
|
||||||
|
<SystemMonitor />
|
||||||
|
) : (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{restarting ? <SystemMonitor /> : content()}
|
{content()}
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
import { Box, Button, Grid, Typography } from '@mui/material';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
import * as SystemApi from 'api/system';
|
import * as SystemApi from 'api/system';
|
||||||
import { API, callAction } from 'api/app';
|
import { API, callAction } from 'api/app';
|
||||||
|
|
||||||
|
import { dialogStyle } from '@/CustomTheme';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import type { APIcall } from 'app/main/types';
|
import type { APIcall } from 'app/main/types';
|
||||||
import SystemMonitor from 'app/status/SystemMonitor';
|
import SystemMonitor from 'app/status/SystemMonitor';
|
||||||
@@ -22,6 +33,8 @@ import { saveFile } from 'utils';
|
|||||||
const DownloadUpload = () => {
|
const DownloadUpload = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
|
const [confirmBackup, setConfirmBackup] = useState<boolean>(false);
|
||||||
|
|
||||||
const [restarting, setRestarting] = useState<boolean>(false);
|
const [restarting, setRestarting] = useState<boolean>(false);
|
||||||
|
|
||||||
const { send: sendExportData } = useRequest(
|
const { send: sendExportData } = useRequest(
|
||||||
@@ -46,93 +59,118 @@ const DownloadUpload = () => {
|
|||||||
|
|
||||||
const doRestart = async () => {
|
const doRestart = async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
try {
|
||||||
(error: Error) => {
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||||
toast.error(error.message);
|
} catch (error) {
|
||||||
}
|
toast.error((error as Error).message);
|
||||||
);
|
setRestarting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||||
|
|
||||||
const content = () => {
|
const handleCloseBackupDialog = () => {
|
||||||
if (!data) {
|
setConfirmBackup(false);
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleDownload = (type: string) => () => {
|
||||||
|
void sendExportData(type);
|
||||||
|
setConfirmBackup(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (restarting) {
|
||||||
|
return <SystemMonitor />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<>
|
<SectionContent>
|
||||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
{LL.DOWNLOAD(0)}
|
</SectionContent>
|
||||||
</Typography>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Typography mb={1} variant="body1" color="warning">
|
return (
|
||||||
{LL.DOWNLOAD_SETTINGS_TEXT()}.
|
<SectionContent>
|
||||||
</Typography>
|
<Dialog
|
||||||
<Grid container spacing={1}>
|
sx={dialogStyle}
|
||||||
|
open={confirmBackup}
|
||||||
|
onClose={handleCloseBackupDialog}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.DOWNLOAD_SYSTEM_BACKUP()}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<WarningIcon color="warning" sx={{ fontSize: 18 }} />
|
||||||
|
|
||||||
|
{LL.WARNING_SYSTEM_BACKUP()}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleCloseBackupDialog}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2 }}
|
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
onClick={handleDownload('systembackup')}
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => sendExportData('settings')}
|
|
||||||
>
|
>
|
||||||
{LL.SETTINGS_OF(LL.APPLICATION())}
|
{LL.DOWNLOAD(0)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Button
|
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||||
sx={{ ml: 2 }}
|
{LL.DOWNLOAD(0)}
|
||||||
startIcon={<DownloadIcon />}
|
</Typography>
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
<Grid
|
||||||
onClick={() => sendExportData('customizations')}
|
container
|
||||||
>
|
spacing={2}
|
||||||
{LL.CUSTOMIZATIONS()}
|
sx={{
|
||||||
</Button>
|
alignItems: 'center'
|
||||||
<Button
|
}}
|
||||||
sx={{ ml: 2 }}
|
>
|
||||||
startIcon={<DownloadIcon />}
|
<Typography variant="body1" color="warning">
|
||||||
variant="outlined"
|
{LL.DOWNLOAD_SETTINGS_TEXT()}:
|
||||||
color="primary"
|
</Typography>
|
||||||
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>
|
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2, mt: 2 }}
|
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => sendExportData('allvalues')}
|
onClick={() => setConfirmBackup(true)}
|
||||||
|
>
|
||||||
|
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mt: 2, alignItems: 'center' }}>
|
||||||
|
<Typography variant="body1" color="warning">
|
||||||
|
{LL.DOWNLOAD_SETTINGS_TEXT2()}:
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleDownload('allvalues')}
|
||||||
>
|
>
|
||||||
{LL.ALLVALUES()}
|
{LL.ALLVALUES()}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||||
{LL.UPLOAD()}
|
{LL.UPLOAD()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box color="warning.main" sx={{ pb: 2 }}>
|
<Typography sx={{ pb: 2 }} color="warning" variant="body1">
|
||||||
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
|
{LL.UPLOAD_TEXT()}:
|
||||||
</Box>
|
</Typography>
|
||||||
|
|
||||||
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
|
<SingleUpload doRestart={doRestart} />
|
||||||
</>
|
</SectionContent>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -28,7 +31,9 @@ import {
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { MqttSettingsType } from 'types';
|
import type { MqttSettingsType } from 'types';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { createMqttSettingsValidator, validate } from 'validators';
|
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
|
||||||
|
|
||||||
|
import { callAction } from '../../api/app';
|
||||||
|
|
||||||
const MqttSettings = () => {
|
const MqttSettings = () => {
|
||||||
const {
|
const {
|
||||||
@@ -52,8 +57,18 @@ const MqttSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
|
const sendResetMQTT = () => {
|
||||||
|
void callAction({ action: 'resetMQTT' })
|
||||||
|
.then(() => {
|
||||||
|
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
@@ -63,37 +78,71 @@ const MqttSettings = () => {
|
|||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = () => {
|
const validateAndSubmit = async () => {
|
||||||
if (!data) {
|
if (!data) return;
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
try {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
await validate(createMqttSettingsValidator(data), data);
|
||||||
|
await saveData();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validateAndSubmit = async () => {
|
const publishIntervalFields = [
|
||||||
try {
|
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||||
setFieldErrors(undefined);
|
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||||
await validate(createMqttSettingsValidator(data), data);
|
{
|
||||||
await saveData();
|
name: 'publish_time_thermostat',
|
||||||
} catch (error) {
|
label: LL.MQTT_INT_THERMOSTATS(),
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
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 }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
<>
|
<>
|
||||||
<BlockFormControlLabel
|
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
|
||||||
control={
|
<BlockFormControlLabel
|
||||||
<Checkbox
|
control={
|
||||||
name="enabled"
|
<Checkbox
|
||||||
checked={data.enabled}
|
name="enabled"
|
||||||
onChange={updateFormValue}
|
checked={data.enabled}
|
||||||
/>
|
onChange={updateFormValue}
|
||||||
}
|
/>
|
||||||
label={LL.ENABLE_MQTT()}
|
}
|
||||||
/>
|
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 container spacing={2} rowSpacing={0}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="host"
|
name="host"
|
||||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||||
multiline
|
multiline
|
||||||
@@ -105,7 +154,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="port"
|
name="port"
|
||||||
label="Port"
|
label="Port"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -117,7 +166,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="base"
|
name="base"
|
||||||
label={LL.BASE_TOPIC()}
|
label={LL.BASE_TOPIC()}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -129,7 +178,7 @@ const MqttSettings = () => {
|
|||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
name="client_id"
|
name="client_id"
|
||||||
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
|
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={data.client_id}
|
value={data.client_id}
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
@@ -158,7 +207,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="keep_alive"
|
name="keep_alive"
|
||||||
label="Keep Alive"
|
label="Keep Alive"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -205,6 +254,7 @@ const MqttSettings = () => {
|
|||||||
label={LL.CERT()}
|
label={LL.CERT()}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={data.rootCA}
|
value={data.rootCA}
|
||||||
|
sx={{ width: '50ch' }}
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
/>
|
/>
|
||||||
@@ -283,188 +333,131 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
{/* <Grid container spacing={2} rowSpacing={0}> */}
|
||||||
<Grid>
|
<Grid>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="ha_enabled"
|
name="ha_enabled"
|
||||||
checked={data.ha_enabled}
|
checked={data.ha_enabled}
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
disabled={data.publish_single}
|
disabled={data.publish_single}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={LL.MQTT_PUBLISH_TEXT_3()}
|
label={LL.MQTT_PUBLISH_TEXT_3()}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
{data.ha_enabled && (
|
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="discovery_type"
|
|
||||||
label={LL.MQTT_PUBLISH_TEXT_5()}
|
|
||||||
value={data.discovery_type}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>Home Assistant</MenuItem>
|
|
||||||
<MenuItem value={1}>Domoticz</MenuItem>
|
|
||||||
<MenuItem value={2}>Domoticz (latest)</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="discovery_prefix"
|
|
||||||
label={LL.MQTT_PUBLISH_TEXT_4()}
|
|
||||||
variant="outlined"
|
|
||||||
value={data.discovery_prefix}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
name="entity_format"
|
|
||||||
label={LL.MQTT_ENTITY_FORMAT()}
|
|
||||||
value={data.entity_format}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
|
||||||
<MenuItem value={3}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_1()} (v3.6)
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={4}>
|
|
||||||
{LL.MQTT_ENTITY_FORMAT_2()} (v3.6)
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
|
|
||||||
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{data.ha_enabled && (
|
||||||
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="discovery_type"
|
||||||
|
label={LL.MQTT_PUBLISH_TEXT_5()}
|
||||||
|
value={data.discovery_type}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
select
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>Home Assistant</MenuItem>
|
||||||
|
<MenuItem value={1}>Domoticz</MenuItem>
|
||||||
|
<MenuItem value={2}>Domoticz (latest)</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="discovery_prefix"
|
||||||
|
label={LL.MQTT_PUBLISH_TEXT_4()}
|
||||||
|
variant="outlined"
|
||||||
|
value={data.discovery_prefix}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<TextField
|
||||||
|
name="entity_format"
|
||||||
|
label={LL.MQTT_ENTITY_FORMAT()}
|
||||||
|
value={data.entity_format}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
margin="normal"
|
||||||
|
select
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
|
||||||
|
<MenuItem value={3}>
|
||||||
|
{LL.MQTT_ENTITY_FORMAT_1()} (v3.5)
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={4}>
|
||||||
|
{LL.MQTT_ENTITY_FORMAT_2()} (v3.5)
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={1}>
|
||||||
|
{LL.MQTT_ENTITY_FORMAT_1()} (latest)
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value={2}>
|
||||||
|
{LL.MQTT_ENTITY_FORMAT_2()} (latest)
|
||||||
|
</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
{data.discovery_type === 0 && (
|
||||||
|
<TextField
|
||||||
|
name="ha_number_mode"
|
||||||
|
label={LL.MQTT_INPUT_NUMBER_FORMAT()}
|
||||||
|
value={data.ha_number_mode}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
sx={{ width: '20ch' }}
|
||||||
|
margin="normal"
|
||||||
|
select
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>Box</MenuItem>
|
||||||
|
<MenuItem value={1}>Slider</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
||||||
{LL.MQTT_PUBLISH_INTERVALS()} (0=auto)
|
{LL.MQTT_PUBLISH_INTERVALS()} (0=auto)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
<Grid>
|
{publishIntervalFields.map((field) => (
|
||||||
<ValidatedTextField
|
<Grid key={field.name}>
|
||||||
fieldErrors={fieldErrors || {}}
|
{field.validated ? (
|
||||||
name="publish_time_heartbeat"
|
<ValidatedTextField
|
||||||
label="Heartbeat"
|
fieldErrors={fieldErrors ?? {}}
|
||||||
slotProps={{
|
name={field.name}
|
||||||
input: SecondsInputProps
|
label={field.label}
|
||||||
}}
|
slotProps={{
|
||||||
variant="outlined"
|
input: SecondsInputProps
|
||||||
value={numberValue(data.publish_time_heartbeat)}
|
}}
|
||||||
type="number"
|
variant="outlined"
|
||||||
onChange={updateFormValue}
|
value={numberValue(
|
||||||
margin="normal"
|
data[field.name as keyof MqttSettingsType] as number
|
||||||
/>
|
)}
|
||||||
</Grid>
|
type="number"
|
||||||
<Grid>
|
onChange={updateFormValue}
|
||||||
<TextField
|
margin="normal"
|
||||||
name="publish_time_boiler"
|
/>
|
||||||
label={LL.MQTT_INT_BOILER()}
|
) : (
|
||||||
variant="outlined"
|
<TextField
|
||||||
value={numberValue(data.publish_time_boiler)}
|
name={field.name}
|
||||||
type="number"
|
label={field.label}
|
||||||
onChange={updateFormValue}
|
variant="outlined"
|
||||||
margin="normal"
|
value={numberValue(
|
||||||
slotProps={{
|
data[field.name as keyof MqttSettingsType] as number
|
||||||
input: SecondsInputProps
|
)}
|
||||||
}}
|
type="number"
|
||||||
/>
|
onChange={updateFormValue}
|
||||||
</Grid>
|
margin="normal"
|
||||||
<Grid>
|
slotProps={{
|
||||||
<TextField
|
input: SecondsInputProps
|
||||||
name="publish_time_thermostat"
|
}}
|
||||||
label={LL.MQTT_INT_THERMOSTATS()}
|
/>
|
||||||
variant="outlined"
|
)}
|
||||||
value={numberValue(data.publish_time_thermostat)}
|
</Grid>
|
||||||
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>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
@@ -491,13 +484,6 @@ const MqttSettings = () => {
|
|||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
{content()}
|
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ import {
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { NTPSettingsType, Time } from 'types';
|
import type { NTPSettingsType, Time } from 'types';
|
||||||
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||||
|
|
||||||
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
|
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
|
||||||
|
|
||||||
const NTPSettings = () => {
|
const NTPSettings = () => {
|
||||||
const {
|
const {
|
||||||
@@ -61,9 +61,16 @@ const NTPSettings = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle('NTP');
|
useLayoutTitle('NTP');
|
||||||
|
|
||||||
|
const timeZoneItems = useTimeZoneSelectItems();
|
||||||
|
|
||||||
|
const selectedTzValue = data
|
||||||
|
? selectedTimeZone(data.tz_label, data.tz_format)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [localTime, setLocalTime] = useState<string>('');
|
const [localTime, setLocalTime] = useState<string>('');
|
||||||
const [settingTime, setSettingTime] = useState<boolean>(false);
|
const [settingTime, setSettingTime] = useState<boolean>(false);
|
||||||
const [processing, setProcessing] = useState<boolean>(false);
|
const [processing, setProcessing] = useState<boolean>(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const { send: updateTime } = useRequest(
|
const { send: updateTime } = useRequest(
|
||||||
(local_time: Time) => NTPApi.updateTime(local_time),
|
(local_time: Time) => NTPApi.updateTime(local_time),
|
||||||
@@ -73,14 +80,12 @@ const NTPSettings = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
);
|
);
|
||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
|
||||||
|
|
||||||
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
setLocalTime(event.target.value);
|
setLocalTime(event.target.value);
|
||||||
|
|
||||||
@@ -92,91 +97,45 @@ const NTPSettings = () => {
|
|||||||
const configureTime = async () => {
|
const configureTime = async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
|
try {
|
||||||
.then(async () => {
|
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
|
||||||
toast.success(LL.TIME_SET());
|
toast.success(LL.TIME_SET());
|
||||||
setSettingTime(false);
|
setSettingTime(false);
|
||||||
await loadData();
|
await loadData();
|
||||||
})
|
} catch {
|
||||||
.catch(() => {
|
toast.error(LL.PROBLEM_UPDATING());
|
||||||
toast.error(LL.PROBLEM_UPDATING());
|
} finally {
|
||||||
})
|
setProcessing(false);
|
||||||
.finally(() => {
|
}
|
||||||
setProcessing(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSetTimeDialog = () => (
|
const handleCloseSetTime = () => setSettingTime(false);
|
||||||
<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 = () => {
|
const validateAndSubmit = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
try {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
await validate(NTP_SETTINGS_VALIDATOR, data);
|
||||||
|
await saveData();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
|
...settings,
|
||||||
|
tz_label: event.target.value,
|
||||||
|
tz_format: TIME_ZONES[event.target.value]
|
||||||
|
}));
|
||||||
|
updateFormValue(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateAndSubmit = async () => {
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(NTP_SETTINGS_VALIDATOR, data);
|
|
||||||
await saveData();
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
|
||||||
...settings,
|
|
||||||
tz_label: event.target.value,
|
|
||||||
tz_format: TIME_ZONES[event.target.value]
|
|
||||||
}));
|
|
||||||
updateFormValue(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
@@ -205,18 +164,18 @@ const NTPSettings = () => {
|
|||||||
label={LL.TIME_ZONE()}
|
label={LL.TIME_ZONE()}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={selectedTimeZone(data.tz_label, data.tz_format)}
|
value={selectedTzValue}
|
||||||
onChange={changeTimeZone}
|
onChange={changeTimeZone}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
|
||||||
{timeZoneSelectItems()}
|
{timeZoneItems}
|
||||||
</ValidatedTextField>
|
</ValidatedTextField>
|
||||||
|
|
||||||
<Box display="flex" flexWrap="wrap">
|
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
{!data.enabled && !dirtyFlags.length && (
|
{!data.enabled && !dirtyFlags.length && (
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
onClick={openSetTime}
|
onClick={openSetTime}
|
||||||
@@ -230,7 +189,6 @@ const NTPSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{renderSetTimeDialog()}
|
|
||||||
|
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
@@ -263,7 +221,47 @@ const NTPSettings = () => {
|
|||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{content()}
|
{renderContent()}
|
||||||
|
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
||||||
|
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography color="warning" variant="body2">
|
||||||
|
{LL.SET_TIME_TEXT()}
|
||||||
|
</Typography>
|
||||||
|
<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>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,83 +1,43 @@
|
|||||||
import { useState } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
|
||||||
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
import {
|
import { List } from '@mui/material';
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Divider,
|
|
||||||
List
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { API } from 'api/app';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import type { APIcall } from 'app/main/types';
|
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
import { SectionContent, useLayoutTitle } from 'components';
|
||||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||||
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
const { versions } = useContext(AuthenticatedContext);
|
||||||
useLayoutTitle(LL.SETTINGS(0));
|
useLayoutTitle(LL.SETTINGS(0));
|
||||||
|
|
||||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||||
|
const firmwareText = versions?.current?.version
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
|
||||||
immediate: false
|
: '';
|
||||||
});
|
|
||||||
|
|
||||||
const doFormat = async () => {
|
|
||||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
|
||||||
setConfirmFactoryReset(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={doFormat}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
<List>
|
<List>
|
||||||
|
<ListMenuItem
|
||||||
|
icon={BuildIcon}
|
||||||
|
bgcolor="#72caf9"
|
||||||
|
label="EMS-ESP Firmware"
|
||||||
|
text={firmwareText}
|
||||||
|
to="/settings/version"
|
||||||
|
badge={upgradeAvailable}
|
||||||
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={TuneIcon}
|
icon={TuneIcon}
|
||||||
bgcolor="#134ba2"
|
bgcolor="#134ba2"
|
||||||
@@ -141,27 +101,6 @@ const Settings = () => {
|
|||||||
to="downloadUpload"
|
to="downloadUpload"
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
{renderFactoryResetDialog()}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Box
|
|
||||||
mt={2}
|
|
||||||
display="flex"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
flexWrap="nowrap"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setConfirmFactoryReset(true)}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { MenuItem } from '@mui/material';
|
import { MenuItem } from '@mui/material';
|
||||||
|
|
||||||
type TimeZones = Record<string, string>;
|
export const TIME_ZONES: Record<string, string> = {
|
||||||
|
|
||||||
export const TIME_ZONES: TimeZones = {
|
|
||||||
'Africa/Abidjan': 'GMT0',
|
'Africa/Abidjan': 'GMT0',
|
||||||
'Africa/Accra': 'GMT0',
|
'Africa/Accra': 'GMT0',
|
||||||
'Africa/Addis_Ababa': 'EAT-3',
|
'Africa/Addis_Ababa': 'EAT-3',
|
||||||
@@ -465,14 +463,23 @@ export const TIME_ZONES: TimeZones = {
|
|||||||
'Pacific/Wallis': 'UNK-12'
|
'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) {
|
export function selectedTimeZone(label: string, format: string) {
|
||||||
return TIME_ZONES[label] === format ? label : undefined;
|
return TIME_ZONES[label] === format ? label : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timeZoneSelectItems() {
|
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
||||||
return Object.keys(TIME_ZONES).map((label) => (
|
<MenuItem key={label} value={label}>
|
||||||
<MenuItem key={label} value={label}>
|
{label}
|
||||||
{label}
|
</MenuItem>
|
||||||
</MenuItem>
|
));
|
||||||
));
|
|
||||||
|
export function useTimeZoneSelectItems() {
|
||||||
|
return precomputedTimeZoneItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeZoneSelectItems() {
|
||||||
|
return precomputedTimeZoneItems;
|
||||||
}
|
}
|
||||||
|
|||||||
958
interface/src/app/settings/Version.tsx
Normal file
958
interface/src/app/settings/Version.tsx
Normal file
@@ -0,0 +1,958 @@
|
|||||||
|
import { memo, useContext, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import CheckIcon from '@mui/icons-material/Done';
|
||||||
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||||
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import * as SystemApi from 'api/system';
|
||||||
|
import { API, callAction } from 'api/app';
|
||||||
|
|
||||||
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import type { APIcall } from 'app/main/types';
|
||||||
|
import SystemMonitor from 'app/status/SystemMonitor';
|
||||||
|
import {
|
||||||
|
FormLoader,
|
||||||
|
SectionContent,
|
||||||
|
SingleUpload,
|
||||||
|
useLayoutTitle
|
||||||
|
} from 'components';
|
||||||
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||||
|
import type { VersionInfo } from 'types';
|
||||||
|
import { prettyDateTime } from 'utils/time';
|
||||||
|
|
||||||
|
// Constants moved outside component to avoid recreation
|
||||||
|
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
||||||
|
const STABLE_RELNOTES_URL =
|
||||||
|
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
||||||
|
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||||
|
const DEV_RELNOTES_URL =
|
||||||
|
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||||
|
|
||||||
|
// Types for better type safety
|
||||||
|
interface PartitionData {
|
||||||
|
partition: string;
|
||||||
|
version: string;
|
||||||
|
install_date?: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionData {
|
||||||
|
emsesp_version: string;
|
||||||
|
arduino_version: string;
|
||||||
|
esp_platform: string;
|
||||||
|
flash_chip_size: number;
|
||||||
|
psram: boolean;
|
||||||
|
build_flags?: string;
|
||||||
|
partition: string;
|
||||||
|
partitions: PartitionData[];
|
||||||
|
developer_mode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized components for better performance
|
||||||
|
const VersionInfoDialog = memo(
|
||||||
|
({
|
||||||
|
showVersionInfo,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
partitionVersion,
|
||||||
|
partition,
|
||||||
|
currentPartition,
|
||||||
|
size,
|
||||||
|
locale,
|
||||||
|
LL,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
showVersionInfo: number;
|
||||||
|
latestVersion: VersionInfo | undefined;
|
||||||
|
latestDevVersion: VersionInfo | undefined;
|
||||||
|
partitionVersion: VersionInfo | undefined;
|
||||||
|
partition: string;
|
||||||
|
currentPartition: string;
|
||||||
|
size: number;
|
||||||
|
locale: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
if (showVersionInfo === 0) return null;
|
||||||
|
|
||||||
|
const isStable = showVersionInfo === 1;
|
||||||
|
const isDev = showVersionInfo === 2;
|
||||||
|
const isPartition = showVersionInfo === 3;
|
||||||
|
|
||||||
|
const version = isStable
|
||||||
|
? latestVersion
|
||||||
|
: isDev
|
||||||
|
? latestDevVersion
|
||||||
|
: partitionVersion;
|
||||||
|
const relNotesUrl = isStable
|
||||||
|
? STABLE_RELNOTES_URL
|
||||||
|
: isDev
|
||||||
|
? DEV_RELNOTES_URL
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
||||||
|
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LL.VERSION()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{isPartition
|
||||||
|
? typeof version === 'string'
|
||||||
|
? version
|
||||||
|
: version?.version
|
||||||
|
: version?.version}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13,
|
||||||
|
width: 140
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{partition === currentPartition && LL.ACTIVE() + ' '}
|
||||||
|
{isStable
|
||||||
|
? LL.STABLE()
|
||||||
|
: isDev
|
||||||
|
? LL.DEVELOPMENT()
|
||||||
|
: 'Partition ' + LL.VERSION()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isPartition && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Partition
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{partition}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{isPartition && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{size} KB
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{version && version.date && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPartition ? 'Install Date' : 'Build Date'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{prettyDateTime(locale, new Date(version.date))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{!isPartition && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={relNotesUrl}
|
||||||
|
target="_blank"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Changelog
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||||
|
{LL.CLOSE()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallDialog = memo(
|
||||||
|
({
|
||||||
|
openInstallDialog,
|
||||||
|
fetchDevVersion,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
upgradeImportantMessageType,
|
||||||
|
downloadOnly,
|
||||||
|
platform,
|
||||||
|
LL,
|
||||||
|
onClose,
|
||||||
|
onInstall
|
||||||
|
}: {
|
||||||
|
openInstallDialog: boolean;
|
||||||
|
fetchDevVersion: boolean;
|
||||||
|
latestVersion: VersionInfo | undefined;
|
||||||
|
latestDevVersion: VersionInfo | undefined;
|
||||||
|
upgradeImportantMessageType: number;
|
||||||
|
downloadOnly: boolean;
|
||||||
|
platform: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstall: (url: string) => void;
|
||||||
|
}) => {
|
||||||
|
const binURL = (() => {
|
||||||
|
if (!latestVersion || !latestDevVersion) return '';
|
||||||
|
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
||||||
|
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
|
||||||
|
return fetchDevVersion
|
||||||
|
? `${DEV_URL}${filename}`
|
||||||
|
: `${STABLE_URL}v${version.version}/${filename}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography sx={{ mb: 2 }}>
|
||||||
|
{LL.INSTALL_VERSION(
|
||||||
|
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
|
||||||
|
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
|
||||||
|
{upgradeImportantMessageType === 1 && (
|
||||||
|
<>
|
||||||
|
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
|
||||||
|
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<Link
|
||||||
|
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: 'lightblue' }}
|
||||||
|
>
|
||||||
|
{LL.ONLINE_HELP()}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={binURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: 'lightblue', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{LL.DOWNLOAD(0)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{!downloadOnly && (
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onInstall(binURL)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallPartitionDialog = memo(
|
||||||
|
({
|
||||||
|
openInstallPartitionDialog,
|
||||||
|
version,
|
||||||
|
partition,
|
||||||
|
LL,
|
||||||
|
onClose,
|
||||||
|
onInstall
|
||||||
|
}: {
|
||||||
|
openInstallPartitionDialog: boolean;
|
||||||
|
version: string;
|
||||||
|
partition: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstall: (partition: string) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography sx={{ mb: 2 }}>
|
||||||
|
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onInstall(partition)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function moved outside component
|
||||||
|
const getPlatform = (data: VersionData): string => {
|
||||||
|
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Version = () => {
|
||||||
|
const { LL, locale } = useI18nContext();
|
||||||
|
const { me, versions } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
|
const [restarting, setRestarting] = useState<boolean>(false);
|
||||||
|
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||||
|
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||||
|
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [partition, setPartition] = useState<string>('');
|
||||||
|
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||||
|
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
|
||||||
|
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
||||||
|
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
||||||
|
|
||||||
|
const latestVersion = useMemo<VersionInfo | undefined>(
|
||||||
|
() =>
|
||||||
|
versions?.stable
|
||||||
|
? { version: versions.stable.version, date: versions.stable.date }
|
||||||
|
: undefined,
|
||||||
|
[versions?.stable]
|
||||||
|
);
|
||||||
|
const latestDevVersion = useMemo<VersionInfo | undefined>(
|
||||||
|
() =>
|
||||||
|
versions?.dev
|
||||||
|
? { version: versions.dev.version, date: versions.dev.date }
|
||||||
|
: undefined,
|
||||||
|
[versions?.dev]
|
||||||
|
);
|
||||||
|
const usingDevVersion = versions?.current?.type === 'dev';
|
||||||
|
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
|
||||||
|
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
|
||||||
|
const internetLive = Boolean(versions?.stable || versions?.dev);
|
||||||
|
|
||||||
|
const { send: sendSetPartition } = useRequest(
|
||||||
|
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
||||||
|
{ immediate: false }
|
||||||
|
).onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
send: loadData,
|
||||||
|
error
|
||||||
|
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
|
||||||
|
const systemData = event.data as VersionData;
|
||||||
|
if (systemData.arduino_version.startsWith('Tasmota')) {
|
||||||
|
setDownloadOnly(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { send: sendUploadURL } = useRequest(
|
||||||
|
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
|
||||||
|
useState<number>(0);
|
||||||
|
|
||||||
|
const { send: checkUpgradeImportantMessages } = useRequest(
|
||||||
|
(version: string) =>
|
||||||
|
callAction({ action: 'upgradeImportantMessages', param: version }),
|
||||||
|
{
|
||||||
|
immediate: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onSuccess((event) => {
|
||||||
|
const upgradeImportantMessageType_n = (
|
||||||
|
event.data as { upgradeImportantMessageType: number }
|
||||||
|
).upgradeImportantMessageType;
|
||||||
|
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const platform = data ? getPlatform(data) : '';
|
||||||
|
|
||||||
|
const otherPartitions =
|
||||||
|
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
|
||||||
|
|
||||||
|
const setPartitionVersionInfo = (partition: string) => {
|
||||||
|
setShowVersionInfo(3);
|
||||||
|
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
||||||
|
if (partitionData) {
|
||||||
|
setPartitionVersion({
|
||||||
|
version: partitionData.version,
|
||||||
|
date: partitionData.install_date ?? ''
|
||||||
|
});
|
||||||
|
setPartition(partitionData.partition);
|
||||||
|
setFirmwareSize(partitionData.size);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRestart = async () => {
|
||||||
|
setConfirmRestart(false);
|
||||||
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
|
(error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setRestarting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doFormat = async () => {
|
||||||
|
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||||
|
setRestarting(true);
|
||||||
|
setConfirmFactoryReset(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
|
||||||
|
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
|
||||||
|
const handleRestartClose = () => setConfirmRestart(false);
|
||||||
|
const handleRestartClick = () => setConfirmRestart(true);
|
||||||
|
|
||||||
|
const installFirmwareURL = async (url: string) => {
|
||||||
|
await sendUploadURL(url).catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
await doRestart();
|
||||||
|
};
|
||||||
|
|
||||||
|
const installPartitionFirmware = async (partition: string) => {
|
||||||
|
await sendSetPartition(partition).catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
setRestarting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPartitionDialog = (
|
||||||
|
version: string,
|
||||||
|
partition: string,
|
||||||
|
install_date: string
|
||||||
|
) => {
|
||||||
|
setOpenInstallPartitionDialog(true);
|
||||||
|
setPartitionVersion({ version: version, date: install_date });
|
||||||
|
setPartition(partition);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showFirmwareDialog = (useDevVersion: boolean) => {
|
||||||
|
setFetchDevVersion(useDevVersion);
|
||||||
|
const targetVersion = useDevVersion
|
||||||
|
? latestDevVersion?.version
|
||||||
|
: latestVersion?.version;
|
||||||
|
if (targetVersion) {
|
||||||
|
void checkUpgradeImportantMessages(targetVersion);
|
||||||
|
}
|
||||||
|
setOpenInstallDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInstallDialog = () => setOpenInstallDialog(false);
|
||||||
|
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
|
||||||
|
|
||||||
|
const handleVersionInfoClose = () => {
|
||||||
|
setShowVersionInfo(0);
|
||||||
|
setPartitionVersion(undefined);
|
||||||
|
setPartition('');
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutTitle('EMS-ESP Firmware');
|
||||||
|
|
||||||
|
const showButtons = (showingDev: boolean) => {
|
||||||
|
const choice = showingDev
|
||||||
|
? !usingDevVersion
|
||||||
|
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
|
||||||
|
: devUpgradeAvailable
|
||||||
|
? LL.UPDATE_AVAILABLE()
|
||||||
|
: undefined
|
||||||
|
: usingDevVersion
|
||||||
|
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
|
||||||
|
: stableUpgradeAvailable
|
||||||
|
? LL.UPDATE_AVAILABLE()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!choice) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckIcon
|
||||||
|
color="success"
|
||||||
|
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
||||||
|
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
|
>
|
||||||
|
{LL.REINSTALL()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me.admin) return null;
|
||||||
|
|
||||||
|
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
variant="outlined"
|
||||||
|
color={isUpdateAvailable ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
|
>
|
||||||
|
{choice}
|
||||||
|
{isUpdateAvailable && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-label="update available"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
ml: 1,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#ffeb3b',
|
||||||
|
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (restarting) {
|
||||||
|
return <SystemMonitor />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
|
||||||
|
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
|
||||||
|
{LL.THIS_VERSION()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{data.emsesp_version}
|
||||||
|
{data.build_flags && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
({data.build_flags})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{platform}
|
||||||
|
<Typography variant="caption">
|
||||||
|
(
|
||||||
|
{data.psram ? (
|
||||||
|
<CheckIcon
|
||||||
|
color="success"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloseIcon
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
PSRAM)
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{internetLive ? (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
|
||||||
|
{LL.AVAILABLE_VERSION()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
rowSpacing={1}
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{otherPartitions.length > 0 && data.developer_mode && (
|
||||||
|
<>
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
{otherPartitions.map((partition) => (
|
||||||
|
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
||||||
|
{partition.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setPartitionVersionInfo(partition.partition)
|
||||||
|
}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 0 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
showPartitionDialog(
|
||||||
|
partition.version,
|
||||||
|
partition.partition,
|
||||||
|
partition.install_date ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{latestVersion?.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowVersionInfo(1)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
{showButtons(false)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{latestDevVersion?.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowVersionInfo(2)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
{showButtons(true)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ mt: 2 }} color="warning">
|
||||||
|
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
||||||
|
{LL.INTERNET_CONNECTION_REQUIRED()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{me.admin && (
|
||||||
|
<>
|
||||||
|
<VersionInfoDialog
|
||||||
|
showVersionInfo={showVersionInfo}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
latestDevVersion={latestDevVersion}
|
||||||
|
partitionVersion={partitionVersion}
|
||||||
|
locale={locale}
|
||||||
|
partition={partition}
|
||||||
|
currentPartition={data?.partition ?? ''}
|
||||||
|
size={firmwareSize}
|
||||||
|
LL={LL}
|
||||||
|
onClose={handleVersionInfoClose}
|
||||||
|
/>
|
||||||
|
<InstallDialog
|
||||||
|
openInstallDialog={openInstallDialog}
|
||||||
|
fetchDevVersion={fetchDevVersion}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
latestDevVersion={latestDevVersion}
|
||||||
|
upgradeImportantMessageType={upgradeImportantMessageType}
|
||||||
|
downloadOnly={downloadOnly}
|
||||||
|
platform={platform}
|
||||||
|
LL={LL}
|
||||||
|
onClose={closeInstallDialog}
|
||||||
|
onInstall={installFirmwareURL}
|
||||||
|
/>
|
||||||
|
<InstallPartitionDialog
|
||||||
|
openInstallPartitionDialog={openInstallPartitionDialog}
|
||||||
|
version={partitionVersion?.version || ''}
|
||||||
|
partition={partition}
|
||||||
|
LL={LL}
|
||||||
|
onClose={closeInstallPartitionDialog}
|
||||||
|
onInstall={installPartitionFirmware}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||||
|
{LL.UPLOAD()}
|
||||||
|
</Typography>
|
||||||
|
<SingleUpload doRestart={doRestart} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{me.admin && (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={confirmFactoryReset}
|
||||||
|
onClose={handleFactoryResetClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFactoryResetClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={doFormat}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.FACTORY_RESET()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={confirmRestart}
|
||||||
|
onClose={handleRestartClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleRestartClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={doRestart}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
gap: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleRestartClick}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
{data.developer_mode && (
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFactoryResetClick}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.FACTORY_RESET()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Version);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
@@ -28,8 +28,7 @@ const Network = () => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
path: '/settings/network/settings',
|
path: '/settings/network/settings',
|
||||||
element: <NetworkSettings />,
|
element: <NetworkSettings />
|
||||||
dog: 'woof'
|
|
||||||
},
|
},
|
||||||
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
|
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
|
||||||
],
|
],
|
||||||
@@ -41,26 +40,23 @@ const Network = () => {
|
|||||||
|
|
||||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
||||||
|
|
||||||
const selectNetwork = useCallback(
|
const selectNetwork = (network: WiFiNetwork) => {
|
||||||
(network: WiFiNetwork) => {
|
setSelectedNetwork(network);
|
||||||
setSelectedNetwork(network);
|
void navigate('/settings/network/settings');
|
||||||
void navigate('/settings/network/settings');
|
};
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deselectNetwork = useCallback(() => {
|
const deselectNetwork = () => {
|
||||||
setSelectedNetwork(undefined);
|
setSelectedNetwork(undefined);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
...(selectedNetwork && { selectedNetwork }),
|
||||||
|
selectNetwork,
|
||||||
|
deselectNetwork
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WiFiConnectionContext.Provider
|
<WiFiConnectionContext.Provider value={contextValue}>
|
||||||
value={{
|
|
||||||
...(selectedNetwork && { selectedNetwork }),
|
|
||||||
selectNetwork,
|
|
||||||
deselectNetwork
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RouterTabs value={routerTab}>
|
<RouterTabs value={routerTab}>
|
||||||
<Tab
|
<Tab
|
||||||
value="/settings/network/settings"
|
value="/settings/network/settings"
|
||||||
@@ -80,4 +76,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 { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { NetworkSettingsType } from 'types';
|
import type { NetworkSettingsType } from 'types';
|
||||||
import { updateValueDirty, useRest } from 'utils';
|
import { updateValueDirty, useRest } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import { createNetworkSettingsValidator } from 'validators/network';
|
import { createNetworkSettingsValidator } from 'validators/network';
|
||||||
|
|
||||||
import SystemMonitor from '../../status/SystemMonitor';
|
import SystemMonitor from '../../status/SystemMonitor';
|
||||||
@@ -109,38 +109,37 @@ const NetworkSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
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 ValidationError).fieldErrors);
|
||||||
|
}
|
||||||
|
deselectNetwork();
|
||||||
|
}, [data, saveData, 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" color="primary">
|
<Typography variant="h6" color="primary">
|
||||||
@@ -165,7 +164,7 @@ const NetworkSettings = () => {
|
|||||||
selectedNetwork.bssid
|
selectedNetwork.bssid
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<IconButton onClick={setCancel}>
|
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -356,8 +355,9 @@ const NetworkSettings = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{restartNeeded && (
|
{restartNeeded && (
|
||||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
|
||||||
<Button
|
<Button
|
||||||
|
sx={{ ml: 2 }}
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -397,12 +397,14 @@ const NetworkSettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return restarting ? (
|
||||||
|
<SystemMonitor />
|
||||||
|
) : (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{restarting ? <SystemMonitor /> : content()}
|
{content()}
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetworkSettings;
|
export default memo(NetworkSettings);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { memo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
@@ -73,4 +73,4 @@ const WiFiNetworkScanner = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WiFiNetworkScanner;
|
export default memo(WiFiNetworkScanner);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from 'react';
|
import { memo, useContext } from 'react';
|
||||||
|
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
@@ -91,10 +91,10 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (networkList.networks.length === 0) {
|
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>;
|
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 CloseIcon from '@mui/icons-material/Close';
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
|||||||
if (open) {
|
if (open) {
|
||||||
void generateToken();
|
void generateToken();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, generateToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -54,19 +54,27 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
|
|||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{token ? (
|
{token ? (
|
||||||
<>
|
<>
|
||||||
<MessageBox message={LL.ACCESS_TOKEN_TEXT()} level="info" my={2} />
|
<MessageBox
|
||||||
<Box mt={2} mb={2}>
|
message={LL.ACCESS_TOKEN_TEXT()}
|
||||||
|
level="info"
|
||||||
|
sx={{ mt: 2, mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 2, mb: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Token"
|
label="Token"
|
||||||
multiline
|
multiline
|
||||||
value={token.token}
|
value={token.token}
|
||||||
fullWidth
|
fullWidth
|
||||||
contentEditable={false}
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
readOnly: true
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box m={4} textAlign="center">
|
<Box sx={{ m: 4, textAlign: 'center' }}>
|
||||||
<LinearProgress />
|
<LinearProgress />
|
||||||
<Typography variant="h6">{LL.GENERATING_TOKEN()}…</Typography>
|
<Typography variant="h6">{LL.GENERATING_TOKEN()}…</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -86,4 +94,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, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -95,70 +95,71 @@ const ManageUsers = () => {
|
|||||||
`
|
`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const noAdminConfigured = () => !data?.users.find((u) => u.admin);
|
||||||
|
|
||||||
|
const removeUser = (toRemove: UserType) => {
|
||||||
|
if (!data) return;
|
||||||
|
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 && data) {
|
||||||
|
const users = [
|
||||||
|
...data.users.filter(
|
||||||
|
(u: { username: string }) => u.username !== user.username
|
||||||
|
),
|
||||||
|
user
|
||||||
|
];
|
||||||
|
updateDataValue({ ...data, users });
|
||||||
|
setUser(undefined);
|
||||||
|
setChanged(changed + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeGenerateToken = useCallback(() => {
|
||||||
|
setGeneratingToken(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateTokenForUser = (username: string) => {
|
||||||
|
setGeneratingToken(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
await saveData();
|
||||||
|
await authenticatedContext.refresh();
|
||||||
|
setChanged(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancelSubmit = async () => {
|
||||||
|
await loadData();
|
||||||
|
setChanged(0);
|
||||||
|
};
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
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 {
|
interface UserType2 {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -166,7 +167,6 @@ const ManageUsers = () => {
|
|||||||
admin: boolean;
|
admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add id to the type, needed for the table
|
|
||||||
const user_table = data.users.map((u) => ({
|
const user_table = data.users.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
id: u.username
|
id: u.username
|
||||||
@@ -196,15 +196,24 @@ const ManageUsers = () => {
|
|||||||
<Cell stiff>
|
<Cell stiff>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label={LL.GENERATING_TOKEN()}
|
||||||
disabled={!authenticatedContext.me.admin}
|
disabled={!authenticatedContext.me.admin}
|
||||||
onClick={() => generateToken(u.username)}
|
onClick={() => generateTokenForUser(u.username)}
|
||||||
>
|
>
|
||||||
<VpnKeyIcon />
|
<VpnKeyIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="small" onClick={() => removeUser(u)}>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => removeUser(u)}
|
||||||
|
aria-label={LL.REMOVE()}
|
||||||
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="small" onClick={() => editUser(u)}>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => editUser(u)}
|
||||||
|
aria-label={LL.EDIT()}
|
||||||
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Cell>
|
</Cell>
|
||||||
@@ -216,12 +225,16 @@ const ManageUsers = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{noAdminConfigured() && (
|
{noAdminConfigured() && (
|
||||||
<MessageBox level="warning" message={LL.USER_WARNING()} my={2} />
|
<MessageBox
|
||||||
|
level="warning"
|
||||||
|
message={LL.USER_WARNING()}
|
||||||
|
sx={{ mt: 2, mb: 2 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display="flex" flexWrap="wrap">
|
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
{changed !== 0 && (
|
{changed !== 0 && (
|
||||||
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
|
<Box sx={{ flexGrow: 1, '& button': { mt: 2 } }}>
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
@@ -246,7 +259,7 @@ const ManageUsers = () => {
|
|||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box flexWrap="nowrap" whiteSpace="nowrap">
|
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<PersonAddIcon />}
|
startIcon={<PersonAddIcon />}
|
||||||
@@ -286,4 +299,4 @@ const ManageUsers = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ManageUsers;
|
export default memo(ManageUsers);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { Tab } from '@mui/material';
|
import { Tab } from '@mui/material';
|
||||||
@@ -12,12 +13,17 @@ const Security = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.SECURITY(0));
|
useLayoutTitle(LL.SECURITY(0));
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const matchedRoutes = matchRoutes(
|
const matchedRoutes = matchRoutes(
|
||||||
[
|
[
|
||||||
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' },
|
{
|
||||||
|
path: '/settings/security/settings',
|
||||||
|
element: <ManageUsers />
|
||||||
|
},
|
||||||
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
||||||
],
|
],
|
||||||
useLocation()
|
location
|
||||||
);
|
);
|
||||||
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||||
|
|
||||||
@@ -42,4 +48,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 CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
@@ -19,7 +19,7 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { SecuritySettingsType } from 'types';
|
import type { SecuritySettingsType } from 'types';
|
||||||
import { updateValueDirty, useRest } from 'utils';
|
import { updateValueDirty, useRest } from 'utils';
|
||||||
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
|
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
const SecuritySettings = () => {
|
const SecuritySettings = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -44,28 +44,29 @@ const SecuritySettings = () => {
|
|||||||
const authenticatedContext = useContext(AuthenticatedContext);
|
const authenticatedContext = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
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 ValidationError).fieldErrors);
|
||||||
|
}
|
||||||
|
}, [data, saveData, authenticatedContext]);
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ValidatedPasswordField
|
<ValidatedPasswordField
|
||||||
@@ -78,7 +79,7 @@ const SecuritySettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
/>
|
/>
|
||||||
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
|
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
@@ -115,4 +116,4 @@ const SecuritySettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SecuritySettings;
|
export default memo(SecuritySettings);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { UserType } from 'types';
|
import type { UserType } from 'types';
|
||||||
import { updateValue } from 'utils';
|
import { updateValue } from 'utils';
|
||||||
import { validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -45,7 +45,14 @@ const User: FC<UserFormProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { LL } = useI18nContext();
|
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 [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const open = !!user;
|
const open = !!user;
|
||||||
|
|
||||||
@@ -62,7 +69,7 @@ const User: FC<UserFormProps> = ({
|
|||||||
await validate(validator, user);
|
await validate(validator, user);
|
||||||
onDoneEditing();
|
onDoneEditing();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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 APStatus = () => {
|
||||||
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
|
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
|
||||||
|
const { LL } = useI18nContext();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
useLayoutTitle(LL.ACCESS_POINT(0));
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
if (!data) {
|
||||||
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 || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -72,19 +78,26 @@ const APStatus = () => {
|
|||||||
<SettingsInputAntennaIcon />
|
<SettingsInputAntennaIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
|
<ListItemText
|
||||||
|
primary={LL.STATUS_OF('')}
|
||||||
|
secondary={getApStatusText(data.status, LL)}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar>IP</Avatar>
|
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
|
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar>
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
<DeviceHubIcon />
|
<DeviceHubIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
@@ -93,21 +106,22 @@ const APStatus = () => {
|
|||||||
secondary={data.mac_address}
|
secondary={data.mac_address}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar>
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
<ComputerIcon />
|
<ComputerIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
|
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
</List>
|
</List>
|
||||||
);
|
</SectionContent>
|
||||||
};
|
);
|
||||||
|
|
||||||
return <SectionContent>{content()}</SectionContent>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default APStatus;
|
export default APStatus;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import { useInterval } from 'utils';
|
|||||||
import { readActivity } from '../../api/app';
|
import { readActivity } from '../../api/app';
|
||||||
import type { Stat } from '../main/types';
|
import type { Stat } from '../main/types';
|
||||||
|
|
||||||
|
const QUALITY_COLORS = {
|
||||||
|
PERFECT: '#00FF7F',
|
||||||
|
WARNING: 'orange',
|
||||||
|
POOR: 'red'
|
||||||
|
} as const;
|
||||||
|
|
||||||
const SystemActivity = () => {
|
const SystemActivity = () => {
|
||||||
const { data, send: loadData, error } = useRequest(readActivity);
|
const { data, send: loadData, error } = useRequest(readActivity);
|
||||||
|
|
||||||
@@ -77,21 +83,25 @@ const SystemActivity = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stat.q === 100) {
|
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) {
|
if (stat.q >= 95) {
|
||||||
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
|
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
|
||||||
} else {
|
} else {
|
||||||
return <div style={{ color: 'red' }}>{stat.q}%</div>;
|
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = () => {
|
if (!data) {
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: data.stats }}
|
data={{ nodes: data.stats }}
|
||||||
theme={stats_theme}
|
theme={stats_theme}
|
||||||
@@ -120,10 +130,8 @@ const SystemActivity = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
</SectionContent>
|
||||||
};
|
);
|
||||||
|
|
||||||
return <SectionContent>{content()}</SectionContent>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemActivity;
|
export default SystemActivity;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
import AppsIcon from '@mui/icons-material/Apps';
|
import AppsIcon from '@mui/icons-material/Apps';
|
||||||
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
|
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
|
||||||
import DevicesIcon from '@mui/icons-material/Devices';
|
import DevicesIcon from '@mui/icons-material/Devices';
|
||||||
@@ -24,10 +26,61 @@ import { useInterval } from 'utils';
|
|||||||
|
|
||||||
import BBQKeesIcon from './bbqkees.svg';
|
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) {
|
function formatNumber(num: number) {
|
||||||
return new Intl.NumberFormat().format(num);
|
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 HardwareStatus = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
@@ -39,175 +92,72 @@ const HardwareStatus = () => {
|
|||||||
void loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = () => {
|
if (!data) {
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<SectionContent>
|
||||||
<ListItem>
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
<ListItemAvatar>
|
</SectionContent>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
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;
|
export default HardwareStatus;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { type FC, memo } from 'react';
|
||||||
|
|
||||||
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import ReportIcon from '@mui/icons-material/Report';
|
import ReportIcon from '@mui/icons-material/Report';
|
||||||
@@ -22,17 +24,28 @@ import type { MqttStatusType } from 'types';
|
|||||||
import { MqttDisconnectReason } from 'types';
|
import { MqttDisconnectReason } from 'types';
|
||||||
import { useInterval } from 'utils';
|
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 = (
|
export const mqttStatusHighlight = (
|
||||||
{ enabled, connected }: MqttStatusType,
|
{ enabled, connected }: MqttStatusType,
|
||||||
theme: Theme
|
theme: Theme
|
||||||
) => {
|
) => {
|
||||||
if (!enabled) {
|
if (!enabled) return theme.palette.info.main;
|
||||||
return theme.palette.info.main;
|
return connected ? theme.palette.success.main : theme.palette.error.main;
|
||||||
}
|
|
||||||
if (connected) {
|
|
||||||
return theme.palette.success.main;
|
|
||||||
}
|
|
||||||
return theme.palette.error.main;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mqttPublishHighlight = (
|
export const mqttPublishHighlight = (
|
||||||
@@ -41,114 +54,99 @@ export const mqttPublishHighlight = (
|
|||||||
) => {
|
) => {
|
||||||
if (mqtt_fails === 0) return theme.palette.success.main;
|
if (mqtt_fails === 0) return theme.palette.success.main;
|
||||||
if (mqtt_fails < 10) return theme.palette.warning.main;
|
if (mqtt_fails < 10) return theme.palette.warning.main;
|
||||||
|
|
||||||
return theme.palette.error.main;
|
return theme.palette.error.main;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mqttQueueHighlight = (
|
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
|
||||||
{ mqtt_queued }: MqttStatusType,
|
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
|
||||||
theme: Theme
|
|
||||||
) => {
|
|
||||||
if (mqtt_queued <= 1) return theme.palette.success.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 MqttStatus = () => {
|
||||||
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
|
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
|
||||||
|
const { LL } = useI18nContext();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
useLayoutTitle('MQTT');
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
const errorMessage = error?.message || '';
|
||||||
useLayoutTitle('MQTT');
|
|
||||||
|
|
||||||
const theme = useTheme();
|
const mqttStatusText = !data
|
||||||
|
? ''
|
||||||
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
|
: !data.enabled
|
||||||
if (!enabled) {
|
? LL.NOT_ENABLED()
|
||||||
return LL.NOT_ENABLED();
|
: data.connected
|
||||||
}
|
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||||
if (connected) {
|
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -156,15 +154,13 @@ const MqttStatus = () => {
|
|||||||
<DeviceHubIcon />
|
<DeviceHubIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
|
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
{data.enabled && renderConnectionStatus()}
|
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
|
||||||
</List>
|
</List>
|
||||||
);
|
</SectionContent>
|
||||||
};
|
);
|
||||||
|
|
||||||
return <SectionContent>{content()}</SectionContent>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MqttStatus;
|
export default MqttStatus;
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ import { NTPSyncStatus } from 'types';
|
|||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
import { formatDateTime } 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 NTPStatus = () => {
|
||||||
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
|
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
|
||||||
|
|
||||||
@@ -33,24 +50,6 @@ const NTPStatus = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle('NTP');
|
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 theme = useTheme();
|
||||||
|
|
||||||
const ntpStatus = ({ status }: NTPStatusType) => {
|
const ntpStatus = ({ status }: NTPStatusType) => {
|
||||||
@@ -66,66 +65,66 @@ const NTPStatus = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = () => {
|
if (!data) {
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SectionContent>
|
||||||
<List>
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
<ListItem>
|
</SectionContent>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
return <SectionContent>{content()}</SectionContent>;
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<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>
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NTPStatus;
|
export default NTPStatus;
|
||||||
|
|||||||
@@ -25,10 +25,17 @@ import type { NetworkStatusType } from 'types';
|
|||||||
import { NetworkConnectionStatus } from 'types';
|
import { NetworkConnectionStatus } from 'types';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
const isConnected = ({ status }: NetworkStatusType) =>
|
const isConnected = ({ status }: NetworkStatusType) =>
|
||||||
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
|
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
|
||||||
status === NetworkConnectionStatus.ETHERNET_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) => {
|
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||||
@@ -55,11 +62,6 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
|
|||||||
return theme.palette.success.main;
|
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) => {
|
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
|
||||||
if (!dns_ip_1) {
|
if (!dns_ip_1) {
|
||||||
return 'none';
|
return 'none';
|
||||||
@@ -81,6 +83,33 @@ const IPs = (status: NetworkStatusType) => {
|
|||||||
return status.local_ip + ', ' + status.local_ipv6;
|
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 NetworkStatus = () => {
|
||||||
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
|
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
|
||||||
|
|
||||||
@@ -93,51 +122,34 @@ const NetworkStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const networkStatus = ({ status }: NetworkStatusType) => {
|
if (!data) {
|
||||||
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 = () => {
|
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
||||||
|
const statusColor = networkStatusHighlight(data, theme);
|
||||||
|
const qualityColor = networkQualityHighlight(data, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
|
<Avatar sx={{ bgcolor: statusColor }}>
|
||||||
{isWiFi(data) && <WifiIcon />}
|
{isWiFi(data) && <WifiIcon />}
|
||||||
{isEthernet(data) && <RouterIcon />}
|
{isEthernet(data) && <RouterIcon />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary="Status" secondary={networkStatus(data)} />
|
<ListItemText primary="Status" secondary={statusText} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
|
<Avatar sx={{ bgcolor: statusColor }}>
|
||||||
<GiteIcon />
|
<GiteIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
@@ -148,13 +160,13 @@ const NetworkStatus = () => {
|
|||||||
<>
|
<>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
|
<Avatar sx={{ bgcolor: qualityColor }}>
|
||||||
<SettingsInputAntennaIcon />
|
<SettingsInputAntennaIcon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="SSID (RSSI)"
|
primary="SSID (RSSI)"
|
||||||
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
|
secondary={`${data.ssid} (${data.rssi} dBm)`}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
@@ -217,10 +229,8 @@ const NetworkStatus = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
);
|
</SectionContent>
|
||||||
};
|
);
|
||||||
|
|
||||||
return <SectionContent>{content()}</SectionContent>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetworkStatus;
|
export default NetworkStatus;
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { useContext, useState } from 'react';
|
import { useContext } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||||
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
||||||
import MemoryIcon from '@mui/icons-material/Memory';
|
import MemoryIcon from '@mui/icons-material/Memory';
|
||||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||||
import RouterIcon from '@mui/icons-material/Router';
|
import RouterIcon from '@mui/icons-material/Router';
|
||||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||||
import TimerIcon from '@mui/icons-material/Timer';
|
|
||||||
import WifiIcon from '@mui/icons-material/Wifi';
|
import WifiIcon from '@mui/icons-material/Wifi';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
@@ -27,21 +18,38 @@ import {
|
|||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { API } from 'api/app';
|
|
||||||
import { readSystemStatus } from 'api/system';
|
import { readSystemStatus } from 'api/system';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { type APIcall, busConnectionStatus } from 'app/main/types';
|
import { busConnectionStatus } from 'app/main/types';
|
||||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
|
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
import { formatDateTime } from 'utils/time';
|
import { formatDateTime } from 'utils/time';
|
||||||
|
|
||||||
import SystemMonitor from './SystemMonitor';
|
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 SystemStatus = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -50,25 +58,7 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
const { data, send: loadData, error } = useRequest(readSystemStatus);
|
||||||
const [restarting, setRestarting] = useState<boolean>();
|
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
|
||||||
immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
send: loadData,
|
|
||||||
error
|
|
||||||
} = useRequest(readSystemStatus, {
|
|
||||||
initialData: [],
|
|
||||||
async middleware(_, next) {
|
|
||||||
if (!restarting) {
|
|
||||||
await next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
@@ -76,51 +66,40 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const formatDurationSec = (duration_sec: number) => {
|
const busStatus = (() => {
|
||||||
const days = Math.trunc((duration_sec * 1000) / 86400000);
|
if (!data) return 'EMS state unknown';
|
||||||
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
|
switch (data.bus_status) {
|
||||||
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
|
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||||
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
|
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
||||||
|
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||||
let formatted = '';
|
return 'EMS ' + LL.TX_ISSUES();
|
||||||
if (days) {
|
case busConnectionStatus.BUS_STATUS_OFFLINE:
|
||||||
formatted += LL.NUM_DAYS({ num: days }) + ' ';
|
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatNumber(num: number) {
|
const systemStatus = (() => {
|
||||||
return new Intl.NumberFormat().format(num);
|
if (!data) return '??';
|
||||||
}
|
switch (data.status) {
|
||||||
|
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
||||||
const busStatus = () => {
|
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
||||||
if (data) {
|
return LL.WAIT_FIRMWARE();
|
||||||
switch (data.bus_status) {
|
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
|
||||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
return LL.ERROR();
|
||||||
return (
|
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
|
||||||
'EMS ' +
|
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
|
||||||
LL.CONNECTED(0) +
|
return LL.RESTARTING_PRE();
|
||||||
' (' +
|
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
||||||
formatDurationSec(data.bus_uptime) +
|
return LL.GPIO_OF(LL.FAILED(0));
|
||||||
')'
|
default:
|
||||||
);
|
return 'OK';
|
||||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
|
||||||
return 'EMS ' + LL.TX_ISSUES();
|
|
||||||
case busConnectionStatus.BUS_STATUS_OFFLINE:
|
|
||||||
return 'EMS ' + LL.DISCONNECTED();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 'EMS state unknown';
|
})();
|
||||||
};
|
|
||||||
|
|
||||||
const busStatusHighlight = () => {
|
const busStatusHighlight = (() => {
|
||||||
|
if (!data) return theme.palette.warning.main;
|
||||||
switch (data.bus_status) {
|
switch (data.bus_status) {
|
||||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
@@ -131,27 +110,26 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
const ntpStatus = () => {
|
const ntpStatus = (() => {
|
||||||
|
if (!data) return LL.UNKNOWN();
|
||||||
switch (data.ntp_status) {
|
switch (data.ntp_status) {
|
||||||
case NTPSyncStatus.NTP_DISABLED:
|
case NTPSyncStatus.NTP_DISABLED:
|
||||||
return LL.NOT_ENABLED();
|
return LL.NOT_ENABLED();
|
||||||
case NTPSyncStatus.NTP_INACTIVE:
|
case NTPSyncStatus.NTP_INACTIVE:
|
||||||
return LL.INACTIVE(0);
|
return LL.INACTIVE(0);
|
||||||
case NTPSyncStatus.NTP_ACTIVE:
|
case NTPSyncStatus.NTP_ACTIVE:
|
||||||
return (
|
return data.ntp_time
|
||||||
LL.ACTIVE() +
|
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
|
||||||
(data.ntp_time !== undefined
|
: LL.ACTIVE();
|
||||||
? ' (' + formatDateTime(data.ntp_time) + ')'
|
|
||||||
: '')
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
const ntpStatusHighlight = () => {
|
const ntpStatusHighlight = (() => {
|
||||||
|
if (!data) return theme.palette.error.main;
|
||||||
switch (data.ntp_status) {
|
switch (data.ntp_status) {
|
||||||
case NTPSyncStatus.NTP_DISABLED:
|
case NTPSyncStatus.NTP_DISABLED:
|
||||||
return theme.palette.info.main;
|
return theme.palette.info.main;
|
||||||
@@ -162,9 +140,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.error.main;
|
return theme.palette.error.main;
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
const networkStatusHighlight = () => {
|
const networkStatusHighlight = (() => {
|
||||||
|
if (!data) return theme.palette.warning.main;
|
||||||
switch (data.network_status) {
|
switch (data.network_status) {
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||||
@@ -179,9 +158,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
const networkStatus = () => {
|
const networkStatus = (() => {
|
||||||
|
if (!data) return LL.UNKNOWN();
|
||||||
switch (data.network_status) {
|
switch (data.network_status) {
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||||
return LL.INACTIVE(1);
|
return LL.INACTIVE(1);
|
||||||
@@ -190,175 +170,114 @@ const SystemStatus = () => {
|
|||||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
||||||
return 'No SSID Available';
|
return 'No SSID Available';
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
|
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:
|
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
|
||||||
return LL.CONNECTED(0) + ' (Ethernet)';
|
return `${LL.CONNECTED(0)} (Ethernet)`;
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
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:
|
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||||
return LL.CONNECTED(1) + ' ' + LL.LOST();
|
return `${LL.CONNECTED(1)} ${LL.LOST()}`;
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||||
return LL.DISCONNECTED();
|
return LL.DISCONNECTED();
|
||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
const activeHighlight = (value: boolean) =>
|
const activeHighlight = (value: boolean) =>
|
||||||
value ? theme.palette.success.main : theme.palette.info.main;
|
value ? theme.palette.success.main : theme.palette.info.main;
|
||||||
|
|
||||||
const doRestart = async () => {
|
if (!data || !LL) {
|
||||||
setConfirmRestart(false);
|
|
||||||
setRestarting(true);
|
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
|
||||||
(error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 content = () => {
|
|
||||||
if (!data || !LL) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SectionContent>
|
||||||
<List>
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
<ListMenuItem
|
</SectionContent>
|
||||||
icon={BuildIcon}
|
|
||||||
bgcolor="#72caf9"
|
|
||||||
label="EMS-ESP Firmware"
|
|
||||||
text={'v' + data.emsesp_version}
|
|
||||||
to="version"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
|
||||||
<TimerIcon />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={LL.UPTIME()}
|
|
||||||
secondary={formatDurationSec(data.uptime)}
|
|
||||||
/>
|
|
||||||
{me.admin && (
|
|
||||||
<Button
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={() => setConfirmRestart(true)}
|
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={MemoryIcon}
|
|
||||||
bgcolor="#68374d"
|
|
||||||
label={LL.HARDWARE()}
|
|
||||||
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
|
|
||||||
to="/status/hardwarestatus"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={DirectionsBusIcon}
|
|
||||||
bgcolor={busStatusHighlight()}
|
|
||||||
label={LL.DATA_TRAFFIC()}
|
|
||||||
text={busStatus()}
|
|
||||||
to="/status/activity"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={
|
|
||||||
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
|
||||||
? WifiIcon
|
|
||||||
: RouterIcon
|
|
||||||
}
|
|
||||||
bgcolor={networkStatusHighlight()}
|
|
||||||
label={LL.NETWORK(1)}
|
|
||||||
text={networkStatus()}
|
|
||||||
to="/status/network"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={DeviceHubIcon}
|
|
||||||
bgcolor={activeHighlight(data.mqtt_status)}
|
|
||||||
label="MQTT"
|
|
||||||
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
|
||||||
to="/status/mqtt"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={AccessTimeIcon}
|
|
||||||
bgcolor={ntpStatusHighlight()}
|
|
||||||
label="NTP"
|
|
||||||
text={ntpStatus()}
|
|
||||||
to="/status/ntp"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={SettingsInputAntennaIcon}
|
|
||||||
bgcolor={activeHighlight(data.ap_status)}
|
|
||||||
label={LL.ACCESS_POINT(0)}
|
|
||||||
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
|
||||||
to="/status/ap"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={LogoDevIcon}
|
|
||||||
bgcolor="#40828f"
|
|
||||||
label={LL.LOG_OF(LL.SYSTEM(0))}
|
|
||||||
text={LL.VIEW_LOG()}
|
|
||||||
to="/status/log"
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{renderRestartDialog()}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
|
<SectionContent>
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||||
|
<MonitorHeartIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||||
|
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={MemoryIcon}
|
||||||
|
bgcolor="#68374d"
|
||||||
|
label={LL.HARDWARE()}
|
||||||
|
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
|
||||||
|
to="/status/hardwarestatus"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={DirectionsBusIcon}
|
||||||
|
bgcolor={busStatusHighlight}
|
||||||
|
label={LL.DATA_TRAFFIC()}
|
||||||
|
text={busStatus}
|
||||||
|
to="/status/activity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={
|
||||||
|
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
||||||
|
? WifiIcon
|
||||||
|
: RouterIcon
|
||||||
|
}
|
||||||
|
bgcolor={networkStatusHighlight}
|
||||||
|
label={LL.NETWORK(1)}
|
||||||
|
text={networkStatus}
|
||||||
|
to="/status/network"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={DeviceHubIcon}
|
||||||
|
bgcolor={activeHighlight(data.mqtt_status)}
|
||||||
|
label="MQTT"
|
||||||
|
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
||||||
|
to="/status/mqtt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={AccessTimeIcon}
|
||||||
|
bgcolor={ntpStatusHighlight}
|
||||||
|
label="NTP"
|
||||||
|
text={ntpStatus}
|
||||||
|
to="/status/ntp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={SettingsInputAntennaIcon}
|
||||||
|
bgcolor={activeHighlight(data.ap_status)}
|
||||||
|
label={LL.ACCESS_POINT(0)}
|
||||||
|
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
||||||
|
to="/status/ap"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={LogoDevIcon}
|
||||||
|
bgcolor="#40828f"
|
||||||
|
label={LL.LOG_OF(LL.SYSTEM(0))}
|
||||||
|
text={LL.VIEW_LOG()}
|
||||||
|
to="/status/log"
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</SectionContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
@@ -47,11 +47,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) => {
|
const levelLabel = (level: LogLevel) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
@@ -71,6 +66,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 SystemLog = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
@@ -102,54 +130,84 @@ const SystemLog = () => {
|
|||||||
const [readOpen, setReadOpen] = useState(false);
|
const [readOpen, setReadOpen] = useState(false);
|
||||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||||
const [autoscroll, setAutoscroll] = useState(true);
|
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 ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = updateValueDirty(
|
||||||
origData,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
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)
|
||||||
|
|
||||||
|
const handleLogMessage = (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];
|
||||||
|
return newLog;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useSSE(fetchLogES, {
|
useSSE(fetchLogES, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
interceptByGlobalResponded: false
|
interceptByGlobalResponded: false
|
||||||
})
|
})
|
||||||
.onMessage((message: { data: string }) => {
|
.onMessage(handleLogMessage)
|
||||||
const rawData = message.data;
|
|
||||||
const logentry = JSON.parse(rawData) as LogEntry;
|
|
||||||
if (lastId < logentry.i) {
|
|
||||||
setLogEntries((log) => [...log, logentry]);
|
|
||||||
setLastId(logentry.i);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onError(() => {
|
.onError(() => {
|
||||||
toast.error('No connection to Log service');
|
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 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 = () => {
|
const onDownload = () => {
|
||||||
let result = '';
|
const result = logEntries
|
||||||
for (const i of logEntries) {
|
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
||||||
result +=
|
.join('\n');
|
||||||
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
|
|
||||||
}
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.setAttribute(
|
a.setAttribute(
|
||||||
'href',
|
'href',
|
||||||
@@ -165,16 +223,20 @@ const SystemLog = () => {
|
|||||||
await saveData();
|
await saveData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// handle scrolling
|
// handle scrolling - optimized to only scroll when needed
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const logWindowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logEntries.length && autoscroll) {
|
if (logEntries.length && autoscroll) {
|
||||||
ref.current?.scrollIntoView({
|
const container = logWindowRef.current;
|
||||||
behavior: 'smooth',
|
if (container) {
|
||||||
block: 'end'
|
requestAnimationFrame(() => {
|
||||||
});
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [logEntries.length]);
|
}, [logEntries.length, autoscroll]);
|
||||||
|
|
||||||
const sendReadCommand = () => {
|
const sendReadCommand = () => {
|
||||||
if (readValue === '') {
|
if (readValue === '') {
|
||||||
@@ -196,7 +258,7 @@ const SystemLog = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Grid container spacing={2} alignItems="center">
|
<Grid container spacing={2} sx={{ alignItems: 'center' }}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
name="level"
|
name="level"
|
||||||
@@ -232,6 +294,8 @@ const SystemLog = () => {
|
|||||||
<MenuItem value={50}>50</MenuItem>
|
<MenuItem value={50}>50</MenuItem>
|
||||||
<MenuItem value={75}>75</MenuItem>
|
<MenuItem value={75}>75</MenuItem>
|
||||||
<MenuItem value={100}>100</MenuItem>
|
<MenuItem value={100}>100</MenuItem>
|
||||||
|
<MenuItem value={500}>500</MenuItem>
|
||||||
|
<MenuItem value={1000}>1000</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
@@ -279,6 +343,7 @@ const SystemLog = () => {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
disableRipple
|
disableRipple
|
||||||
|
aria-label={LL.CANCEL()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReadOpen(false);
|
setReadOpen(false);
|
||||||
setReadValue('');
|
setReadValue('');
|
||||||
@@ -304,7 +369,7 @@ const SystemLog = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{data.developer_mode && (
|
{data.developer_mode && (
|
||||||
<IconButton onClick={sendReadCommand}>
|
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
|
||||||
<PlayArrowIcon color="primary" />
|
<PlayArrowIcon color="primary" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -326,27 +391,20 @@ const SystemLog = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
|
ref={logWindowRef}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: 'black',
|
backgroundColor: 'black',
|
||||||
overflowY: 'scroll',
|
overflowY: 'scroll',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 18,
|
right: 18,
|
||||||
bottom: 18,
|
bottom: 18,
|
||||||
left: () => leftOffset(),
|
left: boxPosition.left,
|
||||||
top: () => topOffset(),
|
top: boxPosition.top,
|
||||||
p: 1
|
p: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logEntries.map((e) => (
|
{logEntries.map((e) => (
|
||||||
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
|
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div ref={ref} />
|
<div ref={ref} />
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material';
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
|
|
||||||
import { callAction } from 'api/app';
|
import { callAction } from 'api/app';
|
||||||
import { readSystemStatus } from 'api/system';
|
import { readSystemStatus } from 'api/system';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import MessageBox from 'components/MessageBox';
|
import MessageBox from 'components/MessageBox';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
@@ -17,11 +16,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
|
|||||||
|
|
||||||
const SystemMonitor = () => {
|
const SystemMonitor = () => {
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
|
const hasInitialized = useRef(false);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
const { send: setSystemStatus } = useRequest(
|
const { send: setSystemStatus } = useRequest(
|
||||||
(status: string) => callAction({ action: 'systemStatus', param: status }),
|
(status: string) => callAction({ action: 'systemStatus', param: status }),
|
||||||
{
|
{
|
||||||
@@ -32,10 +29,12 @@ const SystemMonitor = () => {
|
|||||||
const { data, send } = useRequest(readSystemStatus, {
|
const { data, send } = useRequest(readSystemStatus, {
|
||||||
force: true,
|
force: true,
|
||||||
async middleware(_, next) {
|
async middleware(_, next) {
|
||||||
if (count++ >= 1) {
|
// Skip first request to allow AsyncWS to send its response
|
||||||
// skip first request (1 second) to allow AsyncWS to send its response
|
if (!hasInitialized.current) {
|
||||||
await next();
|
hasInitialized.current = true;
|
||||||
|
return; // Don't await next() on first call
|
||||||
}
|
}
|
||||||
|
await next();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onSuccess((event) => {
|
.onSuccess((event) => {
|
||||||
@@ -58,40 +57,77 @@ const SystemMonitor = () => {
|
|||||||
void send();
|
void send();
|
||||||
}, 1000); // check every 1 second
|
}, 1000); // check every 1 second
|
||||||
|
|
||||||
|
const status = data?.status;
|
||||||
|
|
||||||
|
const statusMessage =
|
||||||
|
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||||
|
? LL.WAIT_FIRMWARE()
|
||||||
|
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
||||||
|
? LL.APPLICATION_RESTARTING()
|
||||||
|
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||||
|
? LL.RESTARTING_PRE()
|
||||||
|
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||||
|
? 'Upload Failed'
|
||||||
|
: LL.RESTARTING_POST();
|
||||||
|
|
||||||
|
const isUploading =
|
||||||
|
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||||
|
const progressValue =
|
||||||
|
isUploading && status
|
||||||
|
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const onCancel = async () => {
|
const onCancel = async () => {
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
await setSystemStatus(
|
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
||||||
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
|
|
||||||
);
|
|
||||||
document.location.href = '/';
|
document.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
|
<Box
|
||||||
<DialogContent dividers>
|
sx={{
|
||||||
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
|
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 sx={{ display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
|
||||||
|
<img
|
||||||
|
src="/app/icon.png"
|
||||||
|
alt="EMS-ESP"
|
||||||
|
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
color="secondary"
|
sx={{ color: 'secondary', fontWeight: 400, textAlign: 'center' }}
|
||||||
variant="h6"
|
variant="h6"
|
||||||
fontWeight={400}
|
|
||||||
textAlign="center"
|
|
||||||
>
|
>
|
||||||
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
{statusMessage}
|
||||||
? 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()}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<MessageBox my={2} level="error" message={errorMessage}>
|
<MessageBox level="error" message={errorMessage}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
sx={{ ml: 2 }}
|
sx={{ ml: 2 }}
|
||||||
|
size="small"
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -102,23 +138,22 @@ const SystemMonitor = () => {
|
|||||||
</MessageBox>
|
</MessageBox>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
<Typography
|
||||||
|
sx={{ mt: 2, fontWeight: 400, textAlign: 'center' }}
|
||||||
|
variant="h6"
|
||||||
|
>
|
||||||
{LL.PLEASE_WAIT()}…
|
{LL.PLEASE_WAIT()}…
|
||||||
</Typography>
|
</Typography>
|
||||||
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
|
{isUploading && (
|
||||||
<Box width="100%" pl={2} pr={2} py={2}>
|
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
||||||
<LinearProgressWithLabel
|
<LinearProgressWithLabel value={progressValue} />
|
||||||
value={Math.round(
|
|
||||||
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</Box>
|
||||||
</Dialog>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,653 +0,0 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, 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';
|
|
||||||
|
|
||||||
import * as SystemApi from 'api/system';
|
|
||||||
import { API, callAction } from 'api/app';
|
|
||||||
import { getDevVersion, getStableVersion } from 'api/system';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import type { APIcall } from 'app/main/types';
|
|
||||||
import SystemMonitor from 'app/status/SystemMonitor';
|
|
||||||
import {
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
SingleUpload,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import type { TranslationFunctions } from 'i18n/i18n-types';
|
|
||||||
import { prettyDateTime } from 'utils/time';
|
|
||||||
|
|
||||||
// Constants moved outside component to avoid recreation
|
|
||||||
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
|
||||||
const STABLE_RELNOTES_URL =
|
|
||||||
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
|
||||||
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
|
||||||
const DEV_RELNOTES_URL =
|
|
||||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
|
||||||
|
|
||||||
// Types for better type safety
|
|
||||||
interface VersionData {
|
|
||||||
emsesp_version: string;
|
|
||||||
arduino_version: string;
|
|
||||||
esp_platform: string;
|
|
||||||
flash_chip_size: number;
|
|
||||||
psram: boolean;
|
|
||||||
build_flags?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpgradeCheckData {
|
|
||||||
emsesp_version: string;
|
|
||||||
dev_upgradeable: boolean;
|
|
||||||
stable_upgradeable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VersionInfo {
|
|
||||||
name: string;
|
|
||||||
published_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized components for better performance
|
|
||||||
const VersionInfoDialog = memo(
|
|
||||||
({
|
|
||||||
showVersionInfo,
|
|
||||||
latestVersion,
|
|
||||||
latestDevVersion,
|
|
||||||
locale,
|
|
||||||
LL,
|
|
||||||
onClose
|
|
||||||
}: {
|
|
||||||
showVersionInfo: number;
|
|
||||||
latestVersion?: VersionInfo;
|
|
||||||
latestDevVersion?: VersionInfo;
|
|
||||||
locale: string;
|
|
||||||
LL: TranslationFunctions;
|
|
||||||
onClose: () => void;
|
|
||||||
}) => {
|
|
||||||
if (showVersionInfo === 0) return null;
|
|
||||||
|
|
||||||
const isStable = showVersionInfo === 1;
|
|
||||||
const version = isStable ? latestVersion : latestDevVersion;
|
|
||||||
const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
|
||||||
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13,
|
|
||||||
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 InstallDialog = memo(
|
|
||||||
({
|
|
||||||
openInstallDialog,
|
|
||||||
fetchDevVersion,
|
|
||||||
latestVersion,
|
|
||||||
latestDevVersion,
|
|
||||||
downloadOnly,
|
|
||||||
platform,
|
|
||||||
LL,
|
|
||||||
onClose,
|
|
||||||
onInstall
|
|
||||||
}: {
|
|
||||||
openInstallDialog: boolean;
|
|
||||||
fetchDevVersion: boolean;
|
|
||||||
latestVersion?: VersionInfo;
|
|
||||||
latestDevVersion?: VersionInfo;
|
|
||||||
downloadOnly: boolean;
|
|
||||||
platform: string;
|
|
||||||
LL: TranslationFunctions;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: (url: string) => void;
|
|
||||||
}) => {
|
|
||||||
const binURL = useMemo(() => {
|
|
||||||
if (!latestVersion || !latestDevVersion) return '';
|
|
||||||
|
|
||||||
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
|
||||||
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
|
|
||||||
|
|
||||||
return fetchDevVersion
|
|
||||||
? `${DEV_URL}${filename}`
|
|
||||||
: `${STABLE_URL}v${version.name}/${filename}`;
|
|
||||||
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{`${LL.UPDATE()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Typography mb={2}>
|
|
||||||
{LL.INSTALL_VERSION(
|
|
||||||
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
|
|
||||||
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<DownloadIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<Link underline="none" target="_blank" href={binURL} color="primary">
|
|
||||||
{LL.DOWNLOAD(0)}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
{!downloadOnly && (
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onInstall(binURL)}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.INSTALL()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// API calls with optimized error handling
|
|
||||||
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 = useCallback(() => {
|
|
||||||
setOpenInstallDialog(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleVersionInfoClose = useCallback(() => {
|
|
||||||
setShowVersionInfo(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Effect for checking upgrades
|
|
||||||
useEffect(() => {
|
|
||||||
if (latestVersion && latestDevVersion) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box p={2} border="1px solid grey" borderRadius={2}>
|
|
||||||
<Typography mb={2} variant="h6" color="primary">
|
|
||||||
{LL.THIS_VERSION()}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
direction="row"
|
|
||||||
rowSpacing={1}
|
|
||||||
sx={{
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
alignItems: 'baseline'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{data.emsesp_version}
|
|
||||||
{data.build_flags && (
|
|
||||||
<Typography variant="caption">
|
|
||||||
({data.build_flags})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{platform}
|
|
||||||
<Typography variant="caption">
|
|
||||||
(
|
|
||||||
{data.psram ? (
|
|
||||||
<CheckIcon
|
|
||||||
color="success"
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.5em',
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CloseIcon
|
|
||||||
color="error"
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.5em',
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
PSRAM)
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid 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>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{internetLive ? (
|
|
||||||
<>
|
|
||||||
<Typography mt={2} mb={2} variant="h6" color="primary">
|
|
||||||
{LL.AVAILABLE_VERSION()}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
direction="row"
|
|
||||||
rowSpacing={1}
|
|
||||||
sx={{
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
alignItems: 'baseline'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{latestVersion?.name}
|
|
||||||
<IconButton onClick={() => setShowVersionInfo(1)}>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
{showButtons(false)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{latestDevVersion?.name}
|
|
||||||
<IconButton onClick={() => setShowVersionInfo(2)}>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
{showButtons(true)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Typography mt={2} color="warning">
|
|
||||||
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
|
||||||
{LL.INTERNET_CONNECTION_REQUIRED()}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{me.admin && (
|
|
||||||
<>
|
|
||||||
<VersionInfoDialog
|
|
||||||
showVersionInfo={showVersionInfo}
|
|
||||||
latestVersion={latestVersion}
|
|
||||||
latestDevVersion={latestDevVersion}
|
|
||||||
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>
|
|
||||||
<SingleUpload text={LL.UPLOAD_DROP_TEXT()} doRestart={doRestart} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(Version);
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { memo } from 'react';
|
import { type FC, type PropsWithChildren, memo } from 'react';
|
||||||
|
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import type { BoxProps } from '@mui/material';
|
import type { BoxProps } from '@mui/material';
|
||||||
|
|
||||||
const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
const ButtonRow: FC<PropsWithChildren<BoxProps>> = memo(({ children, ...rest }) => (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
'& button, & a, & .MuiCard-root': {
|
'& button, & a, & .MuiCard-root': {
|
||||||
@@ -19,6 +19,4 @@ const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
|||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
ButtonRow.displayName = 'ButtonRow';
|
|
||||||
|
|
||||||
export default ButtonRow;
|
export default ButtonRow;
|
||||||
|
|||||||
@@ -1,38 +1,35 @@
|
|||||||
import type { FC } from 'react';
|
import { type FC, type PropsWithChildren, memo } from 'react';
|
||||||
|
|
||||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import ErrorIcon from '@mui/icons-material/Error';
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
|
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
|
||||||
import { Box, Typography, useTheme } from '@mui/material';
|
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';
|
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||||
|
|
||||||
export interface MessageBoxProps extends BoxProps {
|
export interface MessageBoxProps extends BoxProps {
|
||||||
level: MessageBoxLevel;
|
level: MessageBoxLevel;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_ICONS: {
|
const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
|
||||||
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
|
|
||||||
} = {
|
|
||||||
success: CheckCircleOutlineOutlinedIcon,
|
success: CheckCircleOutlineOutlinedIcon,
|
||||||
info: InfoOutlinedIcon,
|
info: InfoOutlinedIcon,
|
||||||
warning: ReportProblemOutlinedIcon,
|
warning: ReportProblemOutlinedIcon,
|
||||||
error: ErrorIcon
|
error: ErrorIcon
|
||||||
};
|
};
|
||||||
|
|
||||||
const LEVEL_BACKGROUNDS: {
|
const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
|
||||||
[type in MessageBoxLevel]: (theme: Theme) => string;
|
success: 'success.dark',
|
||||||
} = {
|
info: 'info.main',
|
||||||
success: (theme: Theme) => theme.palette.success.dark,
|
warning: 'warning.dark',
|
||||||
info: (theme: Theme) => theme.palette.info.main,
|
error: 'error.dark'
|
||||||
warning: (theme: Theme) => theme.palette.warning.dark,
|
|
||||||
error: (theme: Theme) => theme.palette.error.dark
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageBox: FC<MessageBoxProps> = ({
|
const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
||||||
level,
|
level,
|
||||||
message,
|
message,
|
||||||
sx,
|
sx,
|
||||||
@@ -40,25 +37,41 @@ const MessageBox: FC<MessageBoxProps> = ({
|
|||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const Icon = LEVEL_ICONS[level];
|
const Icon = LEVEL_ICONS[level];
|
||||||
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
|
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||||
const color = 'white';
|
const [paletteKeyName, shade] = palettePath.split('.') as [
|
||||||
|
keyof typeof theme.palette,
|
||||||
|
string
|
||||||
|
];
|
||||||
|
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
const backgroundColor = paletteKey[shade];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
p={2}
|
|
||||||
display="flex"
|
|
||||||
alignItems="center"
|
|
||||||
borderRadius={1}
|
|
||||||
sx={{ backgroundColor, color, ...sx }}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 1,
|
||||||
|
backgroundColor,
|
||||||
|
color: 'white',
|
||||||
|
p: 2,
|
||||||
|
...sx
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
<Typography sx={{ ml: 2 }} variant="body1">
|
{(message || children) && (
|
||||||
{message ?? ''}
|
<Typography sx={{ ml: 2 }} variant="body1">
|
||||||
</Typography>
|
{message}
|
||||||
{children}
|
{children}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MessageBox;
|
export default memo(MessageBox);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { Paper } from '@mui/material';
|
import { Paper } from '@mui/material';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
import type { RequiredChildrenProps } from 'utils';
|
import type { RequiredChildrenProps } from 'utils';
|
||||||
|
|
||||||
@@ -8,16 +10,19 @@ interface SectionContentProps extends RequiredChildrenProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionContent: FC<SectionContentProps> = (props) => {
|
// Extract styles to avoid recreation on every render
|
||||||
const { children, id } = props;
|
const paperStyles: SxProps<Theme> = {
|
||||||
return (
|
p: 1.5,
|
||||||
<Paper
|
m: 1.5,
|
||||||
id={id}
|
borderRadius: 3,
|
||||||
sx={{ p: 1.5, m: 1.5, borderRadius: 3, border: '1px solid rgb(65, 65, 65)' }}
|
border: '1px solid rgb(65, 65, 65)'
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 SectionContent } from './SectionContent';
|
||||||
export { default as ButtonRow } from './ButtonRow';
|
export { default as ButtonRow } from './ButtonRow';
|
||||||
export { default as MessageBox } from './MessageBox';
|
export { default as MessageBox } from './MessageBox';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormControlLabel } from '@mui/material';
|
import { FormControlLabel } from '@mui/material';
|
||||||
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default BlockFormControlLabel;
|
export default memo(BlockFormControlLabel);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { type ChangeEventHandler, useContext } from 'react';
|
import { memo, useContext } from 'react';
|
||||||
|
import type { ChangeEventHandler } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { MenuItem, TextField } from '@mui/material';
|
import { MenuItem, TextField } from '@mui/material';
|
||||||
|
|
||||||
@@ -17,8 +19,30 @@ import { I18nContext } from 'i18n/i18n-react';
|
|||||||
import type { Locales } from 'i18n/i18n-types';
|
import type { Locales } from 'i18n/i18n-types';
|
||||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||||
|
|
||||||
|
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
|
||||||
|
|
||||||
|
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 LanguageSelector = () => {
|
||||||
const { setLocale, locale } = useContext(I18nContext);
|
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||||
|
|
||||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||||
target
|
target
|
||||||
@@ -33,57 +57,20 @@ const LanguageSelector = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
name="locale"
|
name="locale"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
aria-label={LL.LANGUAGE()}
|
||||||
value={locale}
|
value={locale}
|
||||||
onChange={onLocaleSelected}
|
onChange={onLocaleSelected}
|
||||||
size="small"
|
size="small"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
<MenuItem key="cz" value="cz">
|
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||||
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
<MenuItem key={key} value={key}>
|
||||||
CZ
|
<img src={flag} style={flagStyle} alt={label} />
|
||||||
</MenuItem>
|
{label}
|
||||||
<MenuItem key="de" value="de">
|
</MenuItem>
|
||||||
<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>
|
|
||||||
</TextField>
|
</TextField>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageSelector;
|
export default memo(LanguageSelector);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user